diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53cf250..aafb37c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: env: # Override deployment target for CI runner compatibility - MACOSX_DEPLOYMENT_TARGET: '15.0' + MACOSX_DEPLOYMENT_TARGET: '14.5' # Skip code signing on CI CODE_SIGN_IDENTITY: '' CODE_SIGNING_REQUIRED: 'NO' @@ -20,15 +20,67 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Select Xcode 26 + run: | + XCODE=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1) + if [ -z "$XCODE" ]; then + echo "::error::No Xcode 26 on this runner. The project relies on Xcode 26 build settings (SWIFT_DEFAULT_ACTOR_ISOLATION)." + exit 1 + fi + sudo xcode-select -s "$XCODE" + xcodebuild -version + - name: Build run: > xcodebuild -scheme "Focusrite Volume Control" -configuration Debug - MACOSX_DEPLOYMENT_TARGET=15.0 + MACOSX_DEPLOYMENT_TARGET=14.5 CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO build - name: Test run: > xcodebuild test -scheme "Focusrite Volume Control" -configuration Debug - MACOSX_DEPLOYMENT_TARGET=15.0 + -resultBundlePath build/TestResults.xcresult + MACOSX_DEPLOYMENT_TARGET=14.5 CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO + + # Parallel test runners don't print why a test host crashed; surface it here + - name: Show test failures and crash reports + if: failure() + run: | + if [ -d build/TestResults.xcresult ]; then + xcrun xcresulttool get test-results tests --path build/TestResults.xcresult \ + | python3 -c 'import json,sys + def walk(n, path=""): + name = n.get("name", "") + if n.get("nodeType") == "Failure Message": + print(f"{path}: {name}") + for c in n.get("children", []): + walk(c, name if n.get("nodeType") == "Test Case" else path) + for t in json.load(sys.stdin).get("testNodes", []): + walk(t)' || true + fi + # Report names contain spaces ("Volume Control-.ips") + find ~/Library/Logs/DiagnosticReports -name 'Volume Control*' -mmin -60 -print0 2>/dev/null | + while IFS= read -r -d '' f; do + echo "=== $f" + # .ips files are one JSON header line plus a JSON body; print the abort reason and crashing frames + tail -n +2 "$f" | python3 -c 'import json,sys + r = json.load(sys.stdin) + print("termination:", r.get("termination", {}).get("indicator")) + print("asi:", json.dumps(r.get("asi"), indent=1)) + t = next((t for t in r.get("threads", []) if t.get("triggered")), None) + imgs = r.get("usedImages", []) + for fr in (t or {}).get("frames", [])[:25]: + img = imgs[fr["imageIndex"]].get("name", "?") if fr.get("imageIndex") is not None and fr["imageIndex"] < len(imgs) else "?" + sym = fr.get("symbol") or hex(fr.get("imageOffset", 0)) + print(" " + img + ": " + sym)' || head -60 "$f" + done + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: TestResults + path: build/TestResults.xcresult + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f95ab14..b549103 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,58 +9,284 @@ jobs: build-dmg: runs-on: macos-15 permissions: - contents: write + contents: write # release upload, appcast commit + actions: write # trigger the Pages deploy after the appcast changes + env: + SCHEME: Focusrite Volume Control # Xcode scheme/target name (unchanged) + APP_NAME: Volume Control # built product: "Volume Control.app" + DMG_PATH: build/VolumeControl.dmg steps: - uses: actions/checkout@v4 - - name: Build Release - run: > - xcodebuild -scheme "Focusrite Volume Control" -configuration Release - MACOSX_DEPLOYMENT_TARGET=15.0 - CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO - build + - name: Select Xcode 26 + run: | + XCODE=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1) + if [ -z "$XCODE" ]; then + echo "::error::No Xcode 26 on this runner. The project relies on Xcode 26 build settings (SWIFT_DEFAULT_ACTOR_ISOLATION)." + exit 1 + fi + sudo xcode-select -s "$XCODE" + xcodebuild -version + + # Sparkle compares CFBundleVersion, so stamp both version keys from the tag. + # A hyphen marks a prerelease (v1.6.0-beta.1): a GitHub prerelease for testers, + # with no Homebrew tap update and no Sparkle appcast entry. + - name: Read version from tag + id: version + run: | + VERSION="${GITHUB_REF_NAME#v}" + # Bundle versions must be dot-separated numbers, so drop the prerelease suffix there + BUNDLE_VERSION="${VERSION%%-*}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "BUNDLE_VERSION=$BUNDLE_VERSION" >> "$GITHUB_ENV" + if [[ "$VERSION" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + echo "::notice title=Prerelease::$GITHUB_REF_NAME is a prerelease: no Homebrew tap update, no Sparkle appcast entry" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + # Signing and notarization run only when every secret below is set. Until then the + # release is built unsigned, as before, and the Homebrew cask clears quarantine. + - name: Check signing secrets + id: signing + env: + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID || secrets.APPLE_TEAM_ID }} + DEVELOPER_ID_CERTIFICATE_P12: ${{ secrets.DEVELOPER_ID_CERTIFICATE_P12 }} + DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }} + APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }} + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }} + run: | + missing=() + for name in APPLE_TEAM_ID DEVELOPER_ID_CERTIFICATE_P12 DEVELOPER_ID_CERTIFICATE_PASSWORD \ + APP_STORE_CONNECT_API_KEY_P8 APP_STORE_CONNECT_API_KEY_ID APP_STORE_CONNECT_API_ISSUER_ID; do + [ -n "${!name}" ] || missing+=("$name") + done + if [ ${#missing[@]} -eq 0 ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "Signing and notarization enabled" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Unsigned release::Building an UNSIGNED, un-notarized DMG. Missing secrets: ${missing[*]}" + fi + + # The appcast only advertises signed, notarized builds: Gatekeeper would block an + # unsigned app that Sparkle installed. + - name: Check Sparkle configuration + id: sparkle + env: + SIGNED: ${{ steps.signing.outputs.enabled }} + PRERELEASE: ${{ steps.version.outputs.prerelease }} + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + run: | + PUBLIC_KEY=$(/usr/libexec/PlistBuddy -c "Print :SUPublicEDKey" Focusrite-Volume-Control-Info.plist) + if [ "$PRERELEASE" = "true" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "Prerelease: not published to the Sparkle appcast" + elif [ "$SIGNED" != "true" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Appcast not updated::Unsigned builds are not published to the Sparkle appcast." + elif [ -z "$SPARKLE_ED_PRIVATE_KEY" ] || [[ "$PUBLIC_KEY" == REPLACE_* ]]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Appcast not updated::Set the SPARKLE_ED_PRIVATE_KEY secret and SUPublicEDKey in Focusrite-Volume-Control-Info.plist to publish Sparkle updates." + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + fi + + - name: Import Developer ID certificate + if: steps.signing.outputs.enabled == 'true' + env: + DEVELOPER_ID_CERTIFICATE_P12: ${{ secrets.DEVELOPER_ID_CERTIFICATE_P12 }} + DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }} + APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }} + run: | + KEYCHAIN="$RUNNER_TEMP/signing.keychain-db" + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + echo "::add-mask::$KEYCHAIN_PASSWORD" + echo "$DEVELOPER_ID_CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 3600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import "$RUNNER_TEMP/certificate.p12" -P "$DEVELOPER_ID_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null + security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + rm "$RUNNER_TEMP/certificate.p12" + + IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN" | grep -m1 "Developer ID Application" | sed -E 's/.*"(.*)"/\1/') + if [ -z "$IDENTITY" ]; then + echo "::error::DEVELOPER_ID_CERTIFICATE_P12 does not contain a Developer ID Application identity" + exit 1 + fi + echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV" + echo "SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + + # notarytool accepts the .p8 either raw or base64-encoded in the secret + KEY_PATH="$RUNNER_TEMP/AuthKey.p8" + if printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" | grep -q "BEGIN PRIVATE KEY"; then + printf '%s\n' "$APP_STORE_CONNECT_API_KEY_P8" > "$KEY_PATH" + else + printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" | base64 --decode > "$KEY_PATH" + fi + echo "NOTARY_KEY_PATH=$KEY_PATH" >> "$GITHUB_ENV" + + - name: Build signed app (archive + Developer ID export) + if: steps.signing.outputs.enabled == 'true' + env: + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID || secrets.APPLE_TEAM_ID }} + run: | + xcodebuild archive \ + -scheme "$SCHEME" -configuration Release \ + -archivePath build/App.xcarchive \ + MACOSX_DEPLOYMENT_TARGET=14.5 \ + MARKETING_VERSION="$BUNDLE_VERSION" CURRENT_PROJECT_VERSION="$BUNDLE_VERSION" \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ + ENABLE_HARDENED_RUNTIME=YES \ + OTHER_CODE_SIGN_FLAGS="--timestamp --keychain $KEYCHAIN" + + # The team ID isn't committed; add it to a copy of ExportOptions.plist + cp ExportOptions.plist build/ExportOptions.plist + plutil -replace teamID -string "$APPLE_TEAM_ID" build/ExportOptions.plist + xcodebuild -exportArchive \ + -archivePath build/App.xcarchive \ + -exportPath build/export \ + -exportOptionsPlist build/ExportOptions.plist + + APP_PATH="build/export/$APP_NAME.app" + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + echo "APP_PATH=$APP_PATH" >> "$GITHUB_ENV" + + - name: Notarize and staple app + if: steps.signing.outputs.enabled == 'true' + env: + NOTARY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + NOTARY_ISSUER: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }} + run: | + ditto -c -k --keepParent "$APP_PATH" build/app.zip + xcrun notarytool submit build/app.zip \ + --key "$NOTARY_KEY_PATH" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER" --wait + xcrun stapler staple "$APP_PATH" + spctl --assess --type execute --verbose=2 "$APP_PATH" + + - name: Build unsigned app + if: steps.signing.outputs.enabled != 'true' + run: | + xcodebuild -scheme "$SCHEME" -configuration Release \ + -derivedDataPath build/DerivedData \ + MACOSX_DEPLOYMENT_TARGET=14.5 \ + MARKETING_VERSION="$BUNDLE_VERSION" CURRENT_PROJECT_VERSION="$BUNDLE_VERSION" \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \ + build + echo "APP_PATH=build/DerivedData/Build/Products/Release/$APP_NAME.app" >> "$GITHUB_ENV" - name: Package DMG + env: + SIGNED: ${{ steps.signing.outputs.enabled }} + NOTARY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + NOTARY_ISSUER: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }} run: | - BUILT_APP=$(find ~/Library/Developer/Xcode/DerivedData/Focusrite_Volume_Control-* \ - -path "*/Build/Products/Release/Focusrite Volume Control.app" -maxdepth 5 | head -1) - mkdir -p build/dmg - cp -R "$BUILT_APP" build/dmg/ - ln -s /Applications build/dmg/Applications - hdiutil create \ - -volname "Focusrite Volume Control" \ - -srcfolder build/dmg \ - -ov -format UDZO \ - build/FocusriteVolumeControl.dmg + if [ "$SIGNED" != "true" ]; then + # build-dmg.sh signs and notarizes only when these are set + unset SIGN_IDENTITY NOTARY_KEY_PATH NOTARY_KEY_ID NOTARY_ISSUER + fi + bash scripts/build-dmg.sh - name: Compute DMG SHA256 id: sha - run: echo "sha256=$(shasum -a 256 build/FocusriteVolumeControl.dmg | awk '{print $1}')" >> "$GITHUB_OUTPUT" + run: echo "sha256=$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Write release notes + env: + SIGNED: ${{ steps.signing.outputs.enabled }} + PRERELEASE: ${{ steps.version.outputs.prerelease }} + DMG_SHA256: ${{ steps.sha.outputs.sha256 }} + run: | + { + if [ "$PRERELEASE" = "true" ]; then + echo '**Test build for hardware testing.** Homebrew and in-app updates do not offer it.' + echo + echo '1. Download `VolumeControl.dmg` and drag **Volume Control** to Applications.' + if [ "$SIGNED" != "true" ]; then + echo '2. This build is not signed or notarized. On first launch, right-click the app and choose **Open**, or run:' + echo ' `xattr -dr com.apple.quarantine "/Applications/Volume Control.app"`' + else + echo '2. This build is signed and notarized; open it normally.' + fi + echo '3. Grant the **Accessibility** permission when prompted (System Settings โ†’ Privacy & Security โ†’ Accessibility).' + echo '4. **Focusrite Control 2** must be installed and running.' + echo + echo "Please report results in [Issues](https://github.com/${GITHUB_REPOSITORY}/issues) with your interface model and FC2 version." + else + echo '**Homebrew:**' + echo '```' + echo 'brew install --cask enum-dot-dev/focusrite-volume-control/focusrite-volume-control' + echo '```' + if [ "$SIGNED" != "true" ]; then + echo + echo '> This build is not signed or notarized. On first launch, right-click the app and choose Open.' + fi + fi + echo + echo "**DMG SHA256:** \`$DMG_SHA256\`" + } > build/release-notes.md - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - files: build/FocusriteVolumeControl.dmg + files: ${{ env.DMG_PATH }} generate_release_notes: true - body: | - **Homebrew:** - ``` - brew tap enum-labs/focusrite-volume-control - brew install --cask focusrite-volume-control - ``` + body_path: build/release-notes.md + prerelease: ${{ steps.version.outputs.prerelease == 'true' }} + make_latest: ${{ steps.version.outputs.prerelease == 'true' && 'false' || 'true' }} - **DMG SHA256:** `${{ steps.sha.outputs.sha256 }}` + - name: Publish Sparkle appcast + if: steps.sparkle.outputs.enabled == 'true' && steps.version.outputs.prerelease != 'true' + env: + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + GH_TOKEN: ${{ github.token }} + SPARKLE_VERSION: 2.10.0 # keep in step with the Sparkle package version in the project + run: | + curl -fsSL -o "$RUNNER_TEMP/sparkle.tar.xz" \ + "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" + mkdir -p "$RUNNER_TEMP/sparkle" && tar -xJf "$RUNNER_TEMP/sparkle.tar.xz" -C "$RUNNER_TEMP/sparkle" + SIGNATURE=$(printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | "$RUNNER_TEMP/sparkle/bin/sign_update" --ed-key-file - -p "$DMG_PATH") + LENGTH=$(stat -f%z "$DMG_PATH") + RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/VolumeControl.dmg" + + # The tag checkout is detached; commit the appcast on top of main + git fetch origin main + git checkout -B appcast-update origin/main + python3 scripts/update_appcast.py site/appcast.xml \ + --version "$VERSION" --url "$RELEASE_URL" --length "$LENGTH" --signature "$SIGNATURE" \ + --minimum-system-version 14.5 \ + --release-notes-url "https://github.com/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add site/appcast.xml + git commit -m "appcast: v${VERSION}" + git push origin HEAD:main + + # Pushes made with GITHUB_TOKEN don't trigger workflows, but workflow_dispatch does + gh workflow run pages.yml --ref main - name: Update Homebrew tap + if: steps.version.outputs.prerelease != 'true' env: GH_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} DMG_SHA256: ${{ steps.sha.outputs.sha256 }} + SIGNED: ${{ steps.signing.outputs.enabled }} + SPARKLE: ${{ steps.sparkle.outputs.enabled }} run: | - VERSION="${GITHUB_REF_NAME#v}" - git clone https://x-access-token:${GH_TOKEN}@github.com/enum-labs/homebrew-focusrite-volume-control.git tap + CASK_FLAGS=() + [ "$SIGNED" = "true" ] || CASK_FLAGS+=(--unsigned) + [ "$SPARKLE" = "true" ] && CASK_FLAGS+=(--auto-updates) + git clone "https://x-access-token:${GH_TOKEN}@github.com/enum-dot-dev/homebrew-focusrite-volume-control.git" tap + python3 scripts/render_cask.py --version "$VERSION" --sha256 "$DMG_SHA256" ${CASK_FLAGS[@]+"${CASK_FLAGS[@]}"} \ + homebrew/Casks/focusrite-volume-control.rb tap/Casks/focusrite-volume-control.rb cd tap - sed -i '' "s/version \".*\"/version \"${VERSION}\"/" Casks/focusrite-volume-control.rb - sed -i '' "s/sha256 \".*\"/sha256 \"${DMG_SHA256}\"/" Casks/focusrite-volume-control.rb git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add Casks/focusrite-volume-control.rb @@ -68,3 +294,9 @@ jobs: git commit -m "bump to v${VERSION}" git push fi + + - name: Remove signing keychain + if: always() + run: | + [ -n "${KEYCHAIN:-}" ] && security delete-keychain "$KEYCHAIN" || true + rm -f "$RUNNER_TEMP/AuthKey.p8" diff --git a/ExportOptions.plist b/ExportOptions.plist index 6cd4546..7fc1efb 100644 --- a/ExportOptions.plist +++ b/ExportOptions.plist @@ -6,5 +6,10 @@ developer-id destination export + signingStyle + manual + signingCertificate + Developer ID Application + diff --git a/Focusrite Volume Control.xcodeproj/project.pbxproj b/Focusrite Volume Control.xcodeproj/project.pbxproj index 8466843..9b0e4c0 100644 --- a/Focusrite Volume Control.xcodeproj/project.pbxproj +++ b/Focusrite Volume Control.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 01A89DA32F2A00000084DBD8 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 01A89DA22F2A00000084DBD8 /* Sparkle */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 01A89D6E2F280BA90084DBD8 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -17,7 +21,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 01A89D572F280BA90084DBD8 /* Focusrite Volume Control.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Focusrite Volume Control.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 01A89D572F280BA90084DBD8 /* Volume Control.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Volume Control.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 01A89D652F280BA90084DBD8 /* Focusrite Volume ControlTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Focusrite Volume ControlTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -39,6 +43,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 01A89DA32F2A00000084DBD8 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -64,7 +69,7 @@ 01A89D582F280BA90084DBD8 /* Products */ = { isa = PBXGroup; children = ( - 01A89D572F280BA90084DBD8 /* Focusrite Volume Control.app */, + 01A89D572F280BA90084DBD8 /* Volume Control.app */, 01A89D652F280BA90084DBD8 /* Focusrite Volume ControlTests.xctest */, ); name = Products; @@ -90,9 +95,10 @@ ); name = "Focusrite Volume Control"; packageProductDependencies = ( + 01A89DA22F2A00000084DBD8 /* Sparkle */, ); productName = "Focusrite Volume Control"; - productReference = 01A89D572F280BA90084DBD8 /* Focusrite Volume Control.app */; + productReference = 01A89D572F280BA90084DBD8 /* Volume Control.app */; productType = "com.apple.product-type.application"; }; 01A89D6A2F280BA90084DBD8 /* Focusrite Volume ControlTests */ = { @@ -146,6 +152,9 @@ ); mainGroup = 01A89D4E2F280BA90084DBD8; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 01A89DA12F2A00000084DBD8 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 01A89D582F280BA90084DBD8 /* Products */; projectDirPath = ""; @@ -254,7 +263,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 14.5; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -312,7 +321,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 14.5; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -333,8 +342,9 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Focusrite-Volume-Control-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Volume Control"; INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "Focusrite Volume Control needs to send Apple events to Focusrite Control 2 to adjust volume and mute settings."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -342,7 +352,8 @@ ); MARKETING_VERSION = 1.4.5; PRODUCT_BUNDLE_IDENTIFIER = solutions.enum.FocusriteVolumeControl; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_MODULE_NAME = Focusrite_Volume_Control; + PRODUCT_NAME = "Volume Control"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -366,8 +377,9 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Focusrite-Volume-Control-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Volume Control"; INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "Focusrite Volume Control needs to send Apple events to Focusrite Control 2 to adjust volume and mute settings."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -375,7 +387,8 @@ ); MARKETING_VERSION = 1.4.5; PRODUCT_BUNDLE_IDENTIFIER = solutions.enum.FocusriteVolumeControl; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_MODULE_NAME = Focusrite_Volume_Control; + PRODUCT_NAME = "Volume Control"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -394,7 +407,7 @@ CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = 5FNR593BWG; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 14.5; MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = solutions.enum.FocusriteVolumeControlTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -403,7 +416,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Focusrite Volume Control.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Focusrite Volume Control"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Volume Control.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Volume Control"; }; name = Debug; }; @@ -415,7 +428,7 @@ CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = 5FNR593BWG; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 14.5; MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = solutions.enum.FocusriteVolumeControlTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -424,7 +437,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Focusrite Volume Control.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Focusrite Volume Control"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Volume Control.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Volume Control"; }; name = Release; }; @@ -459,6 +472,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 01A89DA12F2A00000084DBD8 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = exactVersion; + version = 2.10.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 01A89DA22F2A00000084DBD8 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 01A89DA12F2A00000084DBD8 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 01A89D4F2F280BA90084DBD8 /* Project object */; } diff --git a/Focusrite Volume Control.xcodeproj/xcshareddata/xcschemes/Focusrite Volume Control.xcscheme b/Focusrite Volume Control.xcodeproj/xcshareddata/xcschemes/Focusrite Volume Control.xcscheme index 3fbe009..4f9ea86 100644 --- a/Focusrite Volume Control.xcodeproj/xcshareddata/xcschemes/Focusrite Volume Control.xcscheme +++ b/Focusrite Volume Control.xcodeproj/xcshareddata/xcschemes/Focusrite Volume Control.xcscheme @@ -16,7 +16,7 @@ @@ -58,7 +58,7 @@ @@ -75,7 +75,7 @@ diff --git a/Focusrite Volume Control/AccessibilityBackend.swift b/Focusrite Volume Control/AccessibilityBackend.swift new file mode 100644 index 0000000..eeddb31 --- /dev/null +++ b/Focusrite Volume Control/AccessibilityBackend.swift @@ -0,0 +1,576 @@ +// +// AccessibilityBackend.swift +// Focusrite Volume Control +// +// Controls Focusrite Control 2 in-process through the Accessibility API (AXUIElement). +// Element lookups are cached, so a key press touches only the controls it changes +// instead of spawning osascript and routing through System Events. +// +// The native Accessibility approach is ported from William Chan's version +// (https://github.com/wc2184/focusrite-volume-control, MIT). +// + +import Foundation +import Combine +import os.log +import AppKit +import ApplicationServices + +/// Accessibility-based volume control backend +class AccessibilityBackend: VolumeBackend { + + // MARK: - Constants + + private let logger = Logger(subsystem: "solutions.enum.FocusriteVolumeControl", category: "AccessibilityBackend") + + // MARK: - State + + private var _state = VolumeState() + private let stateSubject = CurrentValueSubject(VolumeState()) + private let fc2 = FC2Accessibility() + + /// Track if FC2 is verified ready (reset on errors) + private var isFC2Verified = false + + /// Bumped on every playback write so a refresh that started earlier can't overwrite it + private var playbackWriteVersion = 0 + + var statePublisher: AnyPublisher { + stateSubject.eraseToAnyPublisher() + } + + var state: VolumeState { + _state + } + + // MARK: - Initialization + + init() {} + + // MARK: - Connection + + func connect() async -> VolumeResult { + // Ensure FC2 is running and Direct tab is active (retry for startup timing) + var lastError = "Unknown error" + for attempt in 1...3 { + do { + try await ensureFC2Ready() + isFC2Verified = true + return await refresh() + } catch { + lastError = error.localizedDescription + logger.info("โณ Connect attempt \(attempt) failed: \(lastError, privacy: .public), retrying...") + } + try? await Task.sleep(for: .seconds(1)) + } + return .error(lastError) + } + + func disconnect() { + isFC2Verified = false + fc2.invalidate() + _state.isConnected = false + _state.statusMessage = "Disconnected" + stateSubject.send(_state) + } + + func refresh() async -> VolumeResult { + do { + let writeVersion = playbackWriteVersion + try await ensureReady() + let snapshot = try await perform { try $0.readSnapshot() } + + // A refresh started before a key press must not replace the newer value + if writeVersion == playbackWriteVersion { + _state.playbackVolume = snapshot.playback.volume + _state.playbackMuted = snapshot.playback.muted + } + _state.input1Volume = snapshot.input1.volume + _state.input1Muted = snapshot.input1.muted + _state.input2Volume = snapshot.input2.volume + _state.input2Muted = snapshot.input2.muted + _state.directMonitorEnabled = snapshot.directMonitorEnabled + _state.isConnected = true + _state.statusMessage = "Connected" + stateSubject.send(_state) + return .success + } catch { + _state.isConnected = false + _state.statusMessage = error.localizedDescription + stateSubject.send(_state) + return .error(error.localizedDescription) + } + } + + // MARK: - Playback Volume + + func getPlaybackVolume() async -> Result { + await read { try $0.level(of: .playback) } + } + + func setPlaybackVolume(_ db: Double) async -> VolumeResult { + await setPlaybackVolume(db, ensureDirectMonitor: false) + } + + /// Checks Direct Monitor at write time, so a change made in FC2 can't leave + /// the keyboard writing to a slider that is no longer audible + func setPlaybackVolume(_ db: Double, ensureDirectMonitor: Bool) async -> VolumeResult { + playbackWriteVersion += 1 + do { + try await ensureReady() + let actual = try await perform { fc2 -> Double in + if ensureDirectMonitor { try fc2.setDirectMonitor(true) } + return try fc2.setLevel(db, of: .playback) + } + _state.playbackVolume = actual + _state.playbackMuted = false + if ensureDirectMonitor { _state.directMonitorEnabled = true } + stateSubject.send(_state) + return .success + } catch { + return .error(error.localizedDescription) + } + } + + func setPlaybackMuted(_ muted: Bool) async -> VolumeResult { + await write { try $0.setMuted(muted, of: .playback) } apply: { $0.playbackMuted = muted } + } + + // MARK: - Input 1 + + func getInput1Volume() async -> Result { + await read { try $0.level(of: .input1) } + } + + func setInput1Volume(_ db: Double) async -> VolumeResult { + await write { _ = try $0.setLevel(db, of: .input1) } apply: { $0.input1Volume = db } + } + + func setInput1Muted(_ muted: Bool) async -> VolumeResult { + await write { try $0.setMuted(muted, of: .input1) } apply: { $0.input1Muted = muted } + } + + // MARK: - Input 2 + + func getInput2Volume() async -> Result { + await read { try $0.level(of: .input2) } + } + + func setInput2Volume(_ db: Double) async -> VolumeResult { + await write { _ = try $0.setLevel(db, of: .input2) } apply: { $0.input2Volume = db } + } + + func setInput2Muted(_ muted: Bool) async -> VolumeResult { + await write { try $0.setMuted(muted, of: .input2) } apply: { $0.input2Muted = muted } + } + + // MARK: - Direct Monitor + + func setDirectMonitorEnabled(_ enabled: Bool) async -> VolumeResult { + await write { try $0.setDirectMonitor(enabled) } apply: { $0.directMonitorEnabled = enabled } + } + + // MARK: - FC2 Window Control + + /// Minimize FC2's window via AXMinimized, which doesn't steal focus from our popover + func minimizeFC2IfNeeded() async { + do { + if try await perform({ try $0.minimizeWindowIfNeeded() }) { + logger.info("๐Ÿ“ฆ FC2: minimized") + } + } catch { + logger.warning("โš ๏ธ Failed to minimize FC2: \(error.localizedDescription, privacy: .public)") + } + } + + // MARK: - Readiness + + /// Ensure FC2 is ready before performing an action (fast path once verified) + private func ensureReady() async throws { + if isFC2Verified { return } + logger.info("๐Ÿ” ensureReady: checking FC2 status...") + try await ensureFC2Ready() + isFC2Verified = true + } + + /// Launch FC2 if needed, reopen its window if it was closed, and switch to the Direct tab + private func ensureFC2Ready() async throws { + guard AXIsProcessTrusted() else { throw VolumeBackendError.accessibilityNotGranted } + + if !FC2Accessibility.isRunning { + logger.info("๐Ÿš€ FC2 not running, launching...") + try await openFC2(activate: false) + // Wait for FC2 to start (up to 10 seconds), then give its UI a moment + for i in 1...20 { + try await Task.sleep(for: .milliseconds(500)) + if FC2Accessibility.isRunning { + logger.info("โœ… FC2 launched after \(i * 500)ms") + try await Task.sleep(for: .milliseconds(1000)) + break + } + } + guard FC2Accessibility.isRunning else { throw VolumeBackendError.fc2NotRunning } + } + + // A closed FC2 window disappears from the AX tree, while a minimized one stays + // accessible, so reopen FC2 only when it has no window at all + if try await perform({ $0.windowCount() }) == 0 { + logger.info("๐ŸชŸ FC2 has no window, reopening...") + try await openFC2(activate: true) + var foundWindow = false + for _ in 1...15 { + if try await perform({ $0.windowCount() }) > 0 { + foundWindow = true + break + } + try await Task.sleep(for: .milliseconds(200)) + } + if !foundWindow { throw VolumeBackendError.fc2WindowNotFound } + } + + if try await perform({ $0.isOnDirectTab() }) { + return + } + logger.info("๐Ÿ”€ Switching to Direct tab...") + try await perform { try $0.switchToDirectTab() } + // Give FC2 a moment to build the Direct tab's controls + for _ in 1...10 { + if try await perform({ $0.isOnDirectTab() }) { return } + try await Task.sleep(for: .milliseconds(100)) + } + throw VolumeBackendError.sliderNotFound("Playback 1 - 2") + } + + /// Launch FC2, or send it a reopen event if it's already running (recreates a closed window) + private func openFC2(activate: Bool) async throws { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: FC2Accessibility.bundleIdentifier) else { + throw VolumeBackendError.fc2NotInstalled + } + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = activate + _ = try await NSWorkspace.shared.openApplication(at: url, configuration: configuration) + } + + // MARK: - Helpers + + /// Run AX work off the main actor, mapping AX failures to backend errors + private func perform(_ body: @escaping @Sendable (FC2Accessibility) throws -> T) async throws -> T { + do { + return try await fc2.run(body) + } catch let error as FC2AccessibilityError { + // FC2 may have changed tabs or relaunched; re-check before the next action + isFC2Verified = false + throw backendError(for: error) + } + } + + private func backendError(for error: FC2AccessibilityError) -> VolumeBackendError { + switch error { + case .notRunning: return .fc2NotRunning + case .windowNotFound: return .fc2WindowNotFound + case .elementNotFound(let name): return .sliderNotFound(name) + case .unexpectedValue(let detail): return .accessibilityError("Unexpected value: \(detail)") + case .ax(let code): return .accessibilityError("AXError \(code)") + } + } + + private func read(_ body: @escaping @Sendable (FC2Accessibility) throws -> Double) async -> Result { + do { + try await ensureReady() + return .success(try await perform(body)) + } catch { + return .failure(error) + } + } + + private func write(_ body: @escaping @Sendable (FC2Accessibility) throws -> Void, + apply: (inout VolumeState) -> Void) async -> VolumeResult { + do { + try await ensureReady() + try await perform(body) + apply(&_state) + stateSubject.send(_state) + return .success + } catch { + return .error(error.localizedDescription) + } + } +} + +// MARK: - FC2 Accessibility Tree + +/// Errors raised while walking FC2's accessibility tree +nonisolated enum FC2AccessibilityError: Error { + case notRunning + case windowNotFound + case elementNotFound(String) + case unexpectedValue(String) + case ax(Int32) +} + +/// FC2 channel strips on the Direct tab +nonisolated enum FC2Channel: String, Sendable { + case playback = "Playback 1 - 2" + case input1 = "Analogue 1" + case input2 = "Analogue 2" +} + +nonisolated struct FC2ChannelReading: Sendable { + let volume: Double + let muted: Bool +} + +nonisolated struct FC2Snapshot: Sendable { + let playback: FC2ChannelReading + let input1: FC2ChannelReading + let input2: FC2ChannelReading + let directMonitorEnabled: Bool +} + +/// Walks and caches FC2's accessibility tree on a private serial queue. Elements are +/// cached per FC2 process and dropped on any failure, so a relaunch or a tab change +/// costs one fresh lookup instead of breaking later key presses. +nonisolated final class FC2Accessibility { + static let bundleIdentifier = "com.focusrite.control" + private static let windowTitle = "Focusrite Control 2" + + private let queue = DispatchQueue(label: "solutions.enum.FocusriteVolumeControl.fc2-ax", qos: .userInitiated) + + // Accessed only on queue + private var pid: pid_t = 0 + private var cache: [String: AXUIElement] = [:] + + static var isRunning: Bool { + !NSRunningApplication.runningApplications(withBundleIdentifier: Self.bundleIdentifier).isEmpty + } + + func invalidate() { + queue.async { self.reset() } + } + + func run(_ body: @escaping @Sendable (FC2Accessibility) throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + continuation.resume(returning: try body(self)) + } catch { + self.reset() + continuation.resume(throwing: error) + } + } + } + } + + // MARK: Operations (call only through run) + + func windowCount() -> Int { + guard let app = try? application() else { return 0 } + return (try? elements(kAXWindowsAttribute, of: app))?.count ?? 0 + } + + /// The Direct tab is active when its playback strip exists (works while minimized) + func isOnDirectTab() -> Bool { + (try? channelGroup(.playback)) != nil + } + + func switchToDirectTab() throws { + let navigation = try child(of: try window(), role: kAXGroupRole, named: "Navigation bar") + let direct = try child(of: navigation, role: kAXCheckBoxRole, named: "Direct") + try check(AXUIElementPerformAction(direct, kAXPressAction as CFString)) + // The Direct tab builds new elements; look them up fresh + cache = cache.filter { $0.key == "window" } + } + + func readSnapshot() throws -> FC2Snapshot { + FC2Snapshot( + playback: try reading(of: .playback), + input1: try reading(of: .input1), + input2: try reading(of: .input2), + directMonitorEnabled: try boolValue(of: try directMonitor()) + ) + } + + func level(of channel: FC2Channel) throws -> Double { + try parseDb(try stringValue(of: try slider(of: channel))) + } + + /// Writes an integer dB value and returns what FC2 reports back + func setLevel(_ db: Double, of channel: FC2Channel) throws -> Double { + let slider = try slider(of: channel) + try check(AXUIElementSetAttributeValue(slider, kAXValueAttribute as CFString, + "\(Int(db.rounded()))dB" as CFString)) + let actual = try parseDb(try stringValue(of: slider)) + guard abs(actual - db.rounded()) < 0.5 else { + throw FC2AccessibilityError.unexpectedValue("\(channel.rawValue) is \(actual) dB after writing \(db) dB") + } + return actual + } + + func setMuted(_ muted: Bool, of channel: FC2Channel) throws { + let mute = try muteCheckbox(of: channel) + if try boolValue(of: mute) != muted { + try check(AXUIElementPerformAction(mute, kAXPressAction as CFString)) + } + } + + func setDirectMonitor(_ enabled: Bool) throws { + let checkbox = try directMonitor() + if try boolValue(of: checkbox) != enabled { + try check(AXUIElementPerformAction(checkbox, kAXPressAction as CFString)) + } + } + + /// Returns true if the window was minimized by this call + func minimizeWindowIfNeeded() throws -> Bool { + let window = try window() + let minimized = try attribute(kAXMinimizedAttribute, of: window) + if (minimized as? NSNumber)?.boolValue == true { return false } + try check(AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, kCFBooleanTrue)) + return true + } + + // MARK: Element lookup + + private func reset() { + pid = 0 + cache = [:] + } + + private func application() throws -> AXUIElement { + guard let app = NSRunningApplication.runningApplications( + withBundleIdentifier: Self.bundleIdentifier).first else { + throw FC2AccessibilityError.notRunning + } + if app.processIdentifier != pid { + reset() + pid = app.processIdentifier + } + let element = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(element, 0.5) + return element + } + + private func cached(_ key: String, _ lookup: () throws -> AXUIElement) throws -> AXUIElement { + _ = try application() // resets the cache if FC2 relaunched + if let element = cache[key] { return element } + let element = try lookup() + cache[key] = element + return element + } + + private func window() throws -> AXUIElement { + try cached("window") { + let windows = try elements(kAXWindowsAttribute, of: try application()) + guard let window = windows.first(where: { name(of: $0) == Self.windowTitle }) ?? windows.first else { + throw FC2AccessibilityError.windowNotFound + } + return window + } + } + + /// The Direct tab's channel strips live in the window's fifth group + private func directGroup() throws -> AXUIElement { + try cached("direct") { + let groups = try elements(kAXChildrenAttribute, of: try window()) + .filter { role(of: $0) == kAXGroupRole } + guard groups.count >= 5 else { throw FC2AccessibilityError.elementNotFound("Direct tab") } + return groups[4] + } + } + + private func channelGroup(_ channel: FC2Channel) throws -> AXUIElement { + try cached("group:\(channel.rawValue)") { + try child(of: try directGroup(), role: kAXGroupRole, named: channel.rawValue) + } + } + + private func slider(of channel: FC2Channel) throws -> AXUIElement { + try cached("slider:\(channel.rawValue)") { + try child(of: try channelGroup(channel), role: kAXSliderRole, named: "Level") + } + } + + private func muteCheckbox(of channel: FC2Channel) throws -> AXUIElement { + try cached("mute:\(channel.rawValue)") { + try child(of: try channelGroup(channel), role: kAXCheckBoxRole, named: "Mute") + } + } + + /// Titled "Direct Monitor" in some FC2 versions and "Direct Monitor mode" in others (issue #1) + private func directMonitor() throws -> AXUIElement { + try cached("directMonitor") { + let match = try elements(kAXChildrenAttribute, of: try window()).first { + role(of: $0) == kAXCheckBoxRole && name(of: $0)?.hasPrefix("Direct Monitor") == true + } + guard let match else { throw FC2AccessibilityError.elementNotFound("Direct Monitor") } + return match + } + } + + private func child(of parent: AXUIElement, role wanted: String, named title: String) throws -> AXUIElement { + let match = try elements(kAXChildrenAttribute, of: parent).first { + role(of: $0) == wanted && name(of: $0) == title + } + guard let match else { throw FC2AccessibilityError.elementNotFound(title) } + return match + } + + private func reading(of channel: FC2Channel) throws -> FC2ChannelReading { + FC2ChannelReading(volume: try level(of: channel), + muted: try boolValue(of: try muteCheckbox(of: channel))) + } + + // MARK: Attribute access + + private func elements(_ key: String, of element: AXUIElement) throws -> [AXUIElement] { + guard let result = try attribute(key, of: element) as? [AXUIElement] else { + throw FC2AccessibilityError.unexpectedValue(key) + } + return result + } + + private func attribute(_ key: String, of element: AXUIElement) throws -> CFTypeRef { + var value: CFTypeRef? + try check(AXUIElementCopyAttributeValue(element, key as CFString, &value)) + guard let value else { throw FC2AccessibilityError.unexpectedValue(key) } + return value + } + + private func stringValue(of element: AXUIElement) throws -> String { + let value = try attribute(kAXValueAttribute, of: element) + if let string = value as? String { return string } + if let number = value as? NSNumber { return number.stringValue } + throw FC2AccessibilityError.unexpectedValue("value of \(name(of: element) ?? "element")") + } + + /// FC2 reports checkbox state as a number or as "true"/"false" depending on the control + private func boolValue(of element: AXUIElement) throws -> Bool { + let value = try attribute(kAXValueAttribute, of: element) + if let number = value as? NSNumber { return number.boolValue } + if let string = (value as? String)?.lowercased() { return string == "true" || string == "1" } + throw FC2AccessibilityError.unexpectedValue("state of \(name(of: element) ?? "checkbox")") + } + + private func role(of element: AXUIElement) -> String? { + try? attribute(kAXRoleAttribute, of: element) as? String + } + + private func name(of element: AXUIElement) -> String? { + for key in [kAXTitleAttribute, kAXDescriptionAttribute, kAXIdentifierAttribute] { + if let value = try? attribute(key, of: element) as? String, !value.isEmpty { return value } + } + return nil + } + + /// Parse "-6dB" or "0 dB" into a number; never default to 0 dB, which is full volume + private func parseDb(_ text: String) throws -> Double { + let numeric = text.replacingOccurrences(of: "dB", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(numeric) else { throw FC2AccessibilityError.unexpectedValue(text) } + return value + } + + private func check(_ error: AXError) throws { + guard error == .success else { throw FC2AccessibilityError.ax(error.rawValue) } + } +} diff --git a/Focusrite Volume Control/AppDelegate.swift b/Focusrite Volume Control/AppDelegate.swift index 1a853ea..e458e3e 100644 --- a/Focusrite Volume Control/AppDelegate.swift +++ b/Focusrite Volume Control/AppDelegate.swift @@ -11,6 +11,7 @@ import Cocoa import SwiftUI import Combine +import Sparkle // Media key codes (from IOKit/hidsystem/ev_keymap.h) private let NX_KEYTYPE_SOUND_UP: Int = 0 @@ -30,7 +31,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var preferencesWindow: NSWindow? private var onboardingWindow: NSWindow? - // Volume controller with AppleScript backend + // Volume controller with Accessibility backend var volumeController: VolumeController! private var cancellables = Set() @@ -47,16 +48,20 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Custom hotkey manager for non-media-key shortcuts private var hotkeyManager: HotkeyManager! + // Sparkle updater; nil until a real SUPublicEDKey replaces the placeholder + private var updaterController: SPUStandardUpdaterController? + // MARK: - App Lifecycle func applicationDidFinishLaunching(_ notification: Notification) { - // Initialize volume controller with AppleScript backend + // Initialize volume controller with Accessibility backend volumeController = VolumeController() // Monitor default audio output device for Focusrite detection audioDeviceMonitor = AudioDeviceMonitor() // Set up menu bar (always visible, even before permissions) + setupUpdater() setupMenuBar() setupStatusMenu() @@ -195,11 +200,20 @@ class AppDelegate: NSObject, NSApplicationDelegate { popover.contentViewController = NSHostingController(rootView: contentView) } + /// Sparkle shows an error at launch if SUPublicEDKey is invalid, so only start it with a real key + private func setupUpdater() { + let publicKey = Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String ?? "" + guard !publicKey.isEmpty, !publicKey.hasPrefix("REPLACE_") else { return } + updaterController = SPUStandardUpdaterController(startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil) + } + private func setupStatusMenu() { statusMenu = NSMenu() // About - let aboutItem = NSMenuItem(title: "About Focusrite Volume Control", action: #selector(showAbout), keyEquivalent: "") + let aboutItem = NSMenuItem(title: "About Volume Control", action: #selector(showAbout), keyEquivalent: "") aboutItem.target = self statusMenu.addItem(aboutItem) @@ -208,6 +222,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { prefsItem.target = self statusMenu.addItem(prefsItem) + // Check for Updates (disabled while the updater isn't configured) + let updateItem = NSMenuItem(title: "Check for Updates...", + action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)), + keyEquivalent: "") + updateItem.target = updaterController + statusMenu.addItem(updateItem) + // Report Issue let issueItem = NSMenuItem(title: "Report Issue / Request Feature...", action: #selector(openIssues), keyEquivalent: "") issueItem.target = self @@ -262,7 +283,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc private func showAbout() { let alert = NSAlert() - alert.messageText = "Focusrite Volume Control" + alert.messageText = "Volume Control" let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" alert.informativeText = "Control your Focusrite Scarlett Solo volume with system media keys.\n\nVersion \(version) (\(build))\n\n\u{00A9} 2026" @@ -339,7 +360,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func openIssues() { - if let url = URL(string: "https://github.com/enum-labs/focusrite-volume-control/issues") { + if let url = URL(string: "https://github.com/enum-dot-dev/focusrite-volume-control/issues") { NSWorkspace.shared.open(url) } } diff --git a/Focusrite Volume Control/AppleScriptBackend.swift b/Focusrite Volume Control/AppleScriptBackend.swift deleted file mode 100644 index cf73774..0000000 --- a/Focusrite Volume Control/AppleScriptBackend.swift +++ /dev/null @@ -1,567 +0,0 @@ -// -// AppleScriptBackend.swift -// Focusrite Volume Control -// -// Controls Focusrite Control 2 via osascript (command-line AppleScript). -// Uses the same approach as the working Node.js volume-control.js script. -// - -import Foundation -import Combine -import os.log - -/// AppleScript-based volume control backend using osascript subprocess -class AppleScriptBackend: VolumeBackend { - - // MARK: - Constants - - private let fc2Process = "Focusrite Control 2" - private let logger = Logger(subsystem: "solutions.enum.FocusriteVolumeControl", category: "AppleScriptBackend") - - // MARK: - State - - private var _state = VolumeState() - private let stateSubject = CurrentValueSubject(VolumeState()) - - /// Track if FC2 is verified ready (reset on errors) - private var isFC2Verified = false - - var statePublisher: AnyPublisher { - stateSubject.eraseToAnyPublisher() - } - - var state: VolumeState { - _state - } - - // MARK: - Initialization - - init() {} - - // MARK: - Connection - - func connect() async -> VolumeResult { - // Ensure FC2 is running and Direct tab is active (retry for startup timing) - var lastError: String = "Unknown error" - for attempt in 1...3 { - let result = await ensureDirectTab() - if case .success = result { - return await refresh() - } - if case .error(let msg) = result { - lastError = msg - logger.info("โณ Connect attempt \(attempt) failed: \(msg, privacy: .public), retrying...") - } - try? await Task.sleep(for: .seconds(1)) - } - return .error(lastError) - } - - func disconnect() { - isFC2Verified = false - _state.isConnected = false - _state.statusMessage = "Disconnected" - stateSubject.send(_state) - } - - func refresh() async -> VolumeResult { - do { - let playbackVol = try await readSliderValue(channel: "Playback 1 - 2") - let playbackMute = try await readMuteState(channel: "Playback 1 - 2") - let input1Vol = try await readSliderValue(channel: "Analogue 1") - let input1Mute = try await readMuteState(channel: "Analogue 1") - let input2Vol = try await readSliderValue(channel: "Analogue 2") - let input2Mute = try await readMuteState(channel: "Analogue 2") - let directMon = try await readDirectMonitorState() - - _state.playbackVolume = playbackVol - _state.playbackMuted = playbackMute - _state.input1Volume = input1Vol - _state.input1Muted = input1Mute - _state.input2Volume = input2Vol - _state.input2Muted = input2Mute - _state.directMonitorEnabled = directMon - _state.isConnected = true - _state.statusMessage = "Connected" - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false // Reset on error to force re-check next time - _state.isConnected = false - _state.statusMessage = error.localizedDescription - stateSubject.send(_state) - return .error(error.localizedDescription) - } - } - - // MARK: - Playback Volume - - func getPlaybackVolume() async -> Result { - do { - let vol = try await readSliderValue(channel: "Playback 1 - 2") - return .success(vol) - } catch { - isFC2Verified = false - return .failure(error) - } - } - - func setPlaybackVolume(_ db: Double) async -> VolumeResult { - do { - try await setSliderValue(channel: "Playback 1 - 2", db: db) - _state.playbackVolume = db - _state.playbackMuted = false - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - func setPlaybackMuted(_ muted: Bool) async -> VolumeResult { - do { - try await setMuteState(channel: "Playback 1 - 2", muted: muted) - _state.playbackMuted = muted - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - // MARK: - Input 1 - - func getInput1Volume() async -> Result { - do { - let vol = try await readSliderValue(channel: "Analogue 1") - return .success(vol) - } catch { - isFC2Verified = false - return .failure(error) - } - } - - func setInput1Volume(_ db: Double) async -> VolumeResult { - do { - try await setSliderValue(channel: "Analogue 1", db: db) - _state.input1Volume = db - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - func setInput1Muted(_ muted: Bool) async -> VolumeResult { - do { - try await setMuteState(channel: "Analogue 1", muted: muted) - _state.input1Muted = muted - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - // MARK: - Input 2 - - func getInput2Volume() async -> Result { - do { - let vol = try await readSliderValue(channel: "Analogue 2") - return .success(vol) - } catch { - isFC2Verified = false - return .failure(error) - } - } - - func setInput2Volume(_ db: Double) async -> VolumeResult { - do { - try await setSliderValue(channel: "Analogue 2", db: db) - _state.input2Volume = db - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - func setInput2Muted(_ muted: Bool) async -> VolumeResult { - do { - try await setMuteState(channel: "Analogue 2", muted: muted) - _state.input2Muted = muted - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - // MARK: - Direct Monitor - - func setDirectMonitorEnabled(_ enabled: Bool) async -> VolumeResult { - do { - try await setDirectMonitor(enabled: enabled) - _state.directMonitorEnabled = enabled - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - // MARK: - FC2 Window Control - - /// Minimize FC2 window to Dock (if not already minimized) - /// Uses AXMinimized attribute for both detection and minimization (no focus stealing) - func minimizeFC2IfNeeded() async { - // Check and set AXMinimized attribute directly - avoids stealing focus from our popover - let checkAndMinimizeScript = """ - tell application "System Events" - tell process "\(fc2Process)" - if (count of windows) = 0 then - return "no_windows" - end if - set isMin to value of attribute "AXMinimized" of window 1 - if isMin then - return "already_minimized" - end if - -- Minimize by setting AXMinimized directly (doesn't steal focus) - set value of attribute "AXMinimized" of window 1 to true - return "minimized" - end tell - end tell - """ - - do { - let result = try await runOsascript(checkAndMinimizeScript) - if result == "minimized" { - logger.info("๐Ÿ“ฆ FC2: \(result, privacy: .public)") - } - } catch { - logger.warning("โš ๏ธ Failed to minimize FC2: \(error.localizedDescription, privacy: .public)") - } - } - - // MARK: - AppleScript Commands via osascript - - /// Ensure FC2 is running and Direct tab is active, minimize if needed - private func ensureDirectTab() async -> VolumeResult { - do { - try await ensureFC2Ready() - return .success - } catch { - return .error(error.localizedDescription) - } - } - - /// Launch FC2 if not running, ensure Direct tab is active - private func ensureFC2Ready() async throws { - logger.info("๐Ÿ”ง ensureFC2Ready: checking if FC2 is running...") - - // Check if FC2 is running - let checkScript = """ - tell application "System Events" - return exists process "\(fc2Process)" - end tell - """ - let isRunning = try await runOsascript(checkScript) == "true" - - if !isRunning { - logger.info("๐Ÿš€ FC2 not running, launching...") - // Launch FC2 - let launchScript = """ - tell application "\(fc2Process)" to launch - """ - _ = try await runOsascript(launchScript) - - // Wait for FC2 to be ready (up to 10 seconds) - for i in 1...20 { - try await Task.sleep(for: .milliseconds(500)) - let ready = try await runOsascript(checkScript) == "true" - if ready { - logger.info("โœ… FC2 launched after \(i * 500)ms") - // Give it a moment more to fully initialize UI - try await Task.sleep(for: .milliseconds(1000)) - break - } - } - } else { - logger.info("โœ… FC2 already running") - } - - // Check if Direct tab is already active (works even with minimized window) - let tabCheckScript = """ - tell application "System Events" - tell process "\(fc2Process)" - if (count of windows) = 0 then - return "no_window" - end if - try - get group "Playback 1 - 2" of group 5 of window "\(fc2Process)" - return "ready" - on error - return "wrong_tab" - end try - end tell - end tell - """ - - let tabStatus = try await runOsascript(tabCheckScript) - - if tabStatus == "ready" { - logger.info("โœ… FC2 already on Direct tab") - return - } - - // Direct tab not active โ€” need to switch - logger.info("๐Ÿ”€ Switching to Direct tab...") - let switchScript = """ - tell application "System Events" - tell process "\(fc2Process)" - if (count of windows) > 0 then - click checkbox "Direct" of group "Navigation bar" of window "\(fc2Process)" - end if - end tell - end tell - """ - _ = try await runOsascript(switchScript) - logger.info("โœ… FC2 ready on Direct tab") - } - - /// Ensure FC2 is ready before performing an action - private func ensureReady() async throws { - // Skip check if already verified (fast path for repeated actions) - if isFC2Verified { - return - } - - logger.info("๐Ÿ” ensureReady: checking FC2 status...") - - // Quick check if process exists and Direct tab is active - let checkScript = """ - tell application "System Events" - if not (exists process "\(fc2Process)") then - return "not_running" - end if - tell process "\(fc2Process)" - if (count of windows) = 0 then - return "no_window" - end if - -- Check if Direct tab is active by checking if expected elements exist - try - get group "Playback 1 - 2" of group 5 of window "\(fc2Process)" - return "ready" - on error - return "wrong_tab" - end try - end tell - end tell - """ - - let status = try await runOsascript(checkScript) - logger.info("๐Ÿ“Š FC2 status: \(status, privacy: .public)") - - if status == "ready" { - isFC2Verified = true - logger.info("โœ… FC2 verified ready") - } else { - logger.info("โš ๏ธ FC2 not ready (status: \(status, privacy: .public)), setting up...") - // Need to set up FC2 - try await ensureFC2Ready() - isFC2Verified = true - } - } - - /// Reset the verified state (call on errors to force re-check) - private func resetVerifiedState() { - isFC2Verified = false - } - - /// Read slider value from a channel group - private func readSliderValue(channel: String) async throws -> Double { - try await ensureReady() - - let script = """ - tell application "System Events" - tell process "\(fc2Process)" - return value of slider "Level" of group "\(channel)" of group 5 of window "\(fc2Process)" - end tell - end tell - """ - - let result = try await runOsascript(script) - return parseDbValue(result) - } - - /// Set slider value for a channel group - private func setSliderValue(channel: String, db: Double) async throws { - try await ensureReady() - - let dbString = formatDbValue(db) - let script = """ - tell application "System Events" - tell process "\(fc2Process)" - set value of slider "Level" of group "\(channel)" of group 5 of window "\(fc2Process)" to "\(dbString)" - end tell - end tell - """ - - _ = try await runOsascript(script) - } - - /// Read mute state from a channel group - private func readMuteState(channel: String) async throws -> Bool { - try await ensureReady() - - let script = """ - tell application "System Events" - tell process "\(fc2Process)" - return value of checkbox "Mute" of group "\(channel)" of group 5 of window "\(fc2Process)" - end tell - end tell - """ - - let result = try await runOsascript(script) - return result.trimmingCharacters(in: .whitespacesAndNewlines) == "1" - } - - /// Set mute state for a channel group - private func setMuteState(channel: String, muted: Bool) async throws { - try await ensureReady() - - let targetValue = muted ? 1 : 0 - let script = """ - tell application "System Events" - tell process "\(fc2Process)" - set cb to checkbox "Mute" of group "\(channel)" of group 5 of window "\(fc2Process)" - if (value of cb) is not \(targetValue) then - click cb - end if - end tell - end tell - """ - - _ = try await runOsascript(script) - } - - /// Read Direct Monitor state - private func readDirectMonitorState() async throws -> Bool { - try await ensureReady() - - let script = """ - tell application "System Events" - tell process "\(fc2Process)" - return value of checkbox "Direct Monitor" of window "\(fc2Process)" - end tell - end tell - """ - - let result = try await runOsascript(script) - // FC2 returns "true" or "false" as strings - return result.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true" - } - - /// Set Direct Monitor state - private func setDirectMonitor(enabled: Bool) async throws { - try await ensureReady() - - // FC2 returns true/false for checkbox values - let targetValue = enabled ? "true" : "false" - let script = """ - tell application "System Events" - tell process "\(fc2Process)" - set cb to checkbox "Direct Monitor" of window "\(fc2Process)" - if (value of cb) is not \(targetValue) then - click cb - end if - end tell - end tell - """ - - _ = try await runOsascript(script) - } - - // MARK: - osascript Execution - - /// Run AppleScript via osascript subprocess (same as Node.js script) - private func runOsascript(_ script: String) async throws -> String { - // Extract first line for logging (avoid logging full scripts) - let scriptPreview = script.components(separatedBy: .newlines).first ?? "script" - - return try await withCheckedThrowingContinuation { [logger] continuation in - DispatchQueue.global(qos: .userInitiated).async { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = ["-e", script] - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - - do { - try process.run() - process.waitUntilExit() - - let outputData = stdout.fileHandleForReading.readDataToEndOfFile() - let errorData = stderr.fileHandleForReading.readDataToEndOfFile() - - if process.terminationStatus != 0 { - let rawError = String(data: errorData, encoding: .utf8) ?? "Unknown error" - logger.error("โŒ osascript failed: \(rawError, privacy: .public) | script: \(scriptPreview, privacy: .public)") - - // Parse osascript error: "138:237: execution error: System Events got an error: ... (-600)" - // Extract the meaningful part after "execution error: " - var cleanMessage = rawError.trimmingCharacters(in: .whitespacesAndNewlines) - if let range = cleanMessage.range(of: "execution error: ") { - cleanMessage = String(cleanMessage[range.upperBound...]) - } - // Strip trailing error codes like "(-600)" and period - cleanMessage = cleanMessage.replacingOccurrences( - of: #"\s*\(-?\d+\)\s*$"#, with: "", options: .regularExpression) - - // Map known errors to specific types - let error: VolumeBackendError - if cleanMessage.contains("Can\u{2019}t get process") || cleanMessage.contains("(-600)") || rawError.contains("(-600)") { - error = .fc2NotRunning - } else if cleanMessage.contains("Can\u{2019}t get window") || cleanMessage.contains("count of windows") { - error = .fc2WindowNotFound - } else { - error = .appleScriptError(cleanMessage) - } - continuation.resume(throwing: error) - } else { - let output = String(data: outputData, encoding: .utf8) ?? "" - continuation.resume(returning: output.trimmingCharacters(in: .whitespacesAndNewlines)) - } - } catch { - logger.error("โŒ osascript exception: \(error.localizedDescription, privacy: .public)") - continuation.resume(throwing: error) - } - } - } - } - - // MARK: - Utilities - - /// Parse dB string like "-6dB" or "0dB" to Double - private func parseDbValue(_ str: String) -> Double { - let cleaned = str.trimmingCharacters(in: .whitespacesAndNewlines) - let numStr = cleaned.replacingOccurrences(of: "dB", with: "") - .replacingOccurrences(of: " ", with: "") - return Double(numStr) ?? 0.0 - } - - /// Format Double to dB string like "-6dB" - private func formatDbValue(_ db: Double) -> String { - return "\(Int(round(db)))dB" - } -} diff --git a/Focusrite Volume Control/FCServerBackend.swift b/Focusrite Volume Control/FCServerBackend.swift index f56b870..79e1f25 100644 --- a/Focusrite Volume Control/FCServerBackend.swift +++ b/Focusrite Volume Control/FCServerBackend.swift @@ -34,7 +34,7 @@ class FCServerBackend: VolumeBackend { newState.statusMessage = "FC Server backend not yet implemented" stateSubject.send(newState) } - return .error("FC Server backend is not yet implemented. Please use AppleScript backend.") + return .error("FC Server backend is not yet implemented. Please use the Accessibility backend.") } func disconnect() { diff --git a/Focusrite Volume Control/HotkeyManager.swift b/Focusrite Volume Control/HotkeyManager.swift index f5f3ceb..8fb5f1c 100644 --- a/Focusrite Volume Control/HotkeyManager.swift +++ b/Focusrite Volume Control/HotkeyManager.swift @@ -26,27 +26,31 @@ class HotkeyManager { // Observe shortcut changes to update handlers shortcutManager.$shortcuts - .sink { [weak self] _ in - self?.updateMonitors() + .combineLatest(shortcutManager.$recordingAction) + .sink { [weak self] shortcuts, recordingAction in + self?.updateMonitors(shortcuts: shortcuts, recordingAction: recordingAction) } .store(in: &cancellables) } func start() { - updateMonitors() + updateMonitors(shortcuts: shortcutManager.shortcuts, + recordingAction: shortcutManager.recordingAction) } func stop() { removeMonitors() } - private func updateMonitors() { + private func updateMonitors(shortcuts: [ShortcutAction: KeyboardShortcut?], + recordingAction: ShortcutAction?) { removeMonitors() + guard recordingAction == nil else { return } // Check if any custom (non-media-key) shortcuts are configured let hasCustomShortcuts = ShortcutAction.allCases.contains { action in - if let shortcut = shortcutManager.shortcut(for: action) { - return !shortcut.isMediaKey && shortcut.keyCode != nil + if let shortcut = shortcuts[action] ?? nil { + return !shortcut.isMediaKey && shortcut.isAllowedGlobalShortcut } return false } @@ -80,6 +84,7 @@ class HotkeyManager { @discardableResult private func handleKeyEvent(_ event: NSEvent) -> Bool { + guard shortcutManager.recordingAction == nil else { return false } let keyCode = event.keyCode let modifiers = event.modifierFlags.intersection([.control, .option, .shift, .command]) @@ -87,6 +92,7 @@ class HotkeyManager { for action in ShortcutAction.allCases { guard let shortcut = shortcutManager.shortcut(for: action), !shortcut.isMediaKey, + shortcut.isAllowedGlobalShortcut, let shortcutKeyCode = shortcut.keyCode, shortcutKeyCode == keyCode, shortcut.modifiers == modifiers else { @@ -95,6 +101,7 @@ class HotkeyManager { // Found a match - execute the action DispatchQueue.main.async { [weak self] in + guard self?.shortcutManager.recordingAction == nil else { return } self?.executeAction(action) } return true @@ -116,7 +123,10 @@ class HotkeyManager { } } - deinit { - removeMonitors() + // Nonisolated for the same reason as ShortcutManager's deinit (isolated deinits abort below + // macOS 26 when freed outside a task); removing NSEvent monitors doesn't need the main actor + nonisolated deinit { + if let globalMonitor { NSEvent.removeMonitor(globalMonitor) } + if let localMonitor { NSEvent.removeMonitor(localMonitor) } } } diff --git a/Focusrite Volume Control/KeyboardShortcuts.swift b/Focusrite Volume Control/KeyboardShortcuts.swift index 6d19084..f9d28ed 100644 --- a/Focusrite Volume Control/KeyboardShortcuts.swift +++ b/Focusrite Volume Control/KeyboardShortcuts.swift @@ -61,6 +61,23 @@ struct KeyboardShortcut: Codable, Equatable { self.mediaKeyType = mediaKeyType } + /// Navigation keys belong to the focused app unless an explicit modifier + /// distinguishes the global shortcut from ordinary navigation/selection. + var isAllowedGlobalShortcut: Bool { + if isMediaKey { return mediaKeyType != nil } + guard let keyCode else { return false } + let navigationKeys: Set = [ + UInt16(kVK_LeftArrow), UInt16(kVK_RightArrow), + UInt16(kVK_UpArrow), UInt16(kVK_DownArrow), + UInt16(kVK_Home), UInt16(kVK_End), + UInt16(kVK_PageUp), UInt16(kVK_PageDown), + UInt16(kVK_Tab), UInt16(kVK_Return), + UInt16(kVK_Delete), UInt16(kVK_ForwardDelete), UInt16(kVK_Escape) + ] + return !navigationKeys.contains(keyCode) + || !modifiers.intersection([.command, .control, .option]).isEmpty + } + // Custom Codable for NSEvent.ModifierFlags enum CodingKeys: String, CodingKey { case keyCode, modifiersRaw, isMediaKey, mediaKeyType @@ -189,23 +206,38 @@ class ShortcutManager: ObservableObject { static let shared = ShortcutManager() @Published var shortcuts: [ShortcutAction: KeyboardShortcut?] = [:] + @Published var recordingAction: ShortcutAction? private let userDefaultsKey = "keyboardShortcuts" + private let preferences: UserDefaults? - init() { + init(preferences: UserDefaults? = .standard) { + self.preferences = preferences loadShortcuts() } + // Main-actor default isolation makes the implicit deinit isolated. Below macOS 26 that runs + // through Swift's back-deployment shim, which aborts ("pointer being freed was not allocated") + // when the object is freed outside a task. Nothing here needs the main actor. + nonisolated deinit {} + func loadShortcuts() { - if let data = UserDefaults.standard.data(forKey: userDefaultsKey), + if let data = preferences?.data(forKey: userDefaultsKey), let decoded = try? JSONDecoder().decode([String: KeyboardShortcut].self, from: data) { + var repaired = false for action in ShortcutAction.allCases { if let shortcut = decoded[action.rawValue] { - shortcuts[action] = shortcut + if shortcut.isAllowedGlobalShortcut { + shortcuts[action] = shortcut + } else { + shortcuts[action] = action.defaultShortcut + repaired = true + } } else { shortcuts[action] = action.defaultShortcut } } + if repaired { saveShortcuts() } } else { // Set defaults for action in ShortcutAction.allCases { @@ -222,11 +254,12 @@ class ShortcutManager: ObservableObject { } } if let data = try? JSONEncoder().encode(toEncode) { - UserDefaults.standard.set(data, forKey: userDefaultsKey) + preferences?.set(data, forKey: userDefaultsKey) } } func setShortcut(_ shortcut: KeyboardShortcut?, for action: ShortcutAction) { + guard shortcut?.isAllowedGlobalShortcut != false else { return } shortcuts[action] = shortcut saveShortcuts() } diff --git a/Focusrite Volume Control/OnboardingView.swift b/Focusrite Volume Control/OnboardingView.swift index 8668292..989abda 100644 --- a/Focusrite Volume Control/OnboardingView.swift +++ b/Focusrite Volume Control/OnboardingView.swift @@ -2,8 +2,8 @@ // OnboardingView.swift // Focusrite Volume Control // -// Permission onboarding wizard โ€” shown on launch until both -// Accessibility and Automation permissions are granted. +// Permission onboarding wizard โ€” shown on launch until the +// Accessibility permission is granted. // import SwiftUI @@ -16,34 +16,8 @@ func isAccessibilityGranted() -> Bool { AXIsProcessTrusted() } -func isAutomationGranted() -> Bool { - // Test automation against Focusrite Control 2 โ€” the actual target app. - // Falls back to System Events if FC2 isn't installed/running. - let scripts = [ - "tell application \"Focusrite Control 2\" to name", - "tell application \"System Events\" to return name of first process" - ] - for script in scripts { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = ["-e", script] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - do { - try process.run() - process.waitUntilExit() - if process.terminationStatus == 0 { return true } - } catch { - continue - } - } - return false -} - func areAllPermissionsGranted() -> Bool { - // Only gate on Accessibility โ€” it can be reliably checked via AXIsProcessTrusted(). - // Automation permission is prompted automatically by macOS on first osascript use, - // so there's no need to pre-check it (and doing so is unreliable). + // FC2 is controlled in-process through the Accessibility API, so this is the only permission isAccessibilityGranted() } @@ -65,7 +39,7 @@ struct OnboardingView: View { .font(.system(size: 48)) .foregroundStyle(.blue) - Text("Focusrite Volume Control Setup") + Text("Volume Control Setup") .font(.title2) .fontWeight(.semibold) @@ -96,17 +70,6 @@ struct OnboardingView: View { .padding(.vertical, 20) .padding(.horizontal, 24) - // Note about Automation - HStack(spacing: 8) { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) - Text("macOS will also ask to allow Automation for Focusrite Control 2 on first use โ€” click OK when prompted.") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 24) - .padding(.bottom, 16) - Divider() .padding(.horizontal, 24) diff --git a/Focusrite Volume Control/PopoverView.swift b/Focusrite Volume Control/PopoverView.swift index 23bb9dd..e0f3610 100644 --- a/Focusrite Volume Control/PopoverView.swift +++ b/Focusrite Volume Control/PopoverView.swift @@ -50,7 +50,7 @@ struct HeaderView: View { .frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 2) { - Text("Focusrite Volume Control") + Text("Volume Control") .font(.headline) Text(volumeController.statusMessage) diff --git a/Focusrite Volume Control/ShortcutRecorderView.swift b/Focusrite Volume Control/ShortcutRecorderView.swift index ebc225b..54067ab 100644 --- a/Focusrite Volume Control/ShortcutRecorderView.swift +++ b/Focusrite Volume Control/ShortcutRecorderView.swift @@ -13,7 +13,20 @@ import AppKit struct ShortcutRecorderView: View { let action: ShortcutAction @ObservedObject var shortcutManager: ShortcutManager - @State private var isRecording = false + private var isRecording: Bool { shortcutManager.recordingAction == action } + + private var recordingBinding: Binding { + Binding( + get: { isRecording }, + set: { recording in + if recording { + shortcutManager.recordingAction = action + } else if isRecording { + shortcutManager.recordingAction = nil + } + } + ) + } var body: some View { HStack(spacing: 8) { @@ -22,7 +35,7 @@ struct ShortcutRecorderView: View { ShortcutRecorderButton( shortcut: shortcutManager.shortcuts[action] ?? nil, - isRecording: $isRecording, + isRecording: recordingBinding, onShortcutRecorded: { shortcut in shortcutManager.setShortcut(shortcut, for: action) } @@ -39,6 +52,9 @@ struct ShortcutRecorderView: View { .opacity(isDefault ? 0.3 : 1.0) .disabled(isDefault) } + .onDisappear { + if isRecording { shortcutManager.recordingAction = nil } + } } private var isDefault: Bool { @@ -107,6 +123,12 @@ struct ShortcutRecorderEventHandler: NSViewRepresentable { func makeNSView(context: Context) -> ShortcutRecorderNSView { let view = ShortcutRecorderNSView() + configure(view) + return view + } + + private func configure(_ view: ShortcutRecorderNSView) { + view.isRecording = isRecording view.onShortcutRecorded = { shortcut in onShortcutRecorded(shortcut) isRecording = false @@ -114,14 +136,17 @@ struct ShortcutRecorderEventHandler: NSViewRepresentable { view.onCancel = { isRecording = false } - return view } func updateNSView(_ nsView: ShortcutRecorderNSView, context: Context) { + configure(nsView) if isRecording { - DispatchQueue.main.async { + DispatchQueue.main.async { [weak nsView] in + guard let nsView, nsView.isRecording else { return } nsView.window?.makeFirstResponder(nsView) } + } else if nsView.window?.firstResponder === nsView { + nsView.window?.makeFirstResponder(nil) } } } @@ -129,14 +154,31 @@ struct ShortcutRecorderEventHandler: NSViewRepresentable { // MARK: - NSView for Key Event Handling class ShortcutRecorderNSView: NSView { + var isRecording = false var onShortcutRecorded: ((KeyboardShortcut) -> Void)? var onCancel: (() -> Void)? - override var acceptsFirstResponder: Bool { true } + override var acceptsFirstResponder: Bool { isRecording } + + override func resignFirstResponder() -> Bool { + let resigned = super.resignFirstResponder() + if resigned && isRecording { + isRecording = false + onCancel?() + } + return resigned + } + + private func finishRecording() { + isRecording = false + if window?.firstResponder === self { window?.makeFirstResponder(nil) } + } override func keyDown(with event: NSEvent) { + guard isRecording, !event.isARepeat else { return } // Escape cancels recording if event.keyCode == 53 { // Escape key + finishRecording() onCancel?() return } @@ -155,6 +197,11 @@ class ShortcutRecorderNSView: NSView { mediaKeyType: nil ) + guard shortcut.isAllowedGlobalShortcut else { + NSSound.beep() + return + } + finishRecording() onShortcutRecorded?(shortcut) } @@ -173,7 +220,7 @@ struct ShortcutsSectionView: View { Text("Keyboard Shortcuts") .font(.headline) - Text("By default, system volume keys are used. Click to set a custom shortcut.") + Text("System volume keys are the default. Click to record a shortcut; Esc cancels. Navigation keys require Command, Control, or Option.") .font(.caption) .foregroundColor(.secondary) diff --git a/Focusrite Volume Control/VolumeBackend.swift b/Focusrite Volume Control/VolumeBackend.swift index 219ea03..d177bcd 100644 --- a/Focusrite Volume Control/VolumeBackend.swift +++ b/Focusrite Volume Control/VolumeBackend.swift @@ -3,7 +3,7 @@ // Focusrite Volume Control // // Protocol defining the interface for volume control backends. -// This allows swapping between AppleScript UI automation and future AES70 API. +// This allows swapping between Accessibility control of FC2 and a future direct protocol. // import Foundation @@ -51,6 +51,10 @@ protocol VolumeBackend: AnyObject { func setPlaybackVolume(_ db: Double) async -> VolumeResult func setPlaybackMuted(_ muted: Bool) async -> VolumeResult + /// Set playback volume, enabling Direct Monitor first when requested. + /// Backends can override this to do both in one round trip. + func setPlaybackVolume(_ db: Double, ensureDirectMonitor: Bool) async -> VolumeResult + // MARK: - Input 1 func getInput1Volume() async -> Result @@ -73,12 +77,23 @@ protocol VolumeBackend: AnyObject { func minimizeFC2IfNeeded() async } +extension VolumeBackend { + func setPlaybackVolume(_ db: Double, ensureDirectMonitor: Bool) async -> VolumeResult { + if ensureDirectMonitor && !state.directMonitorEnabled { + _ = await setDirectMonitorEnabled(true) + } + return await setPlaybackVolume(db) + } +} + /// Errors that can occur during volume operations enum VolumeBackendError: Error, LocalizedError { case fc2NotRunning case fc2WindowNotFound case sliderNotFound(String) - case appleScriptError(String) + case fc2NotInstalled + case accessibilityNotGranted + case accessibilityError(String) case timeout case notConnected @@ -90,8 +105,12 @@ enum VolumeBackendError: Error, LocalizedError { return "Could not find Focusrite Control 2 window" case .sliderNotFound(let name): return "Could not find slider: \(name)" - case .appleScriptError(let msg): - return "AppleScript error: \(msg)" + case .fc2NotInstalled: + return "Focusrite Control 2 is not installed" + case .accessibilityNotGranted: + return "Accessibility permission is not granted" + case .accessibilityError(let msg): + return "Accessibility error: \(msg)" case .timeout: return "Operation timed out" case .notConnected: diff --git a/Focusrite Volume Control/VolumeController.swift b/Focusrite Volume Control/VolumeController.swift index ac4b734..6612cfe 100644 --- a/Focusrite Volume Control/VolumeController.swift +++ b/Focusrite Volume Control/VolumeController.swift @@ -3,7 +3,7 @@ // Focusrite Volume Control // // High-level volume control logic for Focusrite devices. -// Uses a pluggable backend (AppleScript now, AES70 API later). +// Uses a pluggable backend (Accessibility control of FC2 now, AES70 API later). // import Foundation @@ -37,14 +37,14 @@ enum VolumeStepSize: String, CaseIterable, Identifiable { /// Available backend types for volume control enum BackendType: String, CaseIterable, Identifiable { - case appleScript = "AppleScript" + case accessibility = "Accessibility" case fcServer = "FC Server" var id: String { rawValue } var description: String { switch self { - case .appleScript: + case .accessibility: return "Controls via Focusrite Control 2 app" case .fcServer: return "Direct protocol (not yet implemented)" @@ -83,7 +83,7 @@ class VolumeController: ObservableObject { } @Published var ensureDirectMonitorOn: Bool = true // Auto-enable Direct Monitor before volume changes @Published var allowGain: Bool = false // Allow volume above 0dB (up to +6dB) - @Published var backendType: BackendType = .appleScript // Which backend to use + @Published var backendType: BackendType = .accessibility // Which backend to use let minVolume: Double = -127.0 // FC2's actual minimum /// Maximum volume depends on allowGain setting @@ -100,18 +100,19 @@ class VolumeController: ObservableObject { // Tracks the most recent pending volume to prevent stale backend responses // from overwriting optimistic updates during rapid key presses private var pendingVolume: Double? + private var volumeWriteTask: Task? // MARK: - Initialization - init(backend: VolumeBackend, backendType: BackendType = .appleScript) { + init(backend: VolumeBackend, backendType: BackendType = .accessibility) { self.backend = backend self.backendType = backendType setupBindings() } - /// Convenience init with default AppleScript backend + /// Convenience init with default Accessibility backend convenience init() { - self.init(backend: AppleScriptBackend(), backendType: .appleScript) + self.init(backend: AccessibilityBackend(), backendType: .accessibility) } private func setupBindings() { @@ -162,10 +163,15 @@ class VolumeController: ObservableObject { } func disconnect() { + volumeWriteTask?.cancel() + volumeWriteTask = nil + pendingVolume = nil backend.disconnect() } func refresh() { + // A read started mid-write could report the old value + guard pendingVolume == nil else { return } Task { _ = await backend.refresh() } @@ -189,8 +195,8 @@ class VolumeController: ObservableObject { // Create new backend switch newType { - case .appleScript: - backend = AppleScriptBackend() + case .accessibility: + backend = AccessibilityBackend() case .fcServer: backend = FCServerBackend() } @@ -216,23 +222,23 @@ class VolumeController: ObservableObject { playbackMuted = clamped <= minVolume pendingVolume = clamped - DispatchQueue.main.async { - Task { - await self.ensureDirectMonitorIfNeeded() - - let result = await self.backend.setPlaybackVolume(clamped) + // A single writer preserves key-press order. Presses that arrive while FC2 + // is busy only update pendingVolume, so the next write sends the newest value + guard volumeWriteTask == nil else { return } + volumeWriteTask = Task { [weak self] in + guard let self else { return } + while let target = self.pendingVolume, !Task.isCancelled { + let result = await self.backend.setPlaybackVolume( + target, ensureDirectMonitor: self.ensureDirectMonitorOn) if case .error(let msg) = result { print("Failed to set playback volume: \(msg)") } - await self.minimizeFC2IfNeeded() - - // Clear pending if this was the most recent request - await MainActor.run { - if self.pendingVolume == clamped { - self.pendingVolume = nil - } + if self.pendingVolume == target { + self.pendingVolume = nil } } + self.volumeWriteTask = nil + await self.minimizeFC2IfNeeded() } } @@ -244,16 +250,6 @@ class VolumeController: ObservableObject { } } - /// Ensure Direct Monitor is enabled if the setting is on - /// The app requires Direct Monitor to be enabled for volume control to work - private func ensureDirectMonitorIfNeeded() async { - let shouldEnsure = await MainActor.run { ensureDirectMonitorOn } - let isEnabled = await MainActor.run { directMonitorEnabled } - if shouldEnsure && !isEnabled { - _ = await backend.setDirectMonitorEnabled(true) - } - } - /// Play system sound for volume feedback if enabled private func playVolumeFeedback() { guard playVolumeSound else { return } diff --git a/Focusrite Volume ControlTests/MockVolumeBackend.swift b/Focusrite Volume ControlTests/MockVolumeBackend.swift index fa4c325..01ef596 100644 --- a/Focusrite Volume ControlTests/MockVolumeBackend.swift +++ b/Focusrite Volume ControlTests/MockVolumeBackend.swift @@ -3,7 +3,7 @@ // Focusrite Volume ControlTests // // Mock implementation of VolumeBackend for unit testing. -// Allows verifying VolumeController behavior without real AppleScript/FC2. +// Allows verifying VolumeController behavior without a real FC2. // import Foundation diff --git a/Focusrite Volume ControlTests/ShortcutRecorderTests.swift b/Focusrite Volume ControlTests/ShortcutRecorderTests.swift new file mode 100644 index 0000000..ec08c9c --- /dev/null +++ b/Focusrite Volume ControlTests/ShortcutRecorderTests.swift @@ -0,0 +1,197 @@ +// +// ShortcutRecorderTests.swift +// Focusrite Volume ControlTests +// +// Regression tests for the shortcut recorder lifecycle bug (docs/shortcut-bug-postmortem.md). +// Ported from https://github.com/wc2184/focusrite-volume-control (MIT, William Chan). +// + +import XCTest +import AppKit +import Carbon.HIToolbox +import class SwiftUI.NSHostingView +@testable import Focusrite_Volume_Control + +// Main-actor like the other test classes: ShortcutManager and HotkeyManager are main-actor +// types (default isolation), and tearing them down outside the main actor aborts +@MainActor +final class ShortcutRecorderTests: XCTestCase { + func testNativeF12AndLeftArrowAreDistinctAndOnlyF12IsAllowedBare() { + let left = KeyboardShortcut(keyCode: UInt16(kVK_LeftArrow), modifiers: []) + let f12 = KeyboardShortcut(keyCode: UInt16(kVK_F12), modifiers: []) + XCTAssertEqual(left.keyCode, 123) + XCTAssertEqual(f12.keyCode, 111) + XCTAssertEqual(left.displayString, "โ†") + XCTAssertEqual(f12.displayString, "F12") + XCTAssertFalse(left.isAllowedGlobalShortcut) + XCTAssertTrue(f12.isAllowedGlobalShortcut) + } + + func testNavigationNeedsAnExplicitShortcutModifier() { + for code in [kVK_LeftArrow, kVK_RightArrow, kVK_UpArrow, kVK_DownArrow, + kVK_Home, kVK_End, kVK_PageUp, kVK_PageDown, + kVK_Tab, kVK_Return, kVK_Delete, kVK_ForwardDelete, kVK_Escape] { + XCTAssertFalse(KeyboardShortcut(keyCode: UInt16(code), modifiers: []).isAllowedGlobalShortcut) + XCTAssertFalse(KeyboardShortcut(keyCode: UInt16(code), modifiers: [.shift]).isAllowedGlobalShortcut) + for modifier: NSEvent.ModifierFlags in [.command, .control, .option] { + XCTAssertTrue(KeyboardShortcut(keyCode: UInt16(code), modifiers: modifier).isAllowedGlobalShortcut) + } + } + } + + func testLoadingRepairsUnsafeSavedNavigationAndPreservesValidShortcuts() throws { + let domain = "FocusriteShortcutTests.\(UUID().uuidString)" + let preferences = UserDefaults(suiteName: domain)! + defer { preferences.removePersistentDomain(forName: domain) } + let saved: [String: KeyboardShortcut] = [ + "volumeUp": KeyboardShortcut(keyCode: UInt16(kVK_LeftArrow), modifiers: []), + "volumeDown": KeyboardShortcut(keyCode: UInt16(kVK_F12), modifiers: []), + "mute": KeyboardShortcut(keyCode: UInt16(kVK_RightArrow), modifiers: [.option]), + "directMonitor": KeyboardShortcut(keyCode: UInt16(kVK_UpArrow), modifiers: [.shift]) + ] + preferences.set(try JSONEncoder().encode(saved), forKey: "keyboardShortcuts") + for manager in [ShortcutManager(preferences: preferences), ShortcutManager(preferences: preferences)] { + XCTAssertEqual(manager.shortcut(for: .volumeUp), ShortcutAction.volumeUp.defaultShortcut) + XCTAssertEqual(manager.shortcut(for: .volumeDown), saved["volumeDown"]) + XCTAssertEqual(manager.shortcut(for: .mute), saved["mute"]) + XCTAssertNil(manager.shortcut(for: .directMonitor)) + } + } + + func testSettingRejectsBareNavigationWithoutReplacingExistingShortcut() { + let manager = ShortcutManager(preferences: nil) + manager.setShortcut(KeyboardShortcut(keyCode: UInt16(kVK_LeftArrow), modifiers: []), for: .volumeUp) + XCTAssertEqual(manager.shortcut(for: .volumeUp), ShortcutAction.volumeUp.defaultShortcut) + } + + func testRecorderCancellationReleasesFocusAndCannotCaptureAnotherKey() { + let recorder = ShortcutRecorderNSView() + var captures: [KeyboardShortcut] = [] + var cancellations = 0 + recorder.onShortcutRecorded = { captures.append($0) } + recorder.onCancel = { cancellations += 1 } + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 40), + styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = recorder + recorder.isRecording = true + XCTAssertTrue(window.makeFirstResponder(recorder)) + recorder.keyDown(with: keyEvent(UInt16(kVK_Escape), window: window)) + XCTAssertFalse(recorder.isRecording) + XCTAssertFalse(window.firstResponder === recorder) + recorder.keyDown(with: keyEvent(UInt16(kVK_F12), window: window)) + XCTAssertEqual(cancellations, 1) + XCTAssertTrue(captures.isEmpty) + } + + func testRecorderCompletesExactlyOnceAndIgnoresInactiveAndRepeatedKeys() { + let recorder = ShortcutRecorderNSView() + var captures: [KeyboardShortcut] = [] + recorder.onShortcutRecorded = { captures.append($0) } + recorder.keyDown(with: keyEvent(UInt16(kVK_F12))) + XCTAssertTrue(captures.isEmpty) + recorder.isRecording = true + recorder.keyDown(with: keyEvent(UInt16(kVK_F12), repeated: true)) + XCTAssertTrue(captures.isEmpty) + recorder.keyDown(with: keyEvent(UInt16(kVK_F12))) + XCTAssertFalse(recorder.isRecording) + recorder.keyDown(with: keyEvent(UInt16(kVK_F11))) + XCTAssertEqual(captures.map(\.keyCode), [UInt16(kVK_F12)]) + } + + func testRecorderLosingFocusCancelsRecording() { + let recorder = ShortcutRecorderNSView() + var cancellations = 0 + recorder.onCancel = { cancellations += 1 } + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 40), + styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = recorder + recorder.isRecording = true + XCTAssertTrue(window.makeFirstResponder(recorder)) + XCTAssertTrue(window.makeFirstResponder(nil)) + XCTAssertFalse(recorder.isRecording) + XCTAssertEqual(cancellations, 1) + } + + func testSwiftUIRecorderBindingCancelsAndCompletesWithoutLaterAssignments() throws { + let shortcuts = ShortcutManager(preferences: nil) + let host = NSHostingView(rootView: ShortcutRecorderView(action: .volumeUp, shortcutManager: shortcuts)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 320, height: 50), + styleMask: .titled, backing: .buffered, defer: false) + window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + func recorder(in view: NSView) -> ShortcutRecorderNSView? { + if let found = view as? ShortcutRecorderNSView { return found } + return view.subviews.lazy.compactMap { recorder(in: $0) }.first + } + shortcuts.recordingAction = .volumeUp + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + let native = try XCTUnwrap(recorder(in: host)) + XCTAssertTrue(native.isRecording) + XCTAssertTrue(window.firstResponder === native) + native.keyDown(with: keyEvent(UInt16(kVK_Escape), window: window)) + XCTAssertNil(shortcuts.recordingAction) + XCTAssertFalse(window.firstResponder === native) + native.keyDown(with: keyEvent(UInt16(kVK_LeftArrow), window: window)) + XCTAssertEqual(shortcuts.shortcut(for: .volumeUp), ShortcutAction.volumeUp.defaultShortcut) + + shortcuts.recordingAction = .volumeUp + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue(native.isRecording) + native.keyDown(with: keyEvent(UInt16(kVK_F12), window: window)) + XCTAssertNil(shortcuts.recordingAction) + native.keyDown(with: keyEvent(UInt16(kVK_F11), window: window)) + XCTAssertEqual(shortcuts.shortcut(for: .volumeUp)?.keyCode, UInt16(kVK_F12)) + } + + func testHotkeysUpdateImmediatelyPauseForRecordingAndPassOrdinaryArrows() { + let shortcuts = ShortcutManager(preferences: nil) + let hotkeys = HotkeyManager(shortcutManager: shortcuts) + var increases = 0 + hotkeys.onVolumeUp = { increases += 1 } + hotkeys.start() + defer { hotkeys.stop() } + let sink = KeyEventSinkView() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 40), + styleMask: .titled, backing: .buffered, defer: false) + window.contentView = sink + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + window.makeFirstResponder(sink) + func press(_ code: Int) { + let app = NSApplication.shared + app.postEvent(keyEvent(UInt16(code), window: window), atStart: true) + if let event = app.nextEvent(matching: .keyDown, until: Date().addingTimeInterval(0.03), + inMode: .default, dequeue: true) { app.sendEvent(event) } + RunLoop.main.run(until: Date().addingTimeInterval(0.01)) + } + shortcuts.setShortcut(KeyboardShortcut(keyCode: UInt16(kVK_F12), modifiers: []), for: .volumeUp) + press(kVK_F12) + XCTAssertEqual(increases, 1) + shortcuts.recordingAction = .volumeDown + press(kVK_F12) + XCTAssertEqual(increases, 1) + shortcuts.recordingAction = nil + press(kVK_F12) + XCTAssertEqual(increases, 2) + for code in [kVK_LeftArrow, kVK_RightArrow] { press(code) } + XCTAssertEqual(increases, 2) + XCTAssertTrue(sink.receivedKeys.contains(UInt16(kVK_LeftArrow))) + XCTAssertTrue(sink.receivedKeys.contains(UInt16(kVK_RightArrow))) + shortcuts.resetToDefault(for: .volumeUp) + press(kVK_F12) + XCTAssertEqual(increases, 2) + } + + private func keyEvent(_ code: UInt16, window: NSWindow? = nil, repeated: Bool = false) -> NSEvent { + NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: window?.windowNumber ?? 0, context: nil, + characters: "", charactersIgnoringModifiers: "", isARepeat: repeated, keyCode: code)! + } +} + +private final class KeyEventSinkView: NSView { + var receivedKeys: [UInt16] = [] + override var acceptsFirstResponder: Bool { true } + override func keyDown(with event: NSEvent) { receivedKeys.append(event.keyCode) } +} diff --git a/Focusrite Volume ControlTests/VolumeBackendTests.swift b/Focusrite Volume ControlTests/VolumeBackendTests.swift index e7609e6..527c22f 100644 --- a/Focusrite Volume ControlTests/VolumeBackendTests.swift +++ b/Focusrite Volume ControlTests/VolumeBackendTests.swift @@ -92,9 +92,19 @@ final class VolumeBackendTests: XCTestCase { XCTAssertEqual(error.errorDescription, "Could not find slider: Playback 1 - 2") } - func testVolumeBackendError_AppleScriptError_Description() { - let error = VolumeBackendError.appleScriptError("execution error: System Events got an error") - XCTAssertEqual(error.errorDescription, "AppleScript error: execution error: System Events got an error") + func testVolumeBackendError_AccessibilityError_Description() { + let error = VolumeBackendError.accessibilityError("AXError -25204") + XCTAssertEqual(error.errorDescription, "Accessibility error: AXError -25204") + } + + func testVolumeBackendError_FC2NotInstalled_Description() { + let error = VolumeBackendError.fc2NotInstalled + XCTAssertEqual(error.errorDescription, "Focusrite Control 2 is not installed") + } + + func testVolumeBackendError_AccessibilityNotGranted_Description() { + let error = VolumeBackendError.accessibilityNotGranted + XCTAssertEqual(error.errorDescription, "Accessibility permission is not granted") } func testVolumeBackendError_Timeout_Description() { diff --git a/Focusrite Volume ControlTests/VolumeControllerTests.swift b/Focusrite Volume ControlTests/VolumeControllerTests.swift index 3b74dff..60c80bf 100644 --- a/Focusrite Volume ControlTests/VolumeControllerTests.swift +++ b/Focusrite Volume ControlTests/VolumeControllerTests.swift @@ -702,8 +702,8 @@ final class VolumeControllerTests: XCTestCase { // MARK: - Backend Type Tests - func testBackendType_DefaultIsAppleScript() { - XCTAssertEqual(controller.backendType, .appleScript) + func testBackendType_DefaultIsAccessibility() { + XCTAssertEqual(controller.backendType, .accessibility) } func testRestart_CallsDisconnectThenConnect() async throws { @@ -769,4 +769,22 @@ final class VolumeControllerTests: XCTestCase { let directMonitorCalls = calls(named: "setDirectMonitorEnabled") XCTAssertTrue(directMonitorCalls.isEmpty, "should not call setDirectMonitorEnabled when already on") } + + // MARK: - Rapid Write Coalescing Tests + + func testSetPlaybackVolume_RapidCalls_CoalesceToNewestValue() async throws { + mockBackend.setPlaybackVolumeState(-40.0) + try await waitForStateUpdate() + + mockBackend.clearCalls() + controller.setPlaybackVolume(-30.0) + controller.setPlaybackVolume(-25.0) + controller.setPlaybackVolume(-20.0) + try await waitForStateUpdate() + + // Presses made before the writer starts collapse into a single write of the newest value + let written = calls(named: "setPlaybackVolume").compactMap { $0.parameters["db"] } + XCTAssertEqual(written, ["-20.0"]) + XCTAssertEqual(controller.playbackVolume, -20.0) + } } diff --git a/Focusrite-Volume-Control-Info.plist b/Focusrite-Volume-Control-Info.plist new file mode 100644 index 0000000..acab772 --- /dev/null +++ b/Focusrite-Volume-Control-Info.plist @@ -0,0 +1,17 @@ + + + + + + SUFeedURL + https://enum.dev/focusrite-volume-control/appcast.xml + + SUPublicEDKey + REPLACE_WITH_SPARKLE_PUBLIC_ED_KEY + + diff --git a/LICENSE.md b/LICENSE.md index afa5b59..28fa95c 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,7 @@ MIT License Copyright (c) 2026 Enum Solutions Inc. +Copyright (c) 2026 William Chan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index ebc9c47..1aa1253 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Focusrite Volume Control +# Volume Control A macOS menu bar app that lets you control your Focusrite Scarlett Solo volume with your keyboard's media keys and custom shortcuts. @@ -17,19 +17,28 @@ A macOS menu bar app that lets you control your Focusrite Scarlett Solo volume w ### Homebrew ```bash -brew tap enum-labs/focusrite-volume-control -brew install --cask focusrite-volume-control +brew install --cask enum-dot-dev/focusrite-volume-control/focusrite-volume-control ``` +Homebrew 6.0 and later only load casks from third-party taps you've trusted. Installing by the full name, as above, adds the tap and trusts just this cask. If you'd rather tap first and install by the short name, run `brew trust --cask enum-dot-dev/focusrite-volume-control/focusrite-volume-control` before `brew install --cask focusrite-volume-control`. + ### Manual -Download the latest DMG from [Releases](https://github.com/enum-labs/focusrite-volume-control/releases/latest) and drag to Applications. +Download the latest DMG from [Releases](https://github.com/enum-dot-dev/focusrite-volume-control/releases/latest) and drag to Applications. + +Releases are moving to Developer ID signing and notarization. Until a release says it is signed, macOS will block the first launch: right-click the app and choose **Open**. Signed releases update themselves through **Check for Updates...** in the menu bar menu. ## Requirements -- macOS 15.0 (Sequoia) or later -- [Focusrite Control 2](https://focusrite.com/downloads) installed -- Accessibility permission (prompted on first launch) +- macOS 14.5 (Sonoma) or later +- A Focusrite interface managed by [Focusrite Control 2](https://focusrite.com/downloads) (Scarlett 4th Gen), with FC2 installed +- Accessibility permission (prompted on first launch). No Automation permission is needed. + +## Compatibility + +This has only been tested with a **Scarlett Solo 4th Gen**. Older Scarlett models (3rd Gen and earlier) aren't supported by Focusrite Control 2, so they can't work with this app either. + +If you use a larger interface (2i2, 4i4, 16i16 and up) and something doesn't work, its FC2 window is probably laid out differently. [Open an issue](https://github.com/enum-dot-dev/focusrite-volume-control/issues) with your model and FC2 version and I'll try to patch it. Requests for things like preset key bindings are welcome there too. ## Features @@ -42,7 +51,7 @@ Download the latest DMG from [Releases](https://github.com/enum-labs/focusrite-v ## How it works -The app drives Focusrite Control 2 via UI automation (AppleScript + Accessibility API). It intercepts system media keys through a `CGEventTap` and routes them to FC2's sliders instead of macOS's built-in volume control. +The app drives Focusrite Control 2's window in-process through the macOS Accessibility API, caching the controls it looks up so rapid key presses stay fast and in order. It intercepts system media keys through a `CGEventTap` and routes them to FC2's sliders instead of macOS's built-in volume control. Only the Accessibility permission is needed; there is no Automation (Apple Events) prompt. ## Known issues @@ -54,7 +63,15 @@ The app drives Focusrite Control 2 via UI automation (AppleScript + Accessibilit ## Build from source +Requires Xcode 26. Swift Package Manager fetches [Sparkle](https://sparkle-project.org) on the first build. + ```bash xcodebuild -scheme "Focusrite Volume Control" -configuration Release \ CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO build ``` + +`bash scripts/build-dmg.sh` builds and packages a DMG; see the script header for signing and notarization options. + +## Credits + +The native Accessibility control path, rapid key-press handling and the shortcut recorder fix are ported from [William Chan's version](https://github.com/wc2184/focusrite-volume-control) (MIT). His write-up of the shortcut bug is in [docs/shortcut-bug-postmortem.md](docs/shortcut-bug-postmortem.md). diff --git a/TODO.md b/TODO.md index d328d44..eb572a2 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,7 @@ - Shortcuts to switch presets and change inputs volume, with e.g., shift or control + volume - ... - PROFIT! -- [Propose your own ideas with the `enhancement` label](https://github.com/enum-labs/focusrite-volume-control/issues/new) +- [Propose your own ideas with the `enhancement` label](https://github.com/enum-dot-dev/focusrite-volume-control/issues/new) ## Known Issues ## - Mute button causes popover to shift a few pixels when menu bar is set to "Always Hidden". This appears to be a macOS/SwiftUI interaction issue with auto-hiding menu bars and popover repositioning. diff --git a/docs/remote-protocol.md b/docs/remote-protocol.md new file mode 100644 index 0000000..208e38e --- /dev/null +++ b/docs/remote-protocol.md @@ -0,0 +1,101 @@ +# Remote protocol prototype (FC2 over TCP) + +Status: **experimental, unwired, unverified.** Nobody has confirmed that it ever controlled +volume end to end. This page records what the code does so the work isn't lost. + +## Why it exists + +The shipping app controls Focusrite Control 2 (FC2) by operating its window through the +Accessibility API. FC2 also accepts remote-control clients over a local TCP socket, the same +mechanism Focusrite's own remote apps use. Talking that protocol directly would not need FC2's +window, the Direct tab, or the Accessibility permission for FC2 control. + +## What's in the repo + +| File | What it is | +|---|---| +| `Focusrite Volume Control/FocusriteClient.swift` | A working-looking client for FC2's XML-over-TCP protocol. Nothing creates it today. | +| `Focusrite Volume Control/FCServerBackend.swift` | A `VolumeBackend` stub. Every method returns "not yet implemented"; it does not use `FocusriteClient`. Its header says AES70/OCA, but no AES70 code exists; the client speaks the XML protocol below. | +| `Focusrite Volume ControlTests/FCServerBackendTests.swift` | Tests that the stub returns its errors. Nothing tests `FocusriteClient`. | +| `BackendType.fcServer` in `VolumeController.swift` | Selectable in code via `switchBackend(to:)`. There is no UI for it (a picker existed until `4d12ee2` removed it). | + +## How the client works + +**Transport.** TCP to `127.0.0.1` with `NWConnection`. No Bonjour, no UDP. + +**Finding the port.** The client runs `lsof -i TCP -s TCP:LISTEN -n -P`, keeps lines from a +process containing "Focusrite" owned by the current user, and takes ports written as `*:PORT`. +It then appends fallback ports `58323, 58322, 49152, 30096` (a code comment guesses 58323 is +the control port) and tries each one, highest first, moving on when a connection fails or waits. + +**Framing.** Each message is `Length=XXXXXX ` (six hex digits and a space) followed by an XML +body. The client counts characters rather than bytes, which only matters for non-ASCII text. + +**Messages sent** + +```xml + + + + +``` + +**Messages handled** + +- ``: FC2 assigns the client an ID +- ``: the client parses the device's items and subscribes +- `` +- ``: `setValue` refuses to send until this is `true` +- ``: live value updates for subscribed items + +**Pairing.** There is no pairing code in the code. The client sends a fixed `client-key` +("like the original Focusrite Midi Control app", per a comment) and waits for FC2 to approve +it. The original UI told the user to approve the app in **FC2 โ†’ Settings โ†’ Remote Devices**, +where it appears as "FocusriteVolumeControl". Whether FC2 still offers that approval without a +paired phone is unknown. + +## What's missing + +- **Item IDs.** The client knows nothing about which item is the playback level, an input, or + Direct Monitor. The first prototype guessed "the first item whose value is between -70 and 6". +- **Keep-alive probably never fires.** The timer is created on the connection's callback queue, + which has no run loop. +- **No reconnect** when FC2 restarts. +- **`FCServerBackend` doesn't use the client** at all. + +## History + +All on January 26, 2026: + +1. `17e3ff7` "add stuff..." wired `FocusriteClient` into the app. +2. `b314269` "we have a working solution!!! telinta style" added only the `VolumeBackend` protocol. +3. `d36006c` "something worked?" added the AppleScript backend and removed every use of + `FocusriteClient`. + +The TCP route was dropped about five hours after it was added, in favour of UI scripting. That +suggests, but doesn't prove, that it never worked end to end. A later roadmap item still listed +"Focusrite Control Protocol/Server support". + +## Proposal: "Enable experimental remote protocol" + +Not implemented. A sketch of what a settings toggle and setup wizard would need, based on the +code above: + +1. **Toggle** in Preferences, off by default, labelled experimental. Turning it on opens the + wizard; turning it off switches back to the Accessibility backend. +2. **Wizard steps** + 1. *Check FC2 is running* (port discovery reads FC2's live sockets). + 2. *Connect*: show the discovered and fallback ports and which one answered; allow a manual + port as a fallback. + 3. *Approve*: tell the user to open FC2 โ†’ Settings โ†’ Remote Devices and approve + "FocusriteVolumeControl"; wait for ``, and explain what to do + on `false` or a timeout. + 4. *Map controls*: list the device's items and identify playback level, mute and Direct + Monitor, either automatically (by matching values the Accessibility backend reads) or by + asking the user to move a slider in FC2 and watching which item changes. + 5. *Test*: nudge the volume one step and confirm FC2 reflects it, then save the mapping. +3. **Before shipping it:** fix the keep-alive timer, add reconnect, implement `FCServerBackend` + on top of `FocusriteClient`, and add tests that feed recorded protocol traffic to the client. + +The first thing to do is a manual spike on a real Mac: run the client, approve it in FC2, and +log the items a Scarlett exposes. That answers whether this works at all before any UI is built. diff --git a/docs/shortcut-bug-postmortem.md b/docs/shortcut-bug-postmortem.md new file mode 100644 index 0000000..0a177df --- /dev/null +++ b/docs/shortcut-bug-postmortem.md @@ -0,0 +1,59 @@ +# Shortcut recording bug: recap and fix + +> Imported from William Chan's fork, [wc2184/focusrite-volume-control](https://github.com/wc2184/focusrite-volume-control), together with the fix. Version numbers and release notes below refer to that fork's releases. + +October 12, 2026 ยท Fixed in v1.1.1 + +## What happened + +A saved custom shortcut assigned **Left Arrow to Volume Up**, without any modifier keys. With Focusrite selected as the sound output, pressing Left Arrow could therefore change the interface volume while navigating another app. The normal defaults are the system volume and mute keys. + +The affected setting contained `keyCode: 123`, `modifiersRaw: 0`, and `isMediaKey: false`. In native macOS key codes, **123 is Left Arrow and 111 is F12**. JavaScript's legacy `event.keyCode` uses 123 for F12, but this app records native `NSEvent.keyCode` values. + +## Confirmed defects + +The shortcut recorder tracked its visible recording state in SwiftUI, but its native keyboard handler did not check that state. After recording finished, or Escape canceled it, the native view could retain keyboard focus and keep capturing subsequent keys. The capture callback immediately saved those keys against the recorder's action. + +This was reproduced in an isolated native window: Escape set the visible recording state to false, but a subsequent Left Arrow was still captured. The test used a mock callback and did not change actual audio or saved preferences. + +The global shortcut handler also accepted unmodified navigation keys. Consequently, an accidentally recorded navigation key could become a persistent global volume shortcut. Its monitor-update subscription read the old published shortcut state, which could delay registration changes until another update or restart. + +## Where the code originated + +The recorder, shortcut model, and global handler came from [Nick Morozov's Focusrite Volume Control](https://github.com/enum-dot-dev/focusrite-volume-control). Before this fix, all three files were byte-identical to upstream revision `7972b39b21afa88ecff98a1a5395b3d4766f887c`. + +The earliest upstream commit containing the defective recorder is [`739ab79d49ed9390bae2c51a6398f442a925b87a`](https://github.com/enum-dot-dev/focusrite-volume-control/commit/739ab79d49ed9390bae2c51a6398f442a925b87a), **January 27, 2026**, titled "Add customizable keyboard shortcuts with recorder UI." Since that commit, the three files changed only in project-name comments before they were copied into this version. + +The earliest local commit containing them is `61bde8d0a37ba05dd1c93b49d7615f04d979a90e`, **October 10, 2026**, a checkpoint of existing work. The first available project investigation already found those files staged before its source edits. The first public commit, [`d56c23b`](https://github.com/wc2184/focusrite-volume-control/commit/d56c23b6fd649e2b21d47b7a63ff47ec5463f22b), included the same implementation. + +The defect was inherited and missed during review. This identifies its source history, but does not establish how the particular saved Left Arrow assignment was created. + +## What remains unknown + +The available local Codex history was searched across 79 session transcripts, thread metadata, and cached conversation records, including sessions outside the project folder. No original command assigning 123 to Volume Up, instruction to bind F12, or JavaScript/native key-code conversion was found before the incident. + +The original preference writer and creation time remain unknown. Accidental recorder capture and an externally seeded key-code mix-up are possible explanations; neither is established as the cause of this particular setting. Upstream's 46 available commits contain no hardcoded numeric 123 or `0x7B` in text files and default to media keys. + +## The emergency fix also went wrong + +The initial response changed the configuration to Left Arrow โ†’ Volume Down and Right Arrow โ†’ Volume Up. That fixed direction while preserving unwanted global arrow behavior and introduced a new Right Arrow shortcut without a request for one. + +The verification checked that arrows moved one volume tick in the chosen direction. It failed to check the more important requirement: ordinary navigation keys should remain navigation keys. Both assignments were subsequently removed and the system media-key defaults restored. + +Earlier volume tests covered tick movement, pending writes, mute, and the perceptual curve. They did not exercise recorder cancellation, completed recording, or ordinary-key pass-through. Their passing results did not establish keyboard-interaction correctness. + +## What v1.1.1 changes + +- The native recorder accepts keys only while explicitly recording. Completion and cancellation immediately stop capture and release keyboard focus. Losing responder focus cancels recording, and held-key repeats cannot become another assignment. +- One shared recording action keeps only one recorder active. Global custom shortcuts pause during recording so an existing shortcut cannot consume the key being recorded. +- Global arrow, navigation, and editing shortcuts require Command, Control, or Option. Shift alone is insufficient because it is used for normal text selection. Bare function keys, including native F12, remain supported. +- On launch, unsafe saved navigation bindings are replaced with the corresponding default. Valid custom shortcuts and other preferences are preserved. New unsafe assignments are rejected at capture, save, and dispatch boundaries. +- Monitor registration uses the newly published values so adding, changing, resetting, or recording a shortcut takes effect immediately. + +## Verification and release status + +Regression tests cover native F12/Left Arrow distinction, navigation-key policy, persisted-setting repair, rejection of unsafe assignments, cancellation and focus release, completion and repeat handling, focus-loss cancellation, and real AppKit event routing while shortcuts are added, recorded, and reset. The original volume-controller tests remain in place. + +Modern, Ventura, and Monterey packages include the correction. Older published packages contain the recorder defect; their release notes identify v1.1.1 as the replacement. The preserved v1.0.1 Modern archive remains byte-identical as requested. + +Ventura and Monterey builds remain experimental: build and test checks run on Sonoma do not establish behavior on those older operating systems. diff --git a/homebrew/Casks/focusrite-volume-control.rb b/homebrew/Casks/focusrite-volume-control.rb index 1368462..f07ca18 100644 --- a/homebrew/Casks/focusrite-volume-control.rb +++ b/homebrew/Casks/focusrite-volume-control.rb @@ -1,15 +1,17 @@ +# Template: the release workflow renders this with scripts/render_cask.py (version, sha256, +# and a quarantine postflight for unsigned builds) and pushes it to the tap repository. cask "focusrite-volume-control" do - version "1.2" - sha256 :no_check # Updated automatically by release workflow + version "1.5.1" + sha256 :no_check - url "https://github.com/enum-labs/focusrite-volume-control/releases/download/v#{version}/FocusriteVolumeControl.dmg" - name "Focusrite Volume Control" + url "https://github.com/enum-dot-dev/focusrite-volume-control/releases/download/v#{version}/VolumeControl.dmg" + name "Volume Control" desc "Control Focusrite Scarlett 4th gen volume with media keys via Focusrite Control 2" - homepage "https://enum-labs.github.io/focusrite-volume-control/" + homepage "https://enum.dev/focusrite-volume-control/" - depends_on macos: ">= :sequoia" + depends_on macos: ">= :sonoma" - app "Focusrite Volume Control.app" + app "Volume Control.app" caveats <<~EOS If media keys stop working after an update, reset the accessibility permission: @@ -18,6 +20,7 @@ EOS zap trash: [ + "~/Library/Preferences/net.nickmorozov.FocusriteVolumeControl.plist", "~/Library/Preferences/solutions.enum.FocusriteVolumeControl.plist", ] end diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index 2f51d7a..c4c1620 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -1,15 +1,26 @@ #!/bin/bash # -# build-dmg.sh โ€” Build a distributable DMG for Focusrite Volume Control +# build-dmg.sh - Package Volume Control into a DMG, optionally signed and notarized # # Usage: -# bash scripts/build-dmg.sh # uses project signing settings -# ADHOC=1 bash scripts/build-dmg.sh # force ad-hoc signing (for CI) +# bash scripts/build-dmg.sh # build with project signing settings, then package +# ADHOC=1 bash scripts/build-dmg.sh # force ad-hoc signing +# APP_PATH=build/export/"Volume Control.app" bash scripts/build-dmg.sh +# # package an already built (and signed) app # -# Optional environment variables for notarization: -# APPLE_ID โ€” Apple ID email for notarization -# TEAM_ID โ€” Developer Team ID -# APP_PASSWORD โ€” App-specific password for notarization +# Optional environment variables: +# APP_PATH - prebuilt .app to package instead of building one +# DMG_PATH - output path (default: build/VolumeControl.dmg) +# SIGN_IDENTITY - codesign identity for the DMG, e.g. "Developer ID Application: Name (TEAMID)" +# KEYCHAIN - keychain holding SIGN_IDENTITY (default: search list) +# +# Notarization (App Store Connect API key, preferred): +# NOTARY_KEY_PATH - path to AuthKey_XXXX.p8 +# NOTARY_KEY_ID - API key ID +# NOTARY_ISSUER - API issuer ID +# +# Notarization (legacy Apple ID): +# APPLE_ID, TEAM_ID, APP_PASSWORD # set -euo pipefail @@ -18,73 +29,87 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" BUILD_DIR="$PROJECT_DIR/build" SCHEME="Focusrite Volume Control" -APP_NAME="Focusrite Volume Control" -DMG_VOLUME_NAME="Focusrite Volume Control" - -# Only override signing when explicitly requested (e.g., CI without certificates) -SIGN_ARGS="" -if [ "${ADHOC:-}" = "1" ] || [ "${CI:-}" = "true" ]; then - echo "==> Using ad-hoc signing (no certificate)" - SIGN_ARGS="CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO" -fi +APP_NAME="Volume Control" +DMG_VOLUME_NAME="Volume Control" +DMG_FINAL="${DMG_PATH:-$BUILD_DIR/VolumeControl.dmg}" -echo "==> Cleaning build directory..." -rm -rf "$BUILD_DIR" mkdir -p "$BUILD_DIR" -# Build Release -echo "==> Building Release..." -xcodebuild -project "$PROJECT_DIR/Focusrite Volume Control.xcodeproj" \ - -scheme "$SCHEME" \ - -configuration Release \ - MACOSX_DEPLOYMENT_TARGET=15.0 \ - $SIGN_ARGS \ - build \ - -quiet +if [ -n "${APP_PATH:-}" ]; then + echo "==> Packaging prebuilt app: $APP_PATH" + [ -d "$APP_PATH" ] || { echo "ERROR: APP_PATH does not exist: $APP_PATH"; exit 1; } +else + # Only override signing when explicitly requested (e.g., CI without certificates) + SIGN_ARGS=() + if [ "${ADHOC:-}" = "1" ] || [ "${CI:-}" = "true" ]; then + echo "==> Using ad-hoc signing (no certificate)" + SIGN_ARGS=(CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO) + fi + + echo "==> Building Release..." + rm -rf "$BUILD_DIR/DerivedData" "$BUILD_DIR/export" + xcodebuild -project "$PROJECT_DIR/Focusrite Volume Control.xcodeproj" \ + -scheme "$SCHEME" \ + -configuration Release \ + -derivedDataPath "$BUILD_DIR/DerivedData" \ + MACOSX_DEPLOYMENT_TARGET=14.5 \ + ${SIGN_ARGS[@]+"${SIGN_ARGS[@]}"} \ + build \ + -quiet -# Find the built app -BUILT_APP=$(find ~/Library/Developer/Xcode/DerivedData/Focusrite_Volume_Control-* \ - -path "*/Build/Products/Release/${APP_NAME}.app" -maxdepth 5 2>/dev/null | head -1) -if [ -z "$BUILT_APP" ]; then - echo "ERROR: Could not find built app in DerivedData" - exit 1 + mkdir -p "$BUILD_DIR/export" + cp -R "$BUILD_DIR/DerivedData/Build/Products/Release/$APP_NAME.app" "$BUILD_DIR/export/" + APP_PATH="$BUILD_DIR/export/$APP_NAME.app" fi -mkdir -p "$BUILD_DIR/export" -cp -R "$BUILT_APP" "$BUILD_DIR/export/" -# Create DMG with drag-to-Applications layout using create-dmg echo "==> Creating DMG..." -DMG_FINAL="$BUILD_DIR/$APP_NAME.dmg" rm -f "$DMG_FINAL" - -create-dmg \ - --volname "$DMG_VOLUME_NAME" \ - --window-pos 200 120 \ - --window-size 540 380 \ - --icon-size 80 \ - --icon "$APP_NAME.app" 140 180 \ - --app-drop-link 400 180 \ - --no-internet-enable \ - "$DMG_FINAL" \ - "$BUILD_DIR/export/$APP_NAME.app" - +if command -v create-dmg >/dev/null 2>&1; then + # Drag-to-Applications layout + create-dmg \ + --volname "$DMG_VOLUME_NAME" \ + --window-pos 200 120 \ + --window-size 540 380 \ + --icon-size 80 \ + --icon "$APP_NAME.app" 140 180 \ + --app-drop-link 400 180 \ + --no-internet-enable \ + "$DMG_FINAL" \ + "$APP_PATH" +else + STAGING="$(mktemp -d)" + cp -R "$APP_PATH" "$STAGING/" + ln -s /Applications "$STAGING/Applications" + hdiutil create -volname "$DMG_VOLUME_NAME" -srcfolder "$STAGING" -ov -format UDZO "$DMG_FINAL" + rm -rf "$STAGING" +fi echo "==> DMG created: $DMG_FINAL" -# Optional notarization -if [ -n "${APPLE_ID:-}" ] && [ -n "${TEAM_ID:-}" ] && [ -n "${APP_PASSWORD:-}" ]; then - echo "==> Submitting for notarization..." - xcrun notarytool submit "$DMG_FINAL" \ - --apple-id "$APPLE_ID" \ - --team-id "$TEAM_ID" \ - --password "$APP_PASSWORD" \ - --wait +if [ -n "${SIGN_IDENTITY:-}" ]; then + echo "==> Signing DMG..." + KEYCHAIN_ARGS=() + [ -n "${KEYCHAIN:-}" ] && KEYCHAIN_ARGS=(--keychain "$KEYCHAIN") + codesign --force --timestamp --sign "$SIGN_IDENTITY" ${KEYCHAIN_ARGS[@]+"${KEYCHAIN_ARGS[@]}"} "$DMG_FINAL" + codesign --verify --verbose=2 "$DMG_FINAL" +fi + +NOTARY_ARGS=() +if [ -n "${NOTARY_KEY_PATH:-}" ] && [ -n "${NOTARY_KEY_ID:-}" ] && [ -n "${NOTARY_ISSUER:-}" ]; then + NOTARY_ARGS=(--key "$NOTARY_KEY_PATH" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER") +elif [ -n "${APPLE_ID:-}" ] && [ -n "${TEAM_ID:-}" ] && [ -n "${APP_PASSWORD:-}" ]; then + NOTARY_ARGS=(--apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APP_PASSWORD") +fi +if [ ${#NOTARY_ARGS[@]} -gt 0 ]; then + echo "==> Submitting DMG for notarization..." + xcrun notarytool submit "$DMG_FINAL" "${NOTARY_ARGS[@]}" --wait echo "==> Stapling notarization ticket..." xcrun stapler staple "$DMG_FINAL" - echo "==> Notarization complete!" + xcrun stapler validate "$DMG_FINAL" + echo "==> Notarization complete" else echo "" - echo "Skipping notarization (set APPLE_ID, TEAM_ID, APP_PASSWORD to enable)." + echo "Skipping notarization (set NOTARY_KEY_PATH, NOTARY_KEY_ID, NOTARY_ISSUER to enable)." fi echo "" diff --git a/scripts/generate-icon.py b/scripts/generate-icon.py index 32360cb..de2c059 100644 --- a/scripts/generate-icon.py +++ b/scripts/generate-icon.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Generate app icon for Focusrite Volume Control. +Generate app icon for Volume Control. Creates a 1024x1024 master icon and all required sizes for AppIcon.appiconset. Uses only Python standard library + macOS sips for resizing. """ diff --git a/scripts/render_cask.py b/scripts/render_cask.py new file mode 100644 index 0000000..b6f3c72 --- /dev/null +++ b/scripts/render_cask.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Render the Homebrew cask for a release from homebrew/Casks/focusrite-volume-control.rb. + +The release workflow copies the rendered cask into the tap repository, so this repo's +cask stays the single source of truth. Unsigned builds get a postflight that clears +the quarantine attribute, since Gatekeeper blocks un-notarized apps downloaded by brew. + +Usage: + render_cask.py --version 1.6 --sha256 [--unsigned] [--auto-updates] SRC DEST +""" + +import argparse +import re +import sys + +QUARANTINE_POSTFLIGHT = ''' + # Unsigned build: clear quarantine so Gatekeeper doesn't block the app + postflight do + system_command "/usr/bin/xattr", + args: ["-cr", "#{appdir}/Volume Control.app"] + end +''' + + +def render(source: str, version: str, sha256: str, unsigned: bool, auto_updates: bool) -> str: + if not re.fullmatch(r"[0-9]+(\.[0-9]+)*", version): + raise ValueError(f"unexpected version: {version!r}") + if not re.fullmatch(r"[0-9a-f]{64}", sha256): + raise ValueError(f"unexpected sha256: {sha256!r}") + + def sub(pattern: str, replacement: str, text: str) -> str: + result, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if count != 1: + raise ValueError(f"pattern not found in cask: {pattern}") + return result + + cask = sub(r'^ version ".*"$', f' version "{version}"', source) + cask = sub(r'^ sha256 .*$', f' sha256 "{sha256}"', cask) + if auto_updates: + cask = sub(r'^( depends_on .*)$', r' auto_updates true\n\1', cask) + if unsigned: + cask = sub(r'^( app ".*")$', lambda m: m.group(1) + "\n" + QUARANTINE_POSTFLIGHT.rstrip("\n"), cask) + return cask + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--version", required=True) + parser.add_argument("--sha256", required=True) + parser.add_argument("--unsigned", action="store_true", help="add the quarantine-clearing postflight") + parser.add_argument("--auto-updates", action="store_true", help="the app updates itself (Sparkle)") + parser.add_argument("source") + parser.add_argument("dest") + args = parser.parse_args() + + with open(args.source) as f: + source = f.read() + try: + cask = render(source, args.version, args.sha256, args.unsigned, args.auto_updates) + except ValueError as error: + print(f"render_cask: {error}", file=sys.stderr) + return 1 + with open(args.dest, "w") as f: + f.write(cask) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/update_appcast.py b/scripts/update_appcast.py new file mode 100644 index 0000000..d417de6 --- /dev/null +++ b/scripts/update_appcast.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Add a release to the Sparkle appcast (site/appcast.xml), newest first. + +The release workflow runs this after signing the DMG with Sparkle's sign_update, +then commits the appcast so GitHub Pages serves it. An existing item for the same +version is replaced, so re-running a release is safe. + +Usage: + update_appcast.py APPCAST --version 1.6 --url URL --length BYTES --signature SIG + [--minimum-system-version 14.5] [--release-notes-url URL] [--pub-date RFC822] +""" + +import argparse +import sys +import xml.etree.ElementTree as ET +from typing import Optional +from email.utils import formatdate + +SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle" +ET.register_namespace("sparkle", SPARKLE_NS) + + +def sparkle(tag: str) -> str: + return f"{{{SPARKLE_NS}}}{tag}" + + +def add_release(tree: ET.ElementTree, version: str, url: str, length: int, signature: str, + minimum_system_version: str, release_notes_url: Optional[str], pub_date: str) -> None: + channel = tree.getroot().find("channel") + if channel is None: + raise ValueError("appcast has no ") + + for item in channel.findall("item"): + if item.findtext(sparkle("version")) == version: + channel.remove(item) + + item = ET.Element("item") + ET.SubElement(item, "title").text = f"Version {version}" + ET.SubElement(item, "pubDate").text = pub_date + # CFBundleVersion and CFBundleShortVersionString are both stamped from the tag + ET.SubElement(item, sparkle("version")).text = version + ET.SubElement(item, sparkle("shortVersionString")).text = version + ET.SubElement(item, sparkle("minimumSystemVersion")).text = minimum_system_version + if release_notes_url: + ET.SubElement(item, sparkle("fullReleaseNotesLink")).text = release_notes_url + ET.SubElement(item, "enclosure", { + "url": url, + "length": str(length), + "type": "application/octet-stream", + sparkle("edSignature"): signature, + }) + + # Keep items newest first, after the channel's own metadata + items = sorted(channel.findall("item") + [item], + key=lambda i: version_key(i.findtext(sparkle("version")) or ""), reverse=True) + for existing in channel.findall("item"): + channel.remove(existing) + channel.extend(items) + + +def version_key(version: str) -> tuple: + return tuple(int(part) if part.isdigit() else 0 for part in version.split(".")) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("appcast") + parser.add_argument("--version", required=True) + parser.add_argument("--url", required=True) + parser.add_argument("--length", required=True, type=int) + parser.add_argument("--signature", required=True) + parser.add_argument("--minimum-system-version", default="14.5") + parser.add_argument("--release-notes-url") + parser.add_argument("--pub-date", default=formatdate(usegmt=True)) + args = parser.parse_args() + + tree = ET.parse(args.appcast) + try: + add_release(tree, args.version, args.url, args.length, args.signature, + args.minimum_system_version, args.release_notes_url, args.pub_date) + except ValueError as error: + print(f"update_appcast: {error}", file=sys.stderr) + return 1 + ET.indent(tree, space=" ") + tree.write(args.appcast, encoding="utf-8", xml_declaration=True) + with open(args.appcast, "a") as f: + f.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/appcast.xml b/site/appcast.xml new file mode 100644 index 0000000..a4939f0 --- /dev/null +++ b/site/appcast.xml @@ -0,0 +1,9 @@ + + + + Volume Control + https://enum.dev/focusrite-volume-control/appcast.xml + Updates for Volume Control. Maintained by the release workflow. + en + + diff --git a/site/index.html b/site/index.html index 0c26221..f852dfe 100644 --- a/site/index.html +++ b/site/index.html @@ -3,7 +3,7 @@ - Focusrite Volume Control โ€” Control your Focusrite with keyboard shortcuts + Volume Control โ€” media keys for Focusrite Scarlett interfaces @@ -13,14 +13,14 @@
-

