Compare commits
27 Commits
packagesym
...
xcode_26_n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff842ee2d9 | ||
|
|
374da84128 | ||
|
|
3b7d0f664e | ||
|
|
8b428e6c64 | ||
|
|
1196530e27 | ||
|
|
53a23b265a | ||
|
|
e0c2775971 | ||
|
|
413af25169 | ||
|
|
dc714f9b38 | ||
|
|
7413d78558 | ||
|
|
163d38c12e | ||
|
|
f9e512e6c6 | ||
|
|
f8de78210b | ||
|
|
81f5b41d6a | ||
|
|
998f4b9bf4 | ||
|
|
bab76da2ab | ||
|
|
9b02afb20c | ||
|
|
576e625b8f | ||
|
|
304741e019 | ||
|
|
8e707545d1 | ||
|
|
e332b7cb9d | ||
|
|
c09ad3ecc1 | ||
|
|
28a4dafad4 | ||
|
|
c2563be404 | ||
|
|
970e407e29 | ||
|
|
2dc317d398 | ||
|
|
8ea8f0510c |
BIN
.github/readme/app-dark.png
vendored
|
Before Width: | Height: | Size: 520 KiB After Width: | Height: | Size: 572 KiB |
BIN
.github/readme/app-light.png
vendored
|
Before Width: | Height: | Size: 519 KiB After Width: | Height: | Size: 545 KiB |
BIN
.github/readme/notification.png
vendored
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.0 MiB |
16
.github/templates/release.md
vendored
@@ -1,16 +0,0 @@
|
|||||||
Update description
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
|
|
||||||
## Fixes
|
|
||||||
|
|
||||||
|
|
||||||
## Minimum macOS Version
|
|
||||||
|
|
||||||
|
|
||||||
## Build
|
|
||||||
https://github.com/maxgoedjen/secretive/actions/runs/RUN_ID
|
|
||||||
|
|
||||||
## Attestation
|
|
||||||
https://github.com/maxgoedjen/secretive/attestations/ATTESTATION_ID
|
|
||||||
17
.github/workflows/nightly.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Signing
|
- name: Setup Signing
|
||||||
env:
|
env:
|
||||||
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
run: ./.github/scripts/signing.sh
|
run: ./.github/scripts/signing.sh
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app
|
||||||
- name: Update Build Number
|
- name: Update Build Number
|
||||||
env:
|
env:
|
||||||
RUN_ID: ${{ github.run_id }}
|
RUN_ID: ${{ github.run_id }}
|
||||||
@@ -39,11 +39,14 @@ jobs:
|
|||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
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
|
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
|
- name: Document SHAs
|
||||||
id: attest
|
run: |
|
||||||
uses: actions/attest-build-provenance@v2
|
echo "sha-512:"
|
||||||
with:
|
shasum -a 512 Secretive.zip
|
||||||
subject-path: 'Secretive.zip'
|
shasum -a 512 Archive.zip
|
||||||
|
echo "sha-256:"
|
||||||
|
shasum -a 256 Secretive.zip
|
||||||
|
shasum -a 256 Archive.zip
|
||||||
- name: Upload App to Artifacts
|
- name: Upload App to Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
74
.github/workflows/release.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Signing
|
- name: Setup Signing
|
||||||
env:
|
env:
|
||||||
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
||||||
@@ -21,19 +21,18 @@ jobs:
|
|||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
run: ./.github/scripts/signing.sh
|
run: ./.github/scripts/signing.sh
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app
|
||||||
- name: Test
|
- name: Test
|
||||||
run: swift build --build-system swiftbuild --package-path Sources/Packages
|
run: |
|
||||||
|
pushd Sources/Packages
|
||||||
|
swift test
|
||||||
|
popd
|
||||||
build:
|
build:
|
||||||
# runs-on: macOS-latest
|
# runs-on: macOS-latest
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: write
|
|
||||||
attestations: write
|
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Signing
|
- name: Setup Signing
|
||||||
env:
|
env:
|
||||||
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
|
||||||
@@ -44,7 +43,7 @@ jobs:
|
|||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
run: ./.github/scripts/signing.sh
|
run: ./.github/scripts/signing.sh
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app
|
||||||
- name: Update Build Number
|
- name: Update Build Number
|
||||||
env:
|
env:
|
||||||
TAG_NAME: ${{ github.ref }}
|
TAG_NAME: ${{ github.ref }}
|
||||||
@@ -59,29 +58,54 @@ jobs:
|
|||||||
- name: Create ZIPs
|
- name: Create ZIPs
|
||||||
run: |
|
run: |
|
||||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
|
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
|
||||||
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Xcode_Archive.zip
|
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip
|
||||||
- name: Notarize
|
- name: Notarize
|
||||||
env:
|
env:
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
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
|
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
|
- name: Document SHAs
|
||||||
id: attest
|
|
||||||
uses: actions/attest-build-provenance@v2
|
|
||||||
with:
|
|
||||||
subject-path: 'Secretive.zip, Xcode_Archive.zip'
|
|
||||||
- name: Create Release
|
|
||||||
run: |
|
run: |
|
||||||
sed -i.tmp "s/RUN_ID/$RUN_ID/g" .github/templates/release.md
|
echo "sha-512:"
|
||||||
sed -i.tmp "s/ATTESTATION_ID/$ATTESTATION_ID/g" .github/templates/release.md
|
shasum -a 512 Secretive.zip
|
||||||
gh release create $TAG_NAME -d -F .github/templates/release.md
|
shasum -a 512 Archive.zip
|
||||||
gh release upload Secretive.zip
|
echo "sha-256:"
|
||||||
gh release upload Xcode_Archive.zip
|
shasum -a 256 Secretive.zip
|
||||||
|
shasum -a 256 Archive.zip
|
||||||
|
- name: Create Release
|
||||||
|
id: create_release
|
||||||
|
uses: actions/create-release@v1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG_NAME: ${{ github.ref }}
|
with:
|
||||||
RUN_ID: ${{ github.run_id }}
|
tag_name: ${{ github.ref }}
|
||||||
ATTESTATION_ID: ${{ steps.attest.outputs.attestation-id }}
|
release_name: ${{ github.ref }}
|
||||||
|
body: |
|
||||||
|
Update description
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
|
||||||
|
## Minimum macOS Version
|
||||||
|
|
||||||
|
|
||||||
|
## Build
|
||||||
|
https://github.com/maxgoedjen/secretive/actions/runs/${{ github.run_id }}
|
||||||
|
draft: true
|
||||||
|
prerelease: false
|
||||||
|
- name: Upload App to Release
|
||||||
|
id: upload-release-asset-app
|
||||||
|
uses: actions/upload-release-asset@v1.0.1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
asset_path: ./Secretive.zip
|
||||||
|
asset_name: Secretive.zip
|
||||||
|
asset_content_type: application/zip
|
||||||
- name: Upload App to Artifacts
|
- name: Upload App to Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -91,4 +115,4 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: Xcode_Archive.zip
|
name: Xcode_Archive.zip
|
||||||
path: Xcode_Archive.zip
|
path: Archive.zip
|
||||||
|
|||||||
9
.github/workflows/test.yml
vendored
@@ -7,8 +7,11 @@ jobs:
|
|||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- name: Set Environment
|
- name: Set Environment
|
||||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.0.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app
|
||||||
- name: Test
|
- name: Test
|
||||||
run: swift build --build-system swiftbuild --package-path Sources/Packages
|
run: |
|
||||||
|
pushd Sources/Packages
|
||||||
|
swift test
|
||||||
|
popd
|
||||||
|
|||||||
@@ -110,15 +110,6 @@ Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
|||||||
|
|
||||||
Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH)
|
Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH)
|
||||||
|
|
||||||
## Retcon
|
|
||||||
|
|
||||||
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
|
|
||||||
|
|
||||||
```
|
|
||||||
Host *
|
|
||||||
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
# The app I use isn't listed here!
|
# The app I use isn't listed here!
|
||||||
|
|
||||||
If you know how to get it set up, please open a PR for this page and add it! Contributions are very welcome.
|
If you know how to get it set up, please open a PR for this page and add it! Contributions are very welcome.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ If you'd like to contribute a translation, please see [Localizing](LOCALIZING.md
|
|||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
If you make a material contribution to the app, please add yourself to the end of the [credits](https://github.com/maxgoedjen/secretive/blob/main/Sources/Secretive/Credits.rtf).
|
If you make a material contribution to the app, please add yourself to the end of the [credits](https://github.com/maxgoedjen/secretive/blob/main/Secretive/Credits.rtf).
|
||||||
|
|
||||||
## Collaborator Status
|
## Collaborator Status
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 69 KiB |
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"fill" : {
|
|
||||||
"solid" : "srgb:0.00000,0.53333,1.00000,0.00000"
|
|
||||||
},
|
|
||||||
"groups" : [
|
|
||||||
{
|
|
||||||
"blur-material" : 0.5,
|
|
||||||
"layers" : [
|
|
||||||
{
|
|
||||||
"image-name" : "Icon 7.png",
|
|
||||||
"name" : "Signature",
|
|
||||||
"position" : {
|
|
||||||
"scale" : 1,
|
|
||||||
"translation-in-points" : [
|
|
||||||
64.00083178971097,
|
|
||||||
-58.21801551632592
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"image-name" : "Rectangle Copy 10.png",
|
|
||||||
"name" : "Border"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fill-specializations" : [
|
|
||||||
{
|
|
||||||
"appearance" : "tinted",
|
|
||||||
"value" : {
|
|
||||||
"solid" : "display-p3:0.00000,0.00000,0.00000,0.50000"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"image-name" : "Rectangle 2 8.png",
|
|
||||||
"name" : "Backing",
|
|
||||||
"opacity-specializations" : [
|
|
||||||
{
|
|
||||||
"appearance" : "tinted",
|
|
||||||
"value" : 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"shadow" : {
|
|
||||||
"kind" : "layer-color",
|
|
||||||
"opacity" : 0.5
|
|
||||||
},
|
|
||||||
"specular" : true,
|
|
||||||
"translucency" : {
|
|
||||||
"enabled" : true,
|
|
||||||
"value" : 0.5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"supported-platforms" : {
|
|
||||||
"squares" : [
|
|
||||||
"macOS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Sources/Packages/Package.swift
|
|
||||||
@@ -49,7 +49,7 @@ There's a [FAQ here](FAQ.md).
|
|||||||
|
|
||||||
### Auditable Build Process
|
### Auditable Build Process
|
||||||
|
|
||||||
Builds are produced by GitHub Actions with an auditable build and release generation process. Starting with Secretive 3.0, builds are attested using [GitHub Artifact Attestation](https://docs.github.com/en/actions/concepts/security/artifact-attestations). Attestations are viewable in the build log for a build, and also on the [main attestation page](https://github.com/maxgoedjen/secretive/attestations).
|
Builds are produced by GitHub Actions with an auditable build and release generation process. Each build has a "Document SHAs" step, which will output SHA checksums for the build produced by the GitHub Action, so you can verify that the source code for a given build corresponds to any given release.
|
||||||
|
|
||||||
### A Note Around Code Signing and Keychains
|
### A Note Around Code Signing and Keychains
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
// swift-tools-version:6.2
|
// swift-tools-version:6.1
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "SecretivePackages",
|
name: "SecretivePackages",
|
||||||
defaultLocalization: "en",
|
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v14)
|
.macOS(.v14)
|
||||||
],
|
],
|
||||||
@@ -28,14 +27,16 @@ let package = Package(
|
|||||||
.library(
|
.library(
|
||||||
name: "Brief",
|
name: "Brief",
|
||||||
targets: ["Brief"]),
|
targets: ["Brief"]),
|
||||||
|
.library(
|
||||||
|
name: "Common",
|
||||||
|
targets: ["Common"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "SecretKit",
|
name: "SecretKit",
|
||||||
dependencies: [],
|
dependencies: ["Common"],
|
||||||
resources: [localization],
|
|
||||||
swiftSettings: swiftSettings
|
swiftSettings: swiftSettings
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
@@ -45,20 +46,17 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "SecureEnclaveSecretKit",
|
name: "SecureEnclaveSecretKit",
|
||||||
dependencies: ["SecretKit"],
|
dependencies: ["Common", "SecretKit"],
|
||||||
resources: [localization],
|
|
||||||
swiftSettings: swiftSettings
|
swiftSettings: swiftSettings
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "SmartCardSecretKit",
|
name: "SmartCardSecretKit",
|
||||||
dependencies: ["SecretKit"],
|
dependencies: ["Common", "SecretKit"],
|
||||||
resources: [localization],
|
|
||||||
swiftSettings: swiftSettings
|
swiftSettings: swiftSettings
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "SecretAgentKit",
|
name: "SecretAgentKit",
|
||||||
dependencies: ["SecretKit", "SecretAgentKitHeaders"],
|
dependencies: ["Common", "SecretKit", "SecretAgentKitHeaders"],
|
||||||
resources: [localization],
|
|
||||||
swiftSettings: swiftSettings
|
swiftSettings: swiftSettings
|
||||||
),
|
),
|
||||||
.systemLibrary(
|
.systemLibrary(
|
||||||
@@ -70,24 +68,24 @@ let package = Package(
|
|||||||
,
|
,
|
||||||
.target(
|
.target(
|
||||||
name: "Brief",
|
name: "Brief",
|
||||||
dependencies: [],
|
dependencies: ["Common"],
|
||||||
resources: [localization],
|
|
||||||
swiftSettings: swiftSettings
|
swiftSettings: swiftSettings
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "BriefTests",
|
name: "BriefTests",
|
||||||
dependencies: ["Brief"]
|
dependencies: ["Brief"]
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "Common",
|
||||||
|
dependencies: [],
|
||||||
|
swiftSettings: swiftSettings
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
var localization: Resource {
|
|
||||||
.process("../../Localizable.xcstrings")
|
|
||||||
}
|
|
||||||
|
|
||||||
var swiftSettings: [PackageDescription.SwiftSetting] {
|
var swiftSettings: [PackageDescription.SwiftSetting] {
|
||||||
[
|
[
|
||||||
.swiftLanguageMode(.v6),
|
.swiftLanguageMode(.v6),
|
||||||
.treatAllWarnings(as: .error),
|
.unsafeFlags(["-warnings-as-errors"])
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
import os
|
||||||
|
import Common
|
||||||
|
|
||||||
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
|
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
|
||||||
@Observable public final class Updater: UpdaterProtocol, Sendable {
|
@Observable public final class Updater: UpdaterProtocol, ObservableObject, Sendable {
|
||||||
|
|
||||||
private let state = State()
|
|
||||||
@MainActor @Observable public final class State {
|
|
||||||
var update: Release? = nil
|
|
||||||
nonisolated init() {}
|
|
||||||
}
|
|
||||||
public var update: Release? {
|
public var update: Release? {
|
||||||
state.update
|
_update.lockedValue
|
||||||
}
|
}
|
||||||
|
private let _update: OSAllocatedUnfairLock<Release?> = .init(uncheckedState: nil)
|
||||||
public let testBuild: Bool
|
public let testBuild: Bool
|
||||||
|
|
||||||
/// The current OS version.
|
/// The current OS version.
|
||||||
@@ -26,12 +23,7 @@ import Observation
|
|||||||
/// - checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour.
|
/// - checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour.
|
||||||
/// - osVersion: The current OS version.
|
/// - osVersion: The current OS version.
|
||||||
/// - currentVersion: The current version of the app that is running.
|
/// - currentVersion: The current version of the app that is running.
|
||||||
public init(
|
public init(checkOnLaunch: Bool, checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value, osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion), currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")) {
|
||||||
checkOnLaunch: Bool,
|
|
||||||
checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value,
|
|
||||||
osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion),
|
|
||||||
currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")
|
|
||||||
) {
|
|
||||||
self.osVersion = osVersion
|
self.osVersion = osVersion
|
||||||
self.currentVersion = currentVersion
|
self.currentVersion = currentVersion
|
||||||
testBuild = currentVersion == SemVer("0.0.0")
|
testBuild = currentVersion == SemVer("0.0.0")
|
||||||
@@ -62,7 +54,7 @@ import Observation
|
|||||||
guard !release.critical else { return }
|
guard !release.critical else { return }
|
||||||
defaults.set(true, forKey: release.name)
|
defaults.set(true, forKey: release.name)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
state.update = nil
|
_update.lockedValue = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +75,7 @@ extension Updater {
|
|||||||
let latestVersion = SemVer(release.name)
|
let latestVersion = SemVer(release.name)
|
||||||
if latestVersion > currentVersion {
|
if latestVersion > currentVersion {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
state.update = release
|
_update.lockedValue = release
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
/// A protocol for retreiving the latest available version of an app.
|
/// A protocol for retreiving the latest available version of an app.
|
||||||
public protocol UpdaterProtocol: Observable, Sendable {
|
public protocol UpdaterProtocol: Observable {
|
||||||
|
|
||||||
/// The latest update
|
/// The latest update
|
||||||
@MainActor var update: Release? { get }
|
var update: Release? { get }
|
||||||
/// A boolean describing whether or not the current build of the app is a "test" build (ie, a debug build or otherwise special build)
|
/// A boolean describing whether or not the current build of the app is a "test" build (ie, a debug build or otherwise special build)
|
||||||
var testBuild: Bool { get }
|
var testBuild: Bool { get }
|
||||||
|
|
||||||
func ignore(release: Release) async
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
14
Sources/Packages/Sources/Common/Locks.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
public extension OSAllocatedUnfairLock where State: Sendable {
|
||||||
|
|
||||||
|
var lockedValue: State {
|
||||||
|
get {
|
||||||
|
withLock { $0 }
|
||||||
|
}
|
||||||
|
nonmutating set {
|
||||||
|
withLock { $0 = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -22,9 +22,7 @@ public final class Agent: Sendable {
|
|||||||
logger.debug("Agent is running")
|
logger.debug("Agent is running")
|
||||||
self.storeList = storeList
|
self.storeList = storeList
|
||||||
self.witness = witness
|
self.witness = witness
|
||||||
Task { @MainActor in
|
certificateHandler.reloadCertificates(for: storeList.allSecrets)
|
||||||
await certificateHandler.reloadCertificates(for: storeList.allSecrets)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -62,7 +60,7 @@ extension Agent {
|
|||||||
switch requestType {
|
switch requestType {
|
||||||
case .requestIdentities:
|
case .requestIdentities:
|
||||||
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
|
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
|
||||||
response.append(await identities())
|
response.append(identities())
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
|
||||||
case .signRequest:
|
case .signRequest:
|
||||||
let provenance = requestTracer.provenance(from: reader)
|
let provenance = requestTracer.provenance(from: reader)
|
||||||
@@ -85,9 +83,9 @@ extension Agent {
|
|||||||
|
|
||||||
/// Lists the identities available for signing operations
|
/// Lists the identities available for signing operations
|
||||||
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
|
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
|
||||||
func identities() async -> Data {
|
func identities() -> Data {
|
||||||
let secrets = await storeList.allSecrets
|
let secrets = storeList.allSecrets
|
||||||
await certificateHandler.reloadCertificates(for: secrets)
|
certificateHandler.reloadCertificates(for: secrets)
|
||||||
var count = secrets.count
|
var count = secrets.count
|
||||||
var keyData = Data()
|
var keyData = Data()
|
||||||
|
|
||||||
@@ -97,7 +95,7 @@ extension Agent {
|
|||||||
keyData.append(writer.lengthAndData(of: keyBlob))
|
keyData.append(writer.lengthAndData(of: keyBlob))
|
||||||
keyData.append(writer.lengthAndData(of: curveData))
|
keyData.append(writer.lengthAndData(of: curveData))
|
||||||
|
|
||||||
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
|
if let (certificateData, name) = try? certificateHandler.keyBlobAndName(for: secret) {
|
||||||
keyData.append(writer.lengthAndData(of: certificateData))
|
keyData.append(writer.lengthAndData(of: certificateData))
|
||||||
keyData.append(writer.lengthAndData(of: name))
|
keyData.append(writer.lengthAndData(of: name))
|
||||||
count += 1
|
count += 1
|
||||||
@@ -119,13 +117,13 @@ extension Agent {
|
|||||||
let payloadHash = reader.readNextChunk()
|
let payloadHash = reader.readNextChunk()
|
||||||
let hash: Data
|
let hash: Data
|
||||||
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
|
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
|
||||||
if let certificatePublicKey = await certificateHandler.publicKeyHash(from: payloadHash) {
|
if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) {
|
||||||
hash = certificatePublicKey
|
hash = certificatePublicKey
|
||||||
} else {
|
} else {
|
||||||
hash = payloadHash
|
hash = payloadHash
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let (store, secret) = await secret(matching: hash) else {
|
guard let (store, secret) = secret(matching: hash) else {
|
||||||
logger.debug("Agent did not have a key matching \(hash as NSData)")
|
logger.debug("Agent did not have a key matching \(hash as NSData)")
|
||||||
throw AgentError.noMatchingKey
|
throw AgentError.noMatchingKey
|
||||||
}
|
}
|
||||||
@@ -191,10 +189,9 @@ extension Agent {
|
|||||||
|
|
||||||
/// Gives any store with no loaded secrets a chance to reload.
|
/// Gives any store with no loaded secrets a chance to reload.
|
||||||
func reloadSecretsIfNeccessary() async {
|
func reloadSecretsIfNeccessary() async {
|
||||||
for store in await storeList.stores {
|
for store in storeList.stores {
|
||||||
if await store.secrets.isEmpty {
|
if store.secrets.isEmpty {
|
||||||
let name = await store.name
|
logger.debug("Store \(store.name, privacy: .public) has no loaded secrets. Reloading.")
|
||||||
logger.debug("Store \(name, privacy: .public) has no loaded secrets. Reloading.")
|
|
||||||
await store.reloadSecrets()
|
await store.reloadSecrets()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,16 +200,16 @@ extension Agent {
|
|||||||
/// Finds a ``Secret`` matching a specified hash whos signature was requested.
|
/// Finds a ``Secret`` matching a specified hash whos signature was requested.
|
||||||
/// - Parameter hash: The hash to match against.
|
/// - Parameter hash: The hash to match against.
|
||||||
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
|
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
|
||||||
func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
|
func secret(matching hash: Data) -> (AnySecretStore, AnySecret)? {
|
||||||
for store in await storeList.stores {
|
storeList.stores.compactMap { store -> (AnySecretStore, AnySecret)? in
|
||||||
let allMatching = await store.secrets.filter { secret in
|
let allMatching = store.secrets.filter { secret in
|
||||||
hash == writer.data(secret: secret)
|
hash == writer.data(secret: secret)
|
||||||
}
|
}
|
||||||
if let matching = allMatching.first {
|
if let matching = allMatching.first {
|
||||||
return (store, matching)
|
return (store, matching)
|
||||||
}
|
}
|
||||||
}
|
return nil
|
||||||
return nil
|
}.first
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
11
Sources/Packages/Sources/SecretAgentKit/Sendability.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct UncheckedSendable<T>: @unchecked Sendable {
|
||||||
|
|
||||||
|
let value: T
|
||||||
|
|
||||||
|
init(_ value: T) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -82,12 +82,12 @@ public final class SocketController {
|
|||||||
logger.debug("Socket controller has new data available")
|
logger.debug("Socket controller has new data available")
|
||||||
guard let new = notification.object as? FileHandle else { return }
|
guard let new = notification.object as? FileHandle else { return }
|
||||||
logger.debug("Socket controller received new file handle")
|
logger.debug("Socket controller received new file handle")
|
||||||
Task { [handler, logger = logger] in
|
Task { [handler, logger = UncheckedSendable(logger)] in
|
||||||
if((await handler?(new, new)) == true) {
|
if((await handler?(new, new)) == true) {
|
||||||
logger.debug("Socket controller handled data, wait for more data")
|
logger.value.debug("Socket controller handled data, wait for more data")
|
||||||
await new.waitForDataInBackgroundAndNotifyOnMainActor()
|
await new.waitForDataInBackgroundAndNotifyOnMainActor()
|
||||||
} else {
|
} else {
|
||||||
logger.debug("Socket controller called with empty data, socked closed")
|
logger.value.debug("Socket controller called with empty data, socked closed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import Combine
|
|||||||
/// Type eraser for SecretStore.
|
/// Type eraser for SecretStore.
|
||||||
public class AnySecretStore: SecretStore, @unchecked Sendable {
|
public class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||||
|
|
||||||
let base: any Sendable
|
let base: Any
|
||||||
private let _isAvailable: @MainActor @Sendable () -> Bool
|
private let _isAvailable: @Sendable () -> Bool
|
||||||
private let _id: @Sendable () -> UUID
|
private let _id: @Sendable () -> UUID
|
||||||
private let _name: @MainActor @Sendable () -> String
|
private let _name: @Sendable () -> String
|
||||||
private let _secrets: @MainActor @Sendable () -> [AnySecret]
|
private let _secrets: @Sendable () -> [AnySecret]
|
||||||
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
|
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
|
||||||
|
private let _verify: @Sendable (Data, Data, AnySecret) async throws -> Bool
|
||||||
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
|
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
|
||||||
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
|
private let _persistAuthentication: @Sendable (AnySecret, TimeInterval) async throws -> Void
|
||||||
private let _reloadSecrets: @Sendable () async -> Void
|
private let _reloadSecrets: @Sendable () async -> Void
|
||||||
@@ -21,12 +22,13 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
|
|||||||
_id = { secretStore.id }
|
_id = { secretStore.id }
|
||||||
_secrets = { secretStore.secrets.map { AnySecret($0) } }
|
_secrets = { secretStore.secrets.map { AnySecret($0) } }
|
||||||
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
|
_sign = { try await secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
|
||||||
|
_verify = { try await secretStore.verify(signature: $0, for: $1, with: $2.base as! SecretStoreType.SecretType) }
|
||||||
_existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
|
_existingPersistedAuthenticationContext = { await secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
|
||||||
_persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
|
_persistAuthentication = { try await secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
|
||||||
_reloadSecrets = { await secretStore.reloadSecrets() }
|
_reloadSecrets = { await secretStore.reloadSecrets() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor public var isAvailable: Bool {
|
public var isAvailable: Bool {
|
||||||
return _isAvailable()
|
return _isAvailable()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,11 +36,11 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
|
|||||||
return _id()
|
return _id()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor public var name: String {
|
public var name: String {
|
||||||
return _name()
|
return _name()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor public var secrets: [AnySecret] {
|
public var secrets: [AnySecret] {
|
||||||
return _secrets()
|
return _secrets()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +48,10 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
|
|||||||
try await _sign(data, secret, provenance)
|
try await _sign(data, secret, provenance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func verify(signature: Data, for data: Data, with secret: AnySecret) async throws -> Bool {
|
||||||
|
try await _verify(signature, data, secret)
|
||||||
|
}
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? {
|
public func existingPersistedAuthenticationContext(secret: AnySecret) async -> PersistedAuthenticationContext? {
|
||||||
await _existingPersistedAuthenticationContext(secret)
|
await _existingPersistedAuthenticationContext(secret)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import os
|
||||||
|
|
||||||
/// Manages storage and lookup for OpenSSH certificates.
|
/// Manages storage and lookup for OpenSSH certificates.
|
||||||
public actor OpenSSHCertificateHandler: Sendable {
|
public final class OpenSSHCertificateHandler: Sendable {
|
||||||
|
|
||||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
||||||
private let writer = OpenSSHKeyWriter()
|
private let writer = OpenSSHKeyWriter()
|
||||||
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
private let keyBlobsAndNames: OSAllocatedUnfairLock<[AnySecret: (Data, Data)]> = .init(uncheckedState: [:])
|
||||||
|
|
||||||
/// Initializes an OpenSSHCertificateHandler.
|
/// Initializes an OpenSSHCertificateHandler.
|
||||||
public init() {
|
public init() {
|
||||||
@@ -20,11 +21,21 @@ public actor OpenSSHCertificateHandler: Sendable {
|
|||||||
logger.log("No certificates, short circuiting")
|
logger.log("No certificates, short circuiting")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in
|
keyBlobsAndNames.withLock {
|
||||||
partialResult[next] = try? loadKeyblobAndName(for: next)
|
$0 = secrets.reduce(into: [:]) { partialResult, next in
|
||||||
|
partialResult[next] = try? loadKeyblobAndName(for: next)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether or not the certificate handler has a certifiicate associated with a given secret.
|
||||||
|
/// - Parameter secret: The secret to check for a certificate.
|
||||||
|
/// - Returns: A boolean describing whether or not the certificate handler has a certifiicate associated with a given secret
|
||||||
|
public func hasCertificate<SecretType: Secret>(for secret: SecretType) -> Bool {
|
||||||
|
keyBlobsAndNames.lockedValue[AnySecret(secret)] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
|
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
|
||||||
/// - Parameter certBlock: The openssh certificate to extract the public key from
|
/// - Parameter certBlock: The openssh certificate to extract the public key from
|
||||||
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
|
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
|
||||||
@@ -53,7 +64,7 @@ public actor OpenSSHCertificateHandler: Sendable {
|
|||||||
/// - Parameter secret: The secret to search for a certificate with
|
/// - Parameter secret: The secret to search for a certificate with
|
||||||
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
|
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
|
||||||
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
|
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
|
||||||
keyBlobsAndNames[AnySecret(secret)]
|
keyBlobsAndNames.lockedValue[AnySecret(secret)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
||||||
|
|||||||
@@ -1,39 +1,50 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
import os
|
||||||
|
import Common
|
||||||
|
|
||||||
/// A "Store Store," which holds a list of type-erased stores.
|
/// A "Store Store," which holds a list of type-erased stores.
|
||||||
@Observable @MainActor public final class SecretStoreList: Sendable {
|
@Observable public final class SecretStoreList: Sendable {
|
||||||
|
|
||||||
/// The Stores managed by the SecretStoreList.
|
/// The Stores managed by the SecretStoreList.
|
||||||
public var stores: [AnySecretStore] = []
|
public var stores: [AnySecretStore] {
|
||||||
|
__stores.lockedValue
|
||||||
|
}
|
||||||
|
private let __stores: OSAllocatedUnfairLock<[AnySecretStore]> = .init(uncheckedState: [])
|
||||||
|
|
||||||
/// A modifiable store, if one is available.
|
/// A modifiable store, if one is available.
|
||||||
public var modifiableStore: AnySecretStoreModifiable? = nil
|
public var modifiableStore: AnySecretStoreModifiable? {
|
||||||
|
__modifiableStore.withLock { $0 }
|
||||||
|
}
|
||||||
|
private let __modifiableStore: OSAllocatedUnfairLock<AnySecretStoreModifiable?> = .init(uncheckedState: nil)
|
||||||
|
|
||||||
/// Initializes a SecretStoreList.
|
/// Initializes a SecretStoreList.
|
||||||
public nonisolated init() {
|
public init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds a non-type-erased SecretStore to the list.
|
/// Adds a non-type-erased SecretStore to the list.
|
||||||
public func add<SecretStoreType: SecretStore>(store: SecretStoreType) {
|
public func add<SecretStoreType: SecretStore>(store: SecretStoreType) {
|
||||||
stores.append(AnySecretStore(store))
|
__stores.withLock {
|
||||||
|
$0.append(AnySecretStore(store))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds a non-type-erased modifiable SecretStore.
|
/// Adds a non-type-erased modifiable SecretStore.
|
||||||
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
|
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
|
||||||
let modifiable = AnySecretStoreModifiable(modifiable: store)
|
let modifiable = AnySecretStoreModifiable(modifiable: store)
|
||||||
if modifiableStore == nil {
|
__modifiableStore.lockedValue = modifiable
|
||||||
modifiableStore = modifiable
|
__stores.withLock {
|
||||||
|
$0.append(modifiable)
|
||||||
}
|
}
|
||||||
stores.append(modifiable)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A boolean describing whether there are any Stores available.
|
/// A boolean describing whether there are any Stores available.
|
||||||
public var anyAvailable: Bool {
|
public var anyAvailable: Bool {
|
||||||
stores.contains(where: \.isAvailable)
|
__stores.lockedValue.contains(where: \.isAvailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var allSecrets: [AnySecret] {
|
public var allSecrets: [AnySecret] {
|
||||||
stores.flatMap(\.secrets)
|
__stores.lockedValue.flatMap(\.secrets)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ public protocol SecretStore: Identifiable, Sendable {
|
|||||||
associatedtype SecretType: Secret
|
associatedtype SecretType: Secret
|
||||||
|
|
||||||
/// A boolean indicating whether or not the store is available.
|
/// A boolean indicating whether or not the store is available.
|
||||||
@MainActor var isAvailable: Bool { get }
|
var isAvailable: Bool { get }
|
||||||
/// A unique identifier for the store.
|
/// A unique identifier for the store.
|
||||||
var id: UUID { get }
|
var id: UUID { get }
|
||||||
/// A user-facing name for the store.
|
/// A user-facing name for the store.
|
||||||
@MainActor var name: String { get }
|
var name: String { get }
|
||||||
/// The secrets the store manages.
|
/// The secrets the store manages.
|
||||||
@MainActor var secrets: [SecretType] { get }
|
var secrets: [SecretType] { get }
|
||||||
|
|
||||||
/// Signs a data payload with a specified Secret.
|
/// Signs a data payload with a specified Secret.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -23,6 +23,14 @@ public protocol SecretStore: Identifiable, Sendable {
|
|||||||
/// - Returns: The signed data.
|
/// - Returns: The signed data.
|
||||||
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) async throws -> Data
|
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) async throws -> Data
|
||||||
|
|
||||||
|
/// Verifies that a signature is valid over a specified payload.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - signature: The signature over the data.
|
||||||
|
/// - data: The data to verify the signature of.
|
||||||
|
/// - secret: The secret whose signature to verify.
|
||||||
|
/// - Returns: Whether the signature was verified.
|
||||||
|
func verify(signature: Data, for data: Data, with secret: SecretType) async throws -> Bool
|
||||||
|
|
||||||
/// Checks to see if there is currently a valid persisted authentication for a given secret.
|
/// Checks to see if there is currently a valid persisted authentication for a given secret.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - secret: The ``Secret`` to check if there is a persisted authentication for.
|
/// - secret: The ``Secret`` to check if there is a persisted authentication for.
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import LocalAuthentication
|
|
||||||
import SecretKit
|
|
||||||
|
|
||||||
extension SecureEnclave {
|
|
||||||
|
|
||||||
/// A context describing a persisted authentication.
|
|
||||||
final class PersistentAuthenticationContext: PersistedAuthenticationContext {
|
|
||||||
|
|
||||||
/// The Secret to persist authentication for.
|
|
||||||
let secret: Secret
|
|
||||||
/// The LAContext used to authorize the persistent context.
|
|
||||||
nonisolated(unsafe) let context: LAContext
|
|
||||||
/// An expiration date for the context.
|
|
||||||
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
|
||||||
let monotonicExpiration: UInt64
|
|
||||||
|
|
||||||
/// Initializes a context.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - secret: The Secret to persist authentication for.
|
|
||||||
/// - context: The LAContext used to authorize the persistent context.
|
|
||||||
/// - duration: The duration of the authorization context, in seconds.
|
|
||||||
init(secret: Secret, context: LAContext, duration: TimeInterval) {
|
|
||||||
self.secret = secret
|
|
||||||
self.context = context
|
|
||||||
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
|
||||||
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A boolean describing whether or not the context is still valid.
|
|
||||||
var valid: Bool {
|
|
||||||
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
|
||||||
}
|
|
||||||
|
|
||||||
var expiration: Date {
|
|
||||||
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
|
|
||||||
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
|
|
||||||
return Date(timeIntervalSinceNow: remainingInSeconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actor PersistentAuthenticationHandler: Sendable {
|
|
||||||
|
|
||||||
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
|
|
||||||
|
|
||||||
func existingPersistedAuthenticationContext(secret: Secret) -> PersistentAuthenticationContext? {
|
|
||||||
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
|
|
||||||
return persisted
|
|
||||||
}
|
|
||||||
|
|
||||||
func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
|
||||||
let newContext = LAContext()
|
|
||||||
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
|
||||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
|
||||||
|
|
||||||
let formatter = DateComponentsFormatter()
|
|
||||||
formatter.unitsStyle = .spellOut
|
|
||||||
formatter.allowedUnits = [.hour, .minute, .day]
|
|
||||||
|
|
||||||
if let durationString = formatter.string(from: duration) {
|
|
||||||
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
|
|
||||||
} else {
|
|
||||||
newContext.localizedReason = String(localized: .authContextPersistForDurationUnknown(secretName: secret.name))
|
|
||||||
}
|
|
||||||
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
|
||||||
guard success else { return }
|
|
||||||
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
|
||||||
persistedAuthenticationContexts[secret] = context
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -2,30 +2,36 @@ import Foundation
|
|||||||
import Observation
|
import Observation
|
||||||
import Security
|
import Security
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import LocalAuthentication
|
@preconcurrency import LocalAuthentication
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
import os
|
||||||
|
import Common
|
||||||
|
|
||||||
extension SecureEnclave {
|
extension SecureEnclave {
|
||||||
|
|
||||||
/// An implementation of Store backed by the Secure Enclave.
|
/// An implementation of Store backed by the Secure Enclave.
|
||||||
@Observable public final class Store: SecretStoreModifiable {
|
@Observable public final class Store: SecretStoreModifiable {
|
||||||
|
|
||||||
@MainActor public var secrets: [Secret] = []
|
|
||||||
public var isAvailable: Bool {
|
public var isAvailable: Bool {
|
||||||
CryptoKit.SecureEnclave.isAvailable
|
CryptoKit.SecureEnclave.isAvailable
|
||||||
}
|
}
|
||||||
public let id = UUID()
|
public let id = UUID()
|
||||||
public let name = String(localized: .secureEnclave)
|
public let name = String(localized: "secure_enclave")
|
||||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
|
public var secrets: [Secret] {
|
||||||
|
_secrets.lockedValue
|
||||||
|
}
|
||||||
|
private let _secrets: OSAllocatedUnfairLock<[Secret]> = .init(uncheckedState: [])
|
||||||
|
|
||||||
|
private let persistedAuthenticationContexts: OSAllocatedUnfairLock<[Secret: PersistentAuthenticationContext]> = .init(uncheckedState: [:])
|
||||||
|
|
||||||
/// Initializes a Store.
|
/// Initializes a Store.
|
||||||
@MainActor public init() {
|
public init() {
|
||||||
loadSecrets()
|
|
||||||
Task {
|
Task {
|
||||||
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
||||||
await reloadSecretsInternal(notifyAgent: false)
|
await reloadSecretsInternal(notifyAgent: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
loadSecrets()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Public API
|
// MARK: Public API
|
||||||
@@ -99,16 +105,16 @@ extension SecureEnclave {
|
|||||||
await reloadSecretsInternal()
|
await reloadSecretsInternal()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
|
||||||
let context: LAContext
|
var context: LAContext
|
||||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
if let existing = persistedAuthenticationContexts.lockedValue[secret], existing.valid {
|
||||||
context = existing.context
|
context = existing.context
|
||||||
} else {
|
} else {
|
||||||
let newContext = LAContext()
|
let newContext = LAContext()
|
||||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||||
context = newContext
|
context = newContext
|
||||||
}
|
}
|
||||||
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
|
||||||
let attributes = KeychainDictionary([
|
let attributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||||
@@ -129,19 +135,74 @@ extension SecureEnclave {
|
|||||||
}
|
}
|
||||||
let key = untypedSafe as! SecKey
|
let key = untypedSafe as! SecKey
|
||||||
var signError: SecurityError?
|
var signError: SecurityError?
|
||||||
|
|
||||||
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
|
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
|
||||||
throw SigningError(error: signError)
|
throw SigningError(error: signError)
|
||||||
}
|
}
|
||||||
return signature as Data
|
return signature as Data
|
||||||
}
|
}
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
|
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
|
||||||
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
|
let context = LAContext()
|
||||||
|
context.localizedReason = String(localized: "auth_context_request_verify_description_\(secret.name)")
|
||||||
|
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||||
|
let attributes = KeychainDictionary([
|
||||||
|
kSecClass: kSecClassKey,
|
||||||
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||||
|
kSecAttrApplicationLabel: secret.id as CFData,
|
||||||
|
kSecAttrKeyType: Constants.keyType,
|
||||||
|
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
|
||||||
|
kSecAttrApplicationTag: Constants.keyTag,
|
||||||
|
kSecUseAuthenticationContext: context,
|
||||||
|
kSecReturnRef: true
|
||||||
|
])
|
||||||
|
var verifyError: SecurityError?
|
||||||
|
var untyped: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(attributes, &untyped)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
throw KeychainError(statusCode: status)
|
||||||
|
}
|
||||||
|
guard let untypedSafe = untyped else {
|
||||||
|
throw KeychainError(statusCode: errSecSuccess)
|
||||||
|
}
|
||||||
|
let key = untypedSafe as! SecKey
|
||||||
|
let verified = SecKeyVerifySignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, signature as CFData, &verifyError)
|
||||||
|
if !verified, let verifyError {
|
||||||
|
if verifyError.takeUnretainedValue() ~= .verifyError {
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
throw SigningError(error: verifyError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return verified
|
||||||
}
|
}
|
||||||
|
|
||||||
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
|
||||||
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
guard let persisted = persistedAuthenticationContexts.lockedValue[secret], persisted.valid else { return nil }
|
||||||
|
return persisted
|
||||||
|
}
|
||||||
|
|
||||||
|
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) throws {
|
||||||
|
let newContext = LAContext()
|
||||||
|
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||||
|
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||||
|
|
||||||
|
let formatter = DateComponentsFormatter()
|
||||||
|
formatter.unitsStyle = .spellOut
|
||||||
|
formatter.allowedUnits = [.hour, .minute, .day]
|
||||||
|
|
||||||
|
if let durationString = formatter.string(from: duration) {
|
||||||
|
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_\(secret.name)_\(durationString)")
|
||||||
|
} else {
|
||||||
|
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
|
||||||
|
}
|
||||||
|
newContext.evaluatePolicy(LAPolicy.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) { [weak self] success, _ in
|
||||||
|
guard success, let self else { return }
|
||||||
|
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||||
|
self.persistedAuthenticationContexts.withLock {
|
||||||
|
$0[secret] = context
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reloadSecrets() async {
|
public func reloadSecrets() async {
|
||||||
@@ -156,9 +217,11 @@ extension SecureEnclave.Store {
|
|||||||
|
|
||||||
/// Reloads all secrets from the store.
|
/// Reloads all secrets from the store.
|
||||||
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
|
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
|
||||||
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
|
private func reloadSecretsInternal(notifyAgent: Bool = true) async {
|
||||||
let before = secrets
|
let before = secrets
|
||||||
secrets.removeAll()
|
_secrets.withLock {
|
||||||
|
$0.removeAll()
|
||||||
|
}
|
||||||
loadSecrets()
|
loadSecrets()
|
||||||
if secrets != before {
|
if secrets != before {
|
||||||
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
||||||
@@ -169,7 +232,7 @@ extension SecureEnclave.Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Loads all secrets from the store.
|
/// Loads all secrets from the store.
|
||||||
@MainActor private func loadSecrets() {
|
private func loadSecrets() {
|
||||||
let publicAttributes = KeychainDictionary([
|
let publicAttributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
||||||
@@ -205,7 +268,7 @@ extension SecureEnclave.Store {
|
|||||||
nil)!
|
nil)!
|
||||||
|
|
||||||
let wrapped: [SecureEnclave.Secret] = publicTyped.map {
|
let wrapped: [SecureEnclave.Secret] = publicTyped.map {
|
||||||
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
|
||||||
let id = $0[kSecAttrApplicationLabel] as! Data
|
let id = $0[kSecAttrApplicationLabel] as! Data
|
||||||
let publicKeyRef = $0[kSecValueRef] as! SecKey
|
let publicKeyRef = $0[kSecValueRef] as! SecKey
|
||||||
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any]
|
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any]
|
||||||
@@ -220,7 +283,9 @@ extension SecureEnclave.Store {
|
|||||||
}
|
}
|
||||||
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
|
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
|
||||||
}
|
}
|
||||||
secrets.append(contentsOf: wrapped)
|
_secrets.withLock {
|
||||||
|
$0.append(contentsOf: wrapped)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves a public key.
|
/// Saves a public key.
|
||||||
@@ -255,3 +320,42 @@ extension SecureEnclave {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension SecureEnclave {
|
||||||
|
|
||||||
|
/// A context describing a persisted authentication.
|
||||||
|
private final class PersistentAuthenticationContext: PersistedAuthenticationContext {
|
||||||
|
|
||||||
|
/// The Secret to persist authentication for.
|
||||||
|
let secret: Secret
|
||||||
|
/// The LAContext used to authorize the persistent context.
|
||||||
|
nonisolated(unsafe) let context: LAContext
|
||||||
|
/// An expiration date for the context.
|
||||||
|
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
||||||
|
let monotonicExpiration: UInt64
|
||||||
|
|
||||||
|
/// Initializes a context.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - secret: The Secret to persist authentication for.
|
||||||
|
/// - context: The LAContext used to authorize the persistent context.
|
||||||
|
/// - duration: The duration of the authorization context, in seconds.
|
||||||
|
init(secret: Secret, context: LAContext, duration: TimeInterval) {
|
||||||
|
self.secret = secret
|
||||||
|
self.context = context
|
||||||
|
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
||||||
|
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A boolean describing whether or not the context is still valid.
|
||||||
|
var valid: Bool {
|
||||||
|
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiration: Date {
|
||||||
|
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
|
||||||
|
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
|
||||||
|
return Date(timeIntervalSinceNow: remainingInSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
import Observation
|
import Observation
|
||||||
import Security
|
import Security
|
||||||
import CryptoTokenKit
|
import CryptoTokenKit
|
||||||
@@ -7,39 +8,37 @@ import SecretKit
|
|||||||
|
|
||||||
extension SmartCard {
|
extension SmartCard {
|
||||||
|
|
||||||
@MainActor @Observable fileprivate final class State {
|
private struct State {
|
||||||
var isAvailable = false
|
var isAvailable = false
|
||||||
var name = String(localized: .smartCard)
|
var name = String(localized: "smart_card")
|
||||||
var secrets: [Secret] = []
|
var secrets: [Secret] = []
|
||||||
let watcher = TKTokenWatcher()
|
let watcher = TKTokenWatcher()
|
||||||
var tokenID: String? = nil
|
var tokenID: String? = nil
|
||||||
nonisolated init() {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An implementation of Store backed by a Smart Card.
|
/// An implementation of Store backed by a Smart Card.
|
||||||
@Observable public final class Store: SecretStore {
|
@Observable public final class Store: SecretStore {
|
||||||
|
|
||||||
private let state = State()
|
private let state: OSAllocatedUnfairLock<State> = .init(uncheckedState: .init())
|
||||||
public var isAvailable: Bool {
|
public var isAvailable: Bool {
|
||||||
state.isAvailable
|
state.withLock { $0.isAvailable }
|
||||||
}
|
}
|
||||||
|
|
||||||
public let id = UUID()
|
public let id = UUID()
|
||||||
@MainActor public var name: String {
|
public var name: String {
|
||||||
state.name
|
state.withLock { $0.name }
|
||||||
}
|
}
|
||||||
public var secrets: [Secret] {
|
public var secrets: [Secret] {
|
||||||
state.secrets
|
state.withLock { $0.secrets }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initializes a Store.
|
/// Initializes a Store.
|
||||||
public init() {
|
public init() {
|
||||||
Task { @MainActor in
|
state.withLock { state in
|
||||||
if let tokenID = state.tokenID {
|
if let tokenID = state.tokenID {
|
||||||
state.isAvailable = true
|
state.isAvailable = true
|
||||||
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
|
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
|
||||||
}
|
}
|
||||||
loadSecrets()
|
|
||||||
state.watcher.setInsertionHandler { id in
|
state.watcher.setInsertionHandler { id in
|
||||||
// Setting insertion handler will cause it to be called immediately.
|
// Setting insertion handler will cause it to be called immediately.
|
||||||
// Make a thread jump so we don't hit a recursive lock attempt.
|
// Make a thread jump so we don't hit a recursive lock attempt.
|
||||||
@@ -48,6 +47,7 @@ extension SmartCard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
loadSecrets()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Public API
|
// MARK: Public API
|
||||||
@@ -60,11 +60,11 @@ extension SmartCard {
|
|||||||
fatalError("Keys must be deleted on the smart card.")
|
fatalError("Keys must be deleted on the smart card.")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
|
||||||
guard let tokenID = await state.tokenID else { fatalError() }
|
guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() }
|
||||||
let context = LAContext()
|
let context = LAContext()
|
||||||
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
|
||||||
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||||
let attributes = KeychainDictionary([
|
let attributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||||
@@ -89,6 +89,29 @@ extension SmartCard {
|
|||||||
return signature as Data
|
return signature as Data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
|
||||||
|
let attributes = KeychainDictionary([
|
||||||
|
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
|
||||||
|
kSecAttrKeySizeInBits: secret.keySize,
|
||||||
|
kSecAttrKeyClass: kSecAttrKeyClassPublic
|
||||||
|
])
|
||||||
|
var verifyError: SecurityError?
|
||||||
|
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError)
|
||||||
|
guard let untypedSafe = untyped else {
|
||||||
|
throw KeychainError(statusCode: errSecSuccess)
|
||||||
|
}
|
||||||
|
let key = untypedSafe as! SecKey
|
||||||
|
let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, signature as CFData, &verifyError)
|
||||||
|
if !verified, let verifyError {
|
||||||
|
if verifyError.takeUnretainedValue() ~= .verifyError {
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
throw SigningError(error: verifyError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return verified
|
||||||
|
}
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
|
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
@@ -97,7 +120,7 @@ extension SmartCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reloads all secrets from the store.
|
/// Reloads all secrets from the store.
|
||||||
@MainActor public func reloadSecrets() {
|
public func reloadSecrets() {
|
||||||
reloadSecretsInternal()
|
reloadSecretsInternal()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,11 +130,14 @@ extension SmartCard {
|
|||||||
|
|
||||||
extension SmartCard.Store {
|
extension SmartCard.Store {
|
||||||
|
|
||||||
@MainActor private func reloadSecretsInternal() {
|
private func reloadSecretsInternal() {
|
||||||
let before = state.secrets
|
let before = state.withLock {
|
||||||
state.isAvailable = state.tokenID != nil
|
$0.isAvailable = $0.tokenID != nil
|
||||||
state.secrets.removeAll()
|
let before = $0.secrets
|
||||||
loadSecrets()
|
$0.secrets.removeAll()
|
||||||
|
return before
|
||||||
|
}
|
||||||
|
self.loadSecrets()
|
||||||
if self.secrets != before {
|
if self.secrets != before {
|
||||||
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
||||||
}
|
}
|
||||||
@@ -119,31 +145,37 @@ extension SmartCard.Store {
|
|||||||
|
|
||||||
/// Resets the token ID and reloads secrets.
|
/// Resets the token ID and reloads secrets.
|
||||||
/// - Parameter tokenID: The ID of the token that was inserted.
|
/// - Parameter tokenID: The ID of the token that was inserted.
|
||||||
@MainActor private func smartcardInserted(for tokenID: String? = nil) {
|
private func smartcardInserted(for tokenID: String? = nil) {
|
||||||
|
state.withLock { state in
|
||||||
guard let string = state.watcher.nonSecureEnclaveTokens.first else { return }
|
guard let string = state.watcher.nonSecureEnclaveTokens.first else { return }
|
||||||
guard state.tokenID == nil else { return }
|
guard state.tokenID == nil else { return }
|
||||||
guard !string.contains("setoken") else { return }
|
guard !string.contains("setoken") else { return }
|
||||||
state.tokenID = string
|
state.tokenID = string
|
||||||
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
|
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
|
||||||
state.tokenID = string
|
state.tokenID = string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resets the token ID and reloads secrets.
|
/// Resets the token ID and reloads secrets.
|
||||||
/// - Parameter tokenID: The ID of the token that was removed.
|
/// - Parameter tokenID: The ID of the token that was removed.
|
||||||
@MainActor private func smartcardRemoved(for tokenID: String? = nil) {
|
private func smartcardRemoved(for tokenID: String? = nil) {
|
||||||
state.tokenID = nil
|
state.withLock {
|
||||||
|
$0.tokenID = nil
|
||||||
|
}
|
||||||
reloadSecrets()
|
reloadSecrets()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads all secrets from the store.
|
/// Loads all secrets from the store.
|
||||||
@MainActor private func loadSecrets() {
|
private func loadSecrets() {
|
||||||
guard let tokenID = state.tokenID else { return }
|
guard let tokenID = state.withLock({ $0.tokenID }) else { return }
|
||||||
|
|
||||||
let fallbackName = String(localized: .smartCard)
|
let fallbackName = String(localized: "smart_card")
|
||||||
if let driverName = state.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
|
state.withLock {
|
||||||
state.name = driverName
|
if let driverName = $0.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
|
||||||
} else {
|
$0.name = driverName
|
||||||
state.name = fallbackName
|
} else {
|
||||||
|
$0.name = fallbackName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let attributes = KeychainDictionary([
|
let attributes = KeychainDictionary([
|
||||||
@@ -157,7 +189,7 @@ extension SmartCard.Store {
|
|||||||
SecItemCopyMatching(attributes, &untyped)
|
SecItemCopyMatching(attributes, &untyped)
|
||||||
guard let typed = untyped as? [[CFString: Any]] else { return }
|
guard let typed = untyped as? [[CFString: Any]] else { return }
|
||||||
let wrapped = typed.map {
|
let wrapped = typed.map {
|
||||||
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
|
||||||
let tokenID = $0[kSecAttrApplicationLabel] as! Data
|
let tokenID = $0[kSecAttrApplicationLabel] as! Data
|
||||||
let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber)
|
let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber)
|
||||||
let keySize = $0[kSecAttrKeySizeInBits] as! Int
|
let keySize = $0[kSecAttrKeySizeInBits] as! Int
|
||||||
@@ -167,7 +199,9 @@ extension SmartCard.Store {
|
|||||||
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
||||||
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey)
|
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey)
|
||||||
}
|
}
|
||||||
state.secrets.append(contentsOf: wrapped)
|
state.withLock {
|
||||||
|
$0.secrets.append(contentsOf: wrapped)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -184,8 +218,8 @@ extension SmartCard.Store {
|
|||||||
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
|
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
|
||||||
public func encrypt(data: Data, with secret: SecretType) throws -> Data {
|
public func encrypt(data: Data, with secret: SecretType) throws -> Data {
|
||||||
let context = LAContext()
|
let context = LAContext()
|
||||||
context.localizedReason = String(localized: .authContextRequestEncryptDescription(secretName: secret.name))
|
context.localizedReason = String(localized: "auth_context_request_encrypt_description_\(secret.name)")
|
||||||
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||||
let attributes = KeychainDictionary([
|
let attributes = KeychainDictionary([
|
||||||
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
|
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
|
||||||
kSecAttrKeySizeInBits: secret.keySize,
|
kSecAttrKeySizeInBits: secret.keySize,
|
||||||
@@ -210,11 +244,11 @@ extension SmartCard.Store {
|
|||||||
/// - secret: The secret to decrypt with.
|
/// - secret: The secret to decrypt with.
|
||||||
/// - Returns: The decrypted data.
|
/// - Returns: The decrypted data.
|
||||||
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
|
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
|
||||||
public func decrypt(data: Data, with secret: SecretType) async throws -> Data {
|
public func decrypt(data: Data, with secret: SecretType) throws -> Data {
|
||||||
guard let tokenID = await state.tokenID else { fatalError() }
|
guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() }
|
||||||
let context = LAContext()
|
let context = LAContext()
|
||||||
context.localizedReason = String(localized: .authContextRequestDecryptDescription(secretName: secret.name))
|
context.localizedReason = String(localized: "auth_context_request_decrypt_description_\(secret.name)")
|
||||||
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||||
let attributes = KeychainDictionary([
|
let attributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import Foundation
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@MainActor func greatestSelectedIfOldPatchIsPublishedLater() async throws {
|
func greatestSelectedIfOldPatchIsPublishedLater() async throws {
|
||||||
// If 2.x.x series has been published, and a patch for 1.x.x is issued
|
// If 2.x.x series has been published, and a patch for 1.x.x is issued
|
||||||
// 2.x.x should still be selected if user can run it.
|
// 2.x.x should still be selected if user can run it.
|
||||||
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0"))
|
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0"))
|
||||||
@@ -77,7 +77,7 @@ import Foundation
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@MainActor func latestVersionIsRunnable() async throws {
|
func latestVersionIsRunnable() async throws {
|
||||||
// If the 2.x.x series has been published but the user can't run it
|
// If the 2.x.x series has been published but the user can't run it
|
||||||
// the last version the user can run should be selected.
|
// the last version the user can run should be selected.
|
||||||
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0"))
|
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0"))
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
@testable import SecretKit
|
@testable import SecretKit
|
||||||
@testable import SecretAgentKit
|
@testable import SecretAgentKit
|
||||||
|
import Common
|
||||||
|
|
||||||
@Suite struct AgentTests {
|
@Suite struct AgentTests {
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ import CryptoKit
|
|||||||
|
|
||||||
@Test func identitiesList() async {
|
@Test func identitiesList() async {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
|
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple)
|
#expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple)
|
||||||
@@ -29,7 +31,7 @@ import CryptoKit
|
|||||||
|
|
||||||
@Test func noMatchingIdentities() async {
|
@Test func noMatchingIdentities() async {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
|
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
||||||
@@ -40,7 +42,7 @@ import CryptoKit
|
|||||||
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
||||||
_ = requestReader.readNextChunk()
|
_ = requestReader.readNextChunk()
|
||||||
let dataToSign = requestReader.readNextChunk()
|
let dataToSign = requestReader.readNextChunk()
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||||
let outer = OpenSSHReader(data: stubWriter.data[5...])
|
let outer = OpenSSHReader(data: stubWriter.data[5...])
|
||||||
@@ -60,17 +62,25 @@ import CryptoKit
|
|||||||
}
|
}
|
||||||
var rs = r
|
var rs = r
|
||||||
rs.append(s)
|
rs.append(s)
|
||||||
let signature = try P256.Signing.ECDSASignature(rawRepresentation: rs)
|
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs)
|
||||||
// Correct signature
|
let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
|
||||||
#expect(try P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey)
|
let store = list.stores.first!
|
||||||
.isValidSignature(signature, for: dataToSign))
|
let derVerifies = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
|
||||||
|
let invalidRandomSignature = try await store.verify(signature: "invalid".data(using: .utf8)!, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
|
||||||
|
let invalidRandomData = try await store.verify(signature: signature.derRepresentation, for: "invalid".data(using: .utf8)!, with: AnySecret(Constants.Secrets.ecdsa256Secret))
|
||||||
|
let invalidWrongKey = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa384Secret))
|
||||||
|
#expect(referenceValid)
|
||||||
|
#expect(derVerifies)
|
||||||
|
#expect(invalidRandomSignature == false)
|
||||||
|
#expect(invalidRandomData == false)
|
||||||
|
#expect(invalidWrongKey == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Witness protocol
|
// MARK: Witness protocol
|
||||||
|
|
||||||
@Test func witnessObjectionStopsRequest() async {
|
@Test func witnessObjectionStopsRequest() async {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||||
let witness = StubWitness(speakNow: { _,_ in
|
let witness = StubWitness(speakNow: { _,_ in
|
||||||
return true
|
return true
|
||||||
}, witness: { _, _ in })
|
}, witness: { _, _ in })
|
||||||
@@ -81,43 +91,44 @@ import CryptoKit
|
|||||||
|
|
||||||
@Test func witnessSignature() async {
|
@Test func witnessSignature() async {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||||
nonisolated(unsafe) var witnessed = false
|
let witnessed: OSAllocatedUnfairLock<Bool> = .init(uncheckedState: false)
|
||||||
let witness = StubWitness(speakNow: { _, trace in
|
let witness = StubWitness(speakNow: { _, trace in
|
||||||
return false
|
return false
|
||||||
}, witness: { _, trace in
|
}, witness: { _, trace in
|
||||||
witnessed = true
|
witnessed.lockedValue = true
|
||||||
})
|
})
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||||
#expect(witnessed)
|
let value = witnessed.lockedValue
|
||||||
|
#expect(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func requestTracing() async {
|
@Test func requestTracing() async {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||||
nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
|
let speakNowTrace: OSAllocatedUnfairLock<SigningRequestProvenance?> = .init(uncheckedState: nil)
|
||||||
nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
|
let witnessTrace: OSAllocatedUnfairLock<SigningRequestProvenance?> = .init(uncheckedState: nil)
|
||||||
let witness = StubWitness(speakNow: { _, trace in
|
let witness = StubWitness(speakNow: { _, trace in
|
||||||
speakNowTrace = trace
|
speakNowTrace.lockedValue = trace
|
||||||
return false
|
return false
|
||||||
}, witness: { _, trace in
|
}, witness: { _, trace in
|
||||||
witnessTrace = trace
|
witnessTrace.lockedValue = trace
|
||||||
})
|
})
|
||||||
let agent = Agent(storeList: list, witness: witness)
|
let agent = Agent(storeList: list, witness: witness)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||||
#expect(witnessTrace == speakNowTrace)
|
#expect(witnessTrace.lockedValue == speakNowTrace.lockedValue)
|
||||||
#expect(witnessTrace?.origin.displayName == "Finder")
|
#expect(witnessTrace.lockedValue?.origin.displayName == "Finder")
|
||||||
#expect(witnessTrace?.origin.validSignature == true)
|
#expect(witnessTrace.lockedValue?.origin.validSignature == true)
|
||||||
#expect(witnessTrace?.origin.parentPID == 1)
|
#expect(witnessTrace.lockedValue?.origin.parentPID == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Exception Handling
|
// MARK: Exception Handling
|
||||||
|
|
||||||
@Test func signatureException() async {
|
@Test func signatureException() async {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||||
let store = await list.stores.first?.base as! Stub.Store
|
let store = list.stores.first?.base as! Stub.Store
|
||||||
store.shouldThrow = true
|
store.shouldThrow = true
|
||||||
let agent = Agent(storeList: list)
|
let agent = Agent(storeList: list)
|
||||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||||
@@ -137,7 +148,7 @@ import CryptoKit
|
|||||||
|
|
||||||
extension AgentTests {
|
extension AgentTests {
|
||||||
|
|
||||||
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
func storeList(with secrets: [Stub.Secret]) -> SecretStoreList {
|
||||||
let store = Stub.Store()
|
let store = Stub.Store()
|
||||||
store.secrets.append(contentsOf: secrets)
|
store.secrets.append(contentsOf: secrets)
|
||||||
let storeList = SecretStoreList()
|
let storeList = SecretStoreList()
|
||||||
|
|||||||
@@ -61,6 +61,29 @@ extension Stub {
|
|||||||
return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data
|
return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func verify(signature: Data, for data: Data, with secret: Stub.Secret) throws -> Bool {
|
||||||
|
let attributes = KeychainDictionary([
|
||||||
|
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
|
||||||
|
kSecAttrKeySizeInBits: secret.keySize,
|
||||||
|
kSecAttrKeyClass: kSecAttrKeyClassPublic
|
||||||
|
])
|
||||||
|
var verifyError: Unmanaged<CFError>?
|
||||||
|
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError)
|
||||||
|
guard let untypedSafe = untyped else {
|
||||||
|
throw NSError(domain: "test", code: 0, userInfo: nil)
|
||||||
|
}
|
||||||
|
let key = untypedSafe as! SecKey
|
||||||
|
let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret), data as CFData, signature as CFData, &verifyError)
|
||||||
|
if let verifyError {
|
||||||
|
if verifyError.takeUnretainedValue() ~= .verifyError {
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
throw NSError(domain: "test", code: 0, userInfo: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return verified
|
||||||
|
}
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
|
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import Observation
|
|||||||
@main
|
@main
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
@MainActor private let storeList: SecretStoreList = {
|
private let storeList: SecretStoreList = {
|
||||||
let list = SecretStoreList()
|
let list = SecretStoreList()
|
||||||
list.add(store: SecureEnclave.Store())
|
list.add(store: SecureEnclave.Store())
|
||||||
list.add(store: SmartCard.Store())
|
list.add(store: SmartCard.Store())
|
||||||
return list
|
return list
|
||||||
}()
|
}()
|
||||||
private let updater = Updater(checkOnLaunch: true)
|
private let updater = Updater(checkOnLaunch: false)
|
||||||
private let notifier = Notifier()
|
private let notifier = Notifier()
|
||||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
||||||
private lazy var agent: Agent = {
|
private lazy var agent: Agent = {
|
||||||
@@ -47,13 +47,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
_ = withObservationTracking {
|
_ = withObservationTracking {
|
||||||
updater.update
|
updater.update
|
||||||
} onChange: { [updater, notifier] in
|
} onChange: { [updater, notifier] in
|
||||||
Task {
|
notifier.notify(update: updater.update!) { release in
|
||||||
await notifier.notify(update: updater.update!) { release in
|
Task {
|
||||||
await updater.ignore(release: release)
|
await updater.ignore(release: release)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Mac Icon.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Mac Icon@0.25x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 40 KiB |
6
Sources/SecretAgent/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,15 @@ import AppKit
|
|||||||
import SecretKit
|
import SecretKit
|
||||||
import SecretAgentKit
|
import SecretAgentKit
|
||||||
import Brief
|
import Brief
|
||||||
|
import os
|
||||||
|
|
||||||
final class Notifier: Sendable {
|
final class Notifier: Sendable {
|
||||||
|
|
||||||
private let notificationDelegate = NotificationDelegate()
|
private let notificationDelegate = NotificationDelegate()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: String(localized: .updateNotificationUpdateButton), options: [])
|
let updateAction = UNNotificationAction(identifier: Constants.updateActionIdentitifier, title: String(localized: "update_notification_update_button"), options: [])
|
||||||
let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: String(localized: .updateNotificationIgnoreButton), options: [])
|
let ignoreAction = UNNotificationAction(identifier: Constants.ignoreActionIdentitifier, title: String(localized: "update_notification_ignore_button"), options: [])
|
||||||
let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: [])
|
let updateCategory = UNNotificationCategory(identifier: Constants.updateCategoryIdentitifier, actions: [updateAction, ignoreAction], intentIdentifiers: [], options: [])
|
||||||
let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: [])
|
let criticalUpdateCategory = UNNotificationCategory(identifier: Constants.criticalUpdateCategoryIdentitifier, actions: [updateAction], intentIdentifiers: [], options: [])
|
||||||
|
|
||||||
@@ -22,32 +23,33 @@ final class Notifier: Sendable {
|
|||||||
Measurement(value: 24, unit: UnitDuration.hours)
|
Measurement(value: 24, unit: UnitDuration.hours)
|
||||||
]
|
]
|
||||||
|
|
||||||
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: .persistAuthenticationDeclineButton), options: [])
|
let doNotPersistAction = UNNotificationAction(identifier: Constants.doNotPersistActionIdentitifier, title: String(localized: "persist_authentication_decline_button"), options: [])
|
||||||
var allPersistenceActions = [doNotPersistAction]
|
var allPersistenceActions = [doNotPersistAction]
|
||||||
|
|
||||||
let formatter = DateComponentsFormatter()
|
let formatter = DateComponentsFormatter()
|
||||||
formatter.unitsStyle = .spellOut
|
formatter.unitsStyle = .spellOut
|
||||||
formatter.allowedUnits = [.hour, .minute, .day]
|
formatter.allowedUnits = [.hour, .minute, .day]
|
||||||
|
|
||||||
var identifiers: [String: TimeInterval] = [:]
|
|
||||||
for duration in rawDurations {
|
for duration in rawDurations {
|
||||||
let seconds = duration.converted(to: .seconds).value
|
let seconds = duration.converted(to: .seconds).value
|
||||||
guard let string = formatter.string(from: seconds)?.capitalized else { continue }
|
guard let string = formatter.string(from: seconds)?.capitalized else { continue }
|
||||||
let identifier = Constants.persistAuthenticationCategoryIdentitifier.appending("\(seconds)")
|
let identifier = Constants.persistAuthenticationCategoryIdentitifier.appending("\(seconds)")
|
||||||
let action = UNNotificationAction(identifier: identifier, title: string, options: [])
|
let action = UNNotificationAction(identifier: identifier, title: string, options: [])
|
||||||
identifiers[identifier] = seconds
|
notificationDelegate.state.withLock { state in
|
||||||
|
state.persistOptions[identifier] = seconds
|
||||||
|
}
|
||||||
allPersistenceActions.append(action)
|
allPersistenceActions.append(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
let persistAuthenticationCategory = UNNotificationCategory(identifier: Constants.persistAuthenticationCategoryIdentitifier, actions: allPersistenceActions, intentIdentifiers: [], options: [])
|
let persistAuthenticationCategory = UNNotificationCategory(identifier: Constants.persistAuthenticationCategoryIdentitifier, actions: allPersistenceActions, intentIdentifiers: [], options: [])
|
||||||
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
|
if persistAuthenticationCategory.responds(to: Selector(("actionsMenuTitle"))) {
|
||||||
persistAuthenticationCategory.setValue(String(localized: .persistAuthenticationAcceptButton), forKey: "_actionsMenuTitle")
|
persistAuthenticationCategory.setValue(String(localized: "persist_authentication_accept_button"), forKey: "_actionsMenuTitle")
|
||||||
}
|
}
|
||||||
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
|
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
|
||||||
UNUserNotificationCenter.current().delegate = notificationDelegate
|
UNUserNotificationCenter.current().delegate = notificationDelegate
|
||||||
|
|
||||||
Task {
|
notificationDelegate.state.withLock { state in
|
||||||
await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in
|
state.persistAuthentication = { secret, store, duration in
|
||||||
guard let duration = duration else { return }
|
guard let duration = duration else { return }
|
||||||
try? await store.persistAuthentication(secret: secret, forDuration: duration)
|
try? await store.persistAuthentication(secret: secret, forDuration: duration)
|
||||||
}
|
}
|
||||||
@@ -61,11 +63,14 @@ final class Notifier: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async {
|
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async {
|
||||||
await notificationDelegate.state.setPending(secret: secret, store: store)
|
notificationDelegate.state.withLock { state in
|
||||||
|
state.pendingPersistableSecrets[secret.id.description] = secret
|
||||||
|
state.pendingPersistableStores[store.id.description] = store
|
||||||
|
}
|
||||||
let notificationCenter = UNUserNotificationCenter.current()
|
let notificationCenter = UNUserNotificationCenter.current()
|
||||||
let notificationContent = UNMutableNotificationContent()
|
let notificationContent = UNMutableNotificationContent()
|
||||||
notificationContent.title = String(localized: .signedNotificationTitle(appName: provenance.origin.displayName))
|
notificationContent.title = String(localized: "signed_notification_title_\(provenance.origin.displayName)")
|
||||||
notificationContent.subtitle = String(localized: .signedNotificationDescription(secretName: secret.name))
|
notificationContent.subtitle = String(localized: "signed_notification_description_\(secret.name)")
|
||||||
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
|
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
|
||||||
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
|
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
|
||||||
notificationContent.interruptionLevel = .timeSensitive
|
notificationContent.interruptionLevel = .timeSensitive
|
||||||
@@ -79,21 +84,24 @@ final class Notifier: Sendable {
|
|||||||
try? await notificationCenter.add(request)
|
try? await notificationCenter.add(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func notify(update: Release, ignore: (@Sendable (Release) async -> Void)?) async {
|
func notify(update: Release, ignore: (@Sendable (Release) -> Void)?) {
|
||||||
await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore)
|
notificationDelegate.state.withLock { [update] state in
|
||||||
|
state.release = update
|
||||||
|
state.ignore = ignore
|
||||||
|
}
|
||||||
let notificationCenter = UNUserNotificationCenter.current()
|
let notificationCenter = UNUserNotificationCenter.current()
|
||||||
let notificationContent = UNMutableNotificationContent()
|
let notificationContent = UNMutableNotificationContent()
|
||||||
if update.critical {
|
if update.critical {
|
||||||
notificationContent.interruptionLevel = .critical
|
notificationContent.interruptionLevel = .critical
|
||||||
notificationContent.title = String(localized: .updateNotificationUpdateCriticalTitle(updateName: update.name))
|
notificationContent.title = String(localized: "update_notification_update_critical_title_\(update.name)")
|
||||||
} else {
|
} else {
|
||||||
notificationContent.title = String(localized: .updateNotificationUpdateNormalTitle(updateName: update.name))
|
notificationContent.title = String(localized: "update_notification_update_normal_title_\(update.name)")
|
||||||
}
|
}
|
||||||
notificationContent.subtitle = String(localized: .updateNotificationUpdateDescription)
|
notificationContent.subtitle = String(localized: "update_notification_update_description")
|
||||||
notificationContent.body = update.body
|
notificationContent.body = update.body
|
||||||
notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier
|
notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
|
||||||
try? await notificationCenter.add(request)
|
notificationCenter.add(request, withCompletionHandler: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -132,55 +140,28 @@ extension Notifier {
|
|||||||
|
|
||||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
|
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
|
||||||
|
|
||||||
fileprivate actor State {
|
struct State {
|
||||||
typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
|
typealias PersistAuthentication = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
|
||||||
typealias IgnoreAction = (@Sendable (Release) async -> Void)
|
typealias Ignore = ((Release) -> Void)
|
||||||
fileprivate var release: Release?
|
fileprivate var release: Release?
|
||||||
fileprivate var ignoreAction: IgnoreAction?
|
fileprivate var ignore: Ignore?
|
||||||
fileprivate var persistAction: PersistAction?
|
fileprivate var persistAuthentication: PersistAuthentication?
|
||||||
fileprivate var persistOptions: [String: TimeInterval] = [:]
|
fileprivate var persistOptions: [String: TimeInterval] = [:]
|
||||||
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
|
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
|
||||||
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
|
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
|
||||||
|
|
||||||
func setPending(secret: AnySecret, store: AnySecretStore) {
|
|
||||||
pendingPersistableSecrets[secret.id.description] = secret
|
|
||||||
pendingPersistableStores[store.id.description] = store
|
|
||||||
}
|
|
||||||
|
|
||||||
func retrievePending(secretID: String, storeID: String, optionID: String) -> (AnySecret, AnySecretStore, TimeInterval)? {
|
|
||||||
guard let secret = pendingPersistableSecrets[secretID],
|
|
||||||
let store = pendingPersistableStores[storeID],
|
|
||||||
let options = persistOptions[optionID] else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pendingPersistableSecrets.removeValue(forKey: secretID)
|
|
||||||
return (secret, store, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
|
|
||||||
self.persistOptions = options
|
|
||||||
self.persistAction = action
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareForNotification(release: Release, ignoreAction: IgnoreAction?) {
|
|
||||||
self.release = release
|
|
||||||
self.ignoreAction = ignoreAction
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate let state = State()
|
fileprivate let state: OSAllocatedUnfairLock<State> = .init(uncheckedState: .init())
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
|
||||||
let category = response.notification.request.content.categoryIdentifier
|
let category = response.notification.request.content.categoryIdentifier
|
||||||
switch category {
|
switch category {
|
||||||
case Notifier.Constants.updateCategoryIdentitifier:
|
case Notifier.Constants.updateCategoryIdentitifier:
|
||||||
await handleUpdateResponse(response: response)
|
handleUpdateResponse(response: response)
|
||||||
case Notifier.Constants.persistAuthenticationCategoryIdentitifier:
|
case Notifier.Constants.persistAuthenticationCategoryIdentitifier:
|
||||||
await handlePersistAuthenticationResponse(response: response)
|
await handlePersistAuthenticationResponse(response: response)
|
||||||
default:
|
default:
|
||||||
@@ -188,16 +169,18 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUpdateResponse(response: UNNotificationResponse) async {
|
func handleUpdateResponse(response: UNNotificationResponse) {
|
||||||
let id = response.actionIdentifier
|
let id = response.actionIdentifier
|
||||||
guard let update = await state.release else { return }
|
state.withLock { state in
|
||||||
switch id {
|
guard let update = state.release else { return }
|
||||||
case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier:
|
switch id {
|
||||||
NSWorkspace.shared.open(update.html_url)
|
case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier:
|
||||||
case Notifier.Constants.ignoreActionIdentitifier:
|
NSWorkspace.shared.open(update.html_url)
|
||||||
await state.ignoreAction?(update)
|
case Notifier.Constants.ignoreActionIdentitifier:
|
||||||
default:
|
state.ignore?(update)
|
||||||
fatalError()
|
default:
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,15 +189,22 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
|
|||||||
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
|
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let optionID = response.actionIdentifier
|
let id = response.actionIdentifier
|
||||||
guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return }
|
|
||||||
await state.persistAction?(secret, store, persistOptions)
|
let (secret, store, persistOptions, callback): (AnySecret?, AnySecretStore?, TimeInterval?, State.PersistAuthentication?) = state.withLock { state in
|
||||||
|
guard let secret = state.pendingPersistableSecrets[secretID],
|
||||||
|
let store = state.pendingPersistableStores[storeID]
|
||||||
|
else { return (nil, nil, nil, nil) }
|
||||||
|
state.pendingPersistableSecrets[secretID] = nil
|
||||||
|
return (secret, store, state.persistOptions[id], state.persistAuthentication)
|
||||||
|
}
|
||||||
|
guard let secret, let store, let persistOptions else { return }
|
||||||
|
await callback?(secret, store, persistOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
||||||
[.list, .banner]
|
[.list, .banner]
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,7 @@
|
|||||||
5003EF612780081600DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF602780081600DF2006 /* SmartCardSecretKit */; };
|
5003EF612780081600DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF602780081600DF2006 /* SmartCardSecretKit */; };
|
||||||
5003EF632780081B00DF2006 /* SecureEnclaveSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF622780081B00DF2006 /* SecureEnclaveSecretKit */; };
|
5003EF632780081B00DF2006 /* SecureEnclaveSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF622780081B00DF2006 /* SecureEnclaveSecretKit */; };
|
||||||
5003EF652780081B00DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF642780081B00DF2006 /* SmartCardSecretKit */; };
|
5003EF652780081B00DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF642780081B00DF2006 /* SmartCardSecretKit */; };
|
||||||
5008C23E2E525D8900507AC2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5008C23D2E525D8200507AC2 /* Localizable.xcstrings */; };
|
500B93C32B478D8400E157DE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 500B93C22B478D8400E157DE /* Localizable.xcstrings */; };
|
||||||
5008C2402E52792400507AC2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8623FCE48E0099B055 /* Assets.xcassets */; };
|
|
||||||
5008C2412E52D18700507AC2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5008C23D2E525D8200507AC2 /* Localizable.xcstrings */; };
|
|
||||||
501421622781262300BBAA70 /* Brief in Frameworks */ = {isa = PBXBuildFile; productRef = 501421612781262300BBAA70 /* Brief */; };
|
501421622781262300BBAA70 /* Brief in Frameworks */ = {isa = PBXBuildFile; productRef = 501421612781262300BBAA70 /* Brief */; };
|
||||||
501421652781268000BBAA70 /* SecretAgent.app in CopyFiles */ = {isa = PBXBuildFile; fileRef = 50A3B78A24026B7500D209EA /* SecretAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
501421652781268000BBAA70 /* SecretAgent.app in CopyFiles */ = {isa = PBXBuildFile; fileRef = 50A3B78A24026B7500D209EA /* SecretAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
50153E20250AFCB200525160 /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50153E1F250AFCB200525160 /* UpdateView.swift */; };
|
50153E20250AFCB200525160 /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50153E1F250AFCB200525160 /* UpdateView.swift */; };
|
||||||
@@ -47,11 +45,13 @@
|
|||||||
508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */ = {isa = PBXBuildFile; fileRef = 508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */; };
|
508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */ = {isa = PBXBuildFile; fileRef = 508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */; };
|
||||||
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */; };
|
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */; };
|
||||||
5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */; };
|
5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */; };
|
||||||
|
50A3B79124026B7600D209EA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79024026B7600D209EA /* Assets.xcassets */; };
|
||||||
50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79324026B7600D209EA /* Preview Assets.xcassets */; };
|
50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79324026B7600D209EA /* Preview Assets.xcassets */; };
|
||||||
50A3B79724026B7600D209EA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79524026B7600D209EA /* Main.storyboard */; };
|
50A3B79724026B7600D209EA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79524026B7600D209EA /* Main.storyboard */; };
|
||||||
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
|
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
|
||||||
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; };
|
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; };
|
||||||
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; };
|
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; };
|
||||||
|
50E9CF422B51D596004AB36D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 500B93C22B478D8400E157DE /* Localizable.xcstrings */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
50020BAF24064869003D4025 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
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>"; };
|
50033AC227813F1700253856 /* BundleIDs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIDs.swift; sourceTree = "<group>"; };
|
||||||
5003EF39278005C800DF2006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Packages; 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; };
|
500B93C22B478D8400E157DE /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||||
50153E1F250AFCB200525160 /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
5018F54E24064786002EB505 /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = "<group>"; };
|
||||||
@@ -133,6 +133,7 @@
|
|||||||
5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationDirectoryController.swift; sourceTree = "<group>"; };
|
5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationDirectoryController.swift; sourceTree = "<group>"; };
|
||||||
5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSecretView.swift; sourceTree = "<group>"; };
|
5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSecretView.swift; sourceTree = "<group>"; };
|
||||||
50A3B78A24026B7500D209EA /* SecretAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SecretAgent.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
50A3B78A24026B7500D209EA /* SecretAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SecretAgent.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
50A3B79024026B7600D209EA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
50A3B79324026B7600D209EA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
50A3B79324026B7600D209EA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||||
50A3B79624026B7600D209EA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
50A3B79624026B7600D209EA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||||
50A3B79824026B7600D209EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
50A3B79824026B7600D209EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
@@ -210,7 +211,7 @@
|
|||||||
508BF28D25B4F005009EFB7E /* InternetAccessPolicy.plist */,
|
508BF28D25B4F005009EFB7E /* InternetAccessPolicy.plist */,
|
||||||
50617D8F23FCE48E0099B055 /* Secretive.entitlements */,
|
50617D8F23FCE48E0099B055 /* Secretive.entitlements */,
|
||||||
506772C62424784600034DED /* Credits.rtf */,
|
506772C62424784600034DED /* Credits.rtf */,
|
||||||
5008C23D2E525D8200507AC2 /* Localizable.xcstrings */,
|
500B93C22B478D8400E157DE /* Localizable.xcstrings */,
|
||||||
50617D8823FCE48E0099B055 /* Preview Content */,
|
50617D8823FCE48E0099B055 /* Preview Content */,
|
||||||
);
|
);
|
||||||
path = Secretive;
|
path = Secretive;
|
||||||
@@ -280,6 +281,7 @@
|
|||||||
children = (
|
children = (
|
||||||
50020BAF24064869003D4025 /* AppDelegate.swift */,
|
50020BAF24064869003D4025 /* AppDelegate.swift */,
|
||||||
5018F54E24064786002EB505 /* Notifier.swift */,
|
5018F54E24064786002EB505 /* Notifier.swift */,
|
||||||
|
50A3B79024026B7600D209EA /* Assets.xcassets */,
|
||||||
50A3B79524026B7600D209EA /* Main.storyboard */,
|
50A3B79524026B7600D209EA /* Main.storyboard */,
|
||||||
50A3B79824026B7600D209EA /* Info.plist */,
|
50A3B79824026B7600D209EA /* Info.plist */,
|
||||||
508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */,
|
508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */,
|
||||||
@@ -384,8 +386,6 @@
|
|||||||
fi,
|
fi,
|
||||||
ko,
|
ko,
|
||||||
ca,
|
ca,
|
||||||
ru,
|
|
||||||
pl,
|
|
||||||
);
|
);
|
||||||
mainGroup = 50617D7623FCE48D0099B055;
|
mainGroup = 50617D7623FCE48D0099B055;
|
||||||
productRefGroup = 50617D8023FCE48E0099B055 /* Products */;
|
productRefGroup = 50617D8023FCE48E0099B055 /* Products */;
|
||||||
@@ -404,7 +404,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */,
|
50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */,
|
||||||
5008C23E2E525D8900507AC2 /* Localizable.xcstrings in Resources */,
|
500B93C32B478D8400E157DE /* Localizable.xcstrings in Resources */,
|
||||||
50617D8723FCE48E0099B055 /* Assets.xcassets in Resources */,
|
50617D8723FCE48E0099B055 /* Assets.xcassets in Resources */,
|
||||||
506772C72424784600034DED /* Credits.rtf in Resources */,
|
506772C72424784600034DED /* Credits.rtf in Resources */,
|
||||||
508BF28E25B4F005009EFB7E /* InternetAccessPolicy.plist in Resources */,
|
508BF28E25B4F005009EFB7E /* InternetAccessPolicy.plist in Resources */,
|
||||||
@@ -416,10 +416,10 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
50A3B79724026B7600D209EA /* Main.storyboard in Resources */,
|
50A3B79724026B7600D209EA /* Main.storyboard in Resources */,
|
||||||
5008C2412E52D18700507AC2 /* Localizable.xcstrings in Resources */,
|
50E9CF422B51D596004AB36D /* Localizable.xcstrings in Resources */,
|
||||||
50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */,
|
50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */,
|
||||||
|
50A3B79124026B7600D209EA /* Assets.xcassets in Resources */,
|
||||||
508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */,
|
508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */,
|
||||||
5008C2402E52792400507AC2 /* Assets.xcassets in Resources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -526,8 +526,6 @@
|
|||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
ENABLE_ENHANCED_SECURITY = YES;
|
|
||||||
ENABLE_POINTER_AUTHENTICATION = YES;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -600,9 +598,7 @@
|
|||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
ENABLE_ENHANCED_SECURITY = YES;
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_POINTER_AUTHENTICATION = YES;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@@ -641,10 +637,8 @@
|
|||||||
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = Z72PRUAWF6;
|
DEVELOPMENT_TEAM = Z72PRUAWF6;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_ENHANCED_SECURITY = YES;
|
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||||
ENABLE_POINTER_AUTHENTICATION = YES;
|
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||||
INFOPLIST_FILE = Secretive/Info.plist;
|
INFOPLIST_FILE = Secretive/Info.plist;
|
||||||
@@ -673,10 +667,8 @@
|
|||||||
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = Z72PRUAWF6;
|
DEVELOPMENT_TEAM = Z72PRUAWF6;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_ENHANCED_SECURITY = YES;
|
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||||
ENABLE_POINTER_AUTHENTICATION = YES;
|
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||||
INFOPLIST_FILE = Secretive/Info.plist;
|
INFOPLIST_FILE = Secretive/Info.plist;
|
||||||
@@ -731,8 +723,6 @@
|
|||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
ENABLE_ENHANCED_SECURITY = YES;
|
|
||||||
ENABLE_POINTER_AUTHENTICATION = YES;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -770,17 +760,14 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Secretive/Secretive.entitlements;
|
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\"";
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_ENHANCED_SECURITY = YES;
|
|
||||||
ENABLE_HARDENED_RUNTIME = NO;
|
ENABLE_HARDENED_RUNTIME = NO;
|
||||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||||
ENABLE_POINTER_AUTHENTICATION = YES;
|
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||||
INFOPLIST_FILE = Secretive/Info.plist;
|
INFOPLIST_FILE = Secretive/Info.plist;
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>iOSPackagesShouldBuildARM64e</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -6,33 +6,28 @@ import SmartCardSecretKit
|
|||||||
import Brief
|
import Brief
|
||||||
|
|
||||||
extension EnvironmentValues {
|
extension EnvironmentValues {
|
||||||
|
@Entry var secretStoreList: SecretStoreList = {
|
||||||
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
|
|
||||||
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
|
|
||||||
let list = SecretStoreList()
|
let list = SecretStoreList()
|
||||||
list.add(store: SecureEnclave.Store())
|
list.add(store: SecureEnclave.Store())
|
||||||
list.add(store: SmartCard.Store())
|
list.add(store: SmartCard.Store())
|
||||||
return list
|
return list
|
||||||
}()
|
}()
|
||||||
|
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = AgentStatusChecker()
|
||||||
private static let _agentStatusChecker = AgentStatusChecker()
|
@Entry var updater: any UpdaterProtocol = Updater(checkOnLaunch: false)
|
||||||
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = _agentStatusChecker
|
|
||||||
private static let _updater: any UpdaterProtocol = {
|
|
||||||
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
|
||||||
return Updater(checkOnLaunch: hasRunSetup)
|
|
||||||
}()
|
|
||||||
@Entry var updater: any UpdaterProtocol = _updater
|
|
||||||
|
|
||||||
@MainActor var secretStoreList: SecretStoreList {
|
|
||||||
EnvironmentValues._secretStoreList
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Secretive: App {
|
struct Secretive: App {
|
||||||
|
|
||||||
|
private let storeList: SecretStoreList = {
|
||||||
|
let list = SecretStoreList()
|
||||||
|
list.add(store: SecureEnclave.Store())
|
||||||
|
list.add(store: SmartCard.Store())
|
||||||
|
return list
|
||||||
|
}()
|
||||||
|
private let agentStatusChecker = AgentStatusChecker()
|
||||||
private let justUpdatedChecker = JustUpdatedChecker()
|
private let justUpdatedChecker = JustUpdatedChecker()
|
||||||
@Environment(\.agentStatusChecker) var agentStatusChecker
|
|
||||||
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
||||||
@State private var showingSetup = false
|
@State private var showingSetup = false
|
||||||
@State private var showingCreation = false
|
@State private var showingCreation = false
|
||||||
@@ -40,7 +35,9 @@ struct Secretive: App {
|
|||||||
@SceneBuilder var body: some Scene {
|
@SceneBuilder var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
|
ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
|
||||||
.environment(EnvironmentValues._secretStoreList)
|
.environment(storeList)
|
||||||
|
.environment(Updater(checkOnLaunch: hasRunSetup))
|
||||||
|
.environment(agentStatusChecker)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if !hasRunSetup {
|
if !hasRunSetup {
|
||||||
showingSetup = true
|
showingSetup = true
|
||||||
@@ -59,18 +56,18 @@ struct Secretive: App {
|
|||||||
}
|
}
|
||||||
.commands {
|
.commands {
|
||||||
CommandGroup(after: CommandGroupPlacement.newItem) {
|
CommandGroup(after: CommandGroupPlacement.newItem) {
|
||||||
Button(.appMenuNewSecretButton) {
|
Button("app_menu_new_secret_button") {
|
||||||
showingCreation = true
|
showingCreation = true
|
||||||
}
|
}
|
||||||
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
|
.keyboardShortcut(KeyboardShortcut(KeyEquivalent("N"), modifiers: [.command, .shift]))
|
||||||
}
|
}
|
||||||
CommandGroup(replacing: .help) {
|
CommandGroup(replacing: .help) {
|
||||||
Button(.appMenuHelpButton) {
|
Button("app_menu_help_button") {
|
||||||
NSWorkspace.shared.open(Constants.helpURL)
|
NSWorkspace.shared.open(Constants.helpURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CommandGroup(after: .help) {
|
CommandGroup(after: .help) {
|
||||||
Button(.appMenuSetupButton) {
|
Button("app_menu_setup_button") {
|
||||||
showingSetup = true
|
showingSetup = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,53 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "Icon-macOS-ClearDark-16x16@1x.png",
|
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Icon-macOS-ClearDark-16x16@2x.png",
|
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Icon-macOS-ClearDark-32x32@1x.png",
|
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Icon-macOS-ClearDark-32x32@2x.png",
|
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Icon-macOS-ClearDark-128x128@1x.png",
|
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Icon-macOS-ClearDark-128x128@2x.png",
|
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Icon-macOS-ClearDark-256x256@1x.png",
|
"filename" : "Mac Icon.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Icon-macOS-ClearDark-256x256@2x.png",
|
"filename" : "Mac Icon@0.25x.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Icon-macOS-ClearDark-512x512@1x.png",
|
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Icon-macOS-ClearDark-1024x1024@1x.png",
|
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 856 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"info" : {
|
"info" : {
|
||||||
"author" : "xcode",
|
"version" : 1,
|
||||||
"version" : 1
|
"author" : "xcode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,20 +4,17 @@ import AppKit
|
|||||||
import SecretKit
|
import SecretKit
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
@MainActor protocol AgentStatusCheckerProtocol: Observable, Sendable {
|
protocol AgentStatusCheckerProtocol: Observable {
|
||||||
var running: Bool { get }
|
var running: Bool { get }
|
||||||
var developmentBuild: Bool { get }
|
var developmentBuild: Bool { get }
|
||||||
func check()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable @MainActor final class AgentStatusChecker: AgentStatusCheckerProtocol {
|
@Observable class AgentStatusChecker: AgentStatusCheckerProtocol {
|
||||||
|
|
||||||
var running: Bool = false
|
var running: Bool = false
|
||||||
|
|
||||||
nonisolated init() {
|
init() {
|
||||||
Task { @MainActor in
|
check()
|
||||||
check()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func check() {
|
func check() {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import Foundation
|
|||||||
import Combine
|
import Combine
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
protocol JustUpdatedCheckerProtocol: Observable {
|
protocol JustUpdatedCheckerProtocol: ObservableObject {
|
||||||
var justUpdated: Bool { get }
|
var justUpdated: Bool { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable class JustUpdatedChecker: JustUpdatedCheckerProtocol {
|
class JustUpdatedChecker: ObservableObject, JustUpdatedCheckerProtocol {
|
||||||
|
|
||||||
var justUpdated: Bool = false
|
@Published var justUpdated: Bool = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
check()
|
check()
|
||||||
|
|||||||
@@ -10,7 +10,4 @@ class PreviewAgentStatusChecker: AgentStatusCheckerProtocol {
|
|||||||
self.running = running
|
self.running = running
|
||||||
}
|
}
|
||||||
|
|
||||||
func check() {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ extension Preview {
|
|||||||
|
|
||||||
extension Preview {
|
extension Preview {
|
||||||
|
|
||||||
@Observable final class Store: SecretStore {
|
final class Store: SecretStore, ObservableObject {
|
||||||
|
|
||||||
let isAvailable = true
|
let isAvailable = true
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
@@ -40,6 +40,10 @@ extension Preview {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func verify(signature data: Data, for signature: Data, with secret: Preview.Secret) throws -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
|
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
@@ -72,6 +76,10 @@ extension Preview {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func verify(signature data: Data, for signature: Data, with secret: Preview.Secret) throws -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
|
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
@@ -96,7 +104,7 @@ extension Preview {
|
|||||||
|
|
||||||
extension Preview {
|
extension Preview {
|
||||||
|
|
||||||
@MainActor static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList {
|
static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList {
|
||||||
let list = SecretStoreList()
|
let list = SecretStoreList()
|
||||||
for store in stores {
|
for store in stores {
|
||||||
list.add(store: store)
|
list.add(store: store)
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
import Observation
|
import Observation
|
||||||
import Brief
|
import Brief
|
||||||
|
|
||||||
@Observable @MainActor final class PreviewUpdater: UpdaterProtocol {
|
@Observable class PreviewUpdater: UpdaterProtocol {
|
||||||
|
|
||||||
var update: Release? = nil
|
var update: Release? {
|
||||||
|
_update.lockedValue
|
||||||
|
}
|
||||||
|
let _update: OSAllocatedUnfairLock<Release?> = .init(uncheckedState: nil)
|
||||||
|
|
||||||
let testBuild = false
|
let testBuild = false
|
||||||
|
|
||||||
init(update: Update = .none) {
|
init(update: Update = .none) {
|
||||||
switch update {
|
switch update {
|
||||||
case .none:
|
case .none:
|
||||||
self.update = nil
|
_update.lockedValue = nil
|
||||||
case .advisory:
|
case .advisory:
|
||||||
self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
|
_update.lockedValue = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
|
||||||
case .critical:
|
case .critical:
|
||||||
self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
|
_update.lockedValue = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ignore(release: Release) async {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PreviewUpdater {
|
extension PreviewUpdater {
|
||||||
|
|||||||
@@ -2,16 +2,6 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.hardened-process</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.dyld-ro</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.enhanced-security-version</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
<key>com.apple.security.hardened-process.hardened-heap</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.hardened-process.platform-restrictions</key>
|
|
||||||
<integer>2</integer>
|
|
||||||
<key>com.apple.security.smartcard</key>
|
<key>com.apple.security.smartcard</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ struct ContentView: View {
|
|||||||
@State var activeSecret: AnySecret?
|
@State var activeSecret: AnySecret?
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@Environment(\.secretStoreList) private var storeList
|
@Environment(\.secretStoreList) private var storeList: SecretStoreList
|
||||||
@Environment(\.updater) private var updater: any UpdaterProtocol
|
@Environment(\.updater) private var updater: any UpdaterProtocol
|
||||||
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.frame(minWidth: 640, minHeight: 320)
|
.frame(minWidth: 640, minHeight: 320)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
// toolbarItem(updateNoticeView, id: "update")
|
toolbarItem(updateNoticeView, id: "update")
|
||||||
toolbarItem(runningOrRunSetupView, id: "setup")
|
toolbarItem(runningOrRunSetupView, id: "setup")
|
||||||
toolbarItem(appPathNoticeView, id: "appPath")
|
toolbarItem(appPathNoticeView, id: "appPath")
|
||||||
toolbarItem(newItemView, id: "new")
|
toolbarItem(newItemView, id: "new")
|
||||||
@@ -45,16 +45,10 @@ struct ContentView: View {
|
|||||||
extension ContentView {
|
extension ContentView {
|
||||||
|
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
func toolbarItem(_ view: some View, id: String) -> ToolbarItem<String, some View> {
|
||||||
func toolbarItem(_ view: some View, id: String) -> some ToolbarContent {
|
ToolbarItem(id: id) { view }
|
||||||
if #available(macOS 26.0, *) {
|
|
||||||
ToolbarItem(id: id) { view }
|
|
||||||
.sharedBackgroundVisibility(.hidden)
|
|
||||||
} else {
|
|
||||||
ToolbarItem(id: id) { view }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var needsSetup: Bool {
|
var needsSetup: Bool {
|
||||||
(runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild
|
(runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild
|
||||||
}
|
}
|
||||||
@@ -70,15 +64,15 @@ extension ContentView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var updateNoticeContent: (LocalizedStringResource, Color)? {
|
var updateNoticeContent: (LocalizedStringKey, Color)? {
|
||||||
guard let update = updater.update else { return nil }
|
guard let update = updater.update else { return nil }
|
||||||
if update.critical {
|
if update.critical {
|
||||||
return (.updateCriticalNoticeTitle, .red)
|
return ("update_critical_notice_title", .red)
|
||||||
} else {
|
} else {
|
||||||
if updater.testBuild {
|
if updater.testBuild {
|
||||||
return (.updateTestNoticeTitle, .blue)
|
return ("update_test_notice_title", .blue)
|
||||||
} else {
|
} else {
|
||||||
return (.updateNormalNoticeTitle, .orange)
|
return ("update_normal_notice_title", .orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,13 +121,13 @@ extension ContentView {
|
|||||||
}, label: {
|
}, label: {
|
||||||
Group {
|
Group {
|
||||||
if hasRunSetup && !agentStatusChecker.running {
|
if hasRunSetup && !agentStatusChecker.running {
|
||||||
Text(.agentNotRunningNoticeTitle)
|
Text("agent_not_running_notice_title")
|
||||||
} else {
|
} else {
|
||||||
Text(.agentSetupNoticeTitle)
|
Text("agent_setup_notice_title")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
})
|
})
|
||||||
.buttonStyle(ToolbarButtonStyle(color: .orange))
|
.buttonStyle(ToolbarButtonStyle(color: .orange))
|
||||||
}
|
}
|
||||||
@@ -144,7 +138,7 @@ extension ContentView {
|
|||||||
showingAgentInfo = true
|
showingAgentInfo = true
|
||||||
}, label: {
|
}, label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(.agentRunningNoticeTitle)
|
Text("agent_running_notice_title")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white)
|
.foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white)
|
||||||
Circle()
|
Circle()
|
||||||
@@ -155,10 +149,10 @@ extension ContentView {
|
|||||||
.buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05)))
|
.buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05)))
|
||||||
.popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
|
.popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
|
||||||
VStack {
|
VStack {
|
||||||
Text(.agentRunningNoticeDetailTitle)
|
Text("agent_running_notice_detail_title")
|
||||||
.font(.title)
|
.font(.title)
|
||||||
.padding(5)
|
.padding(5)
|
||||||
Text(.agentRunningNoticeDetailDescription)
|
Text("agent_running_notice_detail_description")
|
||||||
.frame(width: 300)
|
.frame(width: 300)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -172,7 +166,7 @@ extension ContentView {
|
|||||||
showingAppPathNotice = true
|
showingAppPathNotice = true
|
||||||
}, label: {
|
}, label: {
|
||||||
Group {
|
Group {
|
||||||
Text(.appNotInApplicationsNoticeTitle)
|
Text("app_not_in_applications_notice_title")
|
||||||
}
|
}
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
@@ -184,7 +178,7 @@ extension ContentView {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 64)
|
.frame(width: 64)
|
||||||
Text(.appNotInApplicationsNoticeDetailDescription)
|
Text("app_not_in_applications_notice_detail_description")
|
||||||
.frame(maxWidth: 300)
|
.frame(maxWidth: 300)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import UniformTypeIdentifiers
|
|||||||
|
|
||||||
struct CopyableView: View {
|
struct CopyableView: View {
|
||||||
|
|
||||||
var title: LocalizedStringResource
|
var title: LocalizedStringKey
|
||||||
var image: Image
|
var image: Image
|
||||||
var text: String
|
var text: String
|
||||||
|
|
||||||
@State private var interactionState: InteractionState = .normal
|
@State private var interactionState: InteractionState = .normal
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
var content: some View {
|
|
||||||
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
image
|
image
|
||||||
@@ -21,7 +22,7 @@ struct CopyableView: View {
|
|||||||
.foregroundColor(primaryTextColor)
|
.foregroundColor(primaryTextColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
if interactionState != .normal {
|
if interactionState != .normal {
|
||||||
hoverIcon
|
Text(hoverText)
|
||||||
.bold()
|
.bold()
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
@@ -38,23 +39,17 @@ struct CopyableView: View {
|
|||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.font(.system(.body, design: .monospaced))
|
.font(.system(.body, design: .monospaced))
|
||||||
}
|
}
|
||||||
._background(interactionState: interactionState)
|
.background(backgroundColor)
|
||||||
.frame(minWidth: 150, maxWidth: .infinity)
|
.frame(minWidth: 150, maxWidth: .infinity)
|
||||||
}
|
.cornerRadius(10)
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
content
|
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
withAnimation {
|
withAnimation {
|
||||||
interactionState = hovering ? .hovering : .normal
|
interactionState = hovering ? .hovering : .normal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDrag({
|
.onDrag {
|
||||||
NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: UTType.utf8PlainText.identifier)
|
NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: UTType.utf8PlainText.identifier)
|
||||||
}, preview: {
|
}
|
||||||
content
|
|
||||||
._background(interactionState: .dragging)
|
|
||||||
})
|
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
copy()
|
copy()
|
||||||
withAnimation {
|
withAnimation {
|
||||||
@@ -71,23 +66,31 @@ struct CopyableView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
var hoverText: LocalizedStringKey {
|
||||||
var hoverIcon: some View {
|
|
||||||
switch interactionState {
|
switch interactionState {
|
||||||
case .hovering:
|
case .hovering:
|
||||||
Image(systemName: "document.on.document")
|
return "copyable_click_to_copy_button"
|
||||||
.accessibilityLabel(String(localized: "copyable_click_to_copy_button"))
|
|
||||||
case .clicking:
|
case .clicking:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
return "copyable_copied"
|
||||||
.accessibilityLabel(String(localized: "copyable_copied"))
|
case .normal:
|
||||||
case .normal, .dragging:
|
fatalError()
|
||||||
EmptyView()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var backgroundColor: Color {
|
||||||
|
switch interactionState {
|
||||||
|
case .normal:
|
||||||
|
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
|
||||||
|
case .hovering:
|
||||||
|
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
|
||||||
|
case .clicking:
|
||||||
|
return .accentColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var primaryTextColor: Color {
|
var primaryTextColor: Color {
|
||||||
switch interactionState {
|
switch interactionState {
|
||||||
case .normal, .hovering, .dragging:
|
case .normal, .hovering:
|
||||||
return Color(.textColor)
|
return Color(.textColor)
|
||||||
case .clicking:
|
case .clicking:
|
||||||
return .white
|
return .white
|
||||||
@@ -96,7 +99,7 @@ struct CopyableView: View {
|
|||||||
|
|
||||||
var secondaryTextColor: Color {
|
var secondaryTextColor: Color {
|
||||||
switch interactionState {
|
switch interactionState {
|
||||||
case .normal, .hovering, .dragging:
|
case .normal, .hovering:
|
||||||
return Color(.secondaryLabelColor)
|
return Color(.secondaryLabelColor)
|
||||||
case .clicking:
|
case .clicking:
|
||||||
return .white
|
return .white
|
||||||
@@ -108,59 +111,10 @@ struct CopyableView: View {
|
|||||||
NSPasteboard.general.setString(text, forType: .string)
|
NSPasteboard.general.setString(text, forType: .string)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
private enum InteractionState {
|
||||||
|
case normal, hovering, clicking
|
||||||
fileprivate enum InteractionState {
|
|
||||||
case normal, hovering, clicking, dragging
|
|
||||||
}
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
|
|
||||||
fileprivate func _background(interactionState: InteractionState) -> some View {
|
|
||||||
modifier(BackgroundViewModifier(interactionState: interactionState))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct BackgroundViewModifier: ViewModifier {
|
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
@Environment(\.appearsActive) private var appearsActive
|
|
||||||
|
|
||||||
let interactionState: InteractionState
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if interactionState == .dragging {
|
|
||||||
content
|
|
||||||
.background(backgroundColor(interactionState: interactionState), in: RoundedRectangle(cornerRadius: 15))
|
|
||||||
} else {
|
|
||||||
if #available(macOS 26.0, *) {
|
|
||||||
content
|
|
||||||
// Very thin opacity lets user hover anywhere over the view, glassEffect doesn't allow.
|
|
||||||
.background(.white.opacity(0.01), in: RoundedRectangle(cornerRadius: 15))
|
|
||||||
.glassEffect(.regular.tint(backgroundColor(interactionState: interactionState)), in: RoundedRectangle(cornerRadius: 15))
|
|
||||||
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
.background(backgroundColor(interactionState: interactionState))
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func backgroundColor(interactionState: InteractionState) -> Color {
|
|
||||||
guard appearsActive else { return Color.clear }
|
|
||||||
switch interactionState {
|
|
||||||
case .normal:
|
|
||||||
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
|
|
||||||
case .hovering, .dragging:
|
|
||||||
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
|
|
||||||
case .clicking:
|
|
||||||
return .accentColor
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|||||||
@@ -14,30 +14,30 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
Text(.createSecretTitle)
|
Text("create_secret_title")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Text(.createSecretNameLabel)
|
Text("create_secret_name_label")
|
||||||
TextField(String(localized: .createSecretNamePlaceholder), text: $name)
|
TextField("create_secret_name_placeholder", text: $name)
|
||||||
.focusable()
|
.focusable()
|
||||||
}
|
}
|
||||||
ThumbnailPickerView(items: [
|
ThumbnailPickerView(items: [
|
||||||
ThumbnailPickerView.Item(value: true, name: .createSecretRequireAuthenticationTitle, description: .createSecretRequireAuthenticationDescription, thumbnail: AuthenticationView()),
|
ThumbnailPickerView.Item(value: true, name: "create_secret_require_authentication_title", description: "create_secret_require_authentication_description", thumbnail: AuthenticationView()),
|
||||||
ThumbnailPickerView.Item(value: false, name: .createSecretNotifyTitle,
|
ThumbnailPickerView.Item(value: false, name: "create_secret_notify_title",
|
||||||
description: .createSecretNotifyDescription,
|
description: "create_secret_notify_description",
|
||||||
thumbnail: NotificationView())
|
thumbnail: NotificationView())
|
||||||
], selection: $requiresAuthentication)
|
], selection: $requiresAuthentication)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(.createSecretCancelButton) {
|
Button("create_secret_cancel_button") {
|
||||||
showing = false
|
showing = false
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
Button(.createSecretCreateButton, action: save)
|
Button("create_secret_create_button", action: save)
|
||||||
.disabled(name.isEmpty)
|
.disabled(name.isEmpty)
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
@@ -98,11 +98,11 @@ extension ThumbnailPickerView {
|
|||||||
struct Item<InnerValueType: Hashable>: Identifiable {
|
struct Item<InnerValueType: Hashable>: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let value: InnerValueType
|
let value: InnerValueType
|
||||||
let name: LocalizedStringResource
|
let name: LocalizedStringKey
|
||||||
let description: LocalizedStringResource
|
let description: LocalizedStringKey
|
||||||
let thumbnail: AnyView
|
let thumbnail: AnyView
|
||||||
|
|
||||||
init<ViewType: View>(value: InnerValueType, name: LocalizedStringResource, description: LocalizedStringResource, thumbnail: ViewType) {
|
init<ViewType: View>(value: InnerValueType, name: LocalizedStringKey, description: LocalizedStringKey, thumbnail: ViewType) {
|
||||||
self.value = value
|
self.value = value
|
||||||
self.name = name
|
self.name = name
|
||||||
self.description = description
|
self.description = description
|
||||||
@@ -112,10 +112,10 @@ extension ThumbnailPickerView {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor @Observable class SystemBackground {
|
@MainActor class SystemBackground: ObservableObject {
|
||||||
|
|
||||||
static let shared = SystemBackground()
|
static let shared = SystemBackground()
|
||||||
var image: NSImage?
|
@Published var image: NSImage?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
if let mainScreen = NSScreen.main, let imageURL = NSWorkspace.shared.desktopImageURL(for: mainScreen) {
|
if let mainScreen = NSScreen.main, let imageURL = NSWorkspace.shared.desktopImageURL(for: mainScreen) {
|
||||||
|
|||||||
@@ -18,24 +18,24 @@ struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
.padding()
|
.padding()
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
Text(.deleteConfirmationTitle(secretName: secret.name)).bold()
|
Text("delete_confirmation_title_\(secret.name)").bold()
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Text(.deleteConfirmationDescription(secretName: secret.name, confirmSecretName: secret.name))
|
Text("delete_confirmation_description_\(secret.name)_\(secret.name)")
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Text(.deleteConfirmationConfirmNameLabel)
|
Text("delete_confirmation_confirm_name_label")
|
||||||
TextField(secret.name, text: $confirm)
|
TextField(secret.name, text: $confirm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(.deleteConfirmationDeleteButton, action: delete)
|
Button("delete_confirmation_delete_button", action: delete)
|
||||||
.disabled(confirm != secret.name)
|
.disabled(confirm != secret.name)
|
||||||
Button(.deleteConfirmationCancelButton) {
|
Button("delete_confirmation_cancel_button") {
|
||||||
dismissalBlock(false)
|
dismissalBlock(false)
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ struct EmptyStoreImmutableView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
Text(.emptyStoreNonmodifiableTitle).bold()
|
Text("empty_store_nonmodifiable_title").bold()
|
||||||
Text(.emptyStoreNonmodifiableDescription)
|
Text("empty_store_nonmodifiable_description")
|
||||||
Text(.emptyStoreNonmodifiableSupportedKeyTypes)
|
Text("empty_store_nonmodifiable_supported_key_types")
|
||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +49,8 @@ struct EmptyStoreModifiableView: View {
|
|||||||
path.addLine(to: CGPoint(x: g.size.width - 3, y: 0))
|
path.addLine(to: CGPoint(x: g.size.width - 3, y: 0))
|
||||||
}.fill()
|
}.fill()
|
||||||
}.frame(height: (windowGeometry.size.height/2) - 20).padding()
|
}.frame(height: (windowGeometry.size.height/2) - 20).padding()
|
||||||
Text(.emptyStoreModifiableClickHereTitle).bold()
|
Text("empty_store_modifiable_click_here_title").bold()
|
||||||
Text(.emptyStoreModifiableClickHereDescription)
|
Text("empty_store_modifiable_click_here_description")
|
||||||
Spacer()
|
Spacer()
|
||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ struct NoStoresView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
Text(.noSecureStorageTitle)
|
Text("no_secure_storage_title")
|
||||||
.bold()
|
.bold()
|
||||||
Text(.noSecureStorageDescription)
|
Text("no_secure_storage_description")
|
||||||
Link(.noSecureStorageYubicoLink, destination: URL(string: "https://www.yubico.com/products/compare-yubikey-5-series/")!)
|
Link("no_secure_storage_yubico_link", destination: URL(string: "https://www.yubico.com/products/compare-yubikey-5-series/")!)
|
||||||
}.padding()
|
}.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ struct RenameSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
.padding()
|
.padding()
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
Text(.renameTitle(secretName: secret.name))
|
Text("rename_title_\(secret.name)")
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
@@ -28,10 +28,10 @@ struct RenameSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(.renameRenameButton, action: rename)
|
Button("rename_rename_button", action: rename)
|
||||||
.disabled(newName.count == 0)
|
.disabled(newName.count == 0)
|
||||||
.keyboardShortcut(.return)
|
.keyboardShortcut(.return)
|
||||||
Button(.renameCancelButton) {
|
Button("rename_cancel_button") {
|
||||||
dismissalBlock(false)
|
dismissalBlock(false)
|
||||||
}.keyboardShortcut(.cancelAction)
|
}.keyboardShortcut(.cancelAction)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ struct SecretDetailView<SecretType: Secret>: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret))
|
CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret))
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
CopyableView(title: .secretDetailMd5FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret))
|
CopyableView(title: "secret_detail_md5_fingerprint_label", image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret))
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
CopyableView(title: .secretDetailPublicKeyLabel, image: Image(systemName: "key"), text: keyString)
|
CopyableView(title: "secret_detail_public_key_label", image: Image(systemName: "key"), text: keyString)
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
CopyableView(title: .secretDetailPublicKeyPathLabel, image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret))
|
CopyableView(title: "secret_detail_public_key_path_label", image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ struct SecretListItemView: View {
|
|||||||
.contextMenu {
|
.contextMenu {
|
||||||
if store is AnySecretStoreModifiable {
|
if store is AnySecretStoreModifiable {
|
||||||
Button(action: { isRenaming = true }) {
|
Button(action: { isRenaming = true }) {
|
||||||
Text(.secretListRenameButton)
|
Text("secret_list_rename_button")
|
||||||
}
|
}
|
||||||
Button(action: { isDeleting = true }) {
|
Button(action: { isDeleting = true }) {
|
||||||
Text(.secretListDeleteButton)
|
Text("secret_list_delete_button")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,124 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SetupView: View {
|
struct SetupView: View {
|
||||||
|
|
||||||
|
@Binding var visible: Bool
|
||||||
|
@Binding var setupComplete: Bool
|
||||||
|
|
||||||
|
@State var installed = false
|
||||||
|
@State var updates = false
|
||||||
|
@State var sshConfig = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
NewStepView(title: "setup_agent_title", description: "setup_agent_description") {
|
||||||
|
OnboardingButton("setup_agent_install_button", installed) {
|
||||||
|
Task {
|
||||||
|
await LaunchAgentController().install()
|
||||||
|
installed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
NewStepView(title: "setup_updates_title", description: "setup_updates_description") {
|
||||||
|
OnboardingButton("setup_updates_ok", false) {
|
||||||
|
Task {
|
||||||
|
updates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
NewStepView(title: "setup_ssh_title", description: "setup_ssh_description") {
|
||||||
|
HStack {
|
||||||
|
OnboardingButton("setup_ssh_added_manually_button", false) {
|
||||||
|
sshConfig = true
|
||||||
|
}
|
||||||
|
OnboardingButton("Add Automatically", false) {
|
||||||
|
// let controller = ShellConfigurationController()
|
||||||
|
// if controller.addToShell(shellInstructions: selectedShellInstruction) {
|
||||||
|
// }
|
||||||
|
sshConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.frame(minWidth: 500, idealWidth: 500, minHeight: 500, idealHeight: 500)
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OnboardingButton: View {
|
||||||
|
|
||||||
|
let label: LocalizedStringResource
|
||||||
|
let complete: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
init(_ label: LocalizedStringResource, _ complete: Bool, action: @escaping () -> Void) {
|
||||||
|
self.label = label
|
||||||
|
self.complete = complete
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(label)
|
||||||
|
if complete {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
.disabled(complete)
|
||||||
|
.styled
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var styled: some View {
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
buttonStyle(.glassProminent)
|
||||||
|
} else {
|
||||||
|
buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewStepView<Content: View>: View {
|
||||||
|
|
||||||
|
let title: LocalizedStringResource
|
||||||
|
let description: LocalizedStringResource
|
||||||
|
let actions: Content
|
||||||
|
|
||||||
|
init(title: LocalizedStringResource, description: LocalizedStringResource, actions: () -> Content) {
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.actions = actions()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.bold()
|
||||||
|
Text(description)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 20)
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OldSetupView: View {
|
||||||
|
|
||||||
@State var stepIndex = 0
|
@State var stepIndex = 0
|
||||||
@Binding var visible: Bool
|
@Binding var visible: Bool
|
||||||
@Binding var setupComplete: Bool
|
@Binding var setupComplete: Bool
|
||||||
@@ -61,7 +179,7 @@ struct StepView: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.foregroundColor(.green)
|
.foregroundColor(.green)
|
||||||
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
|
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
|
||||||
Text(.setupStepCompleteSymbol)
|
Text("setup_step_complete_symbol")
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.bold()
|
.bold()
|
||||||
} else {
|
} else {
|
||||||
@@ -101,14 +219,14 @@ extension StepView {
|
|||||||
|
|
||||||
struct SetupStepView<Content> : View where Content : View {
|
struct SetupStepView<Content> : View where Content : View {
|
||||||
|
|
||||||
let title: LocalizedStringResource
|
let title: LocalizedStringKey
|
||||||
let image: Image
|
let image: Image
|
||||||
let bodyText: LocalizedStringResource
|
let bodyText: LocalizedStringKey
|
||||||
let buttonTitle: LocalizedStringResource
|
let buttonTitle: LocalizedStringKey
|
||||||
let buttonAction: () -> Void
|
let buttonAction: () -> Void
|
||||||
let content: Content
|
let content: Content
|
||||||
|
|
||||||
init(title: LocalizedStringResource, image: Image, bodyText: LocalizedStringResource, buttonTitle: LocalizedStringResource, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) {
|
init(title: LocalizedStringKey, image: Image, bodyText: LocalizedStringKey, buttonTitle: LocalizedStringKey, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.image = image
|
self.image = image
|
||||||
self.bodyText = bodyText
|
self.bodyText = bodyText
|
||||||
@@ -145,12 +263,12 @@ struct SecretAgentSetupView: View {
|
|||||||
let buttonAction: () -> Void
|
let buttonAction: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SetupStepView(title: .setupAgentTitle,
|
SetupStepView(title: "setup_agent_title",
|
||||||
image: Image(nsImage: NSApplication.shared.applicationIconImage),
|
image: Image(nsImage: NSApplication.shared.applicationIconImage),
|
||||||
bodyText: .setupAgentDescription,
|
bodyText: "setup_agent_description",
|
||||||
buttonTitle: .setupAgentInstallButton,
|
buttonTitle: "setup_agent_install_button",
|
||||||
buttonAction: install) {
|
buttonAction: install) {
|
||||||
Text(.setupAgentActivityMonitorDescription)
|
Text("setup_agent_activity_monitor_description")
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,12 +290,12 @@ struct SSHAgentSetupView: View {
|
|||||||
@State private var selectedShellInstruction: ShellConfigInstruction = controller.shellInstructions.first!
|
@State private var selectedShellInstruction: ShellConfigInstruction = controller.shellInstructions.first!
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SetupStepView(title: .setupSshTitle,
|
SetupStepView(title: "setup_ssh_title",
|
||||||
image: Image(systemName: "terminal"),
|
image: Image(systemName: "terminal"),
|
||||||
bodyText: .setupSshDescription,
|
bodyText: "setup_ssh_description",
|
||||||
buttonTitle: .setupSshAddedManuallyButton,
|
buttonTitle: "setup_ssh_added_manually_button",
|
||||||
buttonAction: buttonAction) {
|
buttonAction: buttonAction) {
|
||||||
Link(.setupThirdPartyFaqLink, destination: URL(string: "https://github.com/maxgoedjen/secretive/blob/main/APP_CONFIG.md")!)
|
Link("setup_third_party_faq_link", destination: URL(string: "https://github.com/maxgoedjen/secretive/blob/main/APP_CONFIG.md")!)
|
||||||
Picker(selection: $selectedShellInstruction, label: EmptyView()) {
|
Picker(selection: $selectedShellInstruction, label: EmptyView()) {
|
||||||
ForEach(SSHAgentSetupView.controller.shellInstructions) { instruction in
|
ForEach(SSHAgentSetupView.controller.shellInstructions) { instruction in
|
||||||
Text(instruction.shell)
|
Text(instruction.shell)
|
||||||
@@ -185,8 +303,8 @@ struct SSHAgentSetupView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}.pickerStyle(SegmentedPickerStyle())
|
}.pickerStyle(SegmentedPickerStyle())
|
||||||
CopyableView(title: .setupSshAddToConfigButton(configPath: selectedShellInstruction.shellConfigPath), image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text)
|
CopyableView(title: "setup_ssh_add_to_config_button_\(selectedShellInstruction.shellConfigPath)", image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text)
|
||||||
Button(.setupSshAddForMeButton) {
|
Button("setup_ssh_add_for_me_button") {
|
||||||
let controller = ShellConfigurationController()
|
let controller = ShellConfigurationController()
|
||||||
if controller.addToShell(shellInstructions: selectedShellInstruction) {
|
if controller.addToShell(shellInstructions: selectedShellInstruction) {
|
||||||
buttonAction()
|
buttonAction()
|
||||||
@@ -216,12 +334,12 @@ struct UpdaterExplainerView: View {
|
|||||||
let buttonAction: () -> Void
|
let buttonAction: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SetupStepView(title: .setupUpdatesTitle,
|
SetupStepView(title: "setup_updates_title",
|
||||||
image: Image(systemName: "dot.radiowaves.left.and.right"),
|
image: Image(systemName: "dot.radiowaves.left.and.right"),
|
||||||
bodyText: .setupUpdatesDescription,
|
bodyText: "setup_updates_description",
|
||||||
buttonTitle: .setupUpdatesOk,
|
buttonTitle: "setup_updates_ok",
|
||||||
buttonAction: buttonAction) {
|
buttonAction: buttonAction) {
|
||||||
Link(.setupUpdatesReadmore, destination: SetupView.Constants.updaterFAQURL)
|
Link("setup_updates_readmore", destination: SetupView.Constants.updaterFAQURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ struct StoreListView: View {
|
|||||||
|
|
||||||
@Binding var activeSecret: AnySecret?
|
@Binding var activeSecret: AnySecret?
|
||||||
|
|
||||||
@Environment(\.secretStoreList) private var storeList
|
@Environment(SecretStoreList.self) private var storeList: SecretStoreList
|
||||||
|
|
||||||
private func secretDeleted(secret: AnySecret) {
|
private func secretDeleted(secret: AnySecret) {
|
||||||
activeSecret = nextDefaultSecret
|
activeSecret = nextDefaultSecret
|
||||||
@@ -22,13 +22,17 @@ struct StoreListView: View {
|
|||||||
ForEach(storeList.stores) { store in
|
ForEach(storeList.stores) { store in
|
||||||
if store.isAvailable {
|
if store.isAvailable {
|
||||||
Section(header: Text(store.name)) {
|
Section(header: Text(store.name)) {
|
||||||
ForEach(store.secrets) { secret in
|
if store.secrets.isEmpty {
|
||||||
SecretListItemView(
|
EmptyStoreView(store: store)
|
||||||
store: store,
|
} else {
|
||||||
secret: secret,
|
ForEach(store.secrets) { secret in
|
||||||
deletedSecret: secretDeleted,
|
SecretListItemView(
|
||||||
renamedSecret: secretRenamed
|
store: store,
|
||||||
)
|
secret: secret,
|
||||||
|
deletedSecret: secretDeleted,
|
||||||
|
renamedSecret: secretRenamed
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,12 +41,8 @@ struct StoreListView: View {
|
|||||||
} detail: {
|
} detail: {
|
||||||
if let activeSecret {
|
if let activeSecret {
|
||||||
SecretDetailView(secret: activeSecret)
|
SecretDetailView(secret: activeSecret)
|
||||||
} else if let nextDefaultSecret {
|
|
||||||
// This just means onAppear hasn't executed yet.
|
|
||||||
// Do this to avoid a blip.
|
|
||||||
SecretDetailView(secret: nextDefaultSecret)
|
|
||||||
} else {
|
} else {
|
||||||
EmptyStoreView(store: storeList.modifiableStore ?? storeList.stores.first)
|
EmptyStoreView(store: storeList.stores.first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationSplitViewStyle(.balanced)
|
.navigationSplitViewStyle(.balanced)
|
||||||
@@ -57,7 +57,7 @@ struct StoreListView: View {
|
|||||||
extension StoreListView {
|
extension StoreListView {
|
||||||
|
|
||||||
private var nextDefaultSecret: AnySecret? {
|
private var nextDefaultSecret: AnySecret? {
|
||||||
return storeList.stores.first(where: { !$0.secrets.isEmpty })?.secrets.first
|
return storeList.stores.compactMap(\.secrets.first).first
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,42 +16,22 @@ struct ToolbarButtonStyle: ButtonStyle {
|
|||||||
self.lightColor = lightColor
|
self.lightColor = lightColor
|
||||||
self.darkColor = darkColor
|
self.darkColor = darkColor
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
private var glassTint: Color {
|
|
||||||
if !hovering {
|
|
||||||
colorScheme == .light ? lightColor : darkColor
|
|
||||||
} else {
|
|
||||||
colorScheme == .light ? lightColor.exposureAdjust(1) : darkColor.exposureAdjust(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
if #available(macOS 26.0, *) {
|
configuration.label
|
||||||
configuration
|
.padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
|
||||||
.label
|
.background(colorScheme == .light ? lightColor : darkColor)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
.glassEffect(.regular.tint(glassTint), in: .capsule)
|
.overlay(
|
||||||
.onHover { hovering in
|
RoundedRectangle(cornerRadius: 5)
|
||||||
|
.stroke(colorScheme == .light ? .black.opacity(0.15) : .white.opacity(0.15), lineWidth: 1)
|
||||||
|
.background(hovering ? (colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.05)) : Color.clear)
|
||||||
|
)
|
||||||
|
.onHover { hovering in
|
||||||
|
withAnimation {
|
||||||
self.hovering = hovering
|
self.hovering = hovering
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
configuration
|
|
||||||
.label
|
|
||||||
.background(colorScheme == .light ? lightColor : darkColor)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 5)
|
|
||||||
.stroke(colorScheme == .light ? .black.opacity(0.15) : .white.opacity(0.15), lineWidth: 1)
|
|
||||||
.background(hovering ? (colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.05)) : Color.clear)
|
|
||||||
)
|
|
||||||
.onHover { hovering in
|
|
||||||
withAnimation {
|
|
||||||
self.hovering = hovering
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Brief
|
import Brief
|
||||||
|
|
||||||
struct UpdateDetailView: View {
|
struct UpdateDetailView<UpdaterType: Updater>: View {
|
||||||
|
|
||||||
@Environment(\.updater) var updater: any UpdaterProtocol
|
@Environment(UpdaterType.self) var updater: UpdaterType
|
||||||
|
|
||||||
let update: Release
|
let update: Release
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
Text(.updateVersionName(updateName: update.name)).font(.title)
|
Text("update_version_name_\(update.name)").font(.title)
|
||||||
GroupBox(label: Text(.updateReleaseNotesTitle)) {
|
GroupBox(label: Text("update_release_notes_title")) {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
attributedBody
|
attributedBody
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
if !update.critical {
|
if !update.critical {
|
||||||
Button(.updateIgnoreButton) {
|
Button("update_ignore_button") {
|
||||||
Task {
|
Task {
|
||||||
await updater.ignore(release: update)
|
await updater.ignore(release: update)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
Button(.updateUpdateButton) {
|
Button("update_update_button") {
|
||||||
NSWorkspace.shared.open(update.html_url)
|
NSWorkspace.shared.open(update.html_url)
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
|
|||||||