From 6a020b48e86590da443f5264bbbef2e58698e383 Mon Sep 17 00:00:00 2001 From: Amr Shawqy Date: Thu, 23 Jul 2026 20:47:22 +0300 Subject: [PATCH] Establish stable self-signed macOS releases --- .github/dmg-background.png | Bin 11403 -> 0 bytes .github/dmgbuild-settings.py | 37 -- .github/release-contract-version | 1 + .github/workflows/self-signed-release.yml | 265 ++++++++++ .gitignore | 15 + README.md | 50 +- .../xcshareddata/swiftpm/Package.resolved | 141 +++++ Speaky/AppState.swift | 1 - Speaky/Models/Settings.swift | 59 ++- Speaky/Resources/Info.plist | 20 +- Speaky/Services/ModelManager.swift | 11 +- Speaky/Services/PasteService.swift | 2 +- .../Transcription/ParakeetEngine.swift | 11 +- Speaky/Services/UpdaterManager.swift | 62 --- Speaky/SpeakyApp.swift | 15 +- Speaky/Utilities/Constants.swift | 28 +- Speaky/Utilities/ParakeetModelPaths.swift | 24 + Speaky/Utilities/PersistenceContainer.swift | 26 + Speaky/Views/MainWindow/SettingsView.swift | 19 - Speaky/Views/MenuBar/MenuBarView.swift | 5 - SpeakyTests/Mocks/MockServices.swift | 26 +- SpeakyTests/PersistenceContainerTests.swift | 15 + SpeakyTests/SettingsStoreTests.swift | 30 ++ .../TranscriptionCoordinatorTests.swift | 29 +- build.sh | 485 ++++++++++++------ docs/appcast-arm64.xml | 8 - docs/appcast-x86_64.xml | 8 - docs/build-signing.md | 247 +++++++++ docs/end-user-installation.md | 145 ++++++ docs/maintainer-setup.md | 224 ++++++++ project.yml | 49 +- scripts/create-self-signed-certificate.sh | 90 ++++ scripts/verify-app-identity.sh | 193 +++++++ 33 files changed, 1949 insertions(+), 392 deletions(-) delete mode 100644 .github/dmg-background.png delete mode 100644 .github/dmgbuild-settings.py create mode 100644 .github/release-contract-version create mode 100644 .github/workflows/self-signed-release.yml create mode 100644 Speaky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 Speaky/Services/UpdaterManager.swift create mode 100644 Speaky/Utilities/ParakeetModelPaths.swift create mode 100644 Speaky/Utilities/PersistenceContainer.swift create mode 100644 SpeakyTests/PersistenceContainerTests.swift create mode 100644 SpeakyTests/SettingsStoreTests.swift delete mode 100644 docs/appcast-arm64.xml delete mode 100644 docs/appcast-x86_64.xml create mode 100644 docs/build-signing.md create mode 100644 docs/end-user-installation.md create mode 100644 docs/maintainer-setup.md create mode 100755 scripts/create-self-signed-certificate.sh create mode 100755 scripts/verify-app-identity.sh diff --git a/.github/dmg-background.png b/.github/dmg-background.png deleted file mode 100644 index 30393d5576c77dcf997feed8ec0a6f3a4639619e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11403 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 MB~3-Nym|0{160si-~a#s diff --git a/.github/dmgbuild-settings.py b/.github/dmgbuild-settings.py deleted file mode 100644 index 1b0f4cd..0000000 --- a/.github/dmgbuild-settings.py +++ /dev/null @@ -1,37 +0,0 @@ -import os - -# DMG settings for Speaky -# Used by: dmgbuild -s .github/dmgbuild-settings.py "Speaky" release/Speaky--.dmg - -application = os.environ.get("APP_PATH", "release/Speaky.app") -app_name = os.path.basename(application) -background = os.environ.get("DMG_BACKGROUND", ".github/dmg-background.png") - -# Volume settings -format = "UDZO" -size = None # auto-calculate -files = [application] -symlinks = {"Applications": "/Applications"} - -# Window appearance -window_rect = ((200, 160), (660, 400)) -icon_size = 120 -grid_spacing = 100 -text_size = 14 - -# Icon positions (left = app, right = Applications) -icon_locations = { - app_name: (165, 180), - "Applications": (495, 180), -} - -# Hide UI chrome -show_toolbar = False -show_sidebar = False -show_status_bar = False -show_pathbar = False -show_tab_view = False -show_icon_preview = False - -# Background -background = background if os.path.exists(background) else None diff --git a/.github/release-contract-version b/.github/release-contract-version new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/.github/release-contract-version @@ -0,0 +1 @@ +1 diff --git a/.github/workflows/self-signed-release.yml b/.github/workflows/self-signed-release.yml new file mode 100644 index 0000000..1e93b23 --- /dev/null +++ b/.github/workflows/self-signed-release.yml @@ -0,0 +1,265 @@ +name: Build self-signed release + +on: + workflow_dispatch: + inputs: + release_tag: + description: Existing semantic-version tag to build (for example, v2.1.0) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: self-signed-release-${{ inputs.release_tag }} + cancel-in-progress: false + +env: + DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer + SPEAKY_RELEASE_CONTRACT_VERSION: "1" + SPEAKY_XCODE_VERSION: "26.3" + SPEAKY_XCODE_BUILD: "17C529" + SPEAKY_XCODEGEN_VERSION: "2.46.0" + SPEAKY_XCODEGEN_SHA256: 4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806 + +jobs: + test: + if: github.repository == 'bedriyan/speaky' && github.ref == 'refs/heads/main' + runs-on: macos-15 + timeout-minutes: 45 + outputs: + source_sha: ${{ steps.validate.outputs.source_sha }} + + steps: + - name: Check out tagged source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.release_tag }} + fetch-depth: 0 + persist-credentials: false + + - name: Validate release source and contract + id: validate + env: + RELEASE_TAG: ${{ inputs.release_tag }} + TRUSTED_MAIN_SHA: ${{ github.sha }} + run: | + set -euo pipefail + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "release_tag must use vMAJOR.MINOR.PATCH." + exit 1 + } + git show-ref --verify --quiet "refs/tags/$RELEASE_TAG" + + source_sha="$(git rev-parse "$RELEASE_TAG^{commit}")" + test "$source_sha" = "$(git rev-parse HEAD)" + test "$source_sha" = "$TRUSTED_MAIN_SHA" + echo "Validated release source: $RELEASE_TAG -> $source_sha" + echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" + + project_version="$(awk '/MARKETING_VERSION:/ { gsub(/"/, "", $2); print $2; exit }' project.yml)" + test "$RELEASE_TAG" = "v$project_version" + + test -f .github/release-contract-version + test "$(tr -d '[:space:]' < .github/release-contract-version)" = \ + "$SPEAKY_RELEASE_CONTRACT_VERSION" + test -x build.sh + test -x scripts/verify-app-identity.sh + git ls-files --error-unmatch \ + Speaky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved + grep -Fq 'SPEAKY_ALLOW_ADHOC' build.sh + grep -Fq 'cannot establish a stable identity' scripts/verify-app-identity.sh + + - name: Verify pinned Xcode + run: | + set -euo pipefail + test -x "$DEVELOPER_DIR/usr/bin/xcodebuild" + test "$(xcodebuild -version | sed -n '1p')" = \ + "Xcode $SPEAKY_XCODE_VERSION" + test "$(xcodebuild -version | sed -n '2p')" = \ + "Build version $SPEAKY_XCODE_BUILD" + + - name: Install pinned XcodeGen + run: | + set -euo pipefail + archive="$RUNNER_TEMP/xcodegen.zip" + curl --fail --silent --show-error --location \ + "https://github.com/yonaskolb/XcodeGen/releases/download/$SPEAKY_XCODEGEN_VERSION/xcodegen.zip" \ + --output "$archive" + echo "$SPEAKY_XCODEGEN_SHA256 $archive" | shasum -a 256 -c - + ditto -x -k "$archive" "$RUNNER_TEMP" + test "$("$RUNNER_TEMP/xcodegen/bin/xcodegen" --version)" = \ + "Version: $SPEAKY_XCODEGEN_VERSION" + echo "$RUNNER_TEMP/xcodegen/bin" >> "$GITHUB_PATH" + + - name: Generate project and run tests + run: | + set -euo pipefail + package_lock=Speaky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved + package_lock_sha="$(shasum -a 256 "$package_lock" | awk '{ print $1 }')" + xcodegen generate + test -f "$package_lock" + test "$(shasum -a 256 "$package_lock" | awk '{ print $1 }')" = \ + "$package_lock_sha" + xcodebuild test \ + -project Speaky.xcodeproj \ + -scheme Speaky \ + -configuration Debug \ + -destination 'platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/SpeakyTestsDerivedData" \ + -clonedSourcePackagesDirPath "$RUNNER_TEMP/SpeakyTestPackages" \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile + git diff --exit-code -- "$package_lock" + + build: + if: github.repository == 'bedriyan/speaky' && github.ref == 'refs/heads/main' + needs: test + runs-on: macos-15 + timeout-minutes: 60 + environment: + name: release-signing + + steps: + - name: Check out tested commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.test.outputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Revalidate immutable release source + env: + EXPECTED_SHA: ${{ needs.test.outputs.source_sha }} + RELEASE_TAG: ${{ inputs.release_tag }} + TRUSTED_MAIN_SHA: ${{ github.sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + test "$EXPECTED_SHA" = "$TRUSTED_MAIN_SHA" + git show-ref --verify --quiet "refs/tags/$RELEASE_TAG" + test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$EXPECTED_SHA" + test "$(tr -d '[:space:]' < .github/release-contract-version)" = \ + "$SPEAKY_RELEASE_CONTRACT_VERSION" + test -x scripts/verify-app-identity.sh + git ls-files --error-unmatch \ + Speaky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved + + - name: Verify pinned Xcode + run: | + set -euo pipefail + test -x "$DEVELOPER_DIR/usr/bin/xcodebuild" + test "$(xcodebuild -version | sed -n '1p')" = \ + "Xcode $SPEAKY_XCODE_VERSION" + test "$(xcodebuild -version | sed -n '2p')" = \ + "Build version $SPEAKY_XCODE_BUILD" + + - name: Install pinned XcodeGen + run: | + set -euo pipefail + archive="$RUNNER_TEMP/xcodegen.zip" + curl --fail --silent --show-error --location \ + "https://github.com/yonaskolb/XcodeGen/releases/download/$SPEAKY_XCODEGEN_VERSION/xcodegen.zip" \ + --output "$archive" + echo "$SPEAKY_XCODEGEN_SHA256 $archive" | shasum -a 256 -c - + ditto -x -k "$archive" "$RUNNER_TEMP" + test "$("$RUNNER_TEMP/xcodegen/bin/xcodegen" --version)" = \ + "Version: $SPEAKY_XCODEGEN_VERSION" + echo "$RUNNER_TEMP/xcodegen/bin" >> "$GITHUB_PATH" + + - name: Import stable signing identity + env: + SIGNING_P12_BASE64: ${{ secrets.SPEAKY_SIGNING_CERTIFICATE_P12 }} + SIGNING_P12_PASSWORD: ${{ secrets.SPEAKY_SIGNING_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + : "${SIGNING_P12_BASE64:?Missing SPEAKY_SIGNING_CERTIFICATE_P12 environment secret}" + : "${SIGNING_P12_PASSWORD:?Missing SPEAKY_SIGNING_CERTIFICATE_PASSWORD environment secret}" + + keychain_path="$RUNNER_TEMP/speaky-signing.keychain-db" + p12_path="$RUNNER_TEMP/speaky-signing.p12" + certificate_pem_path="$RUNNER_TEMP/speaky-signing.pem" + certificate_der_path="$RUNNER_TEMP/speaky-signing.cer" + keychain_password="$(openssl rand -hex 32)" + + echo "SPEAKY_SIGNING_CERTIFICATE=$certificate_der_path" >> "$GITHUB_ENV" + echo "SPEAKY_SIGNING_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV" + + printf '%s' "$SIGNING_P12_BASE64" | base64 -D > "$p12_path" + chmod 600 "$p12_path" + + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security list-keychains -d user -s "$keychain_path" + security import "$p12_path" \ + -k "$keychain_path" \ + -P "$SIGNING_P12_PASSWORD" \ + -T /usr/bin/codesign + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$keychain_password" \ + "$keychain_path" + + security find-certificate -a -p "$keychain_path" > "$certificate_pem_path" + openssl x509 \ + -in "$certificate_pem_path" \ + -outform DER \ + -out "$certificate_der_path" + + sudo security add-trusted-cert \ + -d \ + -r trustRoot \ + -p codeSign \ + -k /Library/Keychains/System.keychain \ + "$certificate_der_path" + + signing_identity="$( + security find-identity -v -p codesigning "$keychain_path" | + awk '/^[[:space:]]*[0-9]+\)/ { print $2; exit }' + )" + test -n "$signing_identity" + + echo "SPEAKY_SIGNING_IDENTITY=$signing_identity" >> "$GITHUB_ENV" + + - name: Build and verify release DMGs + run: | + set -euo pipefail + ./build.sh separate + ( + cd build + shasum -a 256 Speaky-*.dmg > SHA256SUMS.txt + shasum -a 256 -c SHA256SUMS.txt + ) + + - name: Remove temporary signing material + if: always() + run: | + if [ -n "${SPEAKY_SIGNING_CERTIFICATE:-}" ] && + [ -f "$SPEAKY_SIGNING_CERTIFICATE" ]; then + sudo security remove-trusted-cert \ + -d \ + "$SPEAKY_SIGNING_CERTIFICATE" + fi + if [ -n "${SPEAKY_SIGNING_KEYCHAIN:-}" ] && + [ -f "$SPEAKY_SIGNING_KEYCHAIN" ]; then + security delete-keychain "$SPEAKY_SIGNING_KEYCHAIN" + fi + rm -f \ + "$RUNNER_TEMP/speaky-signing.p12" \ + "$RUNNER_TEMP/speaky-signing.pem" \ + "$RUNNER_TEMP/speaky-signing.cer" + test ! -e "$RUNNER_TEMP/speaky-signing.p12" + test ! -e "$RUNNER_TEMP/speaky-signing.keychain-db" + + - name: Upload verified artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: Speaky-self-signed-${{ inputs.release_tag }} + path: | + build/Speaky-*.dmg + build/SHA256SUMS.txt + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index 36dfcf7..c01e064 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,17 @@ build/ Packages/ Package.resolved +# The generated project is ignored except for the application dependency lock. +!Speaky.xcodeproj/ +Speaky.xcodeproj/* +!Speaky.xcodeproj/project.xcworkspace/ +Speaky.xcodeproj/project.xcworkspace/* +!Speaky.xcodeproj/project.xcworkspace/xcshareddata/ +Speaky.xcodeproj/project.xcworkspace/xcshareddata/* +!Speaky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ +Speaky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/* +!Speaky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved + # macOS .DS_Store .AppleDouble @@ -35,6 +46,10 @@ Speaky.app/ build-*/ release/ +# Signing secrets +*.p12 +*.pfx + # AI / development tools .claude/ .planning/ diff --git a/README.md b/README.md index 4e32282..64f4111 100644 --- a/README.md +++ b/README.md @@ -28,17 +28,25 @@ | [**Speaky-Apple-Silicon.dmg**](https://github.com/bedriyan/speaky/releases/latest/download/Speaky-2.0.4-Apple-Silicon.dmg) | Apple Silicon (M1/M2/M3/M4) | Parakeet V3 | | [**Speaky-Intel.dmg**](https://github.com/bedriyan/speaky/releases/latest/download/Speaky-2.0.4-Intel.dmg) | Intel (x86_64) | Whisper Medium Q5 | +> **Release identity note:** The v2.0.4 artifacts predate the stable self-signed release process documented in this repository. The maintainer should identify the first release produced with the new process in its release notes. + ### Installation 1. Download the DMG for your Mac. 2. Open the DMG and drag **Speaky** to the **Applications** folder. -3. Before first launch, open Terminal and run: - ```bash - xattr -cr /Applications/Speaky.app - ``` -4. Open Speaky from Applications. On first launch, you may need to **right-click > Open**. +3. Open Speaky from Applications. +4. If macOS blocks the first launch, open **System Settings → Privacy & Security**, select **Open Anyway**, and confirm. +5. Grant microphone and Accessibility access when requested. + +> **Why is approval needed?** Speaky releases are not notarized by Apple. Only approve an app downloaded from the official GitHub Release. + +See [Install Speaky on macOS](docs/end-user-installation.md) for architecture selection, checksum verification, permissions, upgrades, and troubleshooting. + +### Accessibility permissions across updates -> **Why is this needed?** Speaky is open-source and not notarized with Apple ($99/year requirement). The `xattr` command tells macOS you trust this app. You only need to do this once. +Beginning with the first release produced by the documented free signing process, official Speaky releases should use the same stable identity so macOS recognizes updates as the same Accessibility client. Contributor builds use a separate **Speaky Debug** identity and separate mutable data. + +If an older ad-hoc build created duplicate Speaky entries in System Settings, follow the [one-time upgrade cleanup](docs/end-user-installation.md#upgrading-from-an-older-ad-hoc-release). ## Features @@ -77,23 +85,37 @@ You can also import any custom Whisper `.bin` model via Settings > Advanced > Im ## Build from Source -Speaky uses [XcodeGen](https://github.com/yonaskolb/XcodeGen) to generate the Xcode project. +Speaky uses [XcodeGen](https://github.com/yonaskolb/XcodeGen) 2.46.0 to generate the Xcode project. ```bash # Install xcodegen brew install xcodegen +xcodegen --version # Must report Version: 2.46.0 -# Generate project and build +# Generate the project xcodegen generate -xcodebuild -project Speaky.xcodeproj -scheme Speaky -configuration Release build -# Or use the build script for release builds -./build.sh # Universal binary -./build.sh silicon # Apple Silicon only -./build.sh intel # Intel only -./build.sh separate # Both architectures + DMGs +# Routine contributor builds use "Speaky Debug" and +# com.bedriyan.speaky.debug so they do not conflict with an installed release. +xcodebuild \ + -project Speaky.xcodeproj \ + -scheme Speaky \ + -configuration Debug \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile \ + build + +# Unpublished local DMGs require an explicit ad-hoc opt-in +SPEAKY_ALLOW_ADHOC=1 ./build.sh # Universal binary +SPEAKY_ALLOW_ADHOC=1 ./build.sh silicon # Apple Silicon only +SPEAKY_ALLOW_ADHOC=1 ./build.sh intel # Intel only +SPEAKY_ALLOW_ADHOC=1 ./build.sh separate # Both architectures + DMGs ``` +The packaging script fails closed unless it receives the canonical stable identity or the explicit local-only ad-hoc opt-in. Release maintainers should complete [Maintainer release-signing setup](docs/maintainer-setup.md) once, then follow [Release build and signing](docs/build-signing.md) for every version. + +Prefer the Debug build for day-to-day development. An ad-hoc Release DMG uses the production bundle identifier, must not be published, and should not be installed alongside an official release. + ## Architecture ``` diff --git a/Speaky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Speaky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..86fd71d --- /dev/null +++ b/Speaky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,141 @@ +{ + "originHash" : "5a533a9cb905c735f7eb2f0da2d34d4cbf29f0ea3da1ec551dad7b4ac1a0fc2f", + "pins" : [ + { + "identity" : "dynamicnotchkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MrKai77/DynamicNotchKit", + "state" : { + "revision" : "cd0b3e52d537db115ad3a9d89601f20e0bee8d27", + "version" : "1.1.0" + } + }, + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/EventSource.git", + "state" : { + "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version" : "1.4.1" + } + }, + { + "identity" : "fluidaudio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/FluidInference/FluidAudio", + "state" : { + "revision" : "716f1c9648abea9a057f424febe00bf65a77f867", + "version" : "0.13.2" + } + }, + { + "identity" : "keyboardshortcuts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sindresorhus/KeyboardShortcuts", + "state" : { + "revision" : "ac12762853126cf2e7ad63a6a58e1c9f58c6a0ee", + "version" : "1.17.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "47d3869a7291f085c1fb9fb1e6d3b97a793f45c6", + "version" : "4.5.1" + } + }, + { + "identity" : "swift-huggingface", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-huggingface.git", + "state" : { + "revision" : "b721959445b617d0bf03910b2b4aced345fd93bf", + "version" : "0.9.0" + } + }, + { + "identity" : "swift-jinja", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-jinja.git", + "state" : { + "revision" : "7d0b8880ef8e567dd4e0089f8b99fb354129017c", + "version" : "2.4.2" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b", + "version" : "2.101.3" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "50688cacbd41d547e9eb9f7a213542340b7c442b", + "version" : "1.7.5" + } + }, + { + "identity" : "swift-transformers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-transformers", + "state" : { + "revision" : "2fa33e1f5e7131a7fc64c28e6d161dcec0d24820", + "version" : "1.3.3" + } + }, + { + "identity" : "swiftwhisper", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exPHAT/SwiftWhisper", + "state" : { + "revision" : "a192004db08de7c6eaa169eede77f1625e7d23fb", + "version" : "1.2.0" + } + }, + { + "identity" : "yyjson", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ibireme/yyjson.git", + "state" : { + "revision" : "8b4a38dc994a110abaec8a400615567bd996105f", + "version" : "0.12.0" + } + } + ], + "version" : 3 +} diff --git a/Speaky/AppState.swift b/Speaky/AppState.swift index 9f2b9fc..41540b1 100644 --- a/Speaky/AppState.swift +++ b/Speaky/AppState.swift @@ -32,7 +32,6 @@ final class AppState { let settings = AppSettings() let hotkeyManager = HotkeyManager() let modelManager = ModelManager() - let updaterManager = UpdaterManager() private(set) var coordinator: TranscriptionCoordinator! // SwiftData container for saving transcriptions diff --git a/Speaky/Models/Settings.swift b/Speaky/Models/Settings.swift index 92005ac..7402cca 100644 --- a/Speaky/Models/Settings.swift +++ b/Speaky/Models/Settings.swift @@ -3,6 +3,15 @@ import os private let settingsLogger = Logger.speaky(category: "Settings") +protocol SettingsStore: AnyObject { + func object(forKey defaultName: String) -> Any? + func string(forKey defaultName: String) -> String? + func set(_ value: Any?, forKey defaultName: String) + func removeObject(forKey defaultName: String) +} + +extension UserDefaults: SettingsStore {} + /// Controls when the transcription engine is unloaded from memory after idle. enum EngineUnloadOption: String, CaseIterable { case never @@ -77,41 +86,40 @@ enum BackgroundAudioMode: String, CaseIterable { @Observable final class AppSettings { + private let store: any SettingsStore + var selectedModelID: String { - didSet { UserDefaults.standard.set(selectedModelID, forKey: "selectedModelID") } + didSet { store.set(selectedModelID, forKey: "selectedModelID") } } var language: String { - didSet { UserDefaults.standard.set(language, forKey: "language") } + didSet { store.set(language, forKey: "language") } } var backgroundAudioMode: BackgroundAudioMode { - didSet { UserDefaults.standard.set(backgroundAudioMode.rawValue, forKey: "backgroundAudioMode") } + didSet { store.set(backgroundAudioMode.rawValue, forKey: "backgroundAudioMode") } } var selectedAudioDevice: UInt32? { didSet { if let device = selectedAudioDevice { - UserDefaults.standard.set(device, forKey: "selectedAudioDevice") + store.set(device, forKey: "selectedAudioDevice") } else { - UserDefaults.standard.removeObject(forKey: "selectedAudioDevice") + store.removeObject(forKey: "selectedAudioDevice") } } } var autoPaste: Bool { - didSet { UserDefaults.standard.set(autoPaste, forKey: "autoPaste") } + didSet { store.set(autoPaste, forKey: "autoPaste") } } var cleanUpTranscriptions: Bool { - didSet { UserDefaults.standard.set(cleanUpTranscriptions, forKey: "cleanUpTranscriptions") } + didSet { store.set(cleanUpTranscriptions, forKey: "cleanUpTranscriptions") } } var autoUnloadTimeout: TimeInterval { - didSet { UserDefaults.standard.set(autoUnloadTimeout, forKey: "autoUnloadTimeout") } + didSet { store.set(autoUnloadTimeout, forKey: "autoUnloadTimeout") } } var soundEffectsEnabled: Bool { - didSet { UserDefaults.standard.set(soundEffectsEnabled, forKey: "soundEffectsEnabled") } - } - var checkForUpdates: Bool { - didSet { UserDefaults.standard.set(checkForUpdates, forKey: "checkForUpdates") } + didSet { store.set(soundEffectsEnabled, forKey: "soundEffectsEnabled") } } var cleanupInterval: String { - didSet { UserDefaults.standard.set(cleanupInterval, forKey: "cleanupInterval") } + didSet { store.set(cleanupInterval, forKey: "cleanupInterval") } } var cleanupIntervalEnum: CleanupInterval { CleanupInterval(rawValue: cleanupInterval) ?? .never @@ -124,7 +132,9 @@ final class AppSettings { TranscriptionModels.find(selectedModelID) ?? TranscriptionModels.available[0] } - init() { + init(store: any SettingsStore = UserDefaults.standard) { + self.store = store + // Architecture-aware default model let defaultModel: String = { #if arch(arm64) @@ -135,7 +145,7 @@ final class AppSettings { }() // Migrate away from removed models (cloud engines, low-quality, incompatible mel bins) - let savedModel = UserDefaults.standard.string(forKey: "selectedModelID") ?? defaultModel + let savedModel = store.string(forKey: "selectedModelID") ?? defaultModel let removedModelIDs: Set = [ "deepgram-nova-3", "whisper-large-v3-turbo", "whisper-large-v3" @@ -151,31 +161,30 @@ final class AppSettings { if allRemoved.contains(savedModel) { self.selectedModelID = defaultModel - UserDefaults.standard.set(defaultModel, forKey: "selectedModelID") + store.set(defaultModel, forKey: "selectedModelID") } else { self.selectedModelID = savedModel } - self.language = UserDefaults.standard.string(forKey: "language") ?? "auto" - if let savedMode = UserDefaults.standard.string(forKey: "backgroundAudioMode"), + self.language = store.string(forKey: "language") ?? "auto" + if let savedMode = store.string(forKey: "backgroundAudioMode"), let mode = BackgroundAudioMode(rawValue: savedMode) { self.backgroundAudioMode = mode } else { self.backgroundAudioMode = .pauseMedia } - self.autoPaste = UserDefaults.standard.object(forKey: "autoPaste") as? Bool ?? true - self.cleanUpTranscriptions = UserDefaults.standard.object(forKey: "cleanUpTranscriptions") as? Bool ?? true + self.autoPaste = store.object(forKey: "autoPaste") as? Bool ?? true + self.cleanUpTranscriptions = store.object(forKey: "cleanUpTranscriptions") as? Bool ?? true // Default: 0 (never unload) — keeps model in memory for instant transcriptions. // Migrate users on the old 300s default to "never" since it caused cold-start issues. - let savedTimeout = UserDefaults.standard.object(forKey: "autoUnloadTimeout") as? TimeInterval + let savedTimeout = store.object(forKey: "autoUnloadTimeout") as? TimeInterval if savedTimeout == 300 || savedTimeout == nil { self.autoUnloadTimeout = 0 } else { self.autoUnloadTimeout = savedTimeout! } - self.soundEffectsEnabled = UserDefaults.standard.object(forKey: "soundEffectsEnabled") as? Bool ?? true - self.checkForUpdates = UserDefaults.standard.object(forKey: "checkForUpdates") as? Bool ?? true - self.cleanupInterval = UserDefaults.standard.string(forKey: "cleanupInterval") ?? "Never" - let deviceVal = UserDefaults.standard.object(forKey: "selectedAudioDevice") as? UInt32 + self.soundEffectsEnabled = store.object(forKey: "soundEffectsEnabled") as? Bool ?? true + self.cleanupInterval = store.string(forKey: "cleanupInterval") ?? "Never" + let deviceVal = store.object(forKey: "selectedAudioDevice") as? UInt32 self.selectedAudioDevice = deviceVal } } diff --git a/Speaky/Resources/Info.plist b/Speaky/Resources/Info.plist index 59503e8..5b996a2 100644 --- a/Speaky/Resources/Info.plist +++ b/Speaky/Resources/Info.plist @@ -2,19 +2,27 @@ + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(PRODUCT_NAME) + CFBundleExecutable + $(EXECUTABLE_NAME) CFBundleIconName AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) NSMicrophoneUsageDescription Speaky needs microphone access to record speech for transcription. - SUFeedURL - https://bedriyan.github.io/speaky/appcast-arm64.xml - SUPublicEDKey - PLACEHOLDER_ED25519_PUBLIC_KEY - SUEnableAutomaticChecks - diff --git a/Speaky/Services/ModelManager.swift b/Speaky/Services/ModelManager.swift index 0718b43..382bfb4 100644 --- a/Speaky/Services/ModelManager.swift +++ b/Speaky/Services/ModelManager.swift @@ -12,8 +12,7 @@ final class ModelManager: @unchecked Sendable { private let modelsDirectory: URL init() { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - modelsDirectory = appSupport.appendingPathComponent("Speaky/Models", isDirectory: true) + modelsDirectory = Constants.modelsPath try? FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true) scanDownloadedModels() } @@ -93,7 +92,7 @@ final class ModelManager: @unchecked Sendable { func isParakeetDownloaded(_ model: TranscriptionModelInfo) -> Bool { let version = parakeetVersion(for: model.id) - let cacheDir = AsrModels.defaultCacheDirectory(for: version) + let cacheDir = ParakeetModelPaths.cacheDirectory(for: version) return AsrModels.modelsExist(at: cacheDir, version: version) } @@ -103,7 +102,7 @@ final class ModelManager: @unchecked Sendable { @MainActor func downloadParakeetModel(_ model: TranscriptionModelInfo) async throws { let version = parakeetVersion(for: model.id) - let cacheDir = AsrModels.defaultCacheDirectory(for: version) + let cacheDir = ParakeetModelPaths.cacheDirectory(for: version) let expectedBytes: Int64 = model.sizeBytes ?? 484_000_000 downloadProgress[model.id] = 0 @@ -159,7 +158,7 @@ final class ModelManager: @unchecked Sendable { do { // Download + compile (AsrModels.download calls DownloadUtils.loadModels // internally which downloads files and compiles CoreML models) - _ = try await AsrModels.download(version: version) + _ = try await AsrModels.download(to: cacheDir, version: version) monitorTask.cancel() // Verify model files exist on disk @@ -196,7 +195,7 @@ final class ModelManager: @unchecked Sendable { func deleteParakeetModel(_ model: TranscriptionModelInfo) { let version = parakeetVersion(for: model.id) - let cacheDirectory = AsrModels.defaultCacheDirectory(for: version) + let cacheDirectory = ParakeetModelPaths.cacheDirectory(for: version) do { if FileManager.default.fileExists(atPath: cacheDirectory.path) { diff --git a/Speaky/Services/PasteService.swift b/Speaky/Services/PasteService.swift index 2971dd0..8d88e30 100644 --- a/Speaky/Services/PasteService.swift +++ b/Speaky/Services/PasteService.swift @@ -36,7 +36,7 @@ final class PasteService: @unchecked Sendable { // Restore previous clipboard after paste completes DispatchQueue.main.asyncAfter(deadline: .now() + Constants.Timing.pasteboardRestoreDelay) { [weak self] in - self?.restorePasteboard(pasteboard, items: savedItems) + self?.restorePasteboard(.general, items: savedItems) } } return .pasted diff --git a/Speaky/Services/Transcription/ParakeetEngine.swift b/Speaky/Services/Transcription/ParakeetEngine.swift index f938348..acf8341 100644 --- a/Speaky/Services/Transcription/ParakeetEngine.swift +++ b/Speaky/Services/Transcription/ParakeetEngine.swift @@ -89,7 +89,11 @@ actor ParakeetEngine: TranscriptionEngine { models = cached } else { do { - models = try await AsrModels.loadFromCache(configuration: nil, version: version) + models = try await AsrModels.load( + from: ParakeetModelPaths.cacheDirectory(for: version), + configuration: nil, + version: version + ) modelsBox.models = models logger.info("Parakeet models loaded for first time (version: \(String(describing: self.version), privacy: .public))") } catch { @@ -117,10 +121,11 @@ actor ParakeetEngine: TranscriptionEngine { logger.info("Parakeet engine warmed up — inference pipeline primed") } - func cleanup() { + func cleanup() async { logger.info("Parakeet engine cleanup") - asrBox.manager?.cleanup() + let manager = asrBox.manager asrBox.manager = nil vadBox.manager = nil + await manager?.cleanup() } } diff --git a/Speaky/Services/UpdaterManager.swift b/Speaky/Services/UpdaterManager.swift deleted file mode 100644 index 0e0fa44..0000000 --- a/Speaky/Services/UpdaterManager.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -import Sparkle -import os - -private let logger = Logger.speaky(category: "UpdaterManager") - -private final class SparkleDelegate: NSObject, SPUUpdaterDelegate { - func feedURLString(for updater: SPUUpdater) -> String? { - #if arch(arm64) - return "https://bedriyan.github.io/speaky/appcast-arm64.xml" - #else - return "https://bedriyan.github.io/speaky/appcast-x86_64.xml" - #endif - } -} - -@MainActor -final class UpdaterManager: ObservableObject { - private let sparkleDelegate = SparkleDelegate() - private let updater: SPUUpdater - @Published var canCheckForUpdates = false - - private var observation: NSKeyValueObservation? - - init() { - let userDriver = SPUStandardUserDriver(hostBundle: Bundle.main, delegate: nil) - updater = SPUUpdater( - hostBundle: Bundle.main, - applicationBundle: Bundle.main, - userDriver: userDriver, - delegate: sparkleDelegate - ) - - observation = updater.observe( - \.canCheckForUpdates, - options: [.initial, .new] - ) { [weak self] updater, _ in - Task { @MainActor in - self?.canCheckForUpdates = updater.canCheckForUpdates - } - } - } - - func startIfEnabled(checkForUpdates: Bool) { - updater.automaticallyChecksForUpdates = checkForUpdates - do { - try updater.start() - logger.info("Sparkle updater started (autoCheck: \(checkForUpdates))") - } catch { - logger.error("Failed to start Sparkle updater: \(error.localizedDescription, privacy: .public)") - } - } - - func setAutomaticChecks(_ enabled: Bool) { - updater.automaticallyChecksForUpdates = enabled - logger.info("Automatic update checks: \(enabled)") - } - - func checkForUpdates() { - updater.checkForUpdates() - } -} diff --git a/Speaky/SpeakyApp.swift b/Speaky/SpeakyApp.swift index 37fe2c1..a4046f4 100644 --- a/Speaky/SpeakyApp.swift +++ b/Speaky/SpeakyApp.swift @@ -5,9 +5,15 @@ import SwiftData struct SpeakyApp: App { @State private var appState = AppState() @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + private let modelContainer: ModelContainer init() { Self.resetOnboardingIfFreshInstall() + do { + modelContainer = try PersistenceContainer.make() + } catch { + fatalError("Unable to initialize transcription storage: \(error)") + } } var body: some Scene { @@ -19,7 +25,7 @@ struct SpeakyApp: App { appDelegate.appState = appState } } - .modelContainer(for: Transcription.self) + .modelContainer(modelContainer) .defaultSize(width: 440, height: 520) MenuBarExtra { @@ -32,13 +38,10 @@ struct SpeakyApp: App { /// If the sentinel file doesn't exist, this is a fresh install — reset onboarding. private static func resetOnboardingIfFreshInstall() { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - let speakyDir = appSupport.appendingPathComponent("Speaky") - let sentinelFile = speakyDir.appendingPathComponent(".installed") + let sentinelFile = Constants.appSupportPath.appendingPathComponent(".installed") if !FileManager.default.fileExists(atPath: sentinelFile.path) { UserDefaults.standard.set(false, forKey: "hasCompletedOnboarding") - try? FileManager.default.createDirectory(at: speakyDir, withIntermediateDirectories: true) FileManager.default.createFile(atPath: sentinelFile.path, contents: nil) } } @@ -71,8 +74,6 @@ struct ContentRootView: View { // Check if permissions were revoked since last launch appState.checkPermissionsOnLaunch() } - // Start Sparkle updater - appState.updaterManager.startIfEnabled(checkForUpdates: appState.settings.checkForUpdates) } } } diff --git a/Speaky/Utilities/Constants.swift b/Speaky/Utilities/Constants.swift index 211b55b..6d42495 100644 --- a/Speaky/Utilities/Constants.swift +++ b/Speaky/Utilities/Constants.swift @@ -2,15 +2,33 @@ import Foundation import AVFoundation enum Constants { + static let appSupportFolderName: String = { + #if DEBUG + return "Speaky Debug" + #else + return "Speaky" + #endif + }() + static let appSupportPath: URL = { let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appendingPathComponent("Speaky", isDirectory: true) + .appendingPathComponent(appSupportFolderName, isDirectory: true) try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url }() static let modelsPath = appSupportPath.appendingPathComponent("Models", isDirectory: true) + static let transcriptionStorePath: URL = { + #if DEBUG + return appSupportPath.appendingPathComponent("default.store") + #else + // Preserve the location used implicitly by existing Release builds. + return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("default.store") + #endif + }() + static let recordingsPath: URL = { let url = appSupportPath.appendingPathComponent("Recordings", isDirectory: true) try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) @@ -37,7 +55,13 @@ enum Constants { static let escape: UInt16 = 53 } - static let keychainService = "speaky" + static let keychainService: String = { + #if DEBUG + return "speaky-debug" + #else + return "speaky" + #endif + }() static let groqAPIKeyAccount = "groq-api-key" enum Groq { diff --git a/Speaky/Utilities/ParakeetModelPaths.swift b/Speaky/Utilities/ParakeetModelPaths.swift new file mode 100644 index 0000000..2686229 --- /dev/null +++ b/Speaky/Utilities/ParakeetModelPaths.swift @@ -0,0 +1,24 @@ +import Foundation +import FluidAudio + +enum ParakeetModelPaths { + static func cacheDirectory(for version: AsrModelVersion) -> URL { + #if DEBUG + let folderName: String + switch version { + case .v2: + folderName = "parakeet-tdt-0.6b-v2-coreml" + case .v3: + folderName = "parakeet-tdt-0.6b-v3-coreml" + case .tdtCtc110m: + folderName = "parakeet-tdt-ctc-110m-coreml" + } + + return Constants.modelsPath + .appendingPathComponent("FluidAudio", isDirectory: true) + .appendingPathComponent(folderName, isDirectory: true) + #else + return AsrModels.defaultCacheDirectory(for: version) + #endif + } +} diff --git a/Speaky/Utilities/PersistenceContainer.swift b/Speaky/Utilities/PersistenceContainer.swift new file mode 100644 index 0000000..d5b59a8 --- /dev/null +++ b/Speaky/Utilities/PersistenceContainer.swift @@ -0,0 +1,26 @@ +import Foundation +import SwiftData + +enum PersistenceContainer { + static var isRunningTests: Bool { + ProcessInfo.processInfo.environment.keys.contains { + $0.hasPrefix("XCTest") + } + } + + static func make(isStoredInMemoryOnly: Bool? = nil) throws -> ModelContainer { + let configuration: ModelConfiguration + let useInMemoryStore = isStoredInMemoryOnly ?? isRunningTests + + if useInMemoryStore { + configuration = ModelConfiguration(isStoredInMemoryOnly: true) + } else { + configuration = ModelConfiguration(url: Constants.transcriptionStorePath) + } + + return try ModelContainer( + for: Transcription.self, + configurations: configuration + ) + } +} diff --git a/Speaky/Views/MainWindow/SettingsView.swift b/Speaky/Views/MainWindow/SettingsView.swift index cb21174..295381f 100644 --- a/Speaky/Views/MainWindow/SettingsView.swift +++ b/Speaky/Views/MainWindow/SettingsView.swift @@ -60,25 +60,6 @@ struct SettingsView: View { )) .contentShape(Rectangle()) .onTapGesture { settings.soundEffectsEnabled.toggle() } - - Toggle("Check for updates automatically", isOn: Binding( - get: { settings.checkForUpdates }, - set: { newValue in - settings.checkForUpdates = newValue - appState.updaterManager.setAutomaticChecks(newValue) - } - )) - .contentShape(Rectangle()) - .onTapGesture { - let newValue = !settings.checkForUpdates - settings.checkForUpdates = newValue - appState.updaterManager.setAutomaticChecks(newValue) - } - - Button("Check for Updates Now") { - appState.updaterManager.checkForUpdates() - } - .disabled(!appState.updaterManager.canCheckForUpdates) } // Audio Input diff --git a/Speaky/Views/MenuBar/MenuBarView.swift b/Speaky/Views/MenuBar/MenuBarView.swift index f84b79c..b87f006 100644 --- a/Speaky/Views/MenuBar/MenuBarView.swift +++ b/Speaky/Views/MenuBar/MenuBarView.swift @@ -46,11 +46,6 @@ struct MenuBarView: View { } .keyboardShortcut(",") - Button("Check for Updates...") { - appState.updaterManager.checkForUpdates() - NSApp.activate(ignoringOtherApps: true) - } - Divider() Button("Quit Speaky") { diff --git a/SpeakyTests/Mocks/MockServices.swift b/SpeakyTests/Mocks/MockServices.swift index f7a3a39..a905525 100644 --- a/SpeakyTests/Mocks/MockServices.swift +++ b/SpeakyTests/Mocks/MockServices.swift @@ -29,8 +29,32 @@ final class MockAudioRecorder: AudioRecording, @unchecked Sendable { final class MockPasteService: Pasting, @unchecked Sendable { var pastedTexts: [String] = [] - func paste(_ text: String) { + @discardableResult + func paste(_ text: String) -> PasteResult { pastedTexts.append(text) + return .pasted + } +} + +// MARK: - In-memory settings + +final class InMemorySettingsStore: SettingsStore { + private var values: [String: Any] = [:] + + func object(forKey defaultName: String) -> Any? { + values[defaultName] + } + + func string(forKey defaultName: String) -> String? { + values[defaultName] as? String + } + + func set(_ value: Any?, forKey defaultName: String) { + values[defaultName] = value + } + + func removeObject(forKey defaultName: String) { + values.removeValue(forKey: defaultName) } } diff --git a/SpeakyTests/PersistenceContainerTests.swift b/SpeakyTests/PersistenceContainerTests.swift new file mode 100644 index 0000000..2fd2f5e --- /dev/null +++ b/SpeakyTests/PersistenceContainerTests.swift @@ -0,0 +1,15 @@ +import Testing +@testable import Speaky + +@Suite("Persistence container") +struct PersistenceContainerTests { + @Test("Hosted tests are detected") + func hostedTestsAreDetected() { + #expect(PersistenceContainer.isRunningTests) + } + + @Test("In-memory container initializes") + func inMemoryContainerInitializes() throws { + _ = try PersistenceContainer.make(isStoredInMemoryOnly: true) + } +} diff --git a/SpeakyTests/SettingsStoreTests.swift b/SpeakyTests/SettingsStoreTests.swift new file mode 100644 index 0000000..e919d3a --- /dev/null +++ b/SpeakyTests/SettingsStoreTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import Speaky + +@Suite("App settings persistence") +struct SettingsStoreTests { + @Test("Persists through the injected store") + func persistsThroughInjectedStore() { + let store = InMemorySettingsStore() + let first = AppSettings(store: store) + first.backgroundAudioMode = .off + first.selectedAudioDevice = 42 + first.soundEffectsEnabled = false + + let second = AppSettings(store: store) + + #expect(second.backgroundAudioMode == .off) + #expect(second.selectedAudioDevice == 42) + #expect(!second.soundEffectsEnabled) + } + + @Test("Separate stores do not share preferences") + func separateStoresAreIsolated() { + let first = AppSettings(store: InMemorySettingsStore()) + first.backgroundAudioMode = .off + + let second = AppSettings(store: InMemorySettingsStore()) + + #expect(second.backgroundAudioMode == .pauseMedia) + } +} diff --git a/SpeakyTests/TranscriptionCoordinatorTests.swift b/SpeakyTests/TranscriptionCoordinatorTests.swift index fe1a573..0149539 100644 --- a/SpeakyTests/TranscriptionCoordinatorTests.swift +++ b/SpeakyTests/TranscriptionCoordinatorTests.swift @@ -7,6 +7,7 @@ import Foundation struct TranscriptionCoordinatorTests { private func makeCoordinator( + settings: AppSettings? = nil, audioRecorder: MockAudioRecorder = MockAudioRecorder(), pasteService: MockPasteService = MockPasteService(), audioControl: MockAudioControl = MockAudioControl(), @@ -15,9 +16,9 @@ struct TranscriptionCoordinatorTests { soundEffect: MockSoundEffect = MockSoundEffect(), playbackController: MockPlaybackController = MockPlaybackController() ) -> (TranscriptionCoordinator, MockAudioRecorder, MockPasteService, MockAudioControl, MockDeviceGuard, MockSoundEffect, MockPlaybackController) { - let settings = AppSettings() + let resolvedSettings = settings ?? makeSettings() let coordinator = TranscriptionCoordinator( - settings: settings, + settings: resolvedSettings, audioRecorder: audioRecorder, pasteService: pasteService, audioControl: audioControl, @@ -29,6 +30,10 @@ struct TranscriptionCoordinatorTests { return (coordinator, audioRecorder, pasteService, audioControl, deviceGuard, soundEffect, playbackController) } + private func makeSettings() -> AppSettings { + AppSettings(store: InMemorySettingsStore()) + } + @Test("startRecording calls audio recorder") func startRecordingCallsRecorder() throws { let recorder = MockAudioRecorder() @@ -40,7 +45,7 @@ struct TranscriptionCoordinatorTests { @Test("startRecording locks device guard when device is selected") func startRecordingLocksDevice() throws { let guard_ = MockDeviceGuard() - let settings = AppSettings() + let settings = makeSettings() settings.selectedAudioDevice = 42 let coordinator = TranscriptionCoordinator( settings: settings, @@ -59,7 +64,7 @@ struct TranscriptionCoordinatorTests { @Test("startRecording pauses playback when pauseMedia mode") func startRecordingPausesPlayback() throws { let playback = MockPlaybackController() - let settings = AppSettings() + let settings = makeSettings() settings.backgroundAudioMode = .pauseMedia let coordinator = TranscriptionCoordinator( settings: settings, @@ -78,7 +83,7 @@ struct TranscriptionCoordinatorTests { @Test("startRecording does not pause media in muteSystemAudio mode") func startRecordingMutesSystemAudio() throws { let playback = MockPlaybackController() - let settings = AppSettings() + let settings = makeSettings() settings.backgroundAudioMode = .muteSystemAudio let coordinator = TranscriptionCoordinator( settings: settings, @@ -97,7 +102,7 @@ struct TranscriptionCoordinatorTests { @Test("startRecording skips pause when background audio mode is off") func startRecordingSkipsPauseWhenOff() throws { let playback = MockPlaybackController() - let settings = AppSettings() + let settings = makeSettings() settings.backgroundAudioMode = .off let coordinator = TranscriptionCoordinator( settings: settings, @@ -124,7 +129,10 @@ struct TranscriptionCoordinatorTests { let control = MockAudioControl() let guard_ = MockDeviceGuard() let playback = MockPlaybackController() + let settings = makeSettings() + settings.backgroundAudioMode = .pauseMedia let (coordinator, _, _, _, _, _, _) = makeCoordinator( + settings: settings, audioRecorder: recorder, audioControl: control, deviceGuard: guard_, @@ -148,7 +156,10 @@ struct TranscriptionCoordinatorTests { let control = MockAudioControl() let guard_ = MockDeviceGuard() let playback = MockPlaybackController() + let settings = makeSettings() + settings.backgroundAudioMode = .pauseMedia let (coordinator, _, _, _, _, _, _) = makeCoordinator( + settings: settings, audioRecorder: recorder, audioControl: control, deviceGuard: guard_, @@ -165,7 +176,7 @@ struct TranscriptionCoordinatorTests { @Test("playStartSoundAndMute mutes in muteSystemAudio mode") func playStartSoundRespectsSettings() async { let sound = MockSoundEffect() - let settings = AppSettings() + let settings = makeSettings() settings.soundEffectsEnabled = true settings.backgroundAudioMode = .muteSystemAudio @@ -189,7 +200,7 @@ struct TranscriptionCoordinatorTests { @Test("playStartSoundAndMute does not mute in pauseMedia mode") func playStartSoundSkipsMuteInPauseMode() async { let sound = MockSoundEffect() - let settings = AppSettings() + let settings = makeSettings() settings.soundEffectsEnabled = true settings.backgroundAudioMode = .pauseMedia @@ -213,7 +224,7 @@ struct TranscriptionCoordinatorTests { @Test("playStartSoundAndMute skips everything when off") func playStartSoundSkipsWhenOff() async { let sound = MockSoundEffect() - let settings = AppSettings() + let settings = makeSettings() settings.soundEffectsEnabled = false settings.backgroundAudioMode = .off diff --git a/build.sh b/build.sh index 3c60d85..b933d0f 100755 --- a/build.sh +++ b/build.sh @@ -1,156 +1,318 @@ #!/bin/bash -set -eo pipefail +set -euo pipefail PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" APP_NAME="Speaky" +APP_BUNDLE_ID="com.bedriyan.speaky" BUILD_DIR="$PROJECT_DIR/build" +DERIVED_DATA_DIR="$BUILD_DIR/DerivedData" +SOURCE_PACKAGES_DIR="$BUILD_DIR/SourcePackages" +PACKAGE_RESOLVED_PATH="$PROJECT_DIR/Speaky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" VERSION=$(grep 'MARKETING_VERSION' "$PROJECT_DIR/project.yml" | head -1 | sed 's/.*"\(.*\)".*/\1/') -DMGBUILD_SETTINGS="$PROJECT_DIR/.github/dmgbuild-settings.py" -DMG_BACKGROUND="$PROJECT_DIR/.github/dmg-background.png" -DOCS_DIR="$PROJECT_DIR/docs" +ENTITLEMENTS_PATH="$PROJECT_DIR/Speaky/Resources/Speaky.entitlements" +IDENTITY_VERIFIER="$PROJECT_DIR/scripts/verify-app-identity.sh" +MINIMUM_CERTIFICATE_VALIDITY_SECONDS=2592000 + +BUILD_MODE="${1:-universal}" +SIGNING_IDENTITY="${SPEAKY_SIGNING_IDENTITY:-}" +SIGNING_CERTIFICATE="${SPEAKY_SIGNING_CERTIFICATE:-}" +SIGNING_KEYCHAIN="${SPEAKY_SIGNING_KEYCHAIN:-}" +ALLOW_ADHOC="${SPEAKY_ALLOW_ADHOC:-0}" +TEMP_DIRECTORIES=() + +error() { + echo "ERROR: $*" >&2 + exit 1 +} -echo "==> Generating Xcode project..." -cd "$PROJECT_DIR" -xcodegen generate +register_temp_directory() { + local variable_name="$1" + local directory + directory=$(mktemp -d "${TMPDIR:-/tmp}/speaky-build.XXXXXX") + TEMP_DIRECTORIES+=("$directory") + printf -v "$variable_name" '%s' "$directory" +} -# Parse arguments -BUILD_MODE="${1:-universal}" # universal, silicon, intel, separate +cleanup_temp_directories() { + local directory + [ "${#TEMP_DIRECTORIES[@]}" -gt 0 ] || return + for directory in "${TEMP_DIRECTORIES[@]}"; do + [ ! -d "$directory" ] || rm -rf -- "$directory" + done +} -# Helper: wipe DerivedData to avoid stale builds across architectures -clean_derived_data() { - rm -rf ~/Library/Developer/Xcode/DerivedData/"$APP_NAME"-* +trap cleanup_temp_directories EXIT + +certificate_sha1() { + openssl x509 \ + -inform DER \ + -in "$1" \ + -noout \ + -fingerprint \ + -sha1 | + sed 's/.*=//' | + tr -d ':' | + tr '[:lower:]' '[:upper:]' } -# Clean DerivedData to avoid stale cached builds -clean_derived_data +validate_signing_configuration() { + case "$ALLOW_ADHOC" in + 0|1) ;; + *) error "SPEAKY_ALLOW_ADHOC must be either 0 or 1." ;; + esac + + if [ -z "$SIGNING_IDENTITY" ]; then + if [ "$ALLOW_ADHOC" = "1" ]; then + SIGNING_IDENTITY="-" + else + error "Official packaging requires SPEAKY_SIGNING_IDENTITY and SPEAKY_SIGNING_CERTIFICATE. + For an unpublished local test build only, set SPEAKY_ALLOW_ADHOC=1." + fi + fi -# Ensure clean build output directory -mkdir -p "$BUILD_DIR" + if [ "$SIGNING_IDENTITY" = "-" ]; then + [ "$ALLOW_ADHOC" = "1" ] || + error "Ad-hoc signing requires the explicit SPEAKY_ALLOW_ADHOC=1 opt-in." + echo "==> Signing mode: ad hoc (local testing only)" + echo " Hardened runtime is disabled for the final ad-hoc signature." + echo " Do not publish this artifact." + return + fi -# Helper: find the built .app in DerivedData -find_built_app() { - find ~/Library/Developer/Xcode/DerivedData/"$APP_NAME"-*/Build/Products/Release \ - -name "$APP_NAME.app" -maxdepth 1 2>/dev/null | head -1 -} + SIGNING_IDENTITY=$(printf '%s' "$SIGNING_IDENTITY" | tr '[:lower:]' '[:upper:]') + [[ "$SIGNING_IDENTITY" =~ ^[0-9A-F]{40}$ ]] || + error "SPEAKY_SIGNING_IDENTITY must be the certificate's 40-character SHA-1 hash." -# Helper: prepare app for distribution (strip quarantine, re-sign) -prepare_app() { - local app_path="$1" - xattr -cr "$app_path" - # Deep-sign nested frameworks/bundles first, then re-sign the main app - # with explicit identifier so macOS Accessibility matches the bundle ID - codesign --force --deep --sign - "$app_path" - codesign --force --sign - --identifier com.bedriyan.speaky "$app_path" -} + [ -n "$SIGNING_CERTIFICATE" ] && [ -f "$SIGNING_CERTIFICATE" ] || + error "SPEAKY_SIGNING_CERTIFICATE must point to the matching public DER certificate." + case "$SIGNING_CERTIFICATE" in + /*) ;; + *) error "SPEAKY_SIGNING_CERTIFICATE must be an absolute path." ;; + esac -# Helper: create DMG using dmgbuild (with drag-to-Applications visual) -create_dmg() { - local app_path="$1" - local dmg_path="$2" + if [ -n "$SIGNING_KEYCHAIN" ] && [ ! -f "$SIGNING_KEYCHAIN" ]; then + error "SPEAKY_SIGNING_KEYCHAIN does not exist: $SIGNING_KEYCHAIN" + fi + if [ -n "$SIGNING_KEYCHAIN" ]; then + case "$SIGNING_KEYCHAIN" in + /*) ;; + *) error "SPEAKY_SIGNING_KEYCHAIN must be an absolute path." ;; + esac + SIGNING_KEYCHAIN="$( + cd "$(dirname "$SIGNING_KEYCHAIN")" + printf '%s/%s' "$(pwd -P)" "$(basename "$SIGNING_KEYCHAIN")" + )" + + local keychain_search_list + keychain_search_list=$( + security list-keychains -d user | + sed 's/^[[:space:]"]*//; s/[[:space:]"]*$//' + ) + printf '%s\n' "$keychain_search_list" | grep -Fxq "$SIGNING_KEYCHAIN" || + error "SPEAKY_SIGNING_KEYCHAIN must be on the user keychain search list. + Add it with: security list-keychains -d user -s \"$SIGNING_KEYCHAIN\" " + fi - if command -v dmgbuild &>/dev/null && [ -f "$DMGBUILD_SETTINGS" ]; then - APP_PATH="$app_path" DMG_BACKGROUND="$DMG_BACKGROUND" \ - dmgbuild -s "$DMGBUILD_SETTINGS" "$APP_NAME" "$dmg_path" + openssl x509 \ + -inform DER \ + -in "$SIGNING_CERTIFICATE" \ + -noout \ + -checkend "$MINIMUM_CERTIFICATE_VALIDITY_SECONDS" >/dev/null || + error "The release signing certificate is invalid or expires within 30 days. + Complete the documented certificate migration before publishing another release." + openssl x509 -inform DER -in "$SIGNING_CERTIFICATE" -noout -text | + grep -Fq "Code Signing" || + error "The release certificate is not valid for code signing." + + local expected_sha1 + expected_sha1=$(certificate_sha1 "$SIGNING_CERTIFICATE") + [ "$SIGNING_IDENTITY" = "$expected_sha1" ] || + error "The public certificate does not match SPEAKY_SIGNING_IDENTITY. + Certificate: $expected_sha1 + Identity: $SIGNING_IDENTITY" + + local identity_output + if [ -n "$SIGNING_KEYCHAIN" ]; then + identity_output=$(security find-identity -v -p codesigning "$SIGNING_KEYCHAIN" || true) else - echo " (dmgbuild not found, falling back to hdiutil)" - local tmpdir - tmpdir=$(mktemp -d) - ditto "$app_path" "$tmpdir/$APP_NAME.app" - hdiutil create -volname "$APP_NAME" -srcfolder "$tmpdir" -ov -format UDZO "$dmg_path" 2>&1 | tail -2 - rm -rf "$tmpdir" + identity_output=$(security find-identity -v -p codesigning || true) fi + printf '%s\n' "$identity_output" | grep -Fq "$SIGNING_IDENTITY" || + error "Code-signing identity '$SIGNING_IDENTITY' is not available." + + echo "==> Signing mode: stable self-signed identity" + echo " Certificate SHA-1: $SIGNING_IDENTITY" + echo " Requirement anchor: $SIGNING_CERTIFICATE" } -# Helper: find Sparkle tools from SPM checkout -find_sparkle_tools() { - local sparkle_dir - sparkle_dir=$(find ~/Library/Developer/Xcode/DerivedData/"$APP_NAME"-*/SourcePackages/artifacts/sparkle \ - -name "sign_update" -maxdepth 5 2>/dev/null | head -1 | xargs dirname 2>/dev/null) - if [ -z "$sparkle_dir" ]; then - echo "" - else - echo "$sparkle_dir" +codesign_item() { + local code_path="$1" + local arguments=( + --force + --sign "$SIGNING_IDENTITY" + --timestamp=none + ) + + if [ "$SIGNING_IDENTITY" != "-" ]; then + arguments+=(--options runtime) fi + if [ -n "$SIGNING_KEYCHAIN" ]; then + arguments+=(--keychain "$SIGNING_KEYCHAIN") + fi + codesign "${arguments[@]}" "$code_path" } -# Helper: sign DMG with Sparkle EdDSA and generate appcast -sparkle_sign_and_appcast() { - local sparkle_tools - sparkle_tools=$(find_sparkle_tools) - if [ -z "$sparkle_tools" ]; then - echo " WARNING: Sparkle tools not found — skipping EdDSA signing and appcast generation" - echo " (Build the project first so SPM checkouts are available)" - return +sign_nested_code() { + local app_path="$1" + local frameworks_path="$app_path/Contents/Frameworks" + local code_path + + if [ -d "$frameworks_path" ]; then + while IFS= read -r -d '' code_path; do + codesign_item "$code_path" + done < <( + find "$frameworks_path" -mindepth 1 -maxdepth 1 \ + \( -type d -name "*.framework" -o -type f -name "*.dylib" \) \ + -print0 + ) fi - echo "==> Signing DMGs with Sparkle EdDSA..." - local sign_update="$sparkle_tools/sign_update" - local generate_appcast="$sparkle_tools/generate_appcast" - - # Create arch-specific staging dirs for appcast generation - local arm64_dir="$BUILD_DIR/appcast-arm64" - local x86_dir="$BUILD_DIR/appcast-x86_64" - rm -rf "$arm64_dir" "$x86_dir" - mkdir -p "$arm64_dir" "$x86_dir" + for code_path in \ + "$app_path"/Contents/PlugIns/*.appex \ + "$app_path"/Contents/PlugIns/*.xpc \ + "$app_path"/Contents/Library/LoginItems/*.app; do + [ -e "$code_path" ] || continue + codesign_item "$code_path" + done +} - # Copy and sign arch-specific DMGs - local arm64_dmg="$BUILD_DIR/$APP_NAME-$VERSION-Apple-Silicon.dmg" - local x86_dmg="$BUILD_DIR/$APP_NAME-$VERSION-Intel.dmg" +prepare_app() { + local app_path="$1" + local info_plist="$app_path/Contents/Info.plist" + local bundle_id + local executable_name + local arguments=() - if [ -f "$arm64_dmg" ]; then - cp "$arm64_dmg" "$arm64_dir/" - echo " Signing: $(basename "$arm64_dmg")" - "$sign_update" "$arm64_dir/$(basename "$arm64_dmg")" 2>&1 | head -1 || true + xattr -cr "$app_path" + bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$info_plist") + executable_name=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$info_plist") + + [ "$bundle_id" = "$APP_BUNDLE_ID" ] || + error "Expected bundle identifier '$APP_BUNDLE_ID', found '$bundle_id'." + [ -x "$app_path/Contents/MacOS/$executable_name" ] || + error "Bundle executable '$executable_name' is missing." + + sign_nested_code "$app_path" + + arguments=( + --force + --sign "$SIGNING_IDENTITY" + --timestamp=none + --entitlements "$ENTITLEMENTS_PATH" + --identifier "$APP_BUNDLE_ID" + ) + if [ -n "$SIGNING_KEYCHAIN" ]; then + arguments+=(--keychain "$SIGNING_KEYCHAIN") fi - if [ -f "$x86_dmg" ]; then - cp "$x86_dmg" "$x86_dir/" - echo " Signing: $(basename "$x86_dmg")" - "$sign_update" "$x86_dir/$(basename "$x86_dmg")" 2>&1 | head -1 || true + if [ "$SIGNING_IDENTITY" != "-" ]; then + local designated_requirement + designated_requirement="designated => anchor \"$SIGNING_CERTIFICATE\" and identifier \"$APP_BUNDLE_ID\"" + arguments+=( + --options runtime + --requirements "=$designated_requirement" + ) fi - echo "==> Generating appcasts..." - local download_url_prefix="https://github.com/bedriyan/speaky/releases/download/v$VERSION" + codesign "${arguments[@]}" "$app_path" + codesign --verify --deep --strict --verbose=2 "$app_path" - if [ -f "$arm64_dmg" ]; then - "$generate_appcast" --download-url-prefix "$download_url_prefix/" "$arm64_dir" 2>&1 | tail -3 || true - if [ -f "$arm64_dir/appcast.xml" ]; then - mkdir -p "$DOCS_DIR" - cp "$arm64_dir/appcast.xml" "$DOCS_DIR/appcast-arm64.xml" - echo " Created: docs/appcast-arm64.xml" - fi - fi - - if [ -f "$x86_dmg" ]; then - "$generate_appcast" --download-url-prefix "$download_url_prefix/" "$x86_dir" 2>&1 | tail -3 || true - if [ -f "$x86_dir/appcast.xml" ]; then - mkdir -p "$DOCS_DIR" - cp "$x86_dir/appcast.xml" "$DOCS_DIR/appcast-x86_64.xml" - echo " Created: docs/appcast-x86_64.xml" - fi + if [ "$SIGNING_IDENTITY" != "-" ]; then + SPEAKY_SIGNING_CERTIFICATE="$SIGNING_CERTIFICATE" \ + "$IDENTITY_VERIFIER" "$app_path" fi - # Cleanup staging - rm -rf "$arm64_dir" "$x86_dir" + echo " Bundle ID: $bundle_id" + echo " Designated requirement:" + codesign -d -r- "$app_path" 2>&1 | sed 's/^/ /' } -# Helper: sign and package a single build into a DMG -package_build() { - local suffix="$1" # e.g. "Apple-Silicon", "Intel", "Universal", or "" - local dmg_name +create_dmg() { + local app_path="$1" + local dmg_path="$2" + local temporary_directory + + register_temp_directory temporary_directory + ditto "$app_path" "$temporary_directory/$APP_NAME.app" + ln -s /Applications "$temporary_directory/Applications" + hdiutil create \ + -volname "$APP_NAME" \ + -srcfolder "$temporary_directory" \ + -ov \ + -format UDZO \ + "$dmg_path" + hdiutil verify "$dmg_path" >/dev/null + rm -rf -- "$temporary_directory" +} - BUILT_APP=$(find_built_app) - if [ -z "$BUILT_APP" ]; then - echo "ERROR: Build product not found!" - exit 1 - fi +clean_build_products() { + xcodebuild \ + -project "$APP_NAME.xcodeproj" \ + -scheme "$APP_NAME" \ + -derivedDataPath "$DERIVED_DATA_DIR" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile \ + clean >/dev/null +} - echo " Arch: $(lipo -info "$BUILT_APP/Contents/MacOS/$APP_NAME" 2>&1)" +build_architectures() { + local architectures="$1" + clean_build_products + xcodebuild \ + -project "$APP_NAME.xcodeproj" \ + -scheme "$APP_NAME" \ + -configuration Release \ + -destination "platform=macOS" \ + -derivedDataPath "$DERIVED_DATA_DIR" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile \ + ARCHS="$architectures" \ + ONLY_ACTIVE_ARCH=NO \ + -quiet \ + build +} - # Stage, sign, and create DMG +package_build() { + local suffix="$1" + local expected_architectures="$2" + local built_app="$DERIVED_DATA_DIR/Build/Products/Release/$APP_NAME.app" + local executable_path="$built_app/Contents/MacOS/$APP_NAME" + local actual_architectures + local normalized_actual_architectures + local normalized_expected_architectures + local dmg_name local stage_dir - stage_dir=$(mktemp -d) - ditto "$BUILT_APP" "$stage_dir/$APP_NAME.app" + + [ -d "$built_app" ] || error "Build product not found: $built_app" + actual_architectures=$(lipo -archs "$executable_path") + normalized_actual_architectures=$( + for architecture in $actual_architectures; do + printf '%s\n' "$architecture" + done | LC_ALL=C sort | tr '\n' ' ' | sed 's/ $//' + ) + normalized_expected_architectures=$( + for architecture in $expected_architectures; do + printf '%s\n' "$architecture" + done | LC_ALL=C sort | tr '\n' ' ' | sed 's/ $//' + ) + [ "$normalized_actual_architectures" = "$normalized_expected_architectures" ] || + error "Expected architectures '$expected_architectures', found '$actual_architectures'." + echo " Architectures: $actual_architectures" + + register_temp_directory stage_dir + ditto "$built_app" "$stage_dir/$APP_NAME.app" prepare_app "$stage_dir/$APP_NAME.app" if [ -n "$suffix" ]; then @@ -162,59 +324,62 @@ package_build() { echo "==> Creating DMG: $dmg_name..." rm -f "$BUILD_DIR/$dmg_name" create_dmg "$stage_dir/$APP_NAME.app" "$BUILD_DIR/$dmg_name" - rm -rf "$stage_dir" - + rm -rf -- "$stage_dir" echo " $BUILD_DIR/$dmg_name" } case "$BUILD_MODE" in - silicon) - echo "==> Building $APP_NAME (Release, Apple Silicon only)..." - xcodebuild -project "$APP_NAME.xcodeproj" -scheme "$APP_NAME" -configuration Release \ - ARCHS="arm64" ONLY_ACTIVE_ARCH=NO \ - build 2>&1 | tail -5 - package_build "Apple-Silicon" - ;; - intel) - echo "==> Building $APP_NAME (Release, Intel only)..." - xcodebuild -project "$APP_NAME.xcodeproj" -scheme "$APP_NAME" -configuration Release \ - ARCHS="x86_64" ONLY_ACTIVE_ARCH=NO \ - build 2>&1 | tail -5 - package_build "Intel" - ;; - separate) - # --- Apple Silicon --- - echo "==> Building $APP_NAME (Release, Apple Silicon)..." - clean_derived_data - xcodebuild -project "$APP_NAME.xcodeproj" -scheme "$APP_NAME" -configuration Release \ - ARCHS="arm64" ONLY_ACTIVE_ARCH=NO \ - build 2>&1 | tail -5 - package_build "Apple-Silicon" - - # --- Intel --- - echo "==> Building $APP_NAME (Release, Intel)..." - clean_derived_data - xcodebuild -project "$APP_NAME.xcodeproj" -scheme "$APP_NAME" -configuration Release \ - ARCHS="x86_64" ONLY_ACTIVE_ARCH=NO \ - build 2>&1 | tail -5 - package_build "Intel" - ;; - universal|*) - echo "==> Building $APP_NAME (Release, Universal Binary)..." - xcodebuild -project "$APP_NAME.xcodeproj" -scheme "$APP_NAME" -configuration Release \ - ARCHS="arm64 x86_64" ONLY_ACTIVE_ARCH=NO \ - build 2>&1 | tail -5 - package_build "" - ;; + silicon|intel|separate|universal) ;; + *) error "Unknown build mode '$BUILD_MODE'. Use universal, silicon, intel, or separate." ;; esac -# Generate Sparkle appcasts for separate builds -if [ "$BUILD_MODE" = "separate" ]; then - sparkle_sign_and_appcast -fi +echo "==> Generating Xcode project..." +cd "$PROJECT_DIR" +[ -f "$PACKAGE_RESOLVED_PATH" ] || + error "Tracked dependency lock is missing: $PACKAGE_RESOLVED_PATH" +PACKAGE_RESOLVED_SHA=$( + shasum -a 256 "$PACKAGE_RESOLVED_PATH" | + awk '{ print $1 }' +) +xcodegen generate +[ -f "$PACKAGE_RESOLVED_PATH" ] || + error "XcodeGen removed the tracked dependency lock: $PACKAGE_RESOLVED_PATH" +[ "$( + shasum -a 256 "$PACKAGE_RESOLVED_PATH" | + awk '{ print $1 }' +)" = "$PACKAGE_RESOLVED_SHA" ] || + error "XcodeGen changed the tracked dependency lock." +mkdir -p "$BUILD_DIR" "$SOURCE_PACKAGES_DIR" +validate_signing_configuration + +case "$BUILD_MODE" in + silicon) + echo "==> Building $APP_NAME (Release, Apple Silicon)..." + build_architectures "arm64" + package_build "Apple-Silicon" "arm64" + ;; + intel) + echo "==> Building $APP_NAME (Release, Intel)..." + build_architectures "x86_64" + package_build "Intel" "x86_64" + ;; + separate) + echo "==> Building $APP_NAME (Release, Apple Silicon)..." + build_architectures "arm64" + package_build "Apple-Silicon" "arm64" + + echo "==> Building $APP_NAME (Release, Intel)..." + build_architectures "x86_64" + package_build "Intel" "x86_64" + ;; + universal) + echo "==> Building $APP_NAME (Release, Universal Binary)..." + build_architectures "arm64 x86_64" + package_build "" "arm64 x86_64" + ;; +esac echo "" echo "==> Build complete!" -ls -lh "$BUILD_DIR/$APP_NAME-$VERSION"*.dmg 2>/dev/null | awk '{print " " $5 "\t" $NF}' -echo "" -echo "Install from the DMG in build/ directory." +find "$BUILD_DIR" -maxdepth 1 -name "$APP_NAME-$VERSION*.dmg" -exec ls -lh {} \; | + awk '{print " " $5 "\t" $NF}' diff --git a/docs/appcast-arm64.xml b/docs/appcast-arm64.xml deleted file mode 100644 index 0c226d7..0000000 --- a/docs/appcast-arm64.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - Speaky (Apple Silicon) - Speaky auto-update appcast for Apple Silicon - en - - diff --git a/docs/appcast-x86_64.xml b/docs/appcast-x86_64.xml deleted file mode 100644 index 0bd88d7..0000000 --- a/docs/appcast-x86_64.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - Speaky (Intel) - Speaky auto-update appcast for Intel - en - - diff --git a/docs/build-signing.md b/docs/build-signing.md new file mode 100644 index 0000000..7c3325b --- /dev/null +++ b/docs/build-signing.md @@ -0,0 +1,247 @@ +# Release build and signing + +This is the repeatable checklist for every official Speaky release. If the release certificate or GitHub secrets have not been configured yet, complete [Maintainer release-signing setup](maintainer-setup.md) first. + +## Identity model + +macOS associates Accessibility approval with an app's bundle identity and code-signing designated requirement, not only its display name. + +| Build | Display name | Bundle identifier | Signing | +| --- | --- | --- | --- | +| Debug | Speaky Debug | `com.bedriyan.speaky.debug` | Ad hoc by default | +| Official Release | Speaky | `com.bedriyan.speaky` | Stable self-signed identity | + +Debug builds remain separate from an installed release. Because an ad-hoc Debug build has a changing code-hash identity, contributors may occasionally need to reauthorize **Speaky Debug**. + +## 1. Prepare the release + +Before building: + +- Start from the exact commit intended for release. +- Confirm the working tree contains no accidental local changes. +- Update `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `project.yml`. +- Confirm the exact dependencies and tracked `Package.resolved` lock file contain only intentional changes. +- Use Xcode 26.3 (`17C529`) and XcodeGen 2.46.0, matching the release workflow. +- Generate the Xcode project successfully. +- Run the full test suite. +- Review user-facing changes and release notes. +- Obtain the previous official `Speaky.app` for identity comparison. + +Do not proceed when tests, dependency resolution, or supported-architecture builds are failing. + +The direct package versions in `project.yml` are the versions exercised by this release process; `Package.resolved` locks their transitive graph. FluidAudio 0.13.2 is deliberate: it preserves the Swift 6 actor-based `AsrManager` API while fixing the Intel compilation failure present in 0.12.6 and 0.13.1. Treat every dependency change as a separate reviewed update and regenerate the lock file only after both architecture builds and tests pass. + +```bash +xcodegen generate +xcodebuild test \ + -project Speaky.xcodeproj \ + -scheme Speaky \ + -configuration Debug \ + -destination 'platform=macOS' \ + -disableAutomaticPackageResolution \ + -onlyUsePackageVersionsFromResolvedFile +``` + +## 2. Build locally + +Confirm that the canonical identity is available: + +```bash +security find-identity -v -p codesigning +openssl x509 \ + -inform DER \ + -in build/signing/Speaky-Open-Source-Release.cer \ + -noout \ + -enddate +``` + +The certificate must have at least 30 days of validity remaining. Follow the +expiry-migration plan in +[Maintainer release-signing setup](maintainer-setup.md#7-plan-certificate-expiry) +instead of bypassing this release check. + +If `SPEAKY_SIGNING_KEYCHAIN` points to a custom keychain, add that keychain to +the user search list before building. Preserve any existing entries in the same +command; `codesign` cannot use an identity from a keychain that is absent from +this list: + +```bash +security list-keychains -d user +security list-keychains -d user -s \ + /absolute/path/to/release.keychain-db \ + "$HOME/Library/Keychains/login.keychain-db" +``` + +Then build both release architectures: + +```bash +SPEAKY_SIGNING_IDENTITY='<40-character certificate SHA-1>' \ +SPEAKY_SIGNING_CERTIFICATE="$PWD/build/signing/Speaky-Open-Source-Release.cer" \ +./build.sh separate +``` + +The build script: + +- Requires the tracked `Package.resolved` and forbids automatic dependency resolution. +- Validates the release bundle identifier and executable. +- Asserts the exact architecture slices before naming each artifact. +- Signs every embedded framework, dynamic library, extension, and login item before the outer application. +- Signs the main application with only its microphone entitlement. +- Keeps hardened runtime enabled for stable signed releases. +- Embeds a designated requirement anchored to the release certificate and `com.bedriyan.speaky`. +- Rejects a certificate that does not match the selected private identity. +- Verifies the certificate anchor, runtime policy, microphone-only entitlement allowlist, and completed app before packaging it. +- Confirms every nested code object uses hardened runtime and the same release certificate as the outer app. +- Verifies each completed DMG's checksum before reporting it as an output. + +The output DMGs are written to `build/`. + +Speaky does not bundle an automatic updater. This keeps library validation enabled and avoids update-related helper processes and signing keys. + +## 3. Build with GitHub Actions + +Alternatively: + +1. Create the final semantic-version tag, such as `v2.1.0`, on the reviewed current `main` commit. +2. Open the repository's **Actions** tab. +3. Select **Build self-signed release**. +4. Set **Use workflow from** to `main`. +5. Choose **Run workflow** and enter that exact existing tag. +6. After the test job passes, verify its logged tag and commit SHA before approving the protected `release-signing` environment. +7. Download the `Speaky-self-signed-vX.Y.Z` artifact. + +The workflow runs only from `main` and accepts only a `vMAJOR.MINOR.PATCH` tag +that points to the exact selected `main` commit, matches the version in +`project.yml`, and contains the current release-contract marker. Tests run in a +separate job before the protected environment exposes its signing secrets. The +signing job checks out the exact tested commit SHA and fails if the tag moves or +does not match the selected `main` SHA. The workflow builds Apple Silicon and +Intel DMGs, asserts their slices, verifies them, removes all temporary signing +material, and only then uploads the DMGs with `SHA256SUMS.txt`. It does not +publish a GitHub Release. + +## 4. Verify the artifacts + +### Verify bundle metadata and signature + +For each extracted application: + +```bash +codesign --verify --deep --strict /path/to/Speaky.app +/usr/libexec/PlistBuddy \ + -c 'Print :CFBundleIdentifier' \ + /path/to/Speaky.app/Contents/Info.plist +``` + +The bundle identifier must be: + +```text +com.bedriyan.speaky +``` + +Inspect the entitlements: + +```bash +codesign -d --entitlements :- /path/to/Speaky.app +``` + +The Release app must contain the microphone entitlement. It must not contain `com.apple.security.cs.disable-library-validation` or `com.apple.security.get-task-allow`. + +### Compare identity with the previous release + +```bash +./scripts/verify-app-identity.sh \ + /path/to/new/Speaky.app \ + /path/to/previous/Speaky.app +``` + +Do not publish if the designated requirements differ unexpectedly. A changed requirement can make macOS treat the update as a new Accessibility client. + +### Verify architectures + +```bash +lipo -archs /path/to/Speaky.app/Contents/MacOS/Speaky +``` + +Confirm the Apple Silicon build contains `arm64` and the Intel build contains `x86_64`. + +### Test real installations + +On clean test machines: + +1. Install each DMG into `/Applications`. +2. Confirm the app survives first launch. +3. Complete onboarding and download the intended default model. +4. Grant microphone and Accessibility permissions. +5. Record, transcribe, and auto-paste text. +6. Confirm the menu bar item remains available. +7. Install the new version over the previous stable release and confirm Accessibility remains granted. + +### Generate checksums + +```bash +( + cd build + shasum -a 256 Speaky-*.dmg > SHA256SUMS.txt + shasum -a 256 -c SHA256SUMS.txt +) +``` + +The manifest contains artifact-root filenames without a `build/` prefix, so it +works after downloading and extracting the GitHub Actions artifact. Publish +this exact verified manifest with the release. + +## 5. Publish the GitHub Release + +The maintainer should: + +1. Use the already verified version tag and commit. +2. Create a GitHub Release for that tag. +3. Attach the verified Apple Silicon and Intel DMGs. +4. Include their SHA-256 checksums. +5. Describe important changes and known limitations. +6. Link to [Install Speaky on macOS](end-user-installation.md). +7. For the first stable signed release, highlight the one-time Accessibility migration. + +Do not rebuild artifacts after verification. Publish the exact files that were tested. + +## Ad-hoc builds are local-only + +The build script never silently falls back to ad-hoc signing. For an unpublished local test DMG, opt in explicitly: + +```bash +SPEAKY_ALLOW_ADHOC=1 ./build.sh silicon +``` + +Ad-hoc builds: + +- Have a changing designated requirement. +- May require Accessibility approval after every rebuild. +- Disable hardened runtime on the final ad-hoc signatures. +- Use the production bundle identifier; prefer **Speaky Debug** for routine contributor testing and do not install an ad-hoc Release alongside an official release. +- Must not be published as official releases. + +## Expected limitations of the free release path + +- This free self-signed release process does not notarize the app. +- Gatekeeper will require users to approve the first launch. +- macOS always requires the user to grant microphone and Accessibility permissions. +- The stable certificate preserves identity continuity; it does not make the maintainer an Apple-identified developer. + +## Failure handling + +Stop the release when: + +- The canonical signing identity is unavailable. +- The public `.cer` does not match the private signing identity. +- The certificate expires within 30 days. +- The tracked package lock is missing, changed during generation, or cannot satisfy the build. +- `codesign --verify` fails. +- The new and previous designated requirements differ. +- A Release app contains `get-task-allow`. +- A Release app disables library validation. +- An artifact contains architecture slices other than those declared by its filename. +- A supported architecture cannot build or launch. +- End-to-end recording, transcription, or auto-paste fails. + +Do not work around a signing failure by publishing the ad-hoc fallback. diff --git a/docs/end-user-installation.md b/docs/end-user-installation.md new file mode 100644 index 0000000..8bc32a7 --- /dev/null +++ b/docs/end-user-installation.md @@ -0,0 +1,145 @@ +# Install Speaky on macOS + +This guide is for people installing an official Speaky release from GitHub. You do not need Xcode, Terminal experience, a signing certificate, or a developer account. + +The v2.0.4 artifacts predate Speaky's stable self-signed release process. The release notes will identify the first version built with that process. + +## Requirements + +- macOS 15 or later. +- A supported Apple Silicon or Intel Mac. +- Permission to install applications in `/Applications`. +- Microphone permission. +- Accessibility permission for automatic pasting. + +## 1. Download the correct DMG + +Download Speaky only from the official repository's [GitHub Releases](https://github.com/bedriyan/speaky/releases). + +| Mac | Download | +| --- | --- | +| Apple Silicon: M1, M2, M3, M4, or later | Apple Silicon DMG | +| Intel processor | Intel DMG | + +You can identify your Mac from **Apple menu → About This Mac**. + +If the release provides SHA-256 checksums, optionally verify the download in Terminal: + +```bash +shasum -a 256 ~/Downloads/Speaky-*.dmg +``` + +The result must exactly match the checksum published with the GitHub Release. + +## 2. Install Speaky + +1. Open the downloaded DMG. +2. Drag **Speaky** into the **Applications** folder. +3. Eject the Speaky disk image. +4. Open **Applications** and launch **Speaky**. + +Keep only one installed copy of Speaky, preferably `/Applications/Speaky.app`. + +## 3. Approve the first launch + +Speaky releases are not notarized by Apple. macOS may report that Apple cannot check the app for malicious software. + +Only continue if the DMG came from the official GitHub Release and, when provided, its checksum matches. + +After attempting to open Speaky: + +1. Open **System Settings**. +2. Select **Privacy & Security**. +3. Scroll down to **Security**. +4. Click **Open Anyway** next to the Speaky message. +5. Authenticate with your Mac password or Touch ID. +6. Confirm **Open**. + +Apple documents this process in [Safely open apps on your Mac](https://support.apple.com/en-us/102445). The **Open Anyway** button appears only after macOS has blocked a launch and may be available for a limited time. + +Do not disable Gatekeeper globally. Do not import or trust a certificate supplied by another person. + +## 4. Grant microphone access + +Speaky needs the microphone to record speech. + +1. Accept the microphone request when Speaky displays it. +2. If access was previously denied, open **System Settings → Privacy & Security → Microphone**. +3. Enable **Speaky**. +4. Quit and reopen Speaky if macOS requests it. + +## 5. Grant Accessibility access + +Accessibility allows Speaky to paste completed transcriptions into the active application. + +1. Open Speaky's settings. +2. Find **Accessibility Access** and select **Grant Access**. +3. In **System Settings → Privacy & Security → Accessibility**, enable **Speaky**. +4. Authenticate if requested. +5. Quit and reopen Speaky if the status does not update immediately. + +Speaky cannot grant this permission to itself. Keep one installed copy to avoid ambiguous permission entries. + +## 6. Finish onboarding and test + +1. Select or download a transcription model. +2. Choose the microphone input. +3. Configure the recording shortcut. +4. Place the cursor in a text field. +5. Start recording, speak, and stop recording. +6. Confirm that Speaky transcribes and pastes the text. + +Model downloads can be large and may take several minutes. + +## Upgrading from an older ad-hoc release + +Older releases and local builds may use changing ad-hoc identities and can create duplicate Speaky entries in Accessibility settings. Complete this cleanup once when moving to the first release whose notes confirm the stable self-signed identity: + +1. Quit every running Speaky copy. +2. Open **System Settings → Privacy & Security → Accessibility**. +3. Select each existing Speaky entry and remove it with the minus button. +4. Delete obsolete Speaky applications, keeping no old copies in Applications, Downloads, or mounted DMGs. +5. Install the new release as `/Applications/Speaky.app`. +6. Launch it and grant microphone and Accessibility permissions once. + +Later official updates should preserve Accessibility permission while the maintainer keeps the same signing identity. Manually downloaded updates may still require Gatekeeper approval because the app is not notarized. + +Speaky does not update itself automatically. Download future versions from the same official GitHub Releases page, verify their checksums, and replace the existing app in `/Applications`. + +## Troubleshooting + +### Open Anyway is missing + +Try to open Speaky once, dismiss the warning, and return to **System Settings → Privacy & Security**. macOS shows **Open Anyway** only after it has blocked that specific app. + +### Multiple Speaky entries appear in Accessibility + +Quit Speaky, remove every Speaky entry, delete obsolete app copies, and reinstall one official copy in `/Applications`. + +### Microphone access is denied + +Enable Speaky in **System Settings → Privacy & Security → Microphone**, then restart the app. + +### Auto-paste does not work + +Confirm Speaky is enabled in **System Settings → Privacy & Security → Accessibility**. Transcription can succeed without Accessibility, but Speaky cannot paste into another app. + +### Recording cannot start + +In Speaky settings, select a specific available input device instead of an unavailable or disconnected device. Also verify that the device works in **System Settings → Sound → Input**. + +### A managed Mac blocks the app + +Work or school security policies can prevent users from overriding Gatekeeper or granting Accessibility. Contact the organization's administrator; do not disable security controls. + +## Security limitations + +Self-signing gives official releases a stable application identity, but it does not provide Apple notarization. + +For the safest installation: + +- Download only from the official GitHub Release. +- Verify published checksums. +- Keep only one installed copy. +- Do not run commands that globally disable macOS security. +- Install future updates only from the same official project. diff --git a/docs/maintainer-setup.md b/docs/maintainer-setup.md new file mode 100644 index 0000000..3c2ad22 --- /dev/null +++ b/docs/maintainer-setup.md @@ -0,0 +1,224 @@ +# Maintainer release-signing setup + +This guide is for the maintainer who prepares official Speaky releases. Complete it once before producing the first stable self-signed release. For the steps repeated for every version, see [Release build and signing](build-signing.md). + +## What this setup provides + +Speaky uses one long-lived self-signed code-signing certificate to give official releases a stable macOS identity. Keeping the same certificate, bundle identifier, and designated requirement allows macOS to recognize future releases as updates of the same Accessibility client. + +This free signing method does **not** notarize Speaky. Users will still need to approve the first launch through macOS Privacy & Security. This project intentionally documents only the free release path. + +## Prerequisites + +Use a trusted Mac with: + +- Xcode 26.3 (`17C529`) for release parity with CI. +- [XcodeGen](https://github.com/yonaskolb/XcodeGen) 2.46.0. +- OpenSSL 1.1.1 or newer. +- Permission to administer the repository's protected GitHub environments. +- A secure backup location for the release identity and its password. + +Before enabling releases, also confirm that: + +- The exact package versions in `project.yml` and the tracked `Package.resolved` still resolve. +- The app builds on every supported architecture. +- The test suite passes. + +Speaky intentionally has no bundled automatic-update framework. Adding one requires a separate security review, update-signing design, and end-to-end update tests. + +## 1. Generate the release identity + +Choose a long random password and generate the identity exactly once: + +```bash +read -s SPEAKY_CERTIFICATE_PASSWORD +export SPEAKY_CERTIFICATE_PASSWORD +./scripts/create-self-signed-certificate.sh +unset SPEAKY_CERTIFICATE_PASSWORD +``` + +Enter the password only at the local shell prompt. Do not place it in shell history. + +The command creates: + +| File | Purpose | Secret? | +| --- | --- | --- | +| `build/signing/Speaky-Open-Source-Release.p12` | Certificate and private signing key | Yes | +| `build/signing/Speaky-Open-Source-Release.cer` | Public certificate used in the designated requirement | No | + +The script refuses to replace an existing identity. Do not bypass that protection during normal release work. + +Record the certificate fingerprint and expiry date shown by the script. You can +inspect them again at any time: + +```bash +openssl x509 \ + -inform DER \ + -in build/signing/Speaky-Open-Source-Release.cer \ + -noout \ + -fingerprint \ + -sha256 \ + -enddate +``` + +## 2. Back up the identity + +Store these items in a secure location separate from the repository: + +- The `.p12` file. +- Its password. +- The public `.cer` file. +- A note identifying the certificate as the canonical Speaky release identity. +- The certificate's SHA-256 fingerprint and expiry date. + +Never commit, publish, email, or attach the `.p12` to a GitHub Release. Anyone who obtains it and its password can sign applications that match Speaky's release identity. + +Losing or replacing the private key breaks identity continuity. Users would then need to grant Accessibility again for releases signed by the replacement certificate. + +Create calendar reminders well before expiry. The release build refuses a +certificate with less than 30 days of validity remaining. + +## 3. Install the identity on a release Mac + +Import the `.p12` into the login keychain: + +```bash +read -s SPEAKY_CERTIFICATE_PASSWORD +export SPEAKY_CERTIFICATE_PASSWORD +security import \ + build/signing/Speaky-Open-Source-Release.p12 \ + -k "$HOME/Library/Keychains/login.keychain-db" \ + -P "$SPEAKY_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign +unset SPEAKY_CERTIFICATE_PASSWORD +``` + +Run the import only on the trusted release Mac. The `security import -P` interface necessarily passes the password as a process argument for the short lifetime of that command. + +Then: + +1. Open **Keychain Access**. +2. Find **Speaky Open Source Release**. +3. Open the certificate and expand **Trust**. +4. Set **Code Signing** to **Always Trust**. +5. Close the certificate window and authenticate if requested. + +Confirm that macOS recognizes it as a valid code-signing identity: + +```bash +security find-identity -v -p codesigning +``` + +The output must include **Speaky Open Source Release**. Record the 40-character SHA-1 hash shown before the certificate name; release builds select the identity by that exact hash rather than by a potentially ambiguous name. + +Only release machines need this trust setting. End users must not import or trust the maintainer certificate. + +## 4. Configure GitHub Actions + +The manual [Build self-signed release workflow](../.github/workflows/self-signed-release.yml) imports the same identity into an ephemeral macOS runner and uploads Apple Silicon and Intel DMGs as workflow artifacts. + +In **Settings → Environments**, create an environment named exactly `release-signing`, then: + +1. Add required reviewers who understand the release checklist. +2. Prevent self-approval when the repository plan supports that control. +3. Allow deployments only from the repository's default branch, `main`. +4. Store the following as **environment secrets**, not repository-wide secrets. + +| Secret | Value | +| --- | --- | +| `SPEAKY_SIGNING_CERTIFICATE_P12` | Base64-encoded `.p12` file | +| `SPEAKY_SIGNING_CERTIFICATE_PASSWORD` | Password used when the `.p12` was generated | + +On macOS, copy the base64 value with: + +```bash +base64 -i build/signing/Speaky-Open-Source-Release.p12 | pbcopy +``` + +Paste that value into `SPEAKY_SIGNING_CERTIFICATE_P12`. Do not store the encoded value in a file tracked by Git. + +The workflow must be dispatched with **Use workflow from: main**. Its +`release_tag` input identifies the source to build, but it is not the workflow +execution ref used by GitHub Environment branch restrictions. Before approving +the `release-signing` environment, a reviewer must verify: + +- The selected workflow ref is `main`. +- The requested semantic-version tag is the intended release. +- The release tag points to the exact selected `main` workflow commit. +- The test job's `Validated release source` message shows that tag and commit SHA. +- The tag still points to that SHA. + +The workflow also enforces the `main` ref in code, accepts an existing +semantic-version tag only when it points to the exact selected `main` commit, +validates it against `project.yml`, and has read-only repository permissions. +Tags created before the release contract in +`.github/release-contract-version` are rejected. The build job checks out the +exact commit SHA that passed the test job and repeats the `main`-SHA and tag +checks before importing the signing identity. The protected Environment +restriction is the security boundary; the in-workflow checks are defense in +depth. + +CI pins Xcode 26.3 (`17C529`) and downloads XcodeGen 2.46.0 with a fixed SHA-256 checksum. Tests and builds are restricted to the tracked `Package.resolved` graph. Tests run before the protected signing job requests approval or imports the key. Actions are pinned to immutable commit SHAs. Artifacts are retained briefly and are not published automatically. + +The signing certificate, private key, trusted entry, and temporary keychain are +removed immediately after the verified build and before the artifact-upload +action runs. + +The private key is the highest-risk release asset. Keep an offline backup. If the project does not need CI signing, the maintainer may omit the environment secrets and build only on the secured release Mac. + +## 5. Prove identity continuity before the first release + +Before publishing: + +1. Produce two test builds from different source revisions using the same certificate. +2. Extract both `Speaky.app` bundles. +3. Compare them with: + +```bash +./scripts/verify-app-identity.sh \ + /path/to/new/Speaky.app \ + /path/to/previous/Speaky.app +``` + +The comparison must report that both builds share the same designated requirement. + +## 6. Plan the first-release migration + +Older ad-hoc releases used changing code-hash identities. The first release produced with this stable self-signed process is therefore a one-time migration: + +- Mention the Accessibility cleanup in the release notes. +- Link users to [Install Speaky on macOS](end-user-installation.md#upgrading-from-an-older-ad-hoc-release). +- Explain that later releases preserve Accessibility only while the same signing identity is retained. + +## 7. Plan certificate expiry + +The default certificate lifetime is 3,650 days. Certificate expiry is therefore +a planned identity migration, not an indefinite guarantee. + +Renewing the certificate changes its certificate hash—and therefore Speaky's +designated-requirement anchor—even if the same private key is reused. Users will +need to grant Accessibility to the replacement identity. + +Well before expiry: + +1. Announce the migration and the required Accessibility reauthorization. +2. Generate and securely back up the replacement identity without overwriting the old one. +3. Update the protected CI secrets and release-Mac identity together. +4. Build and verify the first replacement-signed release. +5. Confirm the new requirement intentionally differs from the expiring identity. +6. Publish cleanup and reauthorization instructions with that release. + +Never silently regenerate or replace an expired certificate, and never weaken +the build's expiry check to publish one last release. + +## Permanent release rules + +- Generate the release identity once. +- Record and monitor its fingerprint and expiry date. +- Keep its private key restricted to release maintainers and CI secrets. +- Never publish an ad-hoc build as an official release. +- Never silently replace or regenerate the certificate. +- Do not add an automatic updater without a separate security review and signed-update test plan. +- Remove access for maintainers who no longer prepare releases. +- Test and verify every artifact before publishing it. +- If the identity expires, is lost, or is compromised, disclose the migration and require users to reauthorize the new identity. diff --git a/project.yml b/project.yml index 0c1ba2f..8a9fdfd 100644 --- a/project.yml +++ b/project.yml @@ -1,33 +1,25 @@ name: Speaky options: + minimumXcodeGenVersion: "2.46.0" bundleIdPrefix: com.bedriyan deploymentTarget: macOS: "15.0" - xcodeVersion: "16.0" + xcodeVersion: "26.3" createIntermediateGroups: true -settings: - configs: - Release: - ONLY_ACTIVE_ARCH: false - packages: SwiftWhisper: url: https://github.com/exPHAT/SwiftWhisper - from: 1.2.0 + exactVersion: 1.2.0 KeyboardShortcuts: url: https://github.com/sindresorhus/KeyboardShortcuts - from: 1.9.0 + exactVersion: 1.17.0 DynamicNotchKit: url: https://github.com/MrKai77/DynamicNotchKit - from: 1.0.0 + exactVersion: 1.1.0 FluidAudio: url: https://github.com/FluidInference/FluidAudio - branch: main - Sparkle: - url: https://github.com/sparkle-project/Sparkle - from: 2.8.0 - + exactVersion: 0.13.2 targets: Speaky: type: application @@ -42,11 +34,17 @@ targets: configs: Debug: ONLY_ACTIVE_ARCH: true + PRODUCT_BUNDLE_IDENTIFIER: com.bedriyan.speaky.debug + PRODUCT_NAME: Speaky Debug + CODE_SIGN_IDENTITY: "-" Release: ONLY_ACTIVE_ARCH: false + PRODUCT_BUNDLE_IDENTIFIER: com.bedriyan.speaky + PRODUCT_NAME: Speaky + CODE_SIGN_IDENTITY: "-" + CODE_SIGN_INJECT_BASE_ENTITLEMENTS: false base: - PRODUCT_BUNDLE_IDENTIFIER: com.bedriyan.speaky - PRODUCT_NAME: Speaky + PRODUCT_MODULE_NAME: Speaky MARKETING_VERSION: "2.0.4" CURRENT_PROJECT_VERSION: "1" SWIFT_VERSION: "6.0" @@ -56,7 +54,6 @@ targets: INFOPLIST_FILE: Speaky/Resources/Info.plist ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ENABLE_HARDENED_RUNTIME: true - CODE_SIGN_IDENTITY: "-" OTHER_SWIFT_FLAGS: "" GENERATE_INFOPLIST_FILE: false LD_RUNPATH_SEARCH_PATHS: "@executable_path/../Frameworks" @@ -65,7 +62,6 @@ targets: - package: KeyboardShortcuts - package: DynamicNotchKit - package: FluidAudio - - package: Sparkle - sdk: CoreAudio.framework - sdk: AudioToolbox.framework - sdk: Security.framework @@ -82,6 +78,10 @@ targets: sources: - SpeakyTests settings: + configs: + Debug: + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Speaky Debug.app/Contents/MacOS/Speaky Debug" + BUNDLE_LOADER: "$(TEST_HOST)" base: PRODUCT_BUNDLE_IDENTIFIER: com.bedriyan.speaky.tests SWIFT_VERSION: "6.0" @@ -89,3 +89,16 @@ targets: GENERATE_INFOPLIST_FILE: true dependencies: - target: Speaky + +schemes: + Speaky: + build: + targets: + Speaky: all + SpeakyTests: [test] + run: + config: Debug + test: + config: Debug + targets: + - SpeakyTests diff --git a/scripts/create-self-signed-certificate.sh b/scripts/create-self-signed-certificate.sh new file mode 100755 index 0000000..5edf7b2 --- /dev/null +++ b/scripts/create-self-signed-certificate.sh @@ -0,0 +1,90 @@ +#!/bin/bash +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OUTPUT_DIR="${1:-$PROJECT_DIR/build/signing}" +CERTIFICATE_NAME="${SPEAKY_CERTIFICATE_NAME:-Speaky Open Source Release}" +CERTIFICATE_DAYS="${SPEAKY_CERTIFICATE_DAYS:-3650}" +P12_PATH="$OUTPUT_DIR/Speaky-Open-Source-Release.p12" +CERTIFICATE_PATH="$OUTPUT_DIR/Speaky-Open-Source-Release.cer" + +if [ -z "${SPEAKY_CERTIFICATE_PASSWORD:-}" ]; then + echo "ERROR: Set SPEAKY_CERTIFICATE_PASSWORD before generating signing assets." + exit 1 +fi + +if [[ ! "$CERTIFICATE_NAME" =~ ^[A-Za-z0-9._[:space:]-]+$ ]]; then + echo "ERROR: SPEAKY_CERTIFICATE_NAME contains unsupported characters." + exit 1 +fi + +if [[ ! "$CERTIFICATE_DAYS" =~ ^[1-9][0-9]*$ ]]; then + echo "ERROR: SPEAKY_CERTIFICATE_DAYS must be a positive integer." + exit 1 +fi + +if ! openssl req -help 2>&1 | grep -q -- "-addext"; then + echo "ERROR: OpenSSL 1.1.1 or newer is required." + echo " Install it with Homebrew and ensure it is first on PATH." + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" +if [ -e "$P12_PATH" ] || [ -e "$CERTIFICATE_PATH" ]; then + echo "ERROR: Signing assets already exist in '$OUTPUT_DIR'." + echo " Refusing to replace the release identity." + exit 1 +fi + +umask 077 +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/speaky-certificate.XXXXXX") +trap 'rm -rf "$TEMP_DIR"' EXIT + +openssl req \ + -x509 \ + -newkey rsa:3072 \ + -sha256 \ + -nodes \ + -keyout "$TEMP_DIR/private-key.pem" \ + -out "$TEMP_DIR/certificate.pem" \ + -days "$CERTIFICATE_DAYS" \ + -subj "/CN=$CERTIFICATE_NAME/O=Speaky Open Source" \ + -addext "basicConstraints=critical,CA:FALSE" \ + -addext "keyUsage=critical,digitalSignature" \ + -addext "extendedKeyUsage=codeSigning" \ + >/dev/null 2>&1 + +P12_OPTIONS=(-export) +if openssl version | grep -q "^OpenSSL 3"; then + P12_OPTIONS+=(-legacy) +fi + +openssl pkcs12 \ + "${P12_OPTIONS[@]}" \ + -inkey "$TEMP_DIR/private-key.pem" \ + -in "$TEMP_DIR/certificate.pem" \ + -name "$CERTIFICATE_NAME" \ + -out "$P12_PATH" \ + -passout env:SPEAKY_CERTIFICATE_PASSWORD + +openssl x509 \ + -in "$TEMP_DIR/certificate.pem" \ + -outform DER \ + -out "$CERTIFICATE_PATH" + +chmod 600 "$P12_PATH" +chmod 644 "$CERTIFICATE_PATH" + +echo "Created a stable self-signed release identity:" +echo " Private identity: $P12_PATH" +echo " Public anchor: $CERTIFICATE_PATH" +echo "" +openssl x509 \ + -in "$TEMP_DIR/certificate.pem" \ + -noout \ + -subject \ + -dates \ + -fingerprint \ + -sha256 +echo "" +echo "Back up the .p12 and its password securely. Never commit the .p12." diff --git a/scripts/verify-app-identity.sh b/scripts/verify-app-identity.sh new file mode 100755 index 0000000..ebc8784 --- /dev/null +++ b/scripts/verify-app-identity.sh @@ -0,0 +1,193 @@ +#!/bin/bash +set -euo pipefail + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "Usage: $0 [previous-app-path]" >&2 + exit 1 +fi + +APP_PATH="$1" +PREVIOUS_APP_PATH="${2:-}" +EXPECTED_BUNDLE_ID="com.bedriyan.speaky" +EXPECTED_CERTIFICATE="${SPEAKY_SIGNING_CERTIFICATE:-}" +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/speaky-identity-verification.XXXXXX") +trap 'rm -rf "$TEMP_DIR"' EXIT + +error() { + echo "ERROR: $*" >&2 + exit 1 +} + +certificate_sha1() { + openssl x509 \ + -inform DER \ + -in "$1" \ + -noout \ + -fingerprint \ + -sha1 | + sed 's/.*=//' | + tr -d ':' | + tr '[:lower:]' '[:upper:]' +} + +require_entitlement() { + local entitlements_path="$1" + local key="$2" + local value + value=$(/usr/libexec/PlistBuddy -c "Print :$key" "$entitlements_path" 2>/dev/null || true) + [ "$value" = "true" ] || + error "Required entitlement '$key' is missing or false." +} + +reject_entitlement() { + local entitlements_path="$1" + local key="$2" + local value + value=$(/usr/libexec/PlistBuddy -c "Print :$key" "$entitlements_path" 2>/dev/null || true) + [ "$value" != "true" ] || + error "Release app contains forbidden entitlement '$key'." +} + +inspect_runtime_signature() { + local code_path="$1" + local expected_certificate_sha1="$2" + local details + local extracted_certificate_prefix + local actual_certificate_sha1 + + details=$(codesign -dvvv "$code_path" 2>&1) + + printf '%s\n' "$details" | grep -Fq "Signature=adhoc" && + error "Ad-hoc nested signature found: $code_path" + printf '%s\n' "$details" | grep -Fq "Authority=" || + error "Certificate-backed nested signature is missing: $code_path" + printf '%s\n' "$details" | grep -Eq 'flags=.*runtime' || + error "Hardened runtime is missing from nested code: $code_path" + + if [ -n "$expected_certificate_sha1" ]; then + extracted_certificate_prefix="$TEMP_DIR/nested-certificate-" + rm -f "$extracted_certificate_prefix"* + codesign -d \ + --extract-certificates="$extracted_certificate_prefix" \ + "$code_path" >/dev/null 2>&1 + [ -f "${extracted_certificate_prefix}0" ] || + error "Unable to extract the nested signing certificate: $code_path" + actual_certificate_sha1=$(certificate_sha1 "${extracted_certificate_prefix}0") + [ "$actual_certificate_sha1" = "$expected_certificate_sha1" ] || + error "Nested code is signed by a different certificate: $code_path" + fi +} + +inspect_app() { + local app_path="$1" + local info_plist="$app_path/Contents/Info.plist" + local bundle_id + local executable_name + local requirement_output + local requirement + local signature_details + local entitlements_path + local entitlement_key_count + local expected_anchor="" + local nested_path + + [ -d "$app_path" ] || error "App does not exist: $app_path" + [ -f "$info_plist" ] || error "Info.plist is missing from $app_path." + + plutil -lint "$info_plist" >/dev/null + bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$info_plist") + executable_name=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$info_plist") + + [ "$bundle_id" = "$EXPECTED_BUNDLE_ID" ] || + error "Expected '$EXPECTED_BUNDLE_ID', found '$bundle_id' in $app_path." + [ -x "$app_path/Contents/MacOS/$executable_name" ] || + error "Bundle executable '$executable_name' is missing in $app_path." + + codesign --verify --deep --strict --verbose=2 "$app_path" + signature_details=$(codesign -dvvv "$app_path" 2>&1) + + printf '%s\n' "$signature_details" | grep -Fq "Signature=adhoc" && + error "The app is ad-hoc signed and cannot establish a stable identity." + printf '%s\n' "$signature_details" | grep -Fq "Authority=" || + error "The app does not contain a certificate-backed signature." + printf '%s\n' "$signature_details" | grep -Eq 'flags=.*runtime' || + error "The app is missing the hardened-runtime code-signing flag." + + requirement_output=$(codesign -d -r- "$app_path" 2>&1) + requirement=$( + printf '%s\n' "$requirement_output" | + awk '/^(# )?designated => / { sub(/^# /, ""); print; exit }' + ) + [ -n "$requirement" ] || + error "No designated requirement found in $app_path." + + case "$requirement" in + *cdhash*) error "The designated requirement is code-hash based, not certificate anchored." ;; + esac + case "$requirement" in + *'anchor = H"'*|*'certificate root = H"'*) ;; + *) error "The designated requirement is not anchored to a certificate hash." ;; + esac + case "$requirement" in + *"identifier \"$EXPECTED_BUNDLE_ID\""*) ;; + *) error "The designated requirement does not contain the expected bundle identifier." ;; + esac + + if [ -n "$EXPECTED_CERTIFICATE" ]; then + [ -f "$EXPECTED_CERTIFICATE" ] || + error "SPEAKY_SIGNING_CERTIFICATE does not exist: $EXPECTED_CERTIFICATE" + local expected_anchor_lowercase + expected_anchor=$(certificate_sha1 "$EXPECTED_CERTIFICATE") + expected_anchor_lowercase=$(printf '%s' "$expected_anchor" | tr '[:upper:]' '[:lower:]') + case "$requirement" in + *"anchor = H\"$expected_anchor\""*|\ + *"anchor = H\"$expected_anchor_lowercase\""*|\ + *"certificate root = H\"$expected_anchor\""*|\ + *"certificate root = H\"$expected_anchor_lowercase\""*) ;; + *) error "The app requirement is not anchored to SPEAKY_SIGNING_CERTIFICATE." ;; + esac + fi + + entitlements_path="$TEMP_DIR/entitlements.plist" + codesign -d --entitlements :- "$app_path" >"$entitlements_path" 2>/dev/null + require_entitlement "$entitlements_path" "com.apple.security.device.audio-input" + reject_entitlement "$entitlements_path" "com.apple.security.cs.disable-library-validation" + reject_entitlement "$entitlements_path" "com.apple.security.get-task-allow" + entitlement_key_count=$(grep -c '' "$entitlements_path") + [ "$entitlement_key_count" -eq 1 ] || + error "Release app must contain only the microphone entitlement." + + for nested_path in \ + "$app_path"/Contents/Frameworks/*.framework \ + "$app_path"/Contents/Frameworks/*.dylib \ + "$app_path"/Contents/PlugIns/*.appex \ + "$app_path"/Contents/PlugIns/*.xpc \ + "$app_path"/Contents/Library/LoginItems/*.app; do + [ -e "$nested_path" ] || continue + inspect_runtime_signature "$nested_path" "$expected_anchor" + done + + echo "App: $app_path" >&2 + echo "Bundle ID: $bundle_id" >&2 + echo "Executable: $executable_name" >&2 + echo "Requirement: $requirement" >&2 + + printf '%s' "$requirement" +} + +CURRENT_REQUIREMENT=$(inspect_app "$APP_PATH") + +if [ -n "$PREVIOUS_APP_PATH" ]; then + echo "" + PREVIOUS_REQUIREMENT=$(inspect_app "$PREVIOUS_APP_PATH") + + [ "$CURRENT_REQUIREMENT" = "$PREVIOUS_REQUIREMENT" ] || + error "The two builds have different designated requirements. + macOS may treat them as different Accessibility clients." + + echo "" + echo "Identity continuity verified: both builds share the same designated requirement." +else + echo "" + echo "Stable application identity verified." +fi