Focusrite Volume Control

+

Volume Control

Control your Focusrite Scarlett Solo volume with your Mac's media keys and custom shortcuts.

@@ -84,14 +84,13 @@

Auto-Detect

Download

-

Free and open source. Requires macOS 15+ and Focusrite Control 2.

+

Free and open source. Requires macOS 14.5+ and Focusrite Control 2.

Homebrew

- brew tap enum-labs/focusrite-volume-control - brew install --cask focusrite-volume-control + brew install --cask enum-dot-dev/focusrite-volume-control/focusrite-volume-control
@@ -99,14 +98,14 @@

Homebrew

- The app requires Accessibility and Automation permissions โ€” a setup wizard will guide you on first launch. + The app needs the Accessibility permission โ€” a setup wizard will guide you on first launch. Tested with the Scarlett Solo 4th Gen; other models, open an issue if something doesn't work.

@@ -129,8 +128,8 @@

Install Focusrite Control 2

2

Install the app

-

Via Homebrew: brew tap enum-labs/focusrite-volume-control && brew install --cask focusrite-volume-control
- Or download the DMG and drag to Applications.

+

Via Homebrew: brew install --cask enum-dot-dev/focusrite-volume-control/focusrite-volume-control
+ Or download the DMG and drag to Applications.

@@ -138,20 +137,12 @@

Install the app

3

Grant Accessibility permission

-

The setup wizard will prompt you. This lets the app intercept your volume keys. Go to System Settings → Privacy & Security → Accessibility and enable the app.

+

The setup wizard will prompt you. This lets the app intercept your volume keys and adjust Focusrite Control 2's sliders. Go to System Settings → Privacy & Security → Accessibility and enable the app.

4
-
-

Grant Automation permission

-

The app will ask to control System Events and Focusrite Control 2. Click Allow when prompted. This lets it read and set volume levels.

-
-
- -
-
5

Use your volume keys

That's it! Your Mac's volume keys now control your Focusrite. Customize shortcuts and preferences from the menu bar icon.

@@ -165,12 +156,12 @@

Use your volume keys