Compare commits

..
17 Commits
Author SHA1 Message Date
Max Goedjen c8cf0db1c5 Remove some unused stirngs. (#664) 2025-09-04 05:03:48 +00:00
Max Goedjen 412687467b Digest wants 'sha256:' prefix that the upload step doesn't add for some reason (#667) 2025-09-04 05:01:44 +00:00
Max Goedjen 416a7d5f40 Fix attestation issue from double-zip file process in upload action (#666)
* Remove archives since we don’t strip symbols anyway.

* Attest upload result.
2025-09-03 21:38:22 -07:00
Max Goedjen c4605fb60e Update nightly.yml (#665) 2025-09-03 08:49:20 +00:00
Max Goedjen 61eed5987c Add permissions to nightly (#663)
* Add permissions to nightly

* Add archives
2025-09-03 08:39:59 +00:00
Max Goedjen cbef7c6181 Add manual trigger to nightly (#662) 2025-09-03 08:25:00 +00:00
Max Goedjen 63a09390b8 Temporarily disable previews (#661)
* Previews

* Temporarily disable.
2025-09-03 08:11:55 +00:00
Max Goedjen a4e1ab9eb6 Move (#659) 2025-09-03 07:29:58 +00:00
Max Goedjen 147f4d9908 New setup (#657)
* WIP

* WIP

* WIP

* Tweaks.

* WIP

* WIP

* WIP

* WIP

* WIP

* Cleanup

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* REmove setup menu item

* WIP

* .

* .

* .

* Cleaup.
2025-09-03 07:20:24 +00:00
Max Goedjen ddcb2a36ec Fixes to agent reloading. (#656) 2025-09-02 03:09:32 +00:00
Max Goedjen 6dc93806a8 Enable GitHub private security issue reporting and update policies (#653)
* Revise security vulnerability reporting process

Updated security reporting instructions in README.md.

* Change vulnerability reporting email to GitHub feature

Updated the vulnerability reporting method to use GitHub's private reporting feature.
2025-09-02 01:46:06 +00:00
Max Goedjen 99a6d48e53 Specify private key usage explicitly (#652) 2025-09-01 02:41:47 +00:00
Max Goedjen 935ac32ea2 Factor out comment writer. (#651) 2025-08-31 23:07:35 +00:00
Max Goedjen a0a632f245 Save nil if empty string. (#650) 2025-08-31 23:03:59 +00:00
Max Goedjen 51fed9e593 Fix potential timing bug (#649) 2025-08-31 14:22:08 -07:00
Max Goedjen f652d1d961 Return name as identities comment. (#647) 2025-08-31 20:50:16 +00:00
Max Goedjen 8aacd428b1 Fix deleting key attribution (#648) 2025-08-30 22:41:15 +00:00
40 changed files with 883 additions and 1066 deletions
+14 -7
View File
@@ -3,10 +3,16 @@ name: Nightly
on:
schedule:
- cron: "0 8 * * *"
workflow_dispatch:
jobs:
build:
# runs-on: macOS-latest
runs-on: macos-15
permissions:
id-token: write
contents: write
attestations: write
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
@@ -30,22 +36,23 @@ jobs:
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
- name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Create ZIPs
- name: Create ZIP
run: |
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip
- name: Notarize
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
- name: Attest
id: attest
uses: actions/attest-build-provenance@v2
with:
subject-path: 'Secretive.zip'
- name: Upload App to Artifacts
id: upload
uses: actions/upload-artifact@v4
with:
name: Secretive.zip
path: Secretive.zip
- name: Attest
id: attest
uses: actions/attest-build-provenance@v2
with:
subject-name: "Secretive.zip"
subject-digest: sha256:${{ steps.upload.outputs.artifact-digest }}
+9 -14
View File
@@ -56,39 +56,34 @@ jobs:
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
- name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Create ZIPs
- name: Create ZIP
run: |
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Xcode_Archive.zip
- name: Notarize
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
- name: Upload App to Artifacts
id: upload
uses: actions/upload-artifact@v4
with:
name: Secretive.zip
path: Secretive.zip
- name: Attest
id: attest
uses: actions/attest-build-provenance@v2
with:
subject-path: 'Secretive.zip, Xcode_Archive.zip'
subject-name: "Secretive.zip"
subject-digest: ${{ steps.upload.outputs.artifact-digest }}
- name: Create Release
run: |
sed -i.tmp "s/RUN_ID/$RUN_ID/g" .github/templates/release.md
sed -i.tmp "s/ATTESTATION_ID/$ATTESTATION_ID/g" .github/templates/release.md
gh release create $TAG_NAME -d -F .github/templates/release.md
gh release upload Secretive.zip
gh release upload Xcode_Archive.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref }}
RUN_ID: ${{ github.run_id }}
ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}
- name: Upload App to Artifacts
uses: actions/upload-artifact@v4
with:
name: Secretive.zip
path: Secretive.zip
- name: Upload Archive to Artifacts
uses: actions/upload-artifact@v4
with:
name: Xcode_Archive.zip
path: Xcode_Archive.zip
+1 -1
View File
@@ -57,7 +57,7 @@ let package = Package(
)
var localization: Resource {
.process("../../Localizable.xcstrings")
.process("../../Resources/Localizable.xcstrings")
}
var swiftSettings: [PackageDescription.SwiftSetting] {
+1 -1
View File
@@ -61,4 +61,4 @@ Because secrets in the Secure Enclave are not exportable, they are not able to b
## Security
If you discover any vulnerabilities in this project, please notify [max.goedjen@gmail.com](mailto:max.goedjen@gmail.com) with the subject containing "SECRETIVE SECURITY."
Secretive's security policy is detailed in [SECURITY.md](SECURITY.md). To report security issues, please use [GitHub's private reporting feature.](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability)
+1 -1
View File
@@ -24,4 +24,4 @@ The latest version on the [Releases page](https://github.com/maxgoedjen/secretiv
## Reporting a Vulnerability
If you discover any vulnerabilities in this project, please notify max.goedjen@gmail.com with the subject containing "SECRETIVE SECURITY."
To report security issues, please use [GitHub's private reporting feature.](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability)
+1 -1
View File
@@ -82,7 +82,7 @@ let package = Package(
)
var localization: Resource {
.process("../../Localizable.xcstrings")
.process("../../Resources/Localizable.xcstrings")
}
var swiftSettings: [PackageDescription.SwiftSetting] {
@@ -2983,73 +2983,73 @@
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"state" : "needs_review",
"value" : "Secretive suporta claus EC256, EC384, RSA1024 i RSA2048."
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"state" : "needs_review",
"value" : "Secretive unterstützt EC256, EC384, RSA1024 und RSA2048 Schlüssel."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Secretive supports EC256, EC384, RSA1024, and RSA2048 keys."
"value" : "Secretive supports EC256, EC384, and RSA2048 keys."
}
},
"fi" : {
"stringUnit" : {
"state" : "translated",
"state" : "needs_review",
"value" : "Secretive tukee EC256-, EC384-, RSA1024- ja RSA2048-avaimia."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"state" : "needs_review",
"value" : "Secretive prend en charge les clés EC256, EC384, RSA1024 et RSA2048."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"state" : "needs_review",
"value" : "Secretive supporta la cifratura EC256, EC384, RSA1024 e RSA2048."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"state" : "needs_review",
"value" : "SecretiveはEC256、EC384、RSA1024、またはRSA2048の鍵に対応しています。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"state" : "needs_review",
"value" : "Secretive는 EC256, EC384, RSA1024 및 RSA2048 키를 지원합니다."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"state" : "needs_review",
"value" : "Secretive wspiera klucze EC256, EC384, RSA1024 i RSA2048."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"state" : "needs_review",
"value" : "Secretive suporta chaves EC256, EC384, RSA1024 e RSA2048."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"state" : "needs_review",
"value" : "Secretive поддерживает ключи EC256, EC384, RSA1024, и RSA2048."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"state" : "needs_review",
"value" : "Secretive 支持 EC256, EC384, RSA1024, 和RSA2048."
}
}
@@ -3132,6 +3132,12 @@
}
}
},
"export SSH_AUTH_SOCK=%@" : {
"shouldTranslate" : false
},
"Host *\n\tIdentityAgent %@" : {
"shouldTranslate" : false
},
"integrations_add_this_title" : {
"extractionState" : "manual",
"localizations" : {
@@ -3176,6 +3182,50 @@
}
}
},
"integrations_configure_using_secret_empty_create" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "You'll need to create a Secret before configuring this action."
}
}
}
},
"integrations_configure_using_secret_header" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configure Using Secret"
}
}
}
},
"integrations_configure_using_secret_no_secret" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No Secret"
}
}
}
},
"integrations_configure_using_secret_secret_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Secret"
}
}
}
},
"integrations_getting_started_multiple_config" : {
"extractionState" : "manual",
"localizations" : {
@@ -3286,6 +3336,40 @@
}
}
},
"integrations_git_step_gitallowedsigners_description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "~/.gitallowedsigners probably does not exist. You'll need to create it."
}
}
}
},
"integrations_git_step_gitconfig_description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "[user]\n signingkey = %1$(publicKeyPathPlaceholder)@\n[commit]\n gpgsign = true\n[gpg]\n format = ssh\n[gpg \"ssh\"]\n allowedSignersFile = ~/.gitallowedsigners"
}
}
},
"shouldTranslate" : false
},
"integrations_menu_bar_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Integrations…"
}
}
}
},
"integrations_other_section_title" : {
"extractionState" : "manual",
"localizations" : {
@@ -3330,6 +3414,17 @@
}
}
},
"integrations_ssh_specific_key_note" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "You can tell SSH to use a specific key for a given host. See the web documentation for more details."
}
}
}
},
"integrations_system_section_title" : {
"extractionState" : "manual",
"localizations" : {
@@ -3341,6 +3436,65 @@
}
}
},
"integrations_tool_name_bash" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "bash"
}
}
},
"shouldTranslate" : false
},
"integrations_tool_name_fish" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "fish"
}
}
},
"shouldTranslate" : false
},
"integrations_tool_name_git_signing" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Git Signing"
}
}
}
},
"integrations_tool_name_ssh" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "SSH"
}
}
},
"shouldTranslate" : false
},
"integrations_tool_name_zsh" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "zsh"
}
}
},
"shouldTranslate" : false
},
"integrations_view_other_github_link" : {
"extractionState" : "manual",
"localizations" : {
@@ -3363,13 +3517,13 @@
}
}
},
"integrationsMenuBarTitle" : {
"integrationsGitStepGitconfigSectionNote" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Integrations…"
"value" : "If any section (like [user]) already exists, just add the entries in the existing section."
}
}
}
@@ -4269,15 +4423,8 @@
}
}
},
"Setup" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Setup"
}
}
}
"set -x SSH_AUTH_SOCK %@" : {
"shouldTranslate" : false
},
"setup_agent_activity_monitor_description" : {
"extractionState" : "manual",
@@ -4607,154 +4754,6 @@
}
}
},
"setup_ssh_add_for_me_button" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Afegeix-ho per mi"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Für Mich Einfügen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add it For Me"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ajoutez-le pour moi"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aggiungila per me"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "自動で追加する"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "나를 위해 추가해주세요"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dodaj za mnie"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Adicionar para mim"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Добавить для меня"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "为我添加"
}
}
}
},
"setup_ssh_add_to_config_button" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Afegeix a %1$(configPath)@"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "In %1$(configPath)@ einfügen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add to %1$(configPath)@"
}
},
"fi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add to %1$(configPath)@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ajouter à %1$(configPath)@"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aggiungi a %1$(configPath)@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "%1$(configPath)@に追加"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "%1$(configPath)@에 추가"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dodaj do %1$(configPath)@"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Adicionar para %1$(configPath)@"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Добавить к %1$(configPath)@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "添加到 %1$(configPath)@"
}
}
}
},
"setup_ssh_added_manually_button" : {
"extractionState" : "manual",
"localizations" : {
@@ -4826,290 +4825,6 @@
}
}
},
"setup_ssh_description" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Afegeix aquesta línia a la teua configuració del shell per que SSH es comunique amb Secretive quan vulga autenticar. Secretive pot fer aquest procediment automàticament, o pots copiar i pegar açò al teu fitxer de configuració."
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Füge diese Zeile in deine Shell-Konfiguration ein, damit SSH zur Authentifizierung mit dem Secret Agent kommuniziert. Secretive kann dies automatisch tun, oder du kopierst diese Zeile in deine Konfigurationsdatei."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add this line to your shell config telling SSH to talk to Secret Agent when it wants to authenticate. Secretive can either do this for you automatically, or you can copy and paste this into your config file."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ajoutez cette ligne à votre configuration shell pour indiquer à SSH de communiquer à Secret Agent quand il veut s'authentifier. Secretive peut le faire automatiquement pour vous, ou vous pouvez copier et coller cette ligne dans votre fichier de configuration."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aggiungi questa riga alla configurazione del Terminale per dire a SSH di parlare con Secret Agent quando vuole autenticarsi. Secretive può farlo automaticamente per te, oppure puoi copiare e incollare questa riga nel file di configurazione."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "以下の行をシェルの設定に追加してSSHが認証の際にSecretAgentを利用できるようにしてください。Secretiveが自動で追加するか、手動でコピーして設定に追加することもできます。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "SSH가 인증을 원할 때 Secret Agent와 통신하도록 지시하는 이 줄을 쉘 구성에 추가하세요. Secretive는 이 작업을 자동으로 수행하거나 사용자가 이를 복사하여 구성 파일에 붙여넣을 수 있습니다."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dodaj tą linijkę to pliku konfiguracyjnego SSH, aby nawiązać połączenie z Secret Agent kiedy potrzebna jest autoryzacja. Secretive może ustawić to automatycznie lub możesz to zrobić samodzielnie kopiując to do pliku konfiguracyjnego."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Adicione esta linha nas configurações do seu shell para dizer ao SSH para falar com o Secret Agent quando ele necessitar de autenticação. Secretive pode fazer isto para você automaticamente ou você pode copiar e colar isso no seu arquivo de configuração."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Добавьте эту строчку к вашему конфигу shell, так SSH будет использовать SecretAgent в процессе аутентификации. Secretive может сделать это за Вас, либо Вы можете это скопировать сами."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "将以下文本添加到您的SSH 配置中以使用Secret Agent. Secretive 无法自动帮您完成该过程,或者您可以选择拷贝并粘贴该文本到您的配置文件中"
}
}
}
},
"setup_ssh_title" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configura el teu agent SSH"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Konfiguriere deinen SSH Agent"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configure your SSH Agent"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configurer votre Agent SSH"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configura il tuo Agente SSH"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "SSHエージェントを設定"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "SSH Agent 설정"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Skonfiguruj twojego klienta SSH"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configurar seu agente SSH"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Настройте Ваш SSH Agent"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "设置您的SSH 代理"
}
}
}
},
"setup_step_complete_symbol" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓"
}
}
}
},
"setup_third_party_faq_link" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Si tractes de configurar una aplicació de tercers, comprova el FAQ."
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Schaue dir die FAQs an, um eine Drittanbieter-App einzurichten."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "If you're trying to set up a third party app, check out the FAQ."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Si vous essayez de configurer une application tierce, consultez la FAQ."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Se stai cercando di impostare un’app di terze parti, dai un'occhiata alla FAQ."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "その他のアプリから使う場合はよくある質問をご覧ください。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "타사 앱을 설정하려는 경우 FAQ를 확인하세요."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Jeżeli próbujesz ustawić aplikacje stron trzecich, sprawdź FAQ."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Se você estiver tentando configurar um aplicativo de terceiros, verifique o FAQ."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Если Вы пытаетесь настроить сторонее приложение, ознакомьтесь с FAQ."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "如果您想设置第三方APP,请阅读 FAQ。"
}
}
}
},
"setup_updates_description" : {
"extractionState" : "manual",
"localizations" : {
@@ -1 +0,0 @@
@@ -89,9 +89,8 @@ extension Agent {
for secret in secrets {
let keyBlob = publicKeyWriter.data(secret: secret)
let curveData = publicKeyWriter.openSSHIdentifier(for: secret.keyType)
keyData.append(keyBlob.lengthAndData)
keyData.append(curveData.lengthAndData)
keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData)
count += 1
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
@@ -78,7 +78,6 @@ extension SocketController {
provenance = SigningRequestTracer().provenance(from: fileHandle)
(messages, messagesContinuation) = AsyncStream.makeStream()
Task { [messagesContinuation, logger] in
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
for await _ in NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle) {
let data = fileHandle.availableData
guard !data.isEmpty else {
@@ -91,6 +90,9 @@ extension SocketController {
logger.debug("Socket controller yielded data.")
}
}
Task {
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
}
}
/// Writes new data to the socket.
@@ -31,18 +31,7 @@ public struct OpenSSHPublicKeyWriter: Sendable {
/// Generates an OpenSSH string representation of the secret.
/// - Returns: OpenSSH string representation of the secret.
public func openSSHString<SecretType: Secret>(secret: SecretType) -> String {
let resolvedComment: String
if let comment = secret.publicKeyAttribution {
resolvedComment = comment
} else {
let dashedKeyName = secret.name.replacingOccurrences(of: " ", with: "-")
let dashedHostName = ["secretive", Host.current().localizedName, "local"]
.compactMap { $0 }
.joined(separator: ".")
.replacingOccurrences(of: " ", with: "-")
resolvedComment = "\(dashedKeyName)@\(dashedHostName)"
}
return [openSSHIdentifier(for: secret.keyType), data(secret: secret).base64EncodedString(), resolvedComment]
return [openSSHIdentifier(for: secret.keyType), data(secret: secret).base64EncodedString(), comment(secret: secret)]
.compactMap { $0 }
.joined(separator: " ")
}
@@ -65,6 +54,19 @@ public struct OpenSSHPublicKeyWriter: Sendable {
.joined(separator: ":")
}
public func comment<SecretType: Secret>(secret: SecretType) -> String {
if let comment = secret.publicKeyAttribution {
return comment
} else {
let dashedKeyName = secret.name.replacingOccurrences(of: " ", with: "-")
let dashedHostName = ["secretive", Host.current().localizedName, "local"]
.compactMap { $0 }
.joined(separator: ".")
.replacingOccurrences(of: " ", with: "-")
return "\(dashedKeyName)@\(dashedHostName)"
}
}
}
extension OpenSSHPublicKeyWriter {
@@ -26,7 +26,8 @@ public final class PublicKeyFileStoreController: Sendable {
let untracked = Set(fullPathContents)
.subtracting(validPaths)
for path in untracked {
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
// string instead of fileURLWithPath since we're already using fileURL format.
try? FileManager.default.removeItem(at: URL(string: path)!)
}
}
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil)
@@ -30,7 +30,7 @@ extension SecureEnclave {
SecItemCopyMatching(privateAttributes, &privateUntyped)
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
let migratedPublicKeys = Set(store.secrets.map(\.publicKey))
var migrated = false
var migratedAny = false
for key in privateTyped {
let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let id = key[kSecAttrApplicationLabel] as! Data
@@ -45,20 +45,24 @@ extension SecureEnclave {
// Best guess.
let auth: AuthenticationRequirement = String(describing: accessControl)
.contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown
let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID)
let secret = Secret(id: UUID().uuidString, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth))
guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else {
logger.log("Skipping \(name), public key already present. Marking as migrated.")
do {
let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID)
let secret = Secret(id: UUID().uuidString, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth))
guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else {
logger.log("Skipping \(name), public key already present. Marking as migrated.")
try markMigrated(secret: secret, oldID: id)
continue
}
logger.log("Migrating \(name).")
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
logger.log("Migrated \(name).")
try markMigrated(secret: secret, oldID: id)
continue
migratedAny = true
} catch {
logger.error("Failed to migrate \(name): \(error).")
}
logger.log("Migrating \(name).")
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
logger.log("Migrated \(name).")
try markMigrated(secret: secret, oldID: id)
migrated = true
}
if migrated {
if migratedAny {
store.reloadSecrets()
}
}
@@ -26,7 +26,7 @@ extension SecureEnclave {
for await note in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
guard Constants.notificationToken != (note.object as? String) else {
// Don't reload if we're the ones triggering this by reloading.
return
continue
}
reloadSecrets()
}
@@ -112,7 +112,7 @@ extension SecureEnclave {
var accessError: SecurityError?
let flags: SecAccessControlCreateFlags = switch attributes.authentication {
case .notRequired:
[]
[.privateKeyUsage]
case .presenceRequired:
[.userPresence, .privateKeyUsage]
case .biometryCurrent:
+67 -19
View File
@@ -26,6 +26,10 @@
50153E20250AFCB200525160 /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50153E1F250AFCB200525160 /* UpdateView.swift */; };
50153E22250DECA300525160 /* SecretListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50153E21250DECA300525160 /* SecretListItemView.swift */; };
5018F54F24064786002EB505 /* Notifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018F54E24064786002EB505 /* Notifier.swift */; };
504788EC2E680DC800B4556F /* URLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788EB2E680DC400B4556F /* URLs.swift */; };
504788F22E681F3A00B4556F /* Instructions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788F12E681F3A00B4556F /* Instructions.swift */; };
504788F42E681F6900B4556F /* ToolConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788F32E681F6900B4556F /* ToolConfigurationView.swift */; };
504788F62E68206F00B4556F /* GettingStartedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788F52E68206F00B4556F /* GettingStartedView.swift */; };
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50571E0224393C2600F76F6C /* JustUpdatedChecker.swift */; };
50571E0524393D1500F76F6C /* LaunchAgentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50571E0424393D1500F76F6C /* LaunchAgentController.swift */; };
50617D8323FCE48E0099B055 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D8223FCE48E0099B055 /* App.swift */; };
@@ -106,10 +110,14 @@
50020BAF24064869003D4025 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
50033AC227813F1700253856 /* BundleIDs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIDs.swift; sourceTree = "<group>"; };
5003EF39278005C800DF2006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Packages; sourceTree = "<group>"; };
5008C23D2E525D8200507AC2 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = Localizable.xcstrings; path = Packages/Localizable.xcstrings; sourceTree = SOURCE_ROOT; };
5008C23D2E525D8200507AC2 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = Localizable.xcstrings; path = Packages/Resources/Localizable.xcstrings; sourceTree = SOURCE_ROOT; };
50153E1F250AFCB200525160 /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = "<group>"; };
50153E21250DECA300525160 /* SecretListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretListItemView.swift; sourceTree = "<group>"; };
5018F54E24064786002EB505 /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = "<group>"; };
504788EB2E680DC400B4556F /* URLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLs.swift; sourceTree = "<group>"; };
504788F12E681F3A00B4556F /* Instructions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instructions.swift; sourceTree = "<group>"; };
504788F32E681F6900B4556F /* ToolConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolConfigurationView.swift; sourceTree = "<group>"; };
504788F52E68206F00B4556F /* GettingStartedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GettingStartedView.swift; sourceTree = "<group>"; };
50571E0224393C2600F76F6C /* JustUpdatedChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JustUpdatedChecker.swift; sourceTree = "<group>"; };
50571E0424393D1500F76F6C /* LaunchAgentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAgentController.swift; sourceTree = "<group>"; };
50617D7F23FCE48E0099B055 /* Secretive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Secretive.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -185,6 +193,55 @@
path = Helpers;
sourceTree = "<group>";
};
504788ED2E681EB200B4556F /* Styles */ = {
isa = PBXGroup;
children = (
50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */,
50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */,
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
);
path = Styles;
sourceTree = "<group>";
};
504788EE2E681EC300B4556F /* Secrets */ = {
isa = PBXGroup;
children = (
5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */,
50B8550C24138C4F009958AC /* DeleteSecretView.swift */,
2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */,
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */,
506772C82425BB8500034DED /* NoStoresView.swift */,
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
50153E21250DECA300525160 /* SecretListItemView.swift */,
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
);
path = Secrets;
sourceTree = "<group>";
};
504788EF2E681ED700B4556F /* Configuration */ = {
isa = PBXGroup;
children = (
50BDCB752E6450950072D2E7 /* ConfigurationItemView.swift */,
50AE96FF2E5C1A420018C710 /* IntegrationsView.swift */,
504788F12E681F3A00B4556F /* Instructions.swift */,
504788F32E681F6900B4556F /* ToolConfigurationView.swift */,
5066A6C12516F303004B5A36 /* SetupView.swift */,
504788F52E68206F00B4556F /* GettingStartedView.swift */,
);
path = Configuration;
sourceTree = "<group>";
};
504788F02E681F0100B4556F /* Views */ = {
isa = PBXGroup;
children = (
50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */,
50617D8423FCE48E0099B055 /* ContentView.swift */,
5066A6C72516FE6E004B5A36 /* CopyableView.swift */,
50153E1F250AFCB200525160 /* UpdateView.swift */,
);
path = Views;
sourceTree = "<group>";
};
50617D7623FCE48D0099B055 = {
isa = PBXGroup;
children = (
@@ -247,24 +304,10 @@
508A58B0241ED1C40069DC07 /* Views */ = {
isa = PBXGroup;
children = (
50617D8423FCE48E0099B055 /* ContentView.swift */,
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */,
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
50153E21250DECA300525160 /* SecretListItemView.swift */,
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */,
50B8550C24138C4F009958AC /* DeleteSecretView.swift */,
2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */,
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */,
506772C82425BB8500034DED /* NoStoresView.swift */,
50153E1F250AFCB200525160 /* UpdateView.swift */,
5066A6C12516F303004B5A36 /* SetupView.swift */,
50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */,
50AE96FF2E5C1A420018C710 /* IntegrationsView.swift */,
5066A6C72516FE6E004B5A36 /* CopyableView.swift */,
50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */,
50BDCB752E6450950072D2E7 /* ConfigurationItemView.swift */,
504788EF2E681ED700B4556F /* Configuration */,
504788EE2E681EC300B4556F /* Secrets */,
504788ED2E681EB200B4556F /* Styles */,
504788F02E681F0100B4556F /* Views */,
);
path = Views;
sourceTree = "<group>";
@@ -272,6 +315,7 @@
508A58B1241ED1EA0069DC07 /* Controllers */ = {
isa = PBXGroup;
children = (
504788EB2E680DC400B4556F /* URLs.swift */,
508A58B2241ED2180069DC07 /* AgentStatusChecker.swift */,
5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */,
50571E0224393C2600F76F6C /* JustUpdatedChecker.swift */,
@@ -442,12 +486,15 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504788F22E681F3A00B4556F /* Instructions.swift in Sources */,
50BDCB742E6436CA0072D2E7 /* ErrorStyle.swift in Sources */,
2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */,
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
504788EC2E680DC800B4556F /* URLs.swift in Sources */,
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
504788F62E68206F00B4556F /* GettingStartedView.swift in Sources */,
50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */,
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
@@ -465,6 +512,7 @@
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */,
50BDCB762E6450950072D2E7 /* ConfigurationItemView.swift in Sources */,
50617D8323FCE48E0099B055 /* App.swift in Sources */,
504788F42E681F6900B4556F /* ToolConfigurationView.swift in Sources */,
506772C92425BB8500034DED /* NoStoresView.swift in Sources */,
50153E22250DECA300525160 /* SecretListItemView.swift in Sources */,
508A58B5241ED48F0069DC07 /* PreviewAgentStatusChecker.swift in Sources */,
-5
View File
@@ -80,11 +80,6 @@ struct Secretive: App {
NSWorkspace.shared.open(Constants.helpURL)
}
}
CommandGroup(after: .help) {
Button("Setup") {
showingSetup = true
}
}
SidebarCommands()
}
}
+12
View File
@@ -0,0 +1,12 @@
import Foundation
extension URL {
static var agentHomeURL: URL {
URL(fileURLWithPath: URL.homeDirectory.path().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID))
}
static var socketPath: String {
URL.agentHomeURL.appendingPathComponent("socket.ssh").path()
}
}
@@ -32,7 +32,7 @@ struct ConfigurationItemView<Content: View>: View {
Spacer()
switch action {
case .copy(let string):
Button(.copyButton, systemImage: "document.on.document") {
Button(.copyableClickToCopyButton, systemImage: "document.on.document") {
NSPasteboard.general.declareTypes([.string], owner: nil)
NSPasteboard.general.setString(string, forType: .string)
}
@@ -0,0 +1,49 @@
import SwiftUI
struct GettingStartedView: View {
private let instructions = Instructions()
@Binding var selectedInstruction: ConfigurationFileInstructions?
init(selectedInstruction: Binding<ConfigurationFileInstructions?>) {
_selectedInstruction = selectedInstruction
}
var body: some View {
Form {
Section(.integrationsGettingStartedTitle) {
Text(.integrationsGettingStartedTitleDescription)
}
Section {
Group {
Text(.integrationsGettingStartedSuggestionSsh)
.onTapGesture {
self.selectedInstruction = instructions.ssh
}
VStack(alignment: .leading, spacing: 5) {
Text(.integrationsGettingStartedSuggestionShell)
Text(.integrationsGettingStartedSuggestionShellDefault(shellName: String(localized: instructions.defaultShell.tool)))
.font(.caption2)
}
.onTapGesture {
self.selectedInstruction = instructions.defaultShell
}
Text(.integrationsGettingStartedSuggestionGit)
.onTapGesture {
self.selectedInstruction = instructions.git
}
}
.foregroundStyle(.link)
} header: {
Text(.integrationsGettingStartedWhatShouldIConfigureTitle)
}
footer: {
Text(.integrationsGettingStartedMultipleConfig)
}
}
.formStyle(.grouped)
}
}
@@ -0,0 +1,179 @@
import Foundation
struct Instructions {
enum Constants {
static let publicKeyPathPlaceholder = "_PUBLIC_KEY_PATH_PLACEHOLDER_"
static let publicKeyPlaceholder = "_PUBLIC_KEY_PLACEHOLDER_"
}
var defaultShell: ConfigurationFileInstructions {
zsh
}
var gettingStarted: ConfigurationFileInstructions = ConfigurationFileInstructions(.integrationsGettingStartedRowTitle, id: .gettingStarted)
var ssh: ConfigurationFileInstructions {
ConfigurationFileInstructions(
tool: LocalizedStringResource.integrationsToolNameSsh,
configPath: "~/.ssh/config",
configText: "Host *\n\tIdentityAgent \(URL.socketPath)",
website: URL(string: "https://man.openbsd.org/ssh_config.5")!,
note: .integrationsSshSpecificKeyNote,
)
}
var git: ConfigurationFileInstructions {
ConfigurationFileInstructions(
tool: .integrationsToolNameGitSigning,
steps: [
.init(path: "~/.gitconfig", steps: [
.integrationsGitStepGitconfigDescription(publicKeyPathPlaceholder: Constants.publicKeyPathPlaceholder)
],
note: .integrationsGitStepGitconfigSectionNote
),
.init(
path: "~/.gitallowedsigners",
steps: [
LocalizedStringResource(stringLiteral: Constants.publicKeyPlaceholder)
],
note: .integrationsGitStepGitallowedsignersDescription
),
],
website: URL(string: "https://git-scm.com/docs/git-config")!,
)
}
var zsh: ConfigurationFileInstructions {
ConfigurationFileInstructions(
tool: .integrationsToolNameZsh,
configPath: "~/.zshrc",
configText: "export SSH_AUTH_SOCK=\(URL.socketPath)"
)
}
var instructions: [ConfigurationGroup] {
[
ConfigurationGroup(name: .integrationsGettingStartedSectionTitle, instructions: [
gettingStarted
]),
ConfigurationGroup(
name: .integrationsSystemSectionTitle,
instructions: [
ssh,
git,
]
),
ConfigurationGroup(name: .integrationsShellSectionTitle, instructions: [
zsh,
ConfigurationFileInstructions(
tool: .integrationsToolNameBash,
configPath: "~/.bashrc",
configText: "export SSH_AUTH_SOCK=\(URL.socketPath)"
),
ConfigurationFileInstructions(
tool: .integrationsToolNameFish,
configPath: "~/.config/fish/config.fish",
configText: "set -x SSH_AUTH_SOCK \(URL.socketPath)"
),
ConfigurationFileInstructions(.integrationsOtherShellRowTitle, id: .otherShell),
]),
ConfigurationGroup(name: .integrationsOtherSectionTitle, instructions: [
ConfigurationFileInstructions(.integrationsAppsRowTitle, id: .otherApp),
]),
]
}
}
struct ConfigurationGroup: Identifiable {
let id = UUID()
var name: LocalizedStringResource
var instructions: [ConfigurationFileInstructions] = []
}
struct ConfigurationFileInstructions: Hashable, Identifiable {
struct StepGroup: Hashable, Identifiable {
let path: String
let steps: [LocalizedStringResource]
let note: LocalizedStringResource?
var id: String { path }
init(path: String, steps: [LocalizedStringResource], note: LocalizedStringResource? = nil) {
self.path = path
self.steps = steps
self.note = note
}
func hash(into hasher: inout Hasher) {
id.hash(into: &hasher)
}
}
var id: ID
var tool: LocalizedStringResource
var steps: [StepGroup]
var requiresSecret: Bool
var website: URL?
init(
tool: LocalizedStringResource,
configPath: String,
configText: LocalizedStringResource,
requiresSecret: Bool = false,
website: URL? = nil,
note: LocalizedStringResource? = nil
) {
self.id = .tool(String(localized: tool))
self.tool = tool
self.steps = [StepGroup(path: configPath, steps: [configText], note: note)]
self.requiresSecret = requiresSecret
self.website = website
}
init(
tool: LocalizedStringResource,
steps: [StepGroup],
requiresSecret: Bool = false,
website: URL? = nil
) {
self.id = .tool(String(localized: tool))
self.tool = tool
self.steps = steps
self.requiresSecret = true
self.website = website
}
init(_ name: LocalizedStringResource, id: ID) {
self.id = id
tool = name
steps = []
requiresSecret = false
}
func hash(into hasher: inout Hasher) {
id.hash(into: &hasher)
}
enum ID: Identifiable, Hashable {
case gettingStarted
case tool(String)
case otherShell
case otherApp
var id: String {
switch self {
case .gettingStarted:
"getting_started"
case .tool(let name):
name
case .otherShell:
"other_shell"
case .otherApp:
"other_app"
}
}
}
}
@@ -0,0 +1,115 @@
import SwiftUI
struct IntegrationsView: View {
@Environment(\.dismiss) private var dismiss
@State private var selectedInstruction: ConfigurationFileInstructions?
private let instructions = Instructions()
var body: some View {
NavigationSplitView {
List(selection: $selectedInstruction) {
ForEach(instructions.instructions) { group in
Section(group.name) {
ForEach(group.instructions) { instruction in
Text(instruction.tool)
.padding(.vertical, 8)
.tag(instruction)
}
}
}
}
} detail: {
IntegrationsDetailView(selectedInstruction: $selectedInstruction)
.fauxToolbar {
Button(.setupDoneButton) {
dismiss()
}
.normalButton()
}
}
.onAppear {
selectedInstruction = instructions.gettingStarted
}
.frame(minHeight: 500)
}
}
extension View {
func fauxToolbar<Content: View>(content: () -> Content) -> some View {
modifier(FauxToolbarModifier(toolbarContent: content()))
}
}
struct FauxToolbarModifier<ToolbarContent: View>: ViewModifier {
var toolbarContent: ToolbarContent
func body(content: Content) -> some View {
VStack(alignment: .leading, spacing: 0) {
content
Divider()
HStack {
Spacer()
toolbarContent
.padding(.top, 8)
.padding(.trailing, 16)
.padding(.bottom, 16)
}
}
}
}
struct IntegrationsDetailView: View {
@Binding private var selectedInstruction: ConfigurationFileInstructions?
init(selectedInstruction: Binding<ConfigurationFileInstructions?>) {
_selectedInstruction = selectedInstruction
}
var body: some View {
if let selectedInstruction {
switch selectedInstruction.id {
case .gettingStarted:
GettingStartedView(selectedInstruction: $selectedInstruction)
case .tool:
ToolConfigurationView(selectedInstruction: selectedInstruction)
case .otherShell:
Form {
Section {
Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/shells")!)
} header: {
Text(.integrationsCommunityShellListDescription)
.font(.body)
}
}
.formStyle(.grouped)
case .otherApp:
Form {
Section {
Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/apps")!)
} header: {
Text(.integrationsCommunityAppsListDescription)
.font(.body)
}
}
.formStyle(.grouped)
}
}
}
}
#Preview {
IntegrationsView()
.frame(height: 500)
}
@@ -67,7 +67,7 @@ struct SetupView: View {
buttonWidth = width
}
.background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
.frame(minWidth: 700, maxWidth: .infinity)
.frame(minWidth: 600, maxWidth: .infinity)
HStack {
Spacer()
Button(.setupDoneButton) {
@@ -154,17 +154,19 @@ struct StepView<Content: View>: View {
}
var body: some View {
HStack(spacing: 20) {
HStack(spacing: 0) {
icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24)
VStack(alignment: .leading, spacing: 6) {
Spacer()
.frame(width: 20)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.bold()
Text(description)
}
Spacer()
Spacer(minLength: 20)
actions
}
.padding(20)
@@ -0,0 +1,110 @@
import SwiftUI
import SecretKit
struct ToolConfigurationView: View {
private let instructions = Instructions()
let selectedInstruction: ConfigurationFileInstructions
@Environment(\.secretStoreList) private var secretStoreList
@State var creating = false
@State var selectedSecret: AnySecret?
init(selectedInstruction: ConfigurationFileInstructions) {
self.selectedInstruction = selectedInstruction
}
var body: some View {
Form {
if selectedInstruction.requiresSecret {
if secretStoreList.allSecrets.isEmpty {
Section {
Text(.integrationsConfigureUsingSecretEmptyCreate)
if let store = secretStoreList.modifiableStore {
HStack {
Spacer()
Button(.createSecretTitle) {
creating = true
}
.sheet(isPresented: $creating) {
CreateSecretView(store: store) { created in
selectedSecret = created
}
}
}
}
}
} else {
Section {
Picker(.integrationsConfigureUsingSecretSecretTitle, selection: $selectedSecret) {
if selectedSecret == nil {
Text(.integrationsConfigureUsingSecretNoSecret)
.tag(nil as (AnySecret?))
}
ForEach(secretStoreList.allSecrets) { secret in
Text(secret.name)
.tag(secret)
}
}
} header: {
Text(.integrationsConfigureUsingSecretHeader)
}
.onAppear {
selectedSecret = secretStoreList.allSecrets.first
}
}
}
ForEach(selectedInstruction.steps) { stepGroup in
Section {
ConfigurationItemView(title: .integrationsPathTitle, value: stepGroup.path, action: .revealInFinder(stepGroup.path))
ForEach(stepGroup.steps, id: \.self.key) { step in
ConfigurationItemView(title: .integrationsAddThisTitle, action: .copy(String(localized: step))) {
HStack {
Text(placeholdersReplaced(text: String(localized: step)))
.padding(8)
.font(.system(.subheadline, design: .monospaced))
Spacer()
}
.frame(maxWidth: .infinity)
.background {
RoundedRectangle(cornerRadius: 6)
.fill(.black.opacity(0.05))
.stroke(.separator, lineWidth: 1)
}
}
}
} footer: {
if let note = stepGroup.note {
Text(note)
.font(.caption)
}
}
}
if let url = selectedInstruction.website {
Section {
Link(destination: url) {
VStack(alignment: .leading, spacing: 5) {
Text(.integrationsWebLink)
.font(.headline)
Text(url.absoluteString)
.font(.caption2)
}
}
}
}
}
.formStyle(.grouped)
}
func placeholdersReplaced(text: String) -> String {
guard let selectedSecret else { return text }
let writer = OpenSSHPublicKeyWriter()
let fileController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL)
return text
.replacingOccurrences(of: Instructions.Constants.publicKeyPlaceholder, with: writer.openSSHString(secret: selectedSecret))
.replacingOccurrences(of: Instructions.Constants.publicKeyPathPlaceholder, with: fileController.publicKeyPath(for: selectedSecret))
}
}
@@ -1,350 +0,0 @@
import SwiftUI
struct IntegrationsView: View {
@Environment(\.dismiss) private var dismiss
@State private var selectedInstruction: ConfigurationFileInstructions?
private let instructions = Instructions()
var body: some View {
NavigationSplitView {
List(selection: $selectedInstruction) {
ForEach(instructions.instructions) { group in
Section(group.name) {
ForEach(group.instructions) { instruction in
Text(instruction.tool)
.padding(.vertical, 8)
.tag(instruction)
}
}
}
}
} detail: {
IntegrationsDetailView(selectedInstruction: $selectedInstruction)
.fauxToolbar {
Button(.setupDoneButton) {
dismiss()
}
.normalButton()
}
}
.onAppear {
selectedInstruction = instructions.gettingStarted
}
.frame(minHeight: 500)
}
}
extension View {
func fauxToolbar<Content: View>(content: () -> Content) -> some View {
modifier(FauxToolbarModifier(toolbarContent: content()))
}
}
struct FauxToolbarModifier<ToolbarContent: View>: ViewModifier {
var toolbarContent: ToolbarContent
func body(content: Content) -> some View {
VStack(alignment: .leading) {
content
Divider()
HStack {
Spacer()
toolbarContent
.padding(.top, 8)
.padding(.trailing, 16)
.padding(.bottom, 16)
}
}
}
}
struct IntegrationsDetailView: View {
@Binding private var selectedInstruction: ConfigurationFileInstructions?
private let instructions = Instructions()
init(selectedInstruction: Binding<ConfigurationFileInstructions?>) {
_selectedInstruction = selectedInstruction
}
var body: some View {
if let selectedInstruction {
switch selectedInstruction.id {
case .gettingStarted:
Form {
Section(.integrationsGettingStartedTitle) {
Text(.integrationsGettingStartedTitleDescription)
}
Section {
Group {
Text(.integrationsGettingStartedSuggestionSsh)
.onTapGesture {
self.selectedInstruction = instructions.ssh
}
VStack(alignment: .leading, spacing: 5) {
Text(.integrationsGettingStartedSuggestionShell)
Text(.integrationsGettingStartedSuggestionShellDefault(shellName: instructions.defaultShell.tool))
.font(.caption2)
}
.onTapGesture {
self.selectedInstruction = instructions.defaultShell
}
Text(.integrationsGettingStartedSuggestionGit)
.onTapGesture {
self.selectedInstruction = instructions.git
}
}
.foregroundStyle(.link)
} header: {
Text(.integrationsGettingStartedWhatShouldIConfigureTitle)
}
footer: {
Text(.integrationsGettingStartedMultipleConfig)
}
}
.formStyle(.grouped)
case .tool:
Form {
ForEach(selectedInstruction.steps) { stepGroup in
Section {
ConfigurationItemView(title: .integrationsPathTitle, value: stepGroup.path, action: .revealInFinder(stepGroup.path))
ForEach(stepGroup.steps, id: \.self) { step in
ConfigurationItemView(title: .integrationsAddThisTitle, action: .copy(step)) {
HStack {
Text(step)
.padding(8)
.font(.system(.subheadline, design: .monospaced))
Spacer()
}
.frame(maxWidth: .infinity)
.background {
RoundedRectangle(cornerRadius: 6)
.fill(.black.opacity(0.05))
.stroke(.separator, lineWidth: 1)
}
}
}
} footer: {
if let note = stepGroup.note {
Text(note)
.font(.caption)
}
}
}
if let url = selectedInstruction.website {
Section {
Link(destination: url) {
VStack(alignment: .leading, spacing: 5) {
Text(.integrationsWebLink)
.font(.headline)
Text(url.absoluteString)
.font(.caption2)
}
}
}
}
}
.formStyle(.grouped)
case .otherShell:
Form {
Section {
Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/shells")!)
} header: {
Text(.integrationsCommunityShellListDescription)
.font(.body)
}
}
.formStyle(.grouped)
case .otherApp:
Form {
Section {
Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/apps")!)
} header: {
Text(.integrationsCommunityAppsListDescription)
.font(.body)
}
}
.formStyle(.grouped)
}
}
}
}
private struct Instructions {
private let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String
var defaultShell: ConfigurationFileInstructions {
zsh
}
var gettingStarted: ConfigurationFileInstructions = ConfigurationFileInstructions(.integrationsGettingStartedRowTitle, id: .gettingStarted)
var ssh: ConfigurationFileInstructions {
ConfigurationFileInstructions(
tool: "SSH",
configPath: "~/.ssh/config",
configText: "Host *\n\tIdentityAgent \(socketPath)",
website: URL(string: "https://man.openbsd.org/ssh_config.5")!,
note: "You can tell SSH to use a specific key for a given host. See the web documentation for more details.",
)
}
var git: ConfigurationFileInstructions {
ConfigurationFileInstructions(
tool: "Git Signing",
steps: [
.init(path: "~/.gitconfig", steps: [
"""
[user]
signingkey = YOUR_PUBLIC_KEY_PATH
[commit]
gpgsign = true
[gpg]
format = ssh
[gpg "ssh"]
allowedSignersFile = ~/.gitallowedsigners
"""
],
note: "If any section (like [user]) already exists, just add the entries in the existing section."
),
.init(
path: "~/.gitallowedsigners",
steps: [
"YOUR_PUBLIC_KEY"
],
note: "~/.gitallowedsigners probably does not exist. You'll need to create it."
),
],
website: URL(string: "https://git-scm.com/docs/git-config")!,
)
}
var zsh: ConfigurationFileInstructions {
ConfigurationFileInstructions(
tool: "zsh",
configPath: "~/.zshrc",
configText: "export SSH_AUTH_SOCK=\(socketPath)"
)
}
var instructions: [ConfigurationGroup] {
[
ConfigurationGroup(name: .integrationsGettingStartedSectionTitle, instructions: [
gettingStarted
]),
ConfigurationGroup(
name: .integrationsSystemSectionTitle,
instructions: [
ssh,
git,
]
),
ConfigurationGroup(name: .integrationsShellSectionTitle, instructions: [
zsh,
ConfigurationFileInstructions(
tool: "bash",
configPath: "~/.bashrc",
configText: "export SSH_AUTH_SOCK=\(socketPath)"
),
ConfigurationFileInstructions(
tool: "fish",
configPath: "~/.config/fish/config.fish",
configText: "set -x SSH_AUTH_SOCK \(socketPath)"
),
ConfigurationFileInstructions(.integrationsOtherShellRowTitle, id: .otherShell),
]),
ConfigurationGroup(name: .integrationsOtherSectionTitle, instructions: [
ConfigurationFileInstructions(.integrationsAppsRowTitle, id: .otherApp),
]),
]
}
}
struct ConfigurationGroup: Identifiable {
let id = UUID()
var name: LocalizedStringResource
var instructions: [ConfigurationFileInstructions] = []
}
struct ConfigurationFileInstructions: Hashable, Identifiable {
struct StepGroup: Hashable, Identifiable {
let path: String
let steps: [String]
let note: String?
var id: String { path }
init(path: String, steps: [String], note: String? = nil) {
self.path = path
self.steps = steps
self.note = note
}
}
var id: ID
var tool: String
var steps: [StepGroup]
var website: URL?
init(tool: String, configPath: String, configText: String, website: URL? = nil, note: String? = nil) {
self.id = .tool(tool)
self.tool = tool
self.steps = [StepGroup(path: configPath, steps: [configText], note: note)]
self.website = website
}
init(tool: String, steps: [StepGroup], website: URL? = nil) {
self.id = .tool(tool)
self.tool = tool
self.steps = steps
self.website = website
}
init(_ name: LocalizedStringResource, id: ID) {
self.id = id
tool = String(localized: name)
self.steps = []
}
enum ID: Identifiable, Hashable {
case gettingStarted
case tool(String)
case otherShell
case otherApp
var id: String {
switch self {
case .gettingStarted:
"getting_started"
case .tool(let name):
name
case .otherShell:
"other_shell"
case .otherApp:
"other_app"
}
}
}
}
#Preview {
IntegrationsView()
.frame(height: 500)
}
@@ -146,6 +146,6 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
}
#Preview {
CreateSecretView(store: Preview.StoreModifiable()) { _ in }
}
//#Preview {
// CreateSecretView(store: Preview.StoreModifiable()) { _ in }
//}
@@ -54,9 +54,7 @@ struct EditSecretView<StoreType: SecretStoreModifiable>: View {
func rename() {
var attributes = secret.attributes
if !publicKeyAttribution.isEmpty {
attributes.publicKeyAttribution = publicKeyAttribution
}
attributes.publicKeyAttribution = publicKeyAttribution.isEmpty ? nil : publicKeyAttribution
Task {
do {
try await store.update(secret: secret, name: name, attributes: attributes)
@@ -57,15 +57,10 @@ struct EmptyStoreModifiableView: View {
}
}
#if DEBUG
struct EmptyStoreModifiableView_Previews: PreviewProvider {
static var previews: some View {
Group {
EmptyStoreImmutableView()
EmptyStoreModifiableView()
}
}
#Preview {
EmptyStoreImmutableView()
}
#Preview {
EmptyStoreModifiableView()
}
#endif
@@ -13,12 +13,7 @@ struct NoStoresView: View {
}
#if DEBUG
struct NoStoresView_Previews: PreviewProvider {
static var previews: some View {
NoStoresView()
}
#Preview {
NoStoresView()
}
#endif
@@ -37,14 +37,6 @@ struct SecretDetailView<SecretType: Secret>: View {
}
extension URL {
static var agentHomeURL: URL {
URL(fileURLWithPath: URL.homeDirectory.path().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID))
}
}
#Preview {
SecretDetailView(secret: Preview.Secret(name: "Demonstration Secret"))
}
//#Preview {
// SecretDetailView(secret: Preview.Secret(name: "Demonstration Secret"))
//}
-96
View File
@@ -1,96 +0,0 @@
import SwiftUI
import Brief
struct UpdateDetailView: View {
@Environment(\.updater) var updater: any UpdaterProtocol
let update: Release
var body: some View {
VStack {
Text(.updateVersionName(updateName: update.name)).font(.title)
GroupBox(label: Text(.updateReleaseNotesTitle)) {
ScrollView {
Text(attributedBody)
}
}
HStack {
if !update.critical {
Button(.updateIgnoreButton) {
Task {
await updater.ignore(release: update)
}
}
Spacer()
}
Button(.updateUpdateButton) {
NSWorkspace.shared.open(update.html_url)
}
.keyboardShortcut(.defaultAction)
}
}
.padding()
.frame(maxWidth: 500)
}
var attributedBody: AttributedString {
do {
var text = try AttributedString(
markdown: update.body,
options: .init(
allowsExtendedAttributes: true,
interpretedSyntax: .full,
),
baseURL: URL(string: "https://github.com/maxgoedjen/secretive")!
)
.transformingAttributes(AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self) { key in
let font: Font? = switch key.value?.components.first?.kind {
case .header(level: 1):
Font.title
case .header(level: 2):
Font.title2
case .header(level: 3):
Font.title3
default:
nil
}
if let font {
key.replace(with: AttributeScopes.SwiftUIAttributes.FontAttribute.self, value: font)
}
}
let lineBreak = AttributedString("\n\n")
for run in text.runs.reversed() {
text.insert(lineBreak, at: run.range.lowerBound)
}
return text
} catch {
var text = AttributedString()
for line in update.body.split(whereSeparator: \.isNewline) {
let attributed: AttributedString
let split = line.split(separator: " ")
let unprefixed = split.dropFirst().joined(separator: " ")
if let prefix = split.first {
var container = AttributeContainer()
switch prefix {
case "#":
container.font = .title
case "##":
container.font = .title2
case "###":
container.font = .title3
default:
continue
}
attributed = AttributedString(unprefixed, attributes: container)
} else {
attributed = AttributedString(line + "\n\n")
}
text = text + attributed
}
return text
}
}
}
@@ -15,7 +15,6 @@ struct AgentStatusView: View {
struct AgentRunningView: View {
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
private let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String
var body: some View {
Form {
@@ -28,8 +27,8 @@ struct AgentRunningView: View {
)
ConfigurationItemView(
title: .agentDetailsSocketPathTitle,
value: socketPath,
action: .copy(socketPath),
value: URL.socketPath,
action: .copy(URL.socketPath),
)
ConfigurationItemView(
title: .agentDetailsVersionTitle,
@@ -144,11 +143,11 @@ struct AgentNotRunningView: View {
}
#Preview {
AgentStatusView()
.environment(\.agentStatusChecker, PreviewAgentStatusChecker(running: false))
}
#Preview {
AgentStatusView()
.environment(\.agentStatusChecker, PreviewAgentStatusChecker(running: true, process: .current))
}
//#Preview {
// AgentStatusView()
// .environment(\.agentStatusChecker, PreviewAgentStatusChecker(running: false))
//}
//#Preview {
// AgentStatusView()
// .environment(\.agentStatusChecker, PreviewAgentStatusChecker(running: true, process: .current))
//}
@@ -198,25 +198,17 @@ extension ContentView {
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
// Empty on modifiable and nonmodifiable
ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
.environment(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]))
.environment(PreviewUpdater())
// 5 items on modifiable and nonmodifiable
ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
.environment(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()]))
.environment(PreviewUpdater())
}
}
}
#endif
//#Preview {
// // Empty on modifiable and nonmodifiable
// ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
// .environment(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]))
// .environment(PreviewUpdater())
//}
//
//#Preview {
// // 5 items on modifiable and nonmodifiable
// ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
// .environment(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()]))
// .environment(PreviewUpdater())
//}
@@ -76,10 +76,10 @@ struct CopyableView: View {
switch interactionState {
case .hovering:
Image(systemName: "document.on.document")
.accessibilityLabel(String(localized: "copyable_click_to_copy_button"))
.accessibilityLabel(String(localized: .copyableClickToCopyButton))
case .clicking:
Image(systemName: "checkmark.circle.fill")
.accessibilityLabel(String(localized: "copyable_copied"))
.accessibilityLabel(String(localized: .copyableCopied))
case .normal, .dragging:
EmptyView()
}
@@ -163,17 +163,12 @@ fileprivate struct BackgroundViewModifier: ViewModifier {
}
#if DEBUG
struct CopyableView_Previews: PreviewProvider {
static var previews: some View {
Group {
CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "figure.wave"), text: "Hello world.")
.padding()
CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "figure.wave"), text: "Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. ")
.padding()
}
}
#Preview {
CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "figure.wave"), text: "Hello world.")
.padding()
}
#endif
#Preview {
CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "figure.wave"), text: "Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. ")
.padding()
}
@@ -0,0 +1,63 @@
import SwiftUI
import Brief
struct UpdateDetailView: View {
@Environment(\.updater) var updater: any UpdaterProtocol
let update: Release
var body: some View {
VStack {
Text(.updateVersionName(updateName: update.name)).font(.title)
GroupBox(label: Text(.updateReleaseNotesTitle)) {
ScrollView {
attributedBody
}
}
HStack {
if !update.critical {
Button(.updateIgnoreButton) {
Task {
await updater.ignore(release: update)
}
}
Spacer()
}
Button(.updateUpdateButton) {
NSWorkspace.shared.open(update.html_url)
}
.keyboardShortcut(.defaultAction)
}
}
.padding()
.frame(maxWidth: 500)
}
var attributedBody: Text {
var text = Text(verbatim: "")
for line in update.body.split(whereSeparator: \.isNewline) {
let attributed: Text
let split = line.split(separator: " ")
let unprefixed = split.dropFirst().joined(separator: " ")
if let prefix = split.first {
switch prefix {
case "#":
attributed = Text(unprefixed).font(.title) + Text(verbatim: "\n")
case "##":
attributed = Text(unprefixed).font(.title2) + Text(verbatim: "\n")
case "###":
attributed = Text(unprefixed).font(.title3) + Text(verbatim: "\n")
default:
attributed = Text(line) + Text(verbatim: "\n\n")
}
} else {
attributed = Text(line) + Text(verbatim: "\n\n")
}
text = text + attributed
}
return text
}
}