diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2038456..9aab750 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,3 +25,7 @@ jobs: - name: Build App run: | xcodebuild -scheme scrobble -destination 'platform=macOS' -configuration Release build CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO + + - name: Run Tests + run: | + xcodebuild -scheme scrobbleTests -destination 'platform=macOS' test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02a9443..1c97552 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,9 +12,59 @@ jobs: release: runs-on: macos-26 + env: + KEYCHAIN_PASSWORD: ${{ github.run_id }} + steps: - uses: actions/checkout@v4 + - name: Set keychain path + run: echo "KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db" >> "$GITHUB_ENV" + + - name: Install certificates + env: + DEVELOPER_ID_APPLICATION_P12: ${{ secrets.DEVELOPER_ID_APPLICATION_P12 }} + DEVELOPER_ID_INSTALLER_P12: ${{ secrets.DEVELOPER_ID_INSTALLER_P12 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + run: | + # Create temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Import Developer ID Application certificate + private key (for app signing) + echo "$DEVELOPER_ID_APPLICATION_P12" | base64 --decode > /tmp/app_cert.p12 + security import /tmp/app_cert.p12 \ + -P "$P12_PASSWORD" \ + -A \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + # Import Developer ID Installer certificate + private key (for pkg signing) + echo "$DEVELOPER_ID_INSTALLER_P12" | base64 --decode > /tmp/installer_cert.p12 + security import /tmp/installer_cert.p12 \ + -P "$P12_PASSWORD" \ + -A \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + # Add keychain to search list + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + security default-keychain -s "$KEYCHAIN_PATH" + + # Allow codesign to access keychain without UI prompt + security set-key-partition-list -S apple-tool:,apple:,codesign:,productsign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Clean up cert files + rm -f /tmp/app_cert.p12 /tmp/installer_cert.p12 + + # Debug: list all identities in keychain + echo "=== All identities ===" + security find-identity -v "$KEYCHAIN_PATH" + echo "=== Codesigning identities ===" + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + + - name: Set up Secrets run: | mkdir -p Configs @@ -28,8 +78,10 @@ jobs: -archivePath $PWD/build/scrobble.xcarchive \ -configuration Release \ archive \ - CODE_SIGN_IDENTITY="-" \ - CODE_SIGNING_REQUIRED=NO + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="Developer ID Application: Brett Henderson (94XUGF9CU7)" \ + DEVELOPMENT_TEAM=94XUGF9CU7 \ + OTHER_CODE_SIGN_FLAGS="--keychain $KEYCHAIN_PATH" - name: Export App run: | @@ -45,6 +97,10 @@ jobs: export signingStyle manual + signingCertificate + Developer ID Application: Brett Henderson (94XUGF9CU7) + teamID + 94XUGF9CU7 EOF @@ -52,17 +108,61 @@ jobs: xcodebuild -exportArchive \ -archivePath $PWD/build/scrobble.xcarchive \ -exportOptionsPlist ExportOptions.plist \ - -exportPath $PWD/build \ - CODE_SIGN_IDENTITY="-" \ - CODE_SIGNING_REQUIRED=NO + -exportPath $PWD/build - - name: Zip Application + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Store notarization credentials + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + run: | + xcrun notarytool store-credentials "notary-profile" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "94XUGF9CU7" \ + --keychain "$KEYCHAIN_PATH" + + - name: Build, sign, notarize, and staple pkg run: | - cd build - zip -r scrobble.zip scrobble.app + VERSION="${{ steps.version.outputs.VERSION }}" + + # Build unsigned pkg + pkgbuild \ + --component build/scrobble.app \ + --install-location /Applications \ + --identifier computerdata.scrobble \ + --version "$VERSION" \ + build/scrobble-unsigned.pkg + + # Sign pkg with Developer ID Installer certificate + productsign \ + --sign "Developer ID Installer: Brett Henderson (94XUGF9CU7)" \ + --keychain "$KEYCHAIN_PATH" \ + build/scrobble-unsigned.pkg \ + build/scrobble.pkg + + rm build/scrobble-unsigned.pkg + + # Notarize using stored credentials (avoids secrets in process args) + xcrun notarytool submit build/scrobble.pkg \ + --keychain-profile "notary-profile" \ + --keychain "$KEYCHAIN_PATH" \ + --wait + + # Staple the notarization ticket + xcrun stapler staple build/scrobble.pkg + - name: Release uses: softprops/action-gh-release@v1 with: - files: build/scrobble.zip + files: build/scrobble.pkg generate_release_notes: true + + - name: Clean up keychain + if: always() + run: security delete-keychain "$KEYCHAIN_PATH" diff --git a/scrobble.xcodeproj/project.pbxproj b/scrobble.xcodeproj/project.pbxproj index c3876dd..a5ea16b 100644 --- a/scrobble.xcodeproj/project.pbxproj +++ b/scrobble.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ A6A580A92E78DB9F0079DC91 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = A6A580A72E78DB7F0079DC91 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; A6CC84552EE8470C00236D47 /* MarqueeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6CC84542EE8470C00236D47 /* MarqueeText.swift */; }; A6COMPAT012EFBAA0000001 /* GlassEffect+Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6COMPAT012EFBAA0000002 /* GlassEffect+Compat.swift */; }; + A6DESIGN012EFBAA0000001 /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6DESIGN012EFBAA0000002 /* DesignTokens.swift */; }; A6COMPAT012EFBAA0000003 /* LastFMWebAuthViewLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6COMPAT012EFBAA0000004 /* LastFMWebAuthViewLegacy.swift */; }; A6F213002C8C021800E1D23B /* scrobbleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F212FF2C8C021800E1D23B /* scrobbleApp.swift */; }; A6F213022C8C021800E1D23B /* ScrobblingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F213012C8C021800E1D23B /* ScrobblingView.swift */; }; @@ -48,7 +49,23 @@ A6F2FEEC2EE79C16003826F7 /* FriendsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F2FEEB2EE79C16003826F7 /* FriendsModel.swift */; }; A6F2FEEF2EE79E47003826F7 /* Secrets.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = A6F2FEED2EE79E47003826F7 /* Secrets.xcconfig */; }; A6F2FEF22EE7A429003826F7 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F2FEF12EE7A429003826F7 /* Logger.swift */; }; + A6KEYCHAIN01 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6KEYCHAIN02 /* KeychainHelper.swift */; }; A6F954FC2EED236000501378 /* LaunchAtLoginSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F954FB2EED236000501378 /* LaunchAtLoginSettingsView.swift */; }; + A6ONBOARD0001 /* OnboardingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6ONBOARD0002 /* OnboardingState.swift */; }; + A6ONBOARD0003 /* OnboardingContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6ONBOARD0004 /* OnboardingContainerView.swift */; }; + A6ONBOARD0005 /* OnboardingWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6ONBOARD0006 /* OnboardingWelcomeView.swift */; }; + A6ONBOARD0007 /* OnboardingAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6ONBOARD0008 /* OnboardingAuthView.swift */; }; + A6ONBOARD0009 /* OnboardingAppSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6ONBOARD000A /* OnboardingAppSelectionView.swift */; }; + A6ONBOARD000B /* OnboardingPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6ONBOARD000C /* OnboardingPreferencesView.swift */; }; + A6TESTBF0001 /* SupportedMusicAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0001 /* SupportedMusicAppTests.swift */; }; + A6TESTBF0002 /* OnboardingStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0002 /* OnboardingStateTests.swift */; }; + A6TESTBF0003 /* AuthStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0003 /* AuthStateTests.swift */; }; + A6TESTBF0004 /* PreferencesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0004 /* PreferencesManagerTests.swift */; }; + A6TESTBF0005 /* DesignTokensTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0005 /* DesignTokensTests.swift */; }; + A6TESTBF0006 /* ScrobbleDelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0006 /* ScrobbleDelayTests.swift */; }; + A6TESTBF0007 /* UpdateCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0007 /* UpdateCheckerTests.swift */; }; + A6TESTBF0008 /* CodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0008 /* CodableTests.swift */; }; + A6TESTBF0009 /* CryptoUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0009 /* CryptoUtilsTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -65,6 +82,16 @@ }; /* End PBXCopyFilesBuildPhase section */ +/* Begin PBXContainerItemProxy section */ + A6TESTCI0001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A6F212F42C8C021800E1D23B /* Project object */; + proxyType = 1; + remoteGlobalIDString = A6F212FB2C8C021800E1D23B; + remoteInfo = scrobble; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ A61F9BCE2E85E04A00CC1661 /* ScrobblingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrobblingService.swift; sourceTree = ""; }; A61F9BD02E85E07200CC1661 /* BlueskyOAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueskyOAuthManager.swift; sourceTree = ""; }; @@ -93,6 +120,7 @@ A6A580A42E78D9340079DC91 /* NowPlayingFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingFetcher.swift; sourceTree = ""; }; A6CC84542EE8470C00236D47 /* MarqueeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarqueeText.swift; sourceTree = ""; }; A6COMPAT012EFBAA0000002 /* GlassEffect+Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlassEffect+Compat.swift"; sourceTree = ""; }; + A6DESIGN012EFBAA0000002 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = ""; }; A6COMPAT012EFBAA0000004 /* LastFMWebAuthViewLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastFMWebAuthViewLegacy.swift; sourceTree = ""; }; A6F212FC2C8C021800E1D23B /* scrobble.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = scrobble.app; sourceTree = BUILT_PRODUCTS_DIR; }; A6F212FF2C8C021800E1D23B /* scrobbleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = scrobbleApp.swift; sourceTree = ""; }; @@ -110,7 +138,24 @@ A6F2FEEB2EE79C16003826F7 /* FriendsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsModel.swift; sourceTree = ""; }; A6F2FEED2EE79E47003826F7 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; A6F2FEF12EE7A429003826F7 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + A6KEYCHAIN02 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; A6F954FB2EED236000501378 /* LaunchAtLoginSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginSettingsView.swift; sourceTree = ""; }; + A6ONBOARD0002 /* OnboardingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingState.swift; sourceTree = ""; }; + A6ONBOARD0004 /* OnboardingContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingContainerView.swift; sourceTree = ""; }; + A6ONBOARD0006 /* OnboardingWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWelcomeView.swift; sourceTree = ""; }; + A6ONBOARD0008 /* OnboardingAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthView.swift; sourceTree = ""; }; + A6ONBOARD000A /* OnboardingAppSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAppSelectionView.swift; sourceTree = ""; }; + A6ONBOARD000C /* OnboardingPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPreferencesView.swift; sourceTree = ""; }; + A6TESTFR0001 /* SupportedMusicAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedMusicAppTests.swift; sourceTree = ""; }; + A6TESTFR0002 /* OnboardingStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingStateTests.swift; sourceTree = ""; }; + A6TESTFR0003 /* AuthStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStateTests.swift; sourceTree = ""; }; + A6TESTFR0004 /* PreferencesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesManagerTests.swift; sourceTree = ""; }; + A6TESTFR0005 /* DesignTokensTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokensTests.swift; sourceTree = ""; }; + A6TESTFR0006 /* ScrobbleDelayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrobbleDelayTests.swift; sourceTree = ""; }; + A6TESTFR0007 /* UpdateCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateCheckerTests.swift; sourceTree = ""; }; + A6TESTFR0008 /* CodableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableTests.swift; sourceTree = ""; }; + A6TESTFR0009 /* CryptoUtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoUtilsTests.swift; sourceTree = ""; }; + A6TESTFR000A /* scrobbleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scrobbleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -124,6 +169,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A6TESTFB0001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -140,6 +192,7 @@ isa = PBXGroup; children = ( A6F212FE2C8C021800E1D23B /* scrobble */, + A6TESTGR0001 /* scrobbleTests */, A6F212FD2C8C021800E1D23B /* Products */, A6F213142C8C05E800E1D23B /* Frameworks */, A6F2FEEE2EE79E47003826F7 /* Configs */, @@ -150,6 +203,7 @@ isa = PBXGroup; children = ( A6F212FC2C8C021800E1D23B /* scrobble.app */, + A6TESTFR000A /* scrobbleTests.xctest */, ); name = Products; sourceTree = ""; @@ -203,6 +257,7 @@ A6F2FEE72EE79A20003826F7 /* Views */ = { isa = PBXGroup; children = ( + A6ONBOARD000D /* Onboarding */, A62299162EE7D46100592239 /* ErrorMessageView.swift */, A64B5FAB2E7E09D2000F1BAE /* AppSelectionView.swift */, A63A2B182D2717A1000BC92E /* FriendsView.swift */, @@ -229,6 +284,7 @@ A64304C52D52FBA1001998B6 /* AppState.swift */, A6F2FEEB2EE79C16003826F7 /* FriendsModel.swift */, A6F2131D2C8C10AB00E1D23B /* PreferencesManager.swift */, + A6ONBOARD0002 /* OnboardingState.swift */, ); path = Models; sourceTree = ""; @@ -245,11 +301,41 @@ isa = PBXGroup; children = ( A6F2FEF12EE7A429003826F7 /* Logger.swift */, + A6KEYCHAIN02 /* KeychainHelper.swift */, A6COMPAT012EFBAA0000002 /* GlassEffect+Compat.swift */, + A6DESIGN012EFBAA0000002 /* DesignTokens.swift */, ); path = Utils; sourceTree = ""; }; + A6ONBOARD000D /* Onboarding */ = { + isa = PBXGroup; + children = ( + A6ONBOARD0004 /* OnboardingContainerView.swift */, + A6ONBOARD0006 /* OnboardingWelcomeView.swift */, + A6ONBOARD0008 /* OnboardingAuthView.swift */, + A6ONBOARD000A /* OnboardingAppSelectionView.swift */, + A6ONBOARD000C /* OnboardingPreferencesView.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; + A6TESTGR0001 /* scrobbleTests */ = { + isa = PBXGroup; + children = ( + A6TESTFR0001 /* SupportedMusicAppTests.swift */, + A6TESTFR0002 /* OnboardingStateTests.swift */, + A6TESTFR0003 /* AuthStateTests.swift */, + A6TESTFR0004 /* PreferencesManagerTests.swift */, + A6TESTFR0005 /* DesignTokensTests.swift */, + A6TESTFR0006 /* ScrobbleDelayTests.swift */, + A6TESTFR0007 /* UpdateCheckerTests.swift */, + A6TESTFR0008 /* CodableTests.swift */, + A6TESTFR0009 /* CryptoUtilsTests.swift */, + ); + path = scrobbleTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -271,6 +357,23 @@ productReference = A6F212FC2C8C021800E1D23B /* scrobble.app */; productType = "com.apple.product-type.application"; }; + A6TESTNT0001 /* scrobbleTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A6TESTCL0001 /* Build configuration list for PBXNativeTarget "scrobbleTests" */; + buildPhases = ( + A6TESTSB0001 /* Sources */, + A6TESTFB0001 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + A6TESTTD0001 /* PBXTargetDependency */, + ); + name = scrobbleTests; + productName = scrobbleTests; + productReference = A6TESTFR000A /* scrobbleTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -285,6 +388,10 @@ CreatedOnToolsVersion = 15.4; LastSwiftMigration = 2600; }; + A6TESTNT0001 = { + CreatedOnToolsVersion = 15.4; + TestTargetID = A6F212FB2C8C021800E1D23B; + }; }; }; buildConfigurationList = A6F212F72C8C021800E1D23B /* Build configuration list for PBXProject "scrobble" */; @@ -304,6 +411,7 @@ projectRoot = ""; targets = ( A6F212FB2C8C021800E1D23B /* scrobble */, + A6TESTNT0001 /* scrobbleTests */, ); }; /* End PBXProject section */ @@ -327,6 +435,7 @@ buildActionMask = 2147483647; files = ( A6COMPAT012EFBAA0000001 /* GlassEffect+Compat.swift in Sources */, + A6DESIGN012EFBAA0000001 /* DesignTokens.swift in Sources */, A6COMPAT012EFBAA0000003 /* LastFMWebAuthViewLegacy.swift in Sources */, A6F2131C2C8C085900E1D23B /* Music.h in Sources */, A622991D2EE7D72900592239 /* ServicesStatusView.swift in Sources */, @@ -355,17 +464,48 @@ A65671EA2EEADB7300773C58 /* UpdateChecker.swift in Sources */, A6A580A02E78D23A0079DC91 /* MediaRemoteBridge.m in Sources */, A6F2FEF22EE7A429003826F7 /* Logger.swift in Sources */, + A6KEYCHAIN01 /* KeychainHelper.swift in Sources */, A6F213112C8C037500E1D23B /* PreferencesView.swift in Sources */, A6F2130F2C8C027C00E1D23B /* Scrobbler.swift in Sources */, A622991B2EE7D69100592239 /* MenuButtonsView.swift in Sources */, A61F9BCF2E85E04A00CC1661 /* ScrobblingService.swift in Sources */, A6F213002C8C021800E1D23B /* scrobbleApp.swift in Sources */, A62299202EE80F2C00592239 /* LabeledStepper.swift in Sources */, + A6ONBOARD0001 /* OnboardingState.swift in Sources */, + A6ONBOARD0003 /* OnboardingContainerView.swift in Sources */, + A6ONBOARD0005 /* OnboardingWelcomeView.swift in Sources */, + A6ONBOARD0007 /* OnboardingAuthView.swift in Sources */, + A6ONBOARD0009 /* OnboardingAppSelectionView.swift in Sources */, + A6ONBOARD000B /* OnboardingPreferencesView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A6TESTSB0001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A6TESTBF0001 /* SupportedMusicAppTests.swift in Sources */, + A6TESTBF0002 /* OnboardingStateTests.swift in Sources */, + A6TESTBF0003 /* AuthStateTests.swift in Sources */, + A6TESTBF0004 /* PreferencesManagerTests.swift in Sources */, + A6TESTBF0005 /* DesignTokensTests.swift in Sources */, + A6TESTBF0006 /* ScrobbleDelayTests.swift in Sources */, + A6TESTBF0007 /* UpdateCheckerTests.swift in Sources */, + A6TESTBF0008 /* CodableTests.swift in Sources */, + A6TESTBF0009 /* CryptoUtilsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A6TESTTD0001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A6F212FB2C8C021800E1D23B /* scrobble */; + targetProxy = A6TESTCI0001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ A6F213092C8C021900E1D23B /* Debug */ = { isa = XCBuildConfiguration; @@ -503,7 +643,7 @@ CODE_SIGN_ENTITLEMENTS = scrobble/scrobble.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"scrobble/Preview Content\""; ENABLE_APP_SANDBOX = NO; @@ -553,7 +693,7 @@ CODE_SIGN_ENTITLEMENTS = scrobble/scrobble.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"scrobble/Preview Content\""; ENABLE_APP_SANDBOX = NO; @@ -590,6 +730,44 @@ }; name = Release; }; + A6TESTBC0001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 94XUGF9CU7; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = computerdata.scrobbleTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/scrobble.app/Contents/MacOS/scrobble"; + }; + name = Debug; + }; + A6TESTBC0002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 94XUGF9CU7; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = computerdata.scrobbleTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/scrobble.app/Contents/MacOS/scrobble"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -611,6 +789,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A6TESTCL0001 /* Build configuration list for PBXNativeTarget "scrobbleTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A6TESTBC0001 /* Debug */, + A6TESTBC0002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/scrobble.xcodeproj/xcshareddata/xcschemes/scrobble.xcscheme b/scrobble.xcodeproj/xcshareddata/xcschemes/scrobble.xcscheme new file mode 100644 index 0000000..95e8af7 --- /dev/null +++ b/scrobble.xcodeproj/xcshareddata/xcschemes/scrobble.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scrobble.xcodeproj/xcshareddata/xcschemes/scrobbleTests.xcscheme b/scrobble.xcodeproj/xcshareddata/xcschemes/scrobbleTests.xcscheme new file mode 100644 index 0000000..c156973 --- /dev/null +++ b/scrobble.xcodeproj/xcshareddata/xcschemes/scrobbleTests.xcscheme @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scrobble.xcodeproj/xcuserdata/brettwork.xcuserdatad/xcschemes/xcschememanagement.plist b/scrobble.xcodeproj/xcuserdata/brettwork.xcuserdatad/xcschemes/xcschememanagement.plist index 5ec0e91..2c9f359 100644 --- a/scrobble.xcodeproj/xcuserdata/brettwork.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/scrobble.xcodeproj/xcuserdata/brettwork.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,11 @@ orderHint 0 + scrobbleTests.xcscheme_^#shared#^_ + + orderHint + 0 + diff --git a/scrobble/LastFmManagerDesktop.swift b/scrobble/LastFmManagerDesktop.swift index 27a4fbb..9a068ab 100644 --- a/scrobble/LastFmManagerDesktop.swift +++ b/scrobble/LastFmManagerDesktop.swift @@ -5,27 +5,37 @@ // Created by Brett Henderson on 2/4/25. // -import Foundation +import SwiftUI import Combine -import CommonCrypto import CryptoKit -import AppKit -import SwiftUI -import Observation - -import SwiftUI -import Observation struct Secrets { static var lastFmApiKey: String { - return Bundle.main.object(forInfoDictionaryKey: "LastFmApiKey") as? String ?? "" + Bundle.main.object(forInfoDictionaryKey: "LastFmApiKey") as? String ?? "" } - + static var lastFmApiSecret: String { - return Bundle.main.object(forInfoDictionaryKey: "LastFmApiSecret") as? String ?? "" + Bundle.main.object(forInfoDictionaryKey: "LastFmApiSecret") as? String ?? "" } } +// MARK: - Testable Crypto Utilities + +func md5(_ string: String) -> String { + guard let data = string.data(using: .utf8) else { return "" } + return Insecure.MD5.hash(data: data).map { String(format: "%02hhx", $0) }.joined() +} + +func createLastFmSignature(parameters: [String: Any], secret: String) -> String { + let sortedKeys = parameters.keys.sorted() + let concatenatedString = sortedKeys.reduce("") { result, key in + let value = parameters[key]! + return result + key + "\(value)" + } + let signatureString = concatenatedString + secret + return md5(signatureString) +} + @Observable @MainActor class LastFmDesktopManager: LastFmManagerType { @@ -71,28 +81,13 @@ class LastFmDesktopManager: LastFmManagerType { // Retry configuration private let maxRetryAttempts = 3 - private let baseRetryDelay: UInt64 = 2_000_000_000 // 2 seconds in nanoseconds + private let baseRetryDelay: Duration = .seconds(2) enum AuthStatus: Equatable { case unknown case needsAuth case authenticated - case failed(String) // Changed from Error to String since Error isn't Equatable - - static func == (lhs: AuthStatus, rhs: AuthStatus) -> Bool { - switch (lhs, rhs) { - case (.unknown, .unknown): - return true - case (.needsAuth, .needsAuth): - return true - case (.authenticated, .authenticated): - return true - case (.failed(let lhsError), .failed(let rhsError)): - return lhsError == rhsError - default: - return false - } - } + case failed(String) } // Keep same init signature for compatibility @@ -147,9 +142,20 @@ class LastFmDesktopManager: LastFmManagerType { } private func checkSavedAuth() { - if let savedSessionKey = UserDefaults.standard.string(forKey: "lastfm_session_key") { + // Try Keychain first, fall back to UserDefaults for migration + if let savedSessionKey = KeychainHelper.load(key: "lastfm_session_key") { self.sessionKey = savedSessionKey validateSavedSession() + } else if let legacySessionKey = UserDefaults.standard.string(forKey: "lastfm_session_key") { + // Migrate from UserDefaults to Keychain + self.sessionKey = legacySessionKey + _ = KeychainHelper.save(key: "lastfm_session_key", value: legacySessionKey) + UserDefaults.standard.removeObject(forKey: "lastfm_session_key") + if !username.isEmpty { + _ = KeychainHelper.save(key: "lastfm_username", value: username) + } + Log.debug("Migrated session key from UserDefaults to Keychain", category: .auth) + validateSavedSession() } else { authStatus = .needsAuth } @@ -160,30 +166,58 @@ class LastFmDesktopManager: LastFmManagerType { authStatus = .needsAuth return } - + + // Use stored username from Keychain if the init username is empty + let validationUser = !username.isEmpty ? username : (KeychainHelper.load(key: "lastfm_username") ?? "") + // Test the session with a simple API call - let parameters: [String: String] = [ + var parameters: [String: String] = [ "method": "user.getInfo", - "user": username, "api_key": apiKey, "sk": sessionKey ] - + if !validationUser.isEmpty { + parameters["user"] = validationUser + } + makeRequest(parameters: parameters) - .receive(on: DispatchQueue.main) + .receive(on: RunLoop.main) .sink( receiveCompletion: { [weak self] completion in - if case .failure = completion { - Log.debug("Saved session invalid, starting new authentication", category: .auth) - UserDefaults.standard.removeObject(forKey: "lastfm_session_key") - self?.sessionKey = nil - self?.isAuthenticated = false - self?.authStatus = .needsAuth + if case .failure(let error) = completion { + // Only clear the session for explicit auth errors from Last.fm, + // not for transient network failures + let isAuthError: Bool + if let scrobblerError = error as? ScrobblerError, + case .apiError(let message) = scrobblerError { + // Last.fm auth errors: 4 (invalid token), 9 (invalid session), 26 (suspended API key) + isAuthError = message.contains("error 4:") || message.contains("error 9:") || message.contains("error 26:") + } else { + isAuthError = false + } + + if isAuthError { + Log.debug("Saved session invalid, clearing auth", category: .auth) + KeychainHelper.delete(key: "lastfm_session_key") + KeychainHelper.delete(key: "lastfm_username") + self?.sessionKey = nil + self?.isAuthenticated = false + self?.authStatus = .needsAuth + self?.authState.isAuthenticated = false + } else { + // Network or transient error — keep session, assume authenticated + Log.debug("Session validation failed (transient), keeping session: \(error.localizedDescription)", category: .auth) + self?.isAuthenticated = true + self?.authStatus = .authenticated + self?.authState.isAuthenticated = true + } } }, receiveValue: { _ in Log.debug("Saved session validated successfully", category: .auth) + self.isAuthenticated = true self.authStatus = .authenticated + self.authState.isAuthenticated = true } ) .store(in: &cancellables) @@ -221,7 +255,7 @@ class LastFmDesktopManager: LastFmManagerType { authTimeoutTask?.cancel() authTimeoutTask = Task { do { - try await Task.sleep(nanoseconds: 60_000_000_000) // 60 seconds + try await Task.sleep(for: .seconds(60)) // If we get here, auth timed out if !Task.isCancelled && authState.showingAuthSheet { Log.debug("Authentication timed out after 60 seconds", category: .auth) @@ -251,11 +285,14 @@ class LastFmDesktopManager: LastFmManagerType { authStatus = .failed(message) } - private func completeAuthWithSessionKey(_ sessionKey: String) { + private func completeAuthWithSessionKey(_ sessionKey: String, username: String? = nil) { cancelAuthTimeout() self.sessionKey = sessionKey - UserDefaults.standard.set(sessionKey, forKey: "lastfm_session_key") + _ = KeychainHelper.save(key: "lastfm_session_key", value: sessionKey) + if let username = username, !username.isEmpty { + _ = KeychainHelper.save(key: "lastfm_username", value: username) + } self.isAuthenticated = true // Clear pending auth token (auth is complete) @@ -265,6 +302,7 @@ class LastFmDesktopManager: LastFmManagerType { authState.isAuthenticating = false authState.showingAuthSheet = false authState.authError = nil + authState.isAuthenticated = true authStatus = .authenticated authenticationSubject.send(()) @@ -290,8 +328,8 @@ class LastFmDesktopManager: LastFmManagerType { Task { do { - let sessionKey = try await getSessionWithRetry(token: currentAuthToken) - completeAuthWithSessionKey(sessionKey) + let result = try await getSessionWithRetry(token: currentAuthToken) + completeAuthWithSessionKey(result.key, username: result.username) } catch { handleAuthFailure("Authentication failed: \(error.localizedDescription)") } @@ -302,20 +340,20 @@ class LastFmDesktopManager: LastFmManagerType { } /// Gets session with exponential backoff retry - private func getSessionWithRetry(token: String) async throws -> String { + private func getSessionWithRetry(token: String) async throws -> SessionResult { var lastError: Error? for attempt in 0.. 0 { - let delay = baseRetryDelay * UInt64(1 << (attempt - 1)) - Log.debug("Retry attempt \(attempt + 1)/\(maxRetryAttempts) after \(delay / 1_000_000_000)s delay", category: .auth) - try await Task.sleep(nanoseconds: delay) + let delay = baseRetryDelay * (1 << (attempt - 1)) + Log.debug("Retry attempt \(attempt + 1)/\(maxRetryAttempts) after \(delay) delay", category: .auth) + try await Task.sleep(for: delay) } - let sessionKey = try await getSessionAsync(token: token) - return sessionKey + let result = try await getSessionAsync(token: token) + return result } catch { lastError = error Log.debug("Session request attempt \(attempt + 1) failed: \(error.localizedDescription)", category: .auth) @@ -351,11 +389,12 @@ class LastFmDesktopManager: LastFmManagerType { if let url = URL(string: "http://www.last.fm/api/auth/?api_key=\(self.apiKey)&token=\(currentAuthToken)&cb=\(callbackURL)") { let configuration = NSWorkspace.OpenConfiguration() configuration.createsNewApplicationInstance = true - NSWorkspace.shared.open(url, configuration: configuration) { app, error in - if let error = error { - Log.error("Failed to reopen auth URL: \(error)", category: .auth) - } else { + Task { + do { + _ = try await NSWorkspace.shared.open(url, configuration: configuration) Log.debug("Reopened auth URL in new browser window: \(url)", category: .auth) + } catch { + Log.error("Failed to reopen auth URL: \(error)", category: .auth) } } } @@ -386,7 +425,12 @@ class LastFmDesktopManager: LastFmManagerType { return response.token } - private func getSessionAsync(token: String) async throws -> String { + private struct SessionResult { + let key: String + let username: String + } + + private func getSessionAsync(token: String) async throws -> SessionResult { Log.debug("Requesting session with token: \(token)", category: .auth) let parameters: [String: Any] = [ @@ -418,7 +462,7 @@ class LastFmDesktopManager: LastFmManagerType { // Try to decode as a successful session response let response = try JSONDecoder().decode(SessionResponse.self, from: data) Log.debug("Session response received: \(response.session)", category: .auth) - return response.session.key + return SessionResult(key: response.session.key, username: response.session.name) } private func makeRequestAsync(parameters: [String: Any]) async throws -> Data { @@ -473,11 +517,11 @@ class LastFmDesktopManager: LastFmManagerType { Task { do { // Wait 3 seconds before requesting the session (legacy behavior) - try await Task.sleep(nanoseconds: 3_000_000_000) + try await Task.sleep(for: .seconds(3)) Log.debug("Making session request after delay...", category: .auth) - let sessionKey = try await self.getSessionAsync(token: token) - promise(.success(sessionKey)) + let result = try await self.getSessionAsync(token: token) + promise(.success(result.key)) } catch { Log.error("Session request failed with error: \(error)", category: .auth) promise(.failure(error)) @@ -493,7 +537,7 @@ class LastFmDesktopManager: LastFmManagerType { return Fail(error: ScrobblerError.noSessionKey).eraseToAnyPublisher() } - let timestamp = Int(Date().timeIntervalSince1970) + let timestamp = Int(Date.now.timeIntervalSince1970) var parameters: [String: String] = [ "method": "track.scrobble", "artist": artist, @@ -612,18 +656,7 @@ class LastFmDesktopManager: LastFmManagerType { } private func createSignature(parameters: [String: Any]) -> String { - let sortedKeys = parameters.keys.sorted() - let concatenatedString = sortedKeys.reduce("") { result, key in - let value = parameters[key]! - return result + key + "\(value)" - } - let signatureString = concatenatedString + apiSecret - return md5(string: signatureString) - } - - private func md5(string: String) -> String { - guard let data = string.data(using: .utf8) else { return "" } - return Insecure.MD5.hash(data: data).map { String(format: "%02hhx", $0) }.joined() + createLastFmSignature(parameters: parameters, secret: apiSecret) } } @@ -659,7 +692,8 @@ extension LastFmDesktopManager { // Clear all auth state sessionKey = nil currentAuthToken = "" // This also clears UserDefaults pending token - UserDefaults.standard.removeObject(forKey: "lastfm_session_key") + KeychainHelper.delete(key: "lastfm_session_key") + KeychainHelper.delete(key: "lastfm_username") authState.signOut() authStatus = .needsAuth diff --git a/scrobble/Models/AuthState.swift b/scrobble/Models/AuthState.swift index 0a42a2a..df6b379 100644 --- a/scrobble/Models/AuthState.swift +++ b/scrobble/Models/AuthState.swift @@ -5,30 +5,29 @@ // Created by Brett Henderson on 2/4/25. // -import Foundation import SwiftUI -import Combine -import Observation @Observable +@MainActor class AuthState { var isAuthenticated = false var isAuthenticating = false var showingAuthSheet = false var authError: String? - + init() { - // Check for existing session on launch - isAuthenticated = UserDefaults.standard.string(forKey: "lastfm_session_key") != nil + // Check for existing session on launch (Keychain with UserDefaults fallback for migration) + isAuthenticated = KeychainHelper.load(key: "lastfm_session_key") != nil + || UserDefaults.standard.string(forKey: "lastfm_session_key") != nil } - + func startAuth() { // Don't show sheet yet - wait until token is obtained showingAuthSheet = false isAuthenticating = false // WebView shows first; set to true only when requesting session authError = nil } - + func completeAuth(success: Bool) { showingAuthSheet = false isAuthenticating = false @@ -37,11 +36,12 @@ class AuthState { authError = "Authentication failed or was cancelled" } } - + func signOut() { + KeychainHelper.delete(key: "lastfm_session_key") + KeychainHelper.delete(key: "lastfm_username") UserDefaults.standard.removeObject(forKey: "lastfm_session_key") isAuthenticated = false authError = nil } } - diff --git a/scrobble/Models/OnboardingState.swift b/scrobble/Models/OnboardingState.swift new file mode 100644 index 0000000..24632b9 --- /dev/null +++ b/scrobble/Models/OnboardingState.swift @@ -0,0 +1,76 @@ +// +// OnboardingState.swift +// scrobble +// +// Created by Claude on 1/12/26. +// + +import SwiftUI + +@Observable +@MainActor +class OnboardingState { + enum Step: Int, CaseIterable { + case welcome + case authenticate + case selectApp + case preferences + + var title: String { + switch self { + case .welcome: return "Welcome" + case .authenticate: return "Connect Last.fm" + case .selectApp: return "Select App" + case .preferences: return "Preferences" + } + } + + var isFirst: Bool { self == .welcome } + var isLast: Bool { self == .preferences } + } + + var currentStep: Step = .welcome + var isGoingForward: Bool = true + + func canProceed(authState: AuthState) -> Bool { + switch currentStep { + case .welcome: + return true + case .authenticate: + return authState.isAuthenticated + case .selectApp: + return true + case .preferences: + return true + } + } + + func next() { + guard let currentIndex = Step.allCases.firstIndex(of: currentStep), + currentIndex < Step.allCases.count - 1 else { return } + isGoingForward = true + currentStep = Step.allCases[currentIndex + 1] + } + + func back() { + guard let currentIndex = Step.allCases.firstIndex(of: currentStep), + currentIndex > 0 else { return } + isGoingForward = false + currentStep = Step.allCases[currentIndex - 1] + } + + func complete() { + UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding") + } + + static var hasCompletedOnboarding: Bool { + UserDefaults.standard.bool(forKey: "hasCompletedOnboarding") + } + + static var needsOnboarding: Bool { + let completed = UserDefaults.standard.bool(forKey: "hasCompletedOnboarding") + let hasSession = KeychainHelper.load(key: "lastfm_session_key") != nil + || UserDefaults.standard.string(forKey: "lastfm_session_key") != nil + return !completed && !hasSession + } +} diff --git a/scrobble/Scrobbler.swift b/scrobble/Scrobbler.swift index 6f8afae..cd91320 100644 --- a/scrobble/Scrobbler.swift +++ b/scrobble/Scrobbler.swift @@ -36,6 +36,17 @@ import Observation +// MARK: - Testable Scrobble Delay Calculation + +func calculateScrobbleDelay(trackDuration: Double, completionPercentage: Int, useMaxDelay: Bool, maxDelay: Int?) -> Double? { + guard trackDuration > 30 else { return nil } + let percentageDelay = trackDuration * Double(completionPercentage) / 100.0 + if useMaxDelay, let maxDelay = maxDelay { + return min(percentageDelay, Double(maxDelay)) + } + return percentageDelay +} + @Observable class Scrobbler { let lastFmManager: LastFmManagerType diff --git a/scrobble/Utils/DesignTokens.swift b/scrobble/Utils/DesignTokens.swift new file mode 100644 index 0000000..0359305 --- /dev/null +++ b/scrobble/Utils/DesignTokens.swift @@ -0,0 +1,57 @@ +// +// DesignTokens.swift +// scrobble +// +// Centralized design constants for consistent styling across the app. +// + +import SwiftUI + +enum DesignTokens { + + // MARK: - Corner Radii + + /// Buttons, small controls (6pt) + static let cornerRadiusSmall: CGFloat = 6 + + /// Cards, containers, glass surfaces (10pt) + static let cornerRadiusMedium: CGFloat = 10 + + /// Onboarding cards, large interactive surfaces (12pt) + static let cornerRadiusLarge: CGFloat = 12 + + // MARK: - Spacing + + /// Tight spacing for inline elements (4pt) + static let spacingTight: CGFloat = 4 + + /// Default spacing between related items (8pt) + static let spacingDefault: CGFloat = 8 + + /// Spacing between sections or groups (12pt) + static let spacingSection: CGFloat = 12 + + /// Generous spacing between major sections (16pt) + static let spacingLarge: CGFloat = 16 + + // MARK: - Content Padding + + /// Standard horizontal content padding (32pt) — onboarding, modal content + static let contentPaddingHorizontal: CGFloat = 32 + + /// Standard inner padding for cards/containers (12pt) + static let cardPadding: CGFloat = 12 + + // MARK: - Artwork + + /// Now-playing artwork size in menu bar popover + static let artworkSizeSmall: CGFloat = 50 + + /// Track artwork thumbnail in lists + static let artworkSizeThumbnail: CGFloat = 40 + + // MARK: - Onboarding + + /// SF Symbol icon size for onboarding step headers + static let onboardingIconSize: CGFloat = 40 +} diff --git a/scrobble/Utils/GlassEffect+Compat.swift b/scrobble/Utils/GlassEffect+Compat.swift index 2ec962d..e4a3f24 100644 --- a/scrobble/Utils/GlassEffect+Compat.swift +++ b/scrobble/Utils/GlassEffect+Compat.swift @@ -11,33 +11,35 @@ import SwiftUI // MARK: - Adaptive Glass Effect Modifier extension View { - /// Applies a glass effect on macOS 26+, or an ultrathin material background on macOS 15+ + /// Applies a glass effect on macOS 26+, or a subtle fill on macOS 15+. + /// Uses a light fill fallback instead of material to avoid double-material layering + /// when rendered inside material-backed containers (menu bar popover, etc). @ViewBuilder - func compatGlass(in shape: RoundedRectangle = RoundedRectangle(cornerRadius: 8)) -> some View { - if #available(macOS 26, *) { - self.glassEffect(in: shape) - } else { - self.background(.ultraThinMaterial, in: shape) - } - } - - /// Applies a glass effect with custom corner radius - @ViewBuilder - func compatGlass(cornerRadius: CGFloat) -> some View { + func compatGlass( + cornerRadius: CGFloat = DesignTokens.cornerRadiusMedium + ) -> some View { if #available(macOS 26, *) { self.glassEffect(in: .rect(cornerRadius: cornerRadius)) } else { - self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + self.background( + Color.primary.opacity(0.04), + in: RoundedRectangle(cornerRadius: cornerRadius) + ) } } - /// Applies a clear glass effect on macOS 26+, or a subtle overlay on macOS 15+ + /// Applies a clear glass effect on macOS 26+, or a very subtle fill on macOS 15+ @ViewBuilder - func compatGlassClear() -> some View { + func compatGlassClear( + cornerRadius: CGFloat = DesignTokens.cornerRadiusMedium + ) -> some View { if #available(macOS 26, *) { - self.glassEffect(.clear) + self.glassEffect(.clear, in: .rect(cornerRadius: cornerRadius)) } else { - self.background(.ultraThinMaterial.opacity(0.3), in: RoundedRectangle(cornerRadius: 8)) + self.background( + Color.primary.opacity(0.02), + in: RoundedRectangle(cornerRadius: cornerRadius) + ) } } @@ -52,35 +54,74 @@ extension View { } } +// MARK: - Glass Effect Container + +/// Wraps content in a GlassEffectContainer on macOS 26+ for proper rendering +/// performance and morphing transitions. No-op container on macOS 15. +struct CompatGlassContainer: View { + let spacing: CGFloat + @ViewBuilder let content: Content + + init( + spacing: CGFloat = DesignTokens.spacingDefault, + @ViewBuilder content: () -> Content + ) { + self.spacing = spacing + self.content = content() + } + + var body: some View { + if #available(macOS 26, *) { + GlassEffectContainer(spacing: spacing) { + content + } + } else { + content + } + } +} + // MARK: - Adaptive Glass Button Style -/// On macOS 15, provides a material-based fallback for glass buttons. +/// On macOS 15, provides a lightweight fallback for glass buttons. +/// Uses a subtle fill instead of material to avoid double-material layering +/// when rendered on surfaces that already have .ultraThinMaterial. struct CompatGlassButtonStyleLegacy: ButtonStyle { var isSelected: Bool = false func makeBody(configuration: Configuration) -> some View { configuration.label - .padding(.horizontal, 12) - .padding(.vertical, 8) + .padding(.horizontal, 10) + .padding(.vertical, 6) .background { - RoundedRectangle(cornerRadius: 8) - .fill(isSelected ? Color.accentColor.opacity(0.2) : Color.clear) + RoundedRectangle(cornerRadius: DesignTokens.cornerRadiusSmall) + .fill( + isSelected + ? Color.accentColor.opacity(0.2) + : configuration.isPressed + ? Color.primary.opacity(0.08) + : Color.primary.opacity(0.04) + ) } - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) - .opacity(configuration.isPressed ? 0.8 : 1.0) + .contentShape(RoundedRectangle(cornerRadius: DesignTokens.cornerRadiusSmall)) + .opacity(configuration.isPressed ? 0.85 : 1.0) } } // MARK: - Button Style Extensions extension View { - /// Applies glass button styling with backwards compatibility + /// Applies glass button styling with backwards compatibility. + /// On macOS 26, uses native .glass button style. + /// On macOS 15, uses a subtle fill-based style. @ViewBuilder func compatGlassButtonStyle(selected: Bool = false) -> some View { if #available(macOS 26, *) { - self - .tint(selected ? .accentColor : .clear) - .buttonStyle(selected ? .glass(.regular.tint(.accentColor)) : .glass) + self.buttonStyle( + selected + ? .glass(.regular.tint(.accentColor)) + : .glass + ) } else { self.buttonStyle(CompatGlassButtonStyleLegacy(isSelected: selected)) } diff --git a/scrobble/Utils/KeychainHelper.swift b/scrobble/Utils/KeychainHelper.swift new file mode 100644 index 0000000..2a7698c --- /dev/null +++ b/scrobble/Utils/KeychainHelper.swift @@ -0,0 +1,61 @@ +// +// KeychainHelper.swift +// scrobble +// +// Created by Claude on 3/21/26. +// + +import Foundation +import Security + +enum KeychainHelper { + private static let service = "com.brettwork.scrobble" + + static func save(key: String, value: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + + // Delete any existing item first + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + return status == errSecSuccess + } + + static func load(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/scrobble/Views/AppSelectionView.swift b/scrobble/Views/AppSelectionView.swift index 7908c79..002e4d1 100644 --- a/scrobble/Views/AppSelectionView.swift +++ b/scrobble/Views/AppSelectionView.swift @@ -11,9 +11,9 @@ struct AppSelectionView: View { @Environment(PreferencesManager.self) var preferencesManager @Environment(Scrobbler.self) var scrobbler @State private var runningApps: [SupportedMusicApp] = [] - + var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: DesignTokens.spacingDefault) { Text("Select Music App") .font(.headline) .padding(.horizontal) @@ -23,11 +23,11 @@ struct AppSelectionView: View { .font(.subheadline) .foregroundStyle(.secondary) .padding(.horizontal) - + LazyVGrid(columns: [ GridItem(.flexible()), GridItem(.flexible()) - ], spacing: 8) { + ], spacing: DesignTokens.spacingDefault) { ForEach(SupportedMusicApp.allApps, id: \.self) { app in AppSelectionButton( app: app, @@ -42,29 +42,26 @@ struct AppSelectionView: View { .padding(.horizontal) if !runningApps.isEmpty { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .center) { - Image(systemName: "circle.fill") - .foregroundStyle(.green) - .font(.caption) - Text("Currently Running: ") - .font(.caption) - .foregroundStyle(.secondary) - - Text(runningApps.map { $0.displayName }.joined(separator: ", ")) - .font(.caption2) - .foregroundStyle(.secondary) - } - - + HStack { + Image(systemName: "circle.fill") + .foregroundStyle(.green) + .font(.caption) + .accessibilityHidden(true) + Text("Currently Running: ") + .font(.caption) + .foregroundStyle(.secondary) + + Text(runningApps.map { $0.displayName }.joined(separator: ", ")) + .font(.caption2) + .foregroundStyle(.secondary) } .padding(.horizontal) } Divider() .padding(.horizontal) - - VStack(alignment: .leading, spacing: 10) { + + VStack(alignment: .leading, spacing: DesignTokens.spacingDefault) { HStack { Image(systemName: "info.circle") .foregroundStyle(Color.accentColor) @@ -73,7 +70,7 @@ struct AppSelectionView: View { .fontWeight(.medium) } - HStack(alignment: .firstTextBaseline, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.spacingTight) { Image(systemName: preferencesManager.selectedMusicApp.icon) .foregroundStyle(Color.accentColor) .aspectRatio(contentMode: .fill) @@ -90,11 +87,9 @@ struct AppSelectionView: View { .lineLimit(2, reservesSpace: true) } } - } - .padding(8) - .background(Color.accentColor.opacity(0.1)) - .compatGlass(cornerRadius: 8) + .padding(DesignTokens.spacingDefault) + .compatGlass(cornerRadius: DesignTokens.cornerRadiusMedium) } .padding(.horizontal) @@ -105,19 +100,17 @@ struct AppSelectionView: View { updateRunningApps() } .onReceive(NotificationCenter.default.publisher(for: NSWorkspace.didLaunchApplicationNotification)) { notification in - // Only update if a music app launched if isMusicAppNotification(notification) { updateRunningApps() } } .onReceive(NotificationCenter.default.publisher(for: NSWorkspace.didTerminateApplicationNotification)) { notification in - // Only update if a music app terminated if isMusicAppNotification(notification) { updateRunningApps() } } } - + private func updateRunningApps() { if let fetcher = scrobbler.mediaRemoteFetcher { runningApps = fetcher.getRunningMusicApps() @@ -129,7 +122,6 @@ struct AppSelectionView: View { let bundleId = app.bundleIdentifier else { return false } - // Check if this is one of our supported music apps let musicBundleIds = SupportedMusicApp.allApps.map { $0.bundleId }.filter { $0 != "any" } return musicBundleIds.contains(bundleId) } @@ -140,23 +132,24 @@ struct AppSelectionButton: View { let isSelected: Bool let isRunning: Bool let action: () -> Void - + var body: some View { Button(action: action) { - VStack(spacing: 6) { + VStack(spacing: DesignTokens.spacingDefault) { ZStack { Image(systemName: app.icon) .font(.title2) .foregroundStyle(isSelected ? .white : .secondary) - + if isRunning { Circle() .fill(.green) .frame(width: 8, height: 8) .offset(x: 12, y: -12) + .accessibilityHidden(true) } } - + Text(app.displayName) .font(.caption) .fontWeight(.medium) @@ -164,12 +157,12 @@ struct AppSelectionButton: View { .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .padding(.horizontal, 8) - + .padding(.vertical, DesignTokens.spacingSection) + .padding(.horizontal, DesignTokens.spacingDefault) } .compatGlassButtonStyle(selected: isSelected) - + .accessibilityLabel("\(app.displayName)\(isRunning ? ", running" : "")") + .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/scrobble/Views/ErrorMessageView.swift b/scrobble/Views/ErrorMessageView.swift index 068bc27..36419b5 100644 --- a/scrobble/Views/ErrorMessageView.swift +++ b/scrobble/Views/ErrorMessageView.swift @@ -8,20 +8,34 @@ import SwiftUI struct ErrorMessageView: View { + let message: String + + init(_ message: String = "Something went wrong.") { + self.message = message + } + var body: some View { VStack { - Text("Error") + Label("Error", systemImage: "exclamationmark.triangle") .font(.headline) - Text("Something went wrong.") + Text(message) + .font(.caption) + .foregroundStyle(.secondary) } - .padding(20) + .padding() .background { - RoundedRectangle(cornerRadius: 10) - .fill(Color.red) + RoundedRectangle(cornerRadius: DesignTokens.cornerRadiusMedium) + .fill(Color.red.opacity(0.1)) + .overlay { + RoundedRectangle(cornerRadius: DesignTokens.cornerRadiusMedium) + .strokeBorder(Color.red.opacity(0.3), lineWidth: 1) + } } + .accessibilityElement(children: .combine) + .accessibilityLabel("Error: \(message)") } } #Preview { - ErrorMessageView() + ErrorMessageView("Failed to connect to Last.fm") } diff --git a/scrobble/Views/FriendCardView.swift b/scrobble/Views/FriendCardView.swift index 2173120..353172a 100644 --- a/scrobble/Views/FriendCardView.swift +++ b/scrobble/Views/FriendCardView.swift @@ -10,15 +10,15 @@ import SwiftUI struct FriendCardView: View { let friend: Friend let recentTracks: [RecentTracksResponse.RecentTracks.Track] - - private let relativeFormatter: RelativeDateTimeFormatter = { + + private static let relativeFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .short return formatter }() - + var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: DesignTokens.spacingDefault) { HStack { AsyncImage(url: URL(string: friend.image.last?.text ?? "")) { image in image @@ -27,9 +27,10 @@ struct FriendCardView: View { } placeholder: { Image(systemName: "person.circle.fill") } - .frame(width: 40, height: 40) + .frame(width: DesignTokens.artworkSizeThumbnail, height: DesignTokens.artworkSizeThumbnail) .clipShape(Circle()) - + .accessibilityLabel("\(friend.realname ?? friend.name) avatar") + VStack(alignment: .leading) { Text(friend.realname ?? friend.name) .font(.headline) @@ -41,24 +42,25 @@ struct FriendCardView: View { .font(.caption) } } - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } Spacer() } - + if recentTracks.isEmpty { Text("No recent tracks") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } else { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: DesignTokens.spacingDefault) { ForEach(recentTracks.prefix(5), id: \.url) { track in HStack { if track.nowplaying { Image(systemName: "music.note") - .foregroundColor(.green) + .foregroundStyle(.green) + .accessibilityLabel("Now playing") } - + AsyncImage(url: URL(string: track.image.first?.text ?? "")) { image in image .resizable() @@ -67,19 +69,19 @@ struct FriendCardView: View { Color.gray.opacity(0.2) } .frame(width: 24, height: 24) - .cornerRadius(4) - + .clipShape(RoundedRectangle(cornerRadius: 4)) + VStack(alignment: .leading) { Text("\(track.artist.text) - \(track.name)") .font(.subheadline) .lineLimit(1) - + HStack { if !track.album.text.isEmpty { Text(track.album.text) .lineLimit(1) } - + if let date = track.date { Text("•") Text(formatDate(uts: date.uts)) @@ -89,25 +91,24 @@ struct FriendCardView: View { } } .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } Spacer() } + .contentShape(Rectangle()) } } } } .padding() .frame(maxWidth: .infinity, alignment: .leading) - .compatGlass(cornerRadius: 10) - .shadow(radius: 1) - + .compatGlass(cornerRadius: DesignTokens.cornerRadiusMedium) } - + private func formatDate(uts: String) -> String { guard let timestamp = Double(uts) else { return "" } let date = Date(timeIntervalSince1970: timestamp) - return relativeFormatter.localizedString(for: date, relativeTo: Date()) + return Self.relativeFormatter.localizedString(for: date, relativeTo: Date()) } } diff --git a/scrobble/Views/FriendsView.swift b/scrobble/Views/FriendsView.swift index 5f1d7be..ebc0a72 100644 --- a/scrobble/Views/FriendsView.swift +++ b/scrobble/Views/FriendsView.swift @@ -11,13 +11,13 @@ struct FriendsView: View { @Environment(Scrobbler.self) var scrobbler @Environment(PreferencesManager.self) var preferencesManager @State private var model: FriendsModel - + init(lastFmManager: LastFmManagerType) { _model = State(initialValue: FriendsModel(lastFmManager: lastFmManager)) } - + var body: some View { - VStack(spacing: 10) { + VStack(spacing: DesignTokens.spacingDefault) { HStack { Text("Friends Activity") .font(.headline) @@ -27,21 +27,21 @@ struct FriendsView: View { } .disabled(model.isLoading) .compatGlassButtonStyle() + .accessibilityLabel("Refresh friends") } - .background(.clear) .padding(.horizontal) .padding(.top) .compatScrollEdgeEffectStyle() - + if model.isLoading && model.friends.isEmpty { ProgressView() - .progressViewStyle(CircularProgressViewStyle()) + .progressViewStyle(.circular) } else if model.friends.isEmpty { Text("No friends found") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } else { ScrollView { - LazyVStack(spacing: 16) { + LazyVStack(spacing: DesignTokens.spacingLarge) { ForEach(model.friends, id: \.name) { friend in FriendCardView( friend: friend, @@ -57,10 +57,10 @@ struct FriendsView: View { model.refreshData() } } - + if let error = model.errorMessage { Text(error) - .foregroundColor(.red) + .foregroundStyle(.red) .font(.caption) .padding() } @@ -76,13 +76,10 @@ struct FriendsView: View { } } - - - #Preview { @Previewable @State var preferencesManager = PreferencesManager() @Previewable @State var scrobbler = Scrobbler(lastFmManager: LastFmDesktopManager(apiKey: "", apiSecret: "", username: "", authState: AuthState())) - + FriendsView(lastFmManager: scrobbler.lastFmManager) .environment(preferencesManager) .environment(scrobbler) diff --git a/scrobble/Views/LastFMAuthSheetView.swift b/scrobble/Views/LastFMAuthSheetView.swift index 724dfd9..dfc26d9 100644 --- a/scrobble/Views/LastFMAuthSheetView.swift +++ b/scrobble/Views/LastFMAuthSheetView.swift @@ -12,34 +12,35 @@ import Observation struct LastFMAuthSheetView: View { var lastFmManager: LastFmDesktopManager @Environment(AuthState.self) var authState - + var body: some View { VStack(spacing: 0) { if authState.isAuthenticating { VStack(spacing: 20) { Text("Last.fm Authentication") .font(.headline) - + ProgressView("Getting session...") .progressViewStyle(.circular) - + Text("Requesting session from Last.fm...\nThis may take a few seconds.") .font(.caption) .multilineTextAlignment(.center) - - Button("Cancel") { + + Button("Cancel", role: .cancel) { authState.isAuthenticating = false lastFmManager.completeAuthorization(authorized: false) } - + if let error = authState.authError { - Text(error) - .foregroundColor(.red) + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) .font(.caption) } } .padding() - .frame(width: 400, height: 200) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .combine) } else { if #available(macOS 26, *) { LastFMWebAuthView(lastFmManager: lastFmManager) @@ -50,6 +51,7 @@ struct LastFMAuthSheetView: View { } } } + .frame(width: 600, height: 500) } } diff --git a/scrobble/Views/LastFMWebAuthView.swift b/scrobble/Views/LastFMWebAuthView.swift index d54baf5..bd893a9 100644 --- a/scrobble/Views/LastFMWebAuthView.swift +++ b/scrobble/Views/LastFMWebAuthView.swift @@ -20,23 +20,23 @@ struct LastFMWebAuthView: View { var body: some View { VStack(spacing: 0) { // Header - VStack(spacing: 8) { + VStack(spacing: DesignTokens.spacingDefault) { Text("Last.fm Authentication") .font(.headline) if isLoading { - HStack(spacing: 8) { + HStack(spacing: DesignTokens.spacingDefault) { ProgressView() .progressViewStyle(.circular) - .scaleEffect(0.8) + .controlSize(.small) Text("Loading authorization page...") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } .padding() - .background(Color(NSColor.controlBackgroundColor)) + .background(.bar) // WebView WebView(webPage) @@ -46,17 +46,17 @@ struct LastFMWebAuthView: View { .onChange(of: webPage.isLoading) { _, newValue in isLoading = newValue if !newValue { - // Page finished loading, check for authorization completion checkIfAuthorizationComplete() } } // Footer - HStack(spacing: 16) { - Button("Cancel") { + HStack(spacing: DesignTokens.spacingLarge) { + Button("Cancel", role: .cancel) { authState.isAuthenticating = false lastFmManager.completeAuthorization(authorized: false) } + .buttonStyle(.glass) Spacer() @@ -65,15 +65,16 @@ struct LastFMWebAuthView: View { completeAuthorization() } .keyboardShortcut(.defaultAction) + .buttonStyle(.glassProminent) Button("Reload") { loadAuthPage() } - .buttonStyle(.bordered) + .buttonStyle(.glass) } } .padding() - .background(Color(NSColor.controlBackgroundColor)) + .background(.bar) } .frame(width: 600, height: 500) .onDisappear { @@ -102,28 +103,18 @@ struct LastFMWebAuthView: View { private func checkIfAuthorizationComplete() { Log.debug("Checking if authorization is complete...", category: .ui) - // First check the current URL Task { do { - // Get the current URL from the webview let urlScript = "window.location.href" if let currentURLResult = try await webPage.callJavaScript(urlScript) as? String { currentURL = currentURLResult - Log.debug("Current URL: \(currentURL)", category: .ui) - // Skip checking if we're still on the initial auth page if currentURL.contains("/api/auth") { - Log.debug("Still on auth page, waiting for user to authorize...", category: .ui) return } - // Check if this is a success redirect page (Last.fm shows a confirmation page) - // The callback URL (scrobble://auth) should be handled by the system, but - // Last.fm sometimes shows an interstitial page first if currentURL.contains("authorized") || currentURL.contains("/api/grantaccess") { - - Log.debug("Authorization appears complete based on URL, attempting to get session...", category: .ui) await MainActor.run { self.completeAuthorization() } @@ -131,7 +122,6 @@ struct LastFMWebAuthView: View { } } - // Check page content for authorization indicators (fallback) await checkPageContentForSuccess() } catch { Log.error("Error checking URL: \(error)", category: .ui) @@ -141,29 +131,20 @@ struct LastFMWebAuthView: View { } private func checkPageContentForSuccess() async { - // Skip content check if we're on the initial auth page if currentURL.contains("/api/auth") { - Log.debug("Skipping content check - still on auth page", category: .ui) return } do { - // Look for specific Last.fm success indicators only - // Be conservative to avoid false positives let script = """ function checkForSuccess() { const bodyText = document.body.innerText.toLowerCase(); - - // Very specific patterns that only appear after successful authorization const hasExplicitSuccess = bodyText.includes('application has been granted permission') || bodyText.includes('you can now close this window') || bodyText.includes('authorization successful') || bodyText.includes('you have successfully authorized'); - - // Check for Last.fm's specific success page elements const hasSuccessElement = document.querySelector('.auth-success, .grant-success') !== null; - return hasExplicitSuccess || hasSuccessElement; } return checkForSuccess(); @@ -175,8 +156,6 @@ struct LastFMWebAuthView: View { await MainActor.run { self.completeAuthorization() } - } else { - Log.debug("No success indicators found yet", category: .ui) } } catch { Log.error("Error checking page content: \(error)", category: .ui) @@ -184,7 +163,6 @@ struct LastFMWebAuthView: View { } private func completeAuthorization() { - Log.debug("Completing authorization...", category: .ui) authState.isAuthenticating = true lastFmManager.completeAuthorization(authorized: true) } diff --git a/scrobble/Views/LastFMWebAuthViewLegacy.swift b/scrobble/Views/LastFMWebAuthViewLegacy.swift index b83f0fa..1735ff3 100644 --- a/scrobble/Views/LastFMWebAuthViewLegacy.swift +++ b/scrobble/Views/LastFMWebAuthViewLegacy.swift @@ -26,15 +26,15 @@ struct LastFMWebAuthViewLegacy: View { HStack(spacing: 8) { ProgressView() .progressViewStyle(.circular) - .scaleEffect(0.8) + .controlSize(.small) Text("Loading authorization page...") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } .padding() - .background(Color(NSColor.controlBackgroundColor)) + .background(.bar) // WebView LegacyWebView( @@ -48,7 +48,7 @@ struct LastFMWebAuthViewLegacy: View { // Footer HStack(spacing: 16) { - Button("Cancel") { + Button("Cancel", role: .cancel) { authState.isAuthenticating = false lastFmManager.completeAuthorization(authorized: false) } @@ -71,7 +71,7 @@ struct LastFMWebAuthViewLegacy: View { } } .padding() - .background(Color(NSColor.controlBackgroundColor)) + .background(.bar) } .frame(width: 600, height: 500) } diff --git a/scrobble/Views/LaunchAtLoginSettingsView.swift b/scrobble/Views/LaunchAtLoginSettingsView.swift index 3cc0531..b78fee1 100644 --- a/scrobble/Views/LaunchAtLoginSettingsView.swift +++ b/scrobble/Views/LaunchAtLoginSettingsView.swift @@ -14,36 +14,30 @@ struct LaunchAtLoginSettingsView: View { var body: some View { @Bindable var preferencesManager = preferencesManager - Section { - VStack(alignment: .leading, spacing: 4) { - Toggle("Launch at Login", isOn: $preferencesManager.launchAtLogin) - Text(""" - when enabled, scrobble will automatically start when you log in to your computer. - """) - .font(.caption) - .foregroundStyle(.secondary) - } - - + Toggle("Launch at Login", isOn: $preferencesManager.launchAtLogin) } header: { Text("Startup") + } footer: { + Text("When enabled, Scrobble will automatically start when you log in to your computer.") + .font(.caption) + .foregroundStyle(.secondary) } .onAppear { - if SMAppService.mainApp.status == .enabled { - preferencesManager.launchAtLogin = true - } else { - preferencesManager.launchAtLogin = false - } + preferencesManager.launchAtLogin = SMAppService.mainApp.status == .enabled } .onChange(of: preferencesManager.launchAtLogin) { _, newValue in - if newValue == true { - try? SMAppService.mainApp.register() - } else { - try? SMAppService.mainApp.unregister() + do { + if newValue { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + Log.error("Failed to update launch at login: \(error)", category: .general) + preferencesManager.launchAtLogin = !newValue } } - } } diff --git a/scrobble/Views/MenuButtonsView.swift b/scrobble/Views/MenuButtonsView.swift index 079c8e1..0f00b64 100644 --- a/scrobble/Views/MenuButtonsView.swift +++ b/scrobble/Views/MenuButtonsView.swift @@ -9,54 +9,46 @@ import SwiftUI struct MenuButtonsView: View { @Environment(AppState.self) var appState - @Environment(\.openWindow) var openWindow - + var body: some View { - if #available(macOS 26, *) { - GlassEffectContainer(spacing: 10) { - buttonsContent - } - } else { + CompatGlassContainer(spacing: DesignTokens.spacingDefault) { buttonsContent - .padding(10) } } private var buttonsContent: some View { - HStack(alignment: .center, spacing: 4) { + HStack(spacing: DesignTokens.spacingTight) { Button { openWindow(id: "scrobbler") } label: { - Label("Window", systemImage: "rectangle.expand.vertical" ) - .foregroundStyle(.secondary.opacity(0.5)) + Label("Window", systemImage: "rectangle.expand.vertical") + .foregroundStyle(.tertiary) .font(.caption2) } .compatGlassButtonStyle() - + .accessibilityLabel("Open window") + Spacer() - + SettingsLink { - Label("Settings", systemImage: "gearshape" ) - .foregroundStyle(.secondary.opacity(0.5)) + Label("Settings", systemImage: "gearshape") + .foregroundStyle(.tertiary) .font(.caption2) } .compatGlassButtonStyle() - Spacer() Button { NSApplication.shared.terminate(nil) } label: { - Label("Quit", systemImage: "xmark.circle" ) - .foregroundStyle(.secondary.opacity(0.5)) + Label("Quit", systemImage: "xmark.circle") + .foregroundStyle(.quaternary) .font(.caption2) - } .compatGlassButtonStyle() - .foregroundStyle(.tertiary) - + .accessibilityLabel("Quit Scrobble") } .padding(.horizontal) } @@ -64,4 +56,5 @@ struct MenuButtonsView: View { #Preview { MenuButtonsView() + .environment(AppState()) } diff --git a/scrobble/Views/Onboarding/OnboardingAppSelectionView.swift b/scrobble/Views/Onboarding/OnboardingAppSelectionView.swift new file mode 100644 index 0000000..07ff838 --- /dev/null +++ b/scrobble/Views/Onboarding/OnboardingAppSelectionView.swift @@ -0,0 +1,179 @@ +// +// OnboardingAppSelectionView.swift +// scrobble +// +// Created by Claude on 1/12/26. +// + +import SwiftUI + +struct OnboardingAppSelectionView: View { + @Environment(PreferencesManager.self) var preferencesManager + @Environment(Scrobbler.self) var scrobbler + @State private var runningApps: [SupportedMusicApp] = [] + + var body: some View { + VStack(spacing: 20) { + Spacer() + + // Header + VStack(spacing: 8) { + Image(systemName: "music.note.tv") + .font(.system(size: 40)) + .foregroundStyle(Color.accentColor) + + Text("Choose Your Music App") + .font(.title2) + .fontWeight(.semibold) + + Text("Select which app you use to play music. Scrobble will monitor this app for playback.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) + } + + // App selection grid + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 12) { + ForEach(SupportedMusicApp.allApps, id: \.self) { app in + OnboardingAppButton( + app: app, + isSelected: app == preferencesManager.selectedMusicApp, + isRunning: runningApps.contains(app) + ) { + preferencesManager.selectedMusicApp = app + scrobbler.setTargetMusicApp(app) + } + } + } + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) + + // Running apps indicator + if !runningApps.isEmpty { + HStack(spacing: 4) { + Circle() + .fill(.green) + .frame(width: 6, height: 6) + .accessibilityHidden(true) + Text("Running: \(runningApps.map { $0.displayName }.joined(separator: ", "))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + // Current selection info + VStack(spacing: 4) { + HStack(spacing: 6) { + Image(systemName: preferencesManager.selectedMusicApp.icon) + .foregroundStyle(Color.accentColor) + Text("Selected: \(preferencesManager.selectedMusicApp.displayName)") + .font(.subheadline) + .fontWeight(.medium) + } + + if !preferencesManager.selectedMusicApp.alternativeNames.isEmpty && + preferencesManager.selectedMusicApp.bundleId != "any" { + Text("Also recognizes: \(preferencesManager.selectedMusicApp.alternativeNames.prefix(3).joined(separator: ", "))") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding(.bottom, 8) + } + .padding() + .onAppear { + updateRunningApps() + } + .onReceive(NotificationCenter.default.publisher(for: NSWorkspace.didLaunchApplicationNotification)) { notification in + if isMusicAppNotification(notification) { + updateRunningApps() + } + } + .onReceive(NotificationCenter.default.publisher(for: NSWorkspace.didTerminateApplicationNotification)) { notification in + if isMusicAppNotification(notification) { + updateRunningApps() + } + } + } + + private func updateRunningApps() { + if let fetcher = scrobbler.mediaRemoteFetcher { + runningApps = fetcher.getRunningMusicApps() + } + } + + private func isMusicAppNotification(_ notification: NotificationCenter.Publisher.Output) -> Bool { + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let bundleId = app.bundleIdentifier else { + return false + } + let musicBundleIds = SupportedMusicApp.allApps.map { $0.bundleId }.filter { $0 != "any" } + return musicBundleIds.contains(bundleId) + } +} + +struct OnboardingAppButton: View { + let app: SupportedMusicApp + let isSelected: Bool + let isRunning: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + ZStack { + Image(systemName: app.icon) + .font(.title) + .foregroundStyle(isSelected ? .white : .primary) + + if isRunning { + Circle() + .fill(.green) + .frame(width: 8, height: 8) + .offset(x: 14, y: -14) + } + } + + Text(app.displayName) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(isSelected ? .white : .primary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, DesignTokens.spacingLarge) + .padding(.horizontal, DesignTokens.spacingSection) + .background { + RoundedRectangle(cornerRadius: DesignTokens.cornerRadiusLarge) + .fill(isSelected ? Color.accentColor : Color.primary.opacity(0.04)) + } + .overlay { + RoundedRectangle(cornerRadius: DesignTokens.cornerRadiusLarge) + .strokeBorder(isSelected ? Color.clear : Color.secondary.opacity(0.2), lineWidth: 1) + } + } + .buttonStyle(.plain) + .accessibilityLabel("\(app.displayName)\(isRunning ? ", running" : "")") + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +#Preview { + let prefManager = PreferencesManager() + let authState = AuthState() + let lastFmManager = LastFmDesktopManager( + apiKey: prefManager.apiKey, + apiSecret: prefManager.apiSecret, + username: prefManager.username, + authState: authState + ) + + OnboardingAppSelectionView() + .environment(prefManager) + .environment(Scrobbler(lastFmManager: lastFmManager, preferencesManager: prefManager)) + .frame(width: 500, height: 450) +} diff --git a/scrobble/Views/Onboarding/OnboardingAuthView.swift b/scrobble/Views/Onboarding/OnboardingAuthView.swift new file mode 100644 index 0000000..2b8d8bf --- /dev/null +++ b/scrobble/Views/Onboarding/OnboardingAuthView.swift @@ -0,0 +1,342 @@ +// +// OnboardingAuthView.swift +// scrobble +// +// Created by Claude on 1/12/26. +// + +import SwiftUI +import WebKit + +struct OnboardingAuthView: View { + @Environment(Scrobbler.self) var scrobbler + @Environment(AuthState.self) var authState + @State private var isAuthStarted = false + + var body: some View { + VStack(spacing: 20) { + if authState.isAuthenticated { + // Success state + successView + } else if authState.showingAuthSheet { + // Show embedded auth + authWebView + } else { + // Initial state - prompt to connect + connectPromptView + } + } + .padding() + .onChange(of: authState.isAuthenticated) { _, isAuthenticated in + if isAuthenticated { + // Close the auth sheet if open + authState.showingAuthSheet = false + } + } + } + + private var connectPromptView: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "link.circle.fill") + .font(.system(size: DesignTokens.onboardingIconSize)) + .foregroundStyle(Color.accentColor) + + VStack(spacing: 8) { + Text("Connect to Last.fm") + .font(.title2) + .fontWeight(.semibold) + + Text("Sign in to your Last.fm account to start scrobbling your music.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) + } + + Button { + startAuthentication() + } label: { + HStack { + Image(systemName: "person.badge.key") + Text("Connect Last.fm Account") + } + .frame(minWidth: 200) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(authState.isAuthenticating) + + if authState.isAuthenticating { + ProgressView("Preparing authentication...") + .font(.caption) + } + + if let error = authState.authError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + Spacer() + + Text("You'll be redirected to Last.fm to authorize the app.") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + + private var authWebView: some View { + VStack(spacing: 0) { + if let desktopManager = scrobbler.lastFmManager as? LastFmDesktopManager { + if authState.isAuthenticating { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.large) + + Text("Completing authentication...") + .font(.headline) + + Text("Please wait while we verify your account.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + if #available(macOS 26, *) { + OnboardingWebAuthView(lastFmManager: desktopManager) + .environment(authState) + } else { + OnboardingWebAuthViewLegacy(lastFmManager: desktopManager) + .environment(authState) + } + } + } + } + } + + private var successView: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: DesignTokens.onboardingIconSize)) + .foregroundStyle(.green) + .accessibilityLabel("Successfully connected") + + VStack(spacing: 8) { + Text("Connected!") + .font(.title2) + .fontWeight(.semibold) + + if let username = UserDefaults.standard.string(forKey: "lastFmUsername"), + !username.isEmpty { + Text("Signed in as \(username)") + .font(.body) + .foregroundStyle(.secondary) + } + } + + Text("Your Last.fm account is now connected. Click Continue to proceed.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) + + Spacer() + } + } + + private func startAuthentication() { + guard let manager = scrobbler.lastFmManager as? LastFmDesktopManager else { return } + + if manager.currentAuthToken.isEmpty { + manager.startAuth() + } else { + authState.showingAuthSheet = true + } + } +} + +// MARK: - Embedded Web Auth Views for Onboarding + +@available(macOS 26, *) +struct OnboardingWebAuthView: View { + var lastFmManager: LastFmDesktopManager + @Environment(AuthState.self) var authState + @State private var webPage = WebPage() + @State private var isLoading = true + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + if isLoading { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(0.7) + Text("Loading...") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + + Button("Reload") { + loadAuthPage() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.horizontal) + .padding(.vertical, 8) + + // WebView + WebView(webPage) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal) + .onAppear { + loadAuthPage() + } + .onChange(of: webPage.isLoading) { _, newValue in + isLoading = newValue + if !newValue { + checkIfAuthorizationComplete() + } + } + + // Footer + HStack { + Button("Cancel") { + authState.showingAuthSheet = false + lastFmManager.completeAuthorization(authorized: false) + } + .buttonStyle(.bordered) + + Spacer() + + Button("I've Authorized the App") { + completeAuthorization() + } + .buttonStyle(.borderedProminent) + } + .padding() + } + .onDisappear { + webPage.stopLoading() + } + } + + private func loadAuthPage() { + guard !lastFmManager.currentAuthToken.isEmpty else { + authState.authError = "No authentication token available" + return + } + + isLoading = true + authState.authError = nil + + let authURL = "https://www.last.fm/api/auth/?api_key=\(lastFmManager.apiKey)&token=\(lastFmManager.currentAuthToken)&cb=scrobble://auth" + + if let url = URL(string: authURL) { + let request = URLRequest(url: url) + let _ = webPage.load(request) + } + } + + private func checkIfAuthorizationComplete() { + Task { + do { + let urlScript = "window.location.href" + if let currentURL = try await webPage.callJavaScript(urlScript) as? String { + if currentURL.contains("authorized") || currentURL.contains("/api/grantaccess") { + await MainActor.run { + completeAuthorization() + } + } + } + } catch { + Log.error("Error checking URL: \(error)", category: .ui) + } + } + } + + private func completeAuthorization() { + authState.isAuthenticating = true + lastFmManager.completeAuthorization(authorized: true) + } +} + +// Legacy version for macOS 15 +struct OnboardingWebAuthViewLegacy: View { + var lastFmManager: LastFmDesktopManager + @Environment(AuthState.self) var authState + @State private var isLoading = true + + var body: some View { + VStack(spacing: 16) { + Text("Connect to Last.fm") + .font(.headline) + + if isLoading { + ProgressView("Opening Last.fm...") + } + + Text("A browser window will open for you to authorize the app.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + HStack { + Button("Cancel") { + authState.showingAuthSheet = false + lastFmManager.completeAuthorization(authorized: false) + } + .buttonStyle(.bordered) + + Button("I've Authorized") { + authState.isAuthenticating = true + lastFmManager.completeAuthorization(authorized: true) + } + .buttonStyle(.borderedProminent) + } + } + .padding() + .onAppear { + openAuthInBrowser() + } + } + + private func openAuthInBrowser() { + guard !lastFmManager.currentAuthToken.isEmpty else { + authState.authError = "No authentication token available" + return + } + + let authURL = "https://www.last.fm/api/auth/?api_key=\(lastFmManager.apiKey)&token=\(lastFmManager.currentAuthToken)&cb=scrobble://auth" + + if let url = URL(string: authURL) { + NSWorkspace.shared.open(url) + isLoading = false + } + } +} + +#Preview { + let prefManager = PreferencesManager() + let authState = AuthState() + let lastFmManager = LastFmDesktopManager( + apiKey: prefManager.apiKey, + apiSecret: prefManager.apiSecret, + username: prefManager.username, + authState: authState + ) + + OnboardingAuthView() + .environment(Scrobbler(lastFmManager: lastFmManager, preferencesManager: prefManager)) + .environment(authState) + .frame(width: 500, height: 450) +} diff --git a/scrobble/Views/Onboarding/OnboardingContainerView.swift b/scrobble/Views/Onboarding/OnboardingContainerView.swift new file mode 100644 index 0000000..00a6c66 --- /dev/null +++ b/scrobble/Views/Onboarding/OnboardingContainerView.swift @@ -0,0 +1,135 @@ +// +// OnboardingContainerView.swift +// scrobble +// +// Created by Claude on 1/12/26. +// + +import SwiftUI + +struct OnboardingContainerView: View { + @Environment(\.dismissWindow) private var dismissWindow + @Environment(PreferencesManager.self) var preferencesManager + @Environment(Scrobbler.self) var scrobbler + @Environment(AuthState.self) var authState + @State private var onboardingState = OnboardingState() + + var body: some View { + VStack(spacing: 0) { + // Progress indicator + HStack(spacing: DesignTokens.spacingDefault) { + ForEach(OnboardingState.Step.allCases, id: \.self) { step in + Circle() + .fill(stepColor(for: step)) + .frame(width: 8, height: 8) + .animation(.easeInOut(duration: 0.2), value: onboardingState.currentStep) + } + } + .padding(.top, 20) + .padding(.bottom, DesignTokens.spacingDefault) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Step \(onboardingState.currentStep.rawValue + 1) of \(OnboardingState.Step.allCases.count)") + + // Step title + Text(onboardingState.currentStep.title) + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.bottom, DesignTokens.spacingLarge) + + // Content area + ZStack { + switch onboardingState.currentStep { + case .welcome: + OnboardingWelcomeView() + .transition(stepTransition) + + case .authenticate: + OnboardingAuthView() + .environment(scrobbler) + .environment(authState) + .transition(stepTransition) + + case .selectApp: + OnboardingAppSelectionView() + .environment(preferencesManager) + .environment(scrobbler) + .transition(stepTransition) + + case .preferences: + OnboardingPreferencesView() + .environment(preferencesManager) + .transition(stepTransition) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .animation(.easeInOut(duration: 0.3), value: onboardingState.currentStep) + + Divider() + + // Navigation buttons + HStack { + if !onboardingState.currentStep.isFirst { + Button("Back") { + onboardingState.back() + } + .buttonStyle(.bordered) + } + + Spacer() + + if onboardingState.currentStep.isLast { + Button("Complete Setup") { + completeOnboarding() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } else { + Button("Continue") { + onboardingState.next() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!onboardingState.canProceed(authState: authState)) + } + } + .padding() + } + .frame(width: 500, height: 520) + .background(.ultraThinMaterial) + } + + private var stepTransition: AnyTransition { + .asymmetric( + insertion: .move(edge: onboardingState.isGoingForward ? .trailing : .leading).combined(with: .opacity), + removal: .move(edge: onboardingState.isGoingForward ? .leading : .trailing).combined(with: .opacity) + ) + } + + private func stepColor(for step: OnboardingState.Step) -> Color { + step.rawValue <= onboardingState.currentStep.rawValue + ? .accentColor + : .secondary.opacity(0.3) + } + + private func completeOnboarding() { + onboardingState.complete() + dismissWindow(id: "onboarding") + } +} + +#Preview { + let prefManager = PreferencesManager() + let authState = AuthState() + let lastFmManager = LastFmDesktopManager( + apiKey: prefManager.apiKey, + apiSecret: prefManager.apiSecret, + username: prefManager.username, + authState: authState + ) + + OnboardingContainerView() + .environment(prefManager) + .environment(Scrobbler(lastFmManager: lastFmManager, preferencesManager: prefManager)) + .environment(authState) + .padding(100) +} diff --git a/scrobble/Views/Onboarding/OnboardingPreferencesView.swift b/scrobble/Views/Onboarding/OnboardingPreferencesView.swift new file mode 100644 index 0000000..8a5cf68 --- /dev/null +++ b/scrobble/Views/Onboarding/OnboardingPreferencesView.swift @@ -0,0 +1,142 @@ +// +// OnboardingPreferencesView.swift +// scrobble +// +// Created by Claude on 1/12/26. +// + +import SwiftUI +import ServiceManagement + +struct OnboardingPreferencesView: View { + @Environment(PreferencesManager.self) var preferencesManager + + private let scrobblePercentages = [25, 50, 75, 90] + private let maxDelayOptions = [120, 240, 360, 480] + + var body: some View { + @Bindable var preferencesManager = preferencesManager + + VStack(spacing: 20) { + Spacer() + + // Header + VStack(spacing: 8) { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 40)) + .foregroundStyle(Color.accentColor) + + Text("Configure Preferences") + .font(.title2) + .fontWeight(.semibold) + + Text("Set up how Scrobble tracks your listening. You can change these later in Settings.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) + } + + // Settings form + VStack(spacing: 16) { + // Scrobble threshold + PreferenceSection(title: "Scrobble After", icon: "percent") { + Picker("", selection: $preferencesManager.trackCompletionPercentageBeforeScrobble) { + ForEach(scrobblePercentages, id: \.self) { percentage in + Text("\(percentage)% of track").tag(percentage) + } + } + .pickerStyle(.segmented) + + Text("A track is scrobbled after you've listened to this percentage.") + .font(.caption) + .foregroundStyle(.tertiary) + } + + // Max delay + PreferenceSection(title: "Maximum Wait Time", icon: "timer") { + Toggle("Cap scrobble delay", isOn: $preferencesManager.useMaxTrackCompletionScrobbleDelay) + + if preferencesManager.useMaxTrackCompletionScrobbleDelay { + Picker("Max delay", selection: Binding( + get: { preferencesManager.maxTrackCompletionScrobbleDelay ?? 240 }, + set: { preferencesManager.maxTrackCompletionScrobbleDelay = $0 } + )) { + ForEach(maxDelayOptions, id: \.self) { seconds in + Text("\(seconds / 60) minutes").tag(seconds) + } + } + .pickerStyle(.segmented) + } + + Text("For long tracks, limit how long to wait before scrobbling.") + .font(.caption) + .foregroundStyle(.tertiary) + } + + // Launch at login + PreferenceSection(title: "Startup", icon: "power") { + Toggle("Launch Scrobble at login", isOn: $preferencesManager.launchAtLogin) + .onChange(of: preferencesManager.launchAtLogin) { _, newValue in + updateLaunchAtLogin(newValue) + } + + Text("Automatically start Scrobble when you log into your Mac.") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) + + Spacer() + + Text("You're all set! Click Complete Setup to start scrobbling.") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.bottom, 8) + } + .padding() + } + + private func updateLaunchAtLogin(_ enabled: Bool) { + do { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + Log.error("Failed to update launch at login: \(error)", category: .general) + } + } +} + +struct PreferenceSection: View { + let title: String + let icon: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: icon) + .foregroundStyle(Color.accentColor) + .frame(width: 20) + Text(title) + .font(.headline) + } + + VStack(alignment: .leading, spacing: 6) { + content + } + .padding(.leading, 26) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#Preview { + OnboardingPreferencesView() + .environment(PreferencesManager()) + .frame(width: 500, height: 450) +} diff --git a/scrobble/Views/Onboarding/OnboardingWelcomeView.swift b/scrobble/Views/Onboarding/OnboardingWelcomeView.swift new file mode 100644 index 0000000..15e0bf2 --- /dev/null +++ b/scrobble/Views/Onboarding/OnboardingWelcomeView.swift @@ -0,0 +1,94 @@ +// +// OnboardingWelcomeView.swift +// scrobble +// +// Created by Claude on 1/12/26. +// + +import SwiftUI + +struct OnboardingWelcomeView: View { + var body: some View { + VStack(spacing: 24) { + Spacer() + + // App icon + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: 100, height: 100) + .clipShape(RoundedRectangle(cornerRadius: 22)) + .shadow(color: .black.opacity(0.2), radius: 10, y: 5) + + // Welcome text + VStack(spacing: 8) { + Text("scrobble") + .font(.largeTitle) + .fontWeight(.bold) + + Text("Track your desktop music listening with Last.fm compatible services") + .font(.title3) + .foregroundStyle(.secondary) + } + + // Features list + VStack(alignment: .leading, spacing: 16) { + FeatureRow( + icon: "music.note.list", + title: "Automatic Scrobbling", + description: "Tracks what you play in Apple Music, Spotify, and more" + ) + + FeatureRow( + icon: "person.2", + title: "See What Friends Listen To", + description: "View your Last.fm friends' recent tracks" + ) + + FeatureRow( + icon: "menubar.rectangle", + title: "Lives in Your Menu Bar", + description: "Quick access without cluttering your dock" + ) + } + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) + .padding(.top, 8) + + Spacer() + + Text("Get set up in just a few steps.") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.bottom, 8) + } + .padding() + } +} + +struct FeatureRow: View { + let icon: String + let title: String + let description: String + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(Color.accentColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } +} + +#Preview { + OnboardingWelcomeView() + .frame(width: 500, height: 450) +} diff --git a/scrobble/Views/PreferencesView.swift b/scrobble/Views/PreferencesView.swift index 9af0b0c..9fc3eb7 100644 --- a/scrobble/Views/PreferencesView.swift +++ b/scrobble/Views/PreferencesView.swift @@ -8,48 +8,11 @@ import SwiftUI import Observation -struct WaveRenderer: TextRenderer { - var strength: Double - var frequency: Double - var phase: Double // animate this from 0 to 2π repeatedly - - var animatableData: AnimatablePair { - get { AnimatablePair(strength, phase) } - set { - strength = newValue.first - phase = newValue.second - } - } - - func draw(layout: Text.Layout, in context: inout GraphicsContext) { - for line in layout { - for run in line { - for (index, glyph) in run.enumerated() { - let yOffset = strength * sin(Double(index) * frequency + phase) - - var copy = context - copy.translateBy(x: 0, y: yOffset) - copy.draw(glyph, options: .disablesSubpixelQuantization) - } - } - } - } -} - struct BillionsMustScrobbleView: View { - @State private var phase: Double = 0 - - var body: some View { Text("Billions must scrobble!") .font(.caption) - .foregroundColor(.secondary) -// .textRenderer(WaveRenderer(strength: 10.0, frequency: 0.1, phase: phase)) -// .onAppear { -// withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { -// phase = .pi * 2 -// } -// } + .foregroundStyle(.secondary) } } @@ -59,8 +22,6 @@ struct ScrobbleTimingSettingsView: View { var body: some View { @Bindable var preferencesManager = preferencesManager - - Section { Picker("Scrobble after", selection: $preferencesManager.trackCompletionPercentageBeforeScrobble) { Text("25% of track").tag(25) @@ -68,9 +29,9 @@ struct ScrobbleTimingSettingsView: View { Text("75% of track").tag(75) Text("90% of track").tag(90) } - + Toggle("Cap scrobble time for long tracks", isOn: $preferencesManager.useMaxTrackCompletionScrobbleDelay) - + if preferencesManager.useMaxTrackCompletionScrobbleDelay { Picker("Maximum time before scrobble", selection: $preferencesManager.maxTrackCompletionScrobbleDelay) { Text("2 minutes").tag(120) @@ -84,10 +45,9 @@ struct ScrobbleTimingSettingsView: View { } footer: { Text("Adjust when tracks are scrobbled based on how much of the track has played.") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } - } struct PreferencesView: View { @@ -95,122 +55,90 @@ struct PreferencesView: View { @Environment(Scrobbler.self) var scrobbler @Environment(AuthState.self) var authState - @State private var showingBlueskyHelp = false - var body: some View { @Bindable var preferencesManager = preferencesManager - VStack() { - - - - Form { - - Section("About") { - - HStack(alignment: .center) { - Text("scrobble v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")") - .font(.headline) - .fontWeight(.bold) - - Text("[build \(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown")]") - .font(.caption2) - .foregroundColor(.secondary.opacity(0.5)) - - Spacer() - -// Text("by COMPUTER DATA") -// .font(.caption) -// .foregroundColor(.secondary) - } - - - BillionsMustScrobbleView() - - - // repo link and license link - HStack(alignment: .center) { - Text("copyright © 2025 COMPUTER DATA") - .font(.caption2) - .foregroundColor(.secondary) - Spacer() - - Link("GitHub", destination: URL(string: "https://github.com/bretth18/scrobble")!) - .font(.caption2) - Link("License", destination: URL(string: "https://github.com/bretth18/scrobble/blob/main/LICENSE")!) - .font(.caption2) - } - - } - - UpdateSettingsView() - - Section("Display") { - VStack { - LabeledStepper("Friends displayed:", value: $preferencesManager.numberOfFriendsDisplayed, in: 1...10) -// Stepper( -// "Friends shown: \(preferencesManager.numberOfFriendsDisplayed)", -// value: $preferencesManager.numberOfFriendsDisplayed, -// in: 1...10 -// ) - - - LabeledStepper( - "Friend recent tracks", - value: $preferencesManager.numberOfFriendsRecentTracksDisplayed, - in: 1...20 - ) - } + + Form { + Section("About") { + HStack { + Text("scrobble v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")") + .font(.headline) + .fontWeight(.bold) + + Text("[build \(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown")]") + .font(.caption2) + .foregroundStyle(.tertiary) + + Spacer() } - - ScrobbleTimingSettingsView() - - LaunchAtLoginSettingsView() - - Section { - ScrobblingServicesView() - } header: { - Text("Scrobbling Services") - } footer: { - Text("Credentials are stored securely on-device") - .font(.caption) - .foregroundColor(.secondary) + + BillionsMustScrobbleView() + + HStack { + Text("copyright \u{00A9} 2025-2026 COMPUTER DATA") + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + + Link("GitHub", destination: URL(string: "https://github.com/bretth18/scrobble")!) + .font(.caption2) + Link("License", destination: URL(string: "https://github.com/bretth18/scrobble/blob/main/LICENSE")!) + .font(.caption2) } - - Section("Music App") { - VStack(alignment: .leading, spacing: 8) { - Text("Music App Source") - .font(.headline) - - Picker("Select Music App", selection: $preferencesManager.selectedMusicApp) { - ForEach(SupportedMusicApp.allApps, id: \.self) { app in - Label(app.displayName, systemImage: app.icon) - .tag(app) - } - } - .pickerStyle(.menu) - .onChange(of: preferencesManager.selectedMusicApp) { _, newApp in - // Update the scrobbler when the app selection changes - scrobbler.setTargetMusicApp(newApp) - } - - Text("Select which app to monitor for scrobbling") - .font(.caption) - .foregroundStyle(.secondary) + } + + UpdateSettingsView() + + Section("Display") { + LabeledStepper("Friends displayed:", value: $preferencesManager.numberOfFriendsDisplayed, in: 1...10) + + LabeledStepper( + "Friend recent tracks", + value: $preferencesManager.numberOfFriendsRecentTracksDisplayed, + in: 1...20 + ) + } + + ScrobbleTimingSettingsView() + + LaunchAtLoginSettingsView() + + Section { + ScrobblingServicesView() + } header: { + Text("Scrobbling Services") + } footer: { + Text("Credentials are stored securely on-device") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Music App") { + Picker("Music App Source", selection: $preferencesManager.selectedMusicApp) { + ForEach(SupportedMusicApp.allApps, id: \.self) { app in + Label(app.displayName, systemImage: app.icon) + .tag(app) } } + .pickerStyle(.menu) + .onChange(of: preferencesManager.selectedMusicApp) { _, newApp in + scrobbler.setTargetMusicApp(newApp) + } + + Text("Select which app to monitor for scrobbling") + .font(.caption) + .foregroundStyle(.secondary) } - .formStyle(.grouped) - + if let error = authState.authError { - Text(error) - .foregroundColor(.red) - .font(.caption) - .padding() + Section { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + .font(.caption) + } } - } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) + .formStyle(.grouped) } } @@ -223,11 +151,10 @@ struct PreferencesView: View { username: prefManager.username, authState: authState ) - + PreferencesView() .environment(prefManager) .environment(Scrobbler(lastFmManager: lastFmManager, preferencesManager: prefManager)) .environment(authState) .environment(UpdateChecker()) } - diff --git a/scrobble/Views/ScrobblingServicesView.swift b/scrobble/Views/ScrobblingServicesView.swift index 4c98f28..7dd37cb 100644 --- a/scrobble/Views/ScrobblingServicesView.swift +++ b/scrobble/Views/ScrobblingServicesView.swift @@ -180,17 +180,19 @@ struct ServiceRow: View { Toggle(title, isOn: $isEnabled) .toggleStyle(.switch) .labelsHidden() + .accessibilityLabel("Enable \(title)") Spacer() Label(status.label, systemImage: status.icon) .foregroundStyle(status.color) .font(.caption) + .accessibilityLabel("\(title) status: \(status.label)") } if isEnabled { detailContent - .padding(.leading, 16) + .padding(.leading) } } } diff --git a/scrobble/Views/ScrobblingView.swift b/scrobble/Views/ScrobblingView.swift index bf8d34d..e9b7a8f 100644 --- a/scrobble/Views/ScrobblingView.swift +++ b/scrobble/Views/ScrobblingView.swift @@ -7,158 +7,12 @@ import SwiftUI -struct ScrobblingViewHeaderView: View { - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text("SCROBBLE") - .font(.system(.headline, design: .rounded)) - .fontWeight(.semibold) - - Text( - "v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")" - ) - .font(.system(size: 9)) - .foregroundStyle(.tertiary.opacity(0.7)) - - Spacer() - } - } -} - -struct ScrobblingViewStatusCardView: View { - let status: String - - var statusColor: Color { - status.contains("Connected") - ? .green.opacity(0.8) : .orange.opacity(0.8) - } - - var statusIcon: String { - status.contains("Connected") ? "wifi" : "wifi.slash" - } - - var body: some View { - HStack(spacing: 8) { - Image(systemName: statusIcon) - .font(.caption) - .foregroundStyle(statusColor) - .frame(width: 16) - - Text("status:") - .font(.caption) - .foregroundStyle(.secondary) - - Spacer() - - Text(status) - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(statusColor) - .lineLimit(1) - - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .compatGlass(cornerRadius: 8) - } -} - -struct ScrobblingViewNowPlayingCardView: View { - let currentTrack: String - let currentArtwork: NSImage? - let lastScrobbledTrack: String - let artworkSize: CGFloat - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 6) { - Label("Now Playing", systemImage: "play.circle.fill") - .font(.caption) - .foregroundStyle(.secondary.opacity(0.7)) - - HStack(spacing: 10) { - Group { - if let artwork = currentArtwork { - Image(nsImage: artwork) - .resizable() - .aspectRatio(contentMode: .fill) - - } else { - RoundedRectangle(cornerRadius: 6) - .fill(.quaternary) - .overlay { - Image(systemName: "music.note") - .foregroundStyle(.tertiary) - } - } - } - .frame(width: artworkSize, height: artworkSize) - .clipShape(RoundedRectangle(cornerRadius: 6)) - - VStack(alignment: .leading, spacing: 4) { - Text( - currentTrack.isEmpty - ? "No track playing" : currentTrack - ) - .font(.system(size: 11, weight: .medium)) - .lineLimit(3) - .fixedSize(horizontal: false, vertical: true) - - Spacer() - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(height: artworkSize) - } - - Divider() - .opacity(0.3) - - VStack(alignment: .leading, spacing: 6) { - Label("Last Scrobbled", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.secondary) - - Text( - lastScrobbledTrack.isEmpty - ? "No recent scrobbles" : lastScrobbledTrack - ) - .font(.system(size: 11)) - .foregroundStyle(.primary.opacity(0.8)) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background { - ZStack { - if let artwork = currentArtwork { - Image(nsImage: artwork) - .resizable() - .aspectRatio(contentMode: .fill) - .blur(radius: 40) - .opacity(0.5) - .clipped() - } - } - } - } -} - struct ScrobblingView: View { @Environment(Scrobbler.self) var scrobbler @Environment(PreferencesManager.self) var preferencesManager - @State private var servicesRefreshTrigger = UUID() - - @State private var showingPreferences = false - - private let contentWidth: CGFloat = 280 - private let artworkSize: CGFloat = 80 - private let cornerRadius: CGFloat = 10 var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: DesignTokens.spacingSection) { HStack { Text("SCROBBLE") .font(.headline) @@ -166,25 +20,11 @@ struct ScrobblingView: View { "v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")" ) .font(.caption2) - .foregroundStyle(.secondary.opacity(0.5)) + .foregroundStyle(.tertiary) } .padding(.horizontal) .padding(.top) - // HStack { - // Text("status:") - // .font(.caption2.monospaced()) - // .foregroundStyle(.secondary.opacity(0.7)) - // Spacer() - // Text(scrobbler.musicAppStatus) - // .textCase(.lowercase) - // .font(.subheadline) - // .foregroundColor(scrobbler.musicAppStatus.contains("Connected") ? .green.opacity(0.8) : .red.opacity(0.8)) - // } - // .padding() - // .glassEffect(in: .rect(cornerRadius: 8)) - // .frame(maxWidth: .infinity, alignment: .leading) - // Current Target App Display HStack { Label( @@ -192,83 +32,79 @@ struct ScrobblingView: View { systemImage: preferencesManager.selectedMusicApp.icon ) .font(.caption2.monospaced()) - .foregroundStyle(.secondary.opacity(0.7)) + .foregroundStyle(.tertiary) Spacer() Text( preferencesManager.selectedMusicApp.displayName .lowercased() ) .font(.subheadline) - .foregroundColor(.accentColor.opacity(0.8)) + .foregroundStyle(Color.accentColor) } .padding(.horizontal) .frame(maxWidth: .infinity, alignment: .leading) // Scrobbling Services Status - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: DesignTokens.spacingDefault) { Text("scrobbling to:") .font(.caption2.monospaced()) - .foregroundStyle(.secondary.opacity(0.7)) + .foregroundStyle(.tertiary) - ServicesStatusView(refreshTrigger: servicesRefreshTrigger) + ServicesStatusView() .environment(scrobbler) } .padding() - .compatGlass(cornerRadius: 8) + .compatGlass(cornerRadius: DesignTokens.cornerRadiusMedium) .padding(.horizontal) .frame(maxWidth: .infinity, alignment: .leading) - .onChange(of: scrobbler.servicesLastUpdated) { _, _ in - Log.debug( - "ScrobblingView received servicesLastUpdated change", - category: .ui - ) - servicesRefreshTrigger = UUID() - } VStack(alignment: .leading) { - - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: DesignTokens.spacingDefault) { Text("Now Playing:") .font(.subheadline) - .foregroundStyle(.secondary.opacity(0.7)) + .foregroundStyle(.tertiary) MarqueeText( - text: "\(scrobbler.currentTrack)", + text: scrobbler.currentTrack, font: .body, containerWidth: 200 ) -// Text(scrobbler.currentTrack) if let currentArtwork = scrobbler.currentArtwork { Image(nsImage: currentArtwork) .resizable() .scaledToFit() - .frame(width: 50, height: 50) - .cornerRadius(8) + .frame( + width: DesignTokens.artworkSizeSmall, + height: DesignTokens.artworkSizeSmall + ) + .clipShape(RoundedRectangle(cornerRadius: DesignTokens.cornerRadiusSmall)) .shadow(radius: 2) + .accessibilityLabel("Album artwork") } else { - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: DesignTokens.cornerRadiusSmall) .fill(.quaternary) - .frame(width: 50, height: 50) + .frame( + width: DesignTokens.artworkSizeSmall, + height: DesignTokens.artworkSizeSmall + ) .overlay { Image(systemName: "music.note") .foregroundStyle(.tertiary) - } + .accessibilityHidden(true) } Text("Last Scrobbled:") .font(.subheadline) - .foregroundStyle(.secondary.opacity(0.7)) + .foregroundStyle(.tertiary) - HStack(alignment: .center) { + HStack { MarqueeText( - text: "\(scrobbler.lastScrobbledTrack)", + text: scrobbler.lastScrobbledTrack, font: .body, containerWidth: 200 ) -// Text(scrobbler.lastScrobbledTrack) -// .font(.body) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -281,31 +117,27 @@ struct ScrobblingView: View { .scaledToFill() .blur(radius: 30) .opacity(0.3) - } } - .compatGlass(cornerRadius: 8) + .compatGlass(cornerRadius: DesignTokens.cornerRadiusMedium) .padding(.horizontal) if scrobbler.isScrobbling { ProgressView() - .progressViewStyle(CircularProgressViewStyle()) + .progressViewStyle(.circular) .padding(.horizontal) } if let errorMessage = scrobbler.errorMessage { Text(errorMessage) - .foregroundColor(.red) + .foregroundStyle(.red) .font(.caption) .padding(.horizontal) } Spacer() - } .frame(maxWidth: .infinity, maxHeight: .infinity) - - } } diff --git a/scrobble/Views/ServicesStatusView.swift b/scrobble/Views/ServicesStatusView.swift index 62ec0be..689e8dd 100644 --- a/scrobble/Views/ServicesStatusView.swift +++ b/scrobble/Views/ServicesStatusView.swift @@ -9,17 +9,10 @@ import SwiftUI struct ServicesStatusView: View { @Environment(Scrobbler.self) var scrobbler - let refreshTrigger: UUID - + var body: some View { - // The refreshTrigger will force this view to rebuild when ContentView updates it let services = scrobbler.getScrobblingServices() - - let _ = Log.debug("ServicesStatusView updating with trigger \(refreshTrigger), found \(services.count) services", category: .ui) - let _ = services.forEach { service in - Log.debug(" - \(service.serviceName): authenticated = \(service.isAuthenticated)", category: .ui) - } - + if services.isEmpty { HStack { Image(systemName: "exclamationmark.triangle.fill") @@ -29,30 +22,32 @@ struct ServicesStatusView: View { .foregroundStyle(.secondary) Spacer() } + .accessibilityElement(children: .combine) + .accessibilityLabel("Warning: no services enabled") } else { ForEach(services, id: \.serviceId) { service in - HStack(spacing: 6) { + HStack(spacing: DesignTokens.spacingDefault) { Image(systemName: service.isAuthenticated ? "checkmark.circle.fill" : "circle.fill") - .foregroundStyle(service.isAuthenticated ? .green.opacity(0.8) : .secondary.opacity(0.5)) + .foregroundStyle(service.isAuthenticated ? Color.green : Color.secondary.opacity(0.3)) .font(.caption) - + Text(service.serviceName.lowercased()) .font(.caption) .foregroundStyle(service.isAuthenticated ? .primary : .secondary) - + Spacer() - + if !service.isAuthenticated { Text("not authenticated") .font(.caption2) - .foregroundStyle(.secondary.opacity(0.7)) + .foregroundStyle(.tertiary) } } + .accessibilityElement(children: .combine) + .accessibilityLabel( + "\(service.serviceName): \(service.isAuthenticated ? "connected" : "not authenticated")" + ) } } } } - -//#Preview { -// ServicesStatusView(re) -//} diff --git a/scrobble/Views/UpdateView.swift b/scrobble/Views/UpdateView.swift index 1c3387c..b282c8f 100644 --- a/scrobble/Views/UpdateView.swift +++ b/scrobble/Views/UpdateView.swift @@ -9,23 +9,23 @@ import SwiftUI struct UpdateSettingsView: View { @Environment(UpdateChecker.self) var updateChecker - + private let owner = "bretth18" private let repo = "scrobble" - + var body: some View { Section { - HStack(alignment: .center) { + HStack { Text("Current") .font(.caption) - .foregroundColor(.secondary) - + .foregroundStyle(.secondary) + Text(updateChecker.currentVersionString) .font(.caption2) - .foregroundColor(.secondary.opacity(0.5)) - + .foregroundStyle(.tertiary) + Spacer() - + if updateChecker.isChecking { ProgressView() .controlSize(.small) @@ -33,21 +33,22 @@ struct UpdateSettingsView: View { if updateChecker.updateAvailable { Text("Update available: \(latest)") .font(.caption) - .foregroundColor(.blue) + .foregroundStyle(Color.accentColor) } else { Label("Up to date", systemImage: "checkmark.circle.fill") .font(.caption) - .foregroundColor(.green) + .foregroundStyle(.green) } } } - + .accessibilityElement(children: .combine) + if let error = updateChecker.error { Text(error.localizedDescription) .font(.caption2) - .foregroundColor(.red) + .foregroundStyle(.red) } - + HStack { Button("Check for Updates") { Task { @@ -56,7 +57,7 @@ struct UpdateSettingsView: View { } .disabled(updateChecker.isChecking) .controlSize(.small) - + if updateChecker.updateAvailable, let url = updateChecker.downloadURL { Link("Download", destination: url) .controlSize(.small) @@ -70,8 +71,8 @@ struct UpdateSettingsView: View { #Preview { VStack { - UpdateSettingsView() - .environment(UpdateChecker() ) - .padding() + UpdateSettingsView() + .environment(UpdateChecker()) + .padding() } } diff --git a/scrobble/scrobbleApp.swift b/scrobble/scrobbleApp.swift index 0299f20..0d040c0 100644 --- a/scrobble/scrobbleApp.swift +++ b/scrobble/scrobbleApp.swift @@ -6,7 +6,6 @@ // import SwiftUI -import Combine import Observation @main @@ -17,63 +16,51 @@ struct scrobbleApp: App { @State private var appState = AppState() @State private var authState: AuthState @State private var updateChecker = UpdateChecker() - - private var cancellables = Set() - + @State private var hasCheckedOnboarding = false + init() { let prefManager = PreferencesManager() _preferencesManager = State(initialValue: prefManager) - + let auth = AuthState() _authState = State(initialValue: auth) - + let lastFmManager = LastFmDesktopManager( apiKey: prefManager.apiKey, apiSecret: prefManager.apiSecret, username: prefManager.username, authState: auth ) - // Scrobbler is now @Observable so we initialize it as State _scrobbler = State(initialValue: Scrobbler(lastFmManager: lastFmManager, preferencesManager: prefManager)) - - // Monitor preferences changes to refresh scrobbling services - setupPreferencesObserver() } - - private func setupPreferencesObserver() { - // This will be set up after the StateObjects are initialized - DispatchQueue.main.async { - // Note: We can't store this in the struct since it's not mutable - // The scrobbler will handle its own refresh logic - Log.debug("Preferences observer setup completed", category: .general) - } - } - + var body: some Scene { MenuBarExtra { - VStack(spacing: 10) { - ContentView() - .environment(scrobbler) - .environment(preferencesManager) - .environment(authState) - - Divider() - - MenuButtonsView() - .environment(authState) - .environment(appState) - } - .padding(8) - .containerBackground( - .ultraThinMaterial, for: .window - ) - .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) - + VStack(spacing: DesignTokens.spacingDefault) { + ContentView() + .environment(scrobbler) + .environment(preferencesManager) + .environment(authState) + + Divider() + + MenuButtonsView() + .environment(authState) + .environment(appState) + } + .padding(DesignTokens.spacingDefault) + .containerBackground( + .ultraThinMaterial, for: .window + ) + .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) + .background { + OnboardingLauncher(hasCheckedOnboarding: $hasCheckedOnboarding) + } } label: { Image(systemName: "music.note") + .accessibilityLabel("Scrobble") } .menuBarExtraStyle(.window) - WindowGroup("Scrobbler", id: "scrobbler") { ContentView() @@ -83,40 +70,33 @@ struct scrobbleApp: App { .environment(authState) .sheet(isPresented: $authState.showingAuthSheet) { if let desktopManager = scrobbler.lastFmManager as? LastFmDesktopManager { - LastFMAuthSheetView(lastFmManager: desktopManager) + LastFMAuthSheetView(lastFmManager: desktopManager) .environment(authState) } } .containerBackground( .ultraThinMaterial, for: .window ) - .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) - + .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) } .defaultPosition(.center) .defaultSize(width: 400, height: 600) - - Settings { - PreferencesView() - .environment(preferencesManager) - .environment(scrobbler) - .environment(authState) - .environment(updateChecker) - .sheet(isPresented: $authState.showingAuthSheet) { - if let desktopManager = scrobbler.lastFmManager as? LastFmDesktopManager { - LastFMAuthSheetView(lastFmManager: desktopManager) - .environment(authState) - } + PreferencesView() + .environment(preferencesManager) + .environment(scrobbler) + .environment(authState) + .environment(updateChecker) + .sheet(isPresented: $authState.showingAuthSheet) { + if let desktopManager = scrobbler.lastFmManager as? LastFmDesktopManager { + LastFMAuthSheetView(lastFmManager: desktopManager) + .environment(authState) } - - - + } } .windowResizability(.automatic) .defaultSize(width: 800, height: 600) - .commands { CommandGroup(after: .appInfo) { Button { @@ -141,14 +121,48 @@ struct scrobbleApp: App { .disabled(updateChecker.isChecking) } } - - + + // Onboarding window + WindowGroup("Welcome to Scrobble", id: "onboarding") { + OnboardingContainerView() + .environment(preferencesManager) + .environment(scrobbler) + .environment(authState) + } + .windowStyle(.hiddenTitleBar) + .windowResizability(.contentSize) + .defaultPosition(.center) + } + +} + +// MARK: - Onboarding Launcher + +/// Helper view that checks if onboarding should be shown and opens the window. +/// Uses .task instead of .onAppear for structured concurrency. +struct OnboardingLauncher: View { + @Environment(\.openWindow) private var openWindow + @Binding var hasCheckedOnboarding: Bool + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .task { + guard !hasCheckedOnboarding else { return } + hasCheckedOnboarding = true + + if OnboardingState.needsOnboarding { + try? await Task.sleep(for: .milliseconds(500)) + openWindow(id: "onboarding") + NSApp.activate() + } + } } } +@MainActor class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { - // Setup URL event handling for Last.fm authentication callbacks NSAppleEventManager.shared().setEventHandler( self, @@ -157,37 +171,29 @@ class AppDelegate: NSObject, NSApplicationDelegate { andEventID: AEEventID(kAEGetURL) ) } - - + @objc func handleURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) { guard let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return } guard let url = URL(string: urlString) else { return } - - // Handle the URL - look for Last.fm auth callbacks + handleLastFmAuthCallback(url: url) } - + private func handleLastFmAuthCallback(url: URL) { - // Example URL: scrobble://auth?token=abc123 guard url.scheme == "scrobble" else { return } - + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) guard let queryItems = components?.queryItems else { return } - - // Check for token parameter + if let tokenItem = queryItems.first(where: { $0.name == "token" }), let token = tokenItem.value { - // Notify LastFmDesktopManager about successful authorization NotificationCenter.default.post( name: NSNotification.Name("LastFmAuthSuccess"), object: nil, userInfo: ["token": token] ) - } - // Check for error parameter - else if let errorItem = queryItems.first(where: { $0.name == "error" }), - let error = errorItem.value { - // Notify LastFmDesktopManager about failed authorization + } else if let errorItem = queryItems.first(where: { $0.name == "error" }), + let error = errorItem.value { NotificationCenter.default.post( name: NSNotification.Name("LastFmAuthFailure"), object: nil, diff --git a/scrobbleTests/AuthStateTests.swift b/scrobbleTests/AuthStateTests.swift new file mode 100644 index 0000000..031bcc2 --- /dev/null +++ b/scrobbleTests/AuthStateTests.swift @@ -0,0 +1,127 @@ +import Testing +@testable import scrobble + +@Suite("AuthState Tests", .serialized) +@MainActor +struct AuthStateTests { + + private func cleanAuth() { + KeychainHelper.delete(key: "lastfm_session_key") + KeychainHelper.delete(key: "lastfm_username") + UserDefaults.standard.removeObject(forKey: "lastfm_session_key") + } + + @Test("Initial state without session key") + func initialStateNoSession() { + cleanAuth() + let state = AuthState() + #expect(state.isAuthenticated == false) + #expect(state.isAuthenticating == false) + #expect(state.showingAuthSheet == false) + #expect(state.authError == nil) + } + + @Test("Initial state with session key in Keychain") + func initialStateWithSession() { + _ = KeychainHelper.save(key: "lastfm_session_key", value: "test-session-key") + let state = AuthState() + #expect(state.isAuthenticated == true) + cleanAuth() + } + + @Test("Initial state with legacy UserDefaults session key") + func initialStateWithLegacySession() { + cleanAuth() + UserDefaults.standard.set("test-legacy-key", forKey: "lastfm_session_key") + let state = AuthState() + #expect(state.isAuthenticated == true) + cleanAuth() + } + + @Test("startAuth resets error and flags") + func startAuth() { + cleanAuth() + let state = AuthState() + state.authError = "some error" + state.startAuth() + #expect(state.showingAuthSheet == false) + #expect(state.isAuthenticating == false) + #expect(state.authError == nil) + } + + @Test("completeAuth with success sets authenticated") + func completeAuthSuccess() { + cleanAuth() + let state = AuthState() + state.showingAuthSheet = true + state.isAuthenticating = true + state.completeAuth(success: true) + #expect(state.isAuthenticated == true) + #expect(state.showingAuthSheet == false) + #expect(state.isAuthenticating == false) + #expect(state.authError == nil) + } + + @Test("completeAuth with failure sets error") + func completeAuthFailure() { + cleanAuth() + let state = AuthState() + state.completeAuth(success: false) + #expect(state.isAuthenticated == false) + #expect(state.showingAuthSheet == false) + #expect(state.authError != nil) + } + + @Test("signOut clears session key and state") + func signOut() { + _ = KeychainHelper.save(key: "lastfm_session_key", value: "test-key") + let state = AuthState() + #expect(state.isAuthenticated == true) + state.signOut() + #expect(state.isAuthenticated == false) + #expect(state.authError == nil) + #expect(KeychainHelper.load(key: "lastfm_session_key") == nil) + } + + @Test("completeAuth failure message is correct") + func completeAuthFailureMessage() { + cleanAuth() + let state = AuthState() + state.completeAuth(success: false) + #expect(state.authError == "Authentication failed or was cancelled") + } + + @Test("State transition: start then complete success") + func stateTransitionSuccess() { + cleanAuth() + let state = AuthState() + #expect(state.isAuthenticated == false) + state.startAuth() + state.completeAuth(success: true) + #expect(state.isAuthenticated == true) + } + + @Test("State transition: failure then retry success") + func stateTransitionFailureThenSuccess() { + cleanAuth() + let state = AuthState() + state.startAuth() + state.completeAuth(success: false) + #expect(state.isAuthenticated == false) + #expect(state.authError != nil) + state.startAuth() + #expect(state.authError == nil) + state.completeAuth(success: true) + #expect(state.isAuthenticated == true) + } + + @Test("signOut after successful auth") + func signOutAfterAuth() { + cleanAuth() + let state = AuthState() + state.completeAuth(success: true) + #expect(state.isAuthenticated == true) + state.signOut() + #expect(state.isAuthenticated == false) + } +} diff --git a/scrobbleTests/CodableTests.swift b/scrobbleTests/CodableTests.swift new file mode 100644 index 0000000..f9f69dc --- /dev/null +++ b/scrobbleTests/CodableTests.swift @@ -0,0 +1,212 @@ +import Testing +@testable import scrobble + +@Suite("Codable Tests") +struct CodableTests { + + @Test("ErrorResponse decodes correctly") + func errorResponseDecode() throws { + let json = """ + {"error": 6, "message": "User not found"} + """ + let data = json.data(using: .utf8)! + let response = try JSONDecoder().decode(ErrorResponse.self, from: data) + #expect(response.error == 6) + #expect(response.message == "User not found") + } + + @Test("AuthResponse decodes correctly") + func authResponseDecode() throws { + let json = """ + {"session": {"name": "testuser", "key": "abc123"}} + """ + let data = json.data(using: .utf8)! + let response = try JSONDecoder().decode(AuthResponse.self, from: data) + #expect(response.session.name == "testuser") + #expect(response.session.key == "abc123") + } + + @Test("Friend decodes with all fields") + func friendDecodeAllFields() throws { + let json = """ + { + "name": "testfriend", + "realname": "Test Friend", + "url": "https://last.fm/user/testfriend", + "image": [{"#text": "https://example.com/img.jpg", "size": "medium"}], + "country": "US", + "playcount": "5000", + "registered": {"unixtime": "1234567890"}, + "subscriber": "0" + } + """ + let data = json.data(using: .utf8)! + let friend = try JSONDecoder().decode(Friend.self, from: data) + #expect(friend.name == "testfriend") + #expect(friend.realname == "Test Friend") + #expect(friend.country == "US") + #expect(friend.playcount == "5000") + #expect(friend.image.count == 1) + #expect(friend.image.first?.size == "medium") + } + + @Test("Friend decodes with optional fields missing") + func friendDecodeOptionalFields() throws { + let json = """ + { + "name": "minimalfriend", + "url": "https://last.fm/user/minimal", + "image": [] + } + """ + let data = json.data(using: .utf8)! + let friend = try JSONDecoder().decode(Friend.self, from: data) + #expect(friend.name == "minimalfriend") + #expect(friend.realname == nil) + #expect(friend.country == nil) + #expect(friend.playcount == nil) + #expect(friend.registered == nil) + } + + @Test("ScrobbleResponse decodes correctly") + func scrobbleResponseDecode() throws { + let json = """ + { + "scrobbles": { + "scrobble": { + "artist": {"corrected": "0", "#text": "Radiohead"}, + "album": {"corrected": "0", "#text": "OK Computer"}, + "track": {"corrected": "0", "#text": "Paranoid Android"}, + "ignoredMessage": {"code": "0", "#text": ""}, + "albumArtist": {"corrected": "0", "#text": "Radiohead"}, + "timestamp": "1234567890" + }, + "@attr": {"ignored": 0, "accepted": 1} + } + } + """ + let data = json.data(using: .utf8)! + let response = try JSONDecoder().decode(ScrobbleResponse.self, from: data) + #expect(response.scrobbles.scrobble.artist.text == "Radiohead") + #expect(response.scrobbles.scrobble.track.text == "Paranoid Android") + #expect(response.scrobbles.scrobble.album.text == "OK Computer") + #expect(response.scrobbles.attr.accepted == 1) + #expect(response.scrobbles.attr.ignored == 0) + } + + @Test("FriendsResponse decodes correctly") + func friendsResponseDecode() throws { + let json = """ + { + "friends": { + "user": [ + { + "name": "friend1", + "url": "https://last.fm/user/friend1", + "image": [] + } + ], + "@attr": { + "page": "1", + "total": "50", + "user": "testuser", + "perPage": "10", + "totalPages": "5" + } + } + } + """ + let data = json.data(using: .utf8)! + let response = try JSONDecoder().decode(FriendsResponse.self, from: data) + #expect(response.friends.user.count == 1) + #expect(response.friends.user.first?.name == "friend1") + #expect(response.friends.attr.total == "50") + } + + @Test("RecentTracksResponse decodes now-playing track") + func recentTracksNowPlaying() throws { + let json = """ + { + "recenttracks": { + "track": [ + { + "artist": {"mbid": "", "#text": "Radiohead"}, + "streamable": "0", + "image": [{"size": "small", "#text": "https://example.com/img.jpg"}], + "mbid": "", + "album": {"mbid": "", "#text": "OK Computer"}, + "name": "Paranoid Android", + "url": "https://last.fm/track", + "@attr": {"nowplaying": "true"} + } + ], + "@attr": { + "user": "testuser", + "page": "1", + "perPage": "10", + "totalPages": "1", + "total": "1" + } + } + } + """ + let data = json.data(using: .utf8)! + let response = try JSONDecoder().decode(RecentTracksResponse.self, from: data) + #expect(response.recenttracks.track.count == 1) + let track = response.recenttracks.track.first! + #expect(track.name == "Paranoid Android") + #expect(track.artist.text == "Radiohead") + #expect(track.nowplaying == true) + } + + @Test("RecentTracksResponse decodes track with date") + func recentTracksWithDate() throws { + let json = """ + { + "recenttracks": { + "track": [ + { + "artist": {"mbid": "", "#text": "Radiohead"}, + "streamable": "0", + "image": [], + "mbid": "", + "album": {"mbid": "", "#text": "OK Computer"}, + "name": "Karma Police", + "url": "https://last.fm/track", + "date": {"uts": "1234567890", "#text": "13 Feb 2009, 16:31"} + } + ], + "@attr": { + "user": "testuser", + "page": "1", + "perPage": "10", + "totalPages": "1", + "total": "1" + } + } + } + """ + let data = json.data(using: .utf8)! + let response = try JSONDecoder().decode(RecentTracksResponse.self, from: data) + let track = response.recenttracks.track.first! + #expect(track.name == "Karma Police") + #expect(track.date?.uts == "1234567890") + #expect(track.nowplaying == false) + } + + @Test("mockFriend has expected values") + func mockFriendValues() { + #expect(mockFriend.name == "test") + #expect(mockFriend.realname == "Test User") + #expect(mockFriend.country == "USA") + } + + @Test("SupportedMusicApp encoding excludes id field") + func supportedMusicAppCodingExcludesId() throws { + let app = SupportedMusicApp.allApps.first! + let data = try JSONEncoder().encode(app) + let jsonString = String(data: data, encoding: .utf8)! + #expect(!jsonString.contains("\"id\"")) + #expect(jsonString.contains("bundleId")) + } +} diff --git a/scrobbleTests/CryptoUtilsTests.swift b/scrobbleTests/CryptoUtilsTests.swift new file mode 100644 index 0000000..0c4aa71 --- /dev/null +++ b/scrobbleTests/CryptoUtilsTests.swift @@ -0,0 +1,60 @@ +import Testing +@testable import scrobble + +@Suite("CryptoUtils Tests") +struct CryptoUtilsTests { + + @Test("MD5 of empty string") + func md5EmptyString() { + let result = md5("") + #expect(result == "d41d8cd98f00b204e9800998ecf8427e") + } + + @Test("MD5 of 'hello'") + func md5Hello() { + let result = md5("hello") + #expect(result == "5d41402abc4b2a76b9719d911017c592") + } + + @Test("MD5 of 'Hello World'") + func md5HelloWorld() { + let result = md5("Hello World") + #expect(result == "b10a8db164e0754105b7a99be72e3fe5") + } + + @Test("createLastFmSignature with known parameters") + func signatureGeneration() { + let params: [String: Any] = [ + "method": "auth.getToken", + "api_key": "testkey123" + ] + let signature = createLastFmSignature(parameters: params, secret: "testsecret") + // Signature = MD5("api_keytestkey123methodauth.getTokentestsecret") + let expected = md5("api_keytestkey123methodauth.getTokentestsecret") + #expect(signature == expected) + } + + @Test("createLastFmSignature sorts parameters alphabetically") + func signatureAlphabeticalSorting() { + let params1: [String: Any] = [ + "z_param": "last", + "a_param": "first", + "m_param": "middle" + ] + let params2: [String: Any] = [ + "a_param": "first", + "m_param": "middle", + "z_param": "last" + ] + let sig1 = createLastFmSignature(parameters: params1, secret: "secret") + let sig2 = createLastFmSignature(parameters: params2, secret: "secret") + #expect(sig1 == sig2) + } + + @Test("MD5 produces 32-character hex string") + func md5Length() { + let result = md5("test string") + #expect(result.count == 32) + #expect(result.allSatisfy { $0.isHexDigit }) + } +} diff --git a/scrobbleTests/DesignTokensTests.swift b/scrobbleTests/DesignTokensTests.swift new file mode 100644 index 0000000..d358ba3 --- /dev/null +++ b/scrobbleTests/DesignTokensTests.swift @@ -0,0 +1,43 @@ +import Testing +@testable import scrobble + +@Suite("DesignTokens Tests") +struct DesignTokensTests { + + @Test("All corner radii are positive") + func cornerRadiiPositive() { + #expect(DesignTokens.cornerRadiusSmall > 0) + #expect(DesignTokens.cornerRadiusMedium > 0) + #expect(DesignTokens.cornerRadiusLarge > 0) + } + + @Test("Corner radii are in ascending order") + func cornerRadiiOrdering() { + #expect(DesignTokens.cornerRadiusSmall < DesignTokens.cornerRadiusMedium) + #expect(DesignTokens.cornerRadiusMedium < DesignTokens.cornerRadiusLarge) + } + + @Test("All spacing values are positive") + func spacingPositive() { + #expect(DesignTokens.spacingTight > 0) + #expect(DesignTokens.spacingDefault > 0) + #expect(DesignTokens.spacingSection > 0) + #expect(DesignTokens.spacingLarge > 0) + } + + @Test("Spacing values are in ascending order") + func spacingOrdering() { + #expect(DesignTokens.spacingTight < DesignTokens.spacingDefault) + #expect(DesignTokens.spacingDefault < DesignTokens.spacingSection) + #expect(DesignTokens.spacingSection < DesignTokens.spacingLarge) + } + + @Test("All other constants are positive") + func otherConstantsPositive() { + #expect(DesignTokens.artworkSizeSmall > 0) + #expect(DesignTokens.artworkSizeThumbnail > 0) + #expect(DesignTokens.contentPaddingHorizontal > 0) + #expect(DesignTokens.cardPadding > 0) + #expect(DesignTokens.onboardingIconSize > 0) + } +} diff --git a/scrobbleTests/OnboardingStateTests.swift b/scrobbleTests/OnboardingStateTests.swift new file mode 100644 index 0000000..f28128f --- /dev/null +++ b/scrobbleTests/OnboardingStateTests.swift @@ -0,0 +1,128 @@ +import Testing +@testable import scrobble + +@Suite("OnboardingState Tests") +@MainActor +struct OnboardingStateTests { + + @Test("Initial state is welcome") + func initialState() { + let state = OnboardingState() + #expect(state.currentStep == .welcome) + } + + @Test("Initial isGoingForward is true") + func initialDirection() { + let state = OnboardingState() + #expect(state.isGoingForward == true) + } + + @Test("next() advances from welcome to authenticate") + func nextFromWelcome() { + let state = OnboardingState() + state.next() + #expect(state.currentStep == .authenticate) + } + + @Test("next() advances through all steps in order") + func nextThroughAllSteps() { + let state = OnboardingState() + state.next() + #expect(state.currentStep == .authenticate) + state.next() + #expect(state.currentStep == .selectApp) + state.next() + #expect(state.currentStep == .preferences) + } + + @Test("next() sets isGoingForward to true") + func nextSetsForward() { + let state = OnboardingState() + state.isGoingForward = false + state.next() + #expect(state.isGoingForward == true) + } + + @Test("back() goes to previous step") + func backToPrevious() { + let state = OnboardingState() + state.next() // authenticate + state.back() + #expect(state.currentStep == .welcome) + } + + @Test("back() sets isGoingForward to false") + func backSetsBackward() { + let state = OnboardingState() + state.next() + state.back() + #expect(state.isGoingForward == false) + } + + @Test("next() at last step does nothing") + func nextAtLastStep() { + let state = OnboardingState() + state.next() // authenticate + state.next() // selectApp + state.next() // preferences (last) + state.next() // should stay + #expect(state.currentStep == .preferences) + } + + @Test("back() at first step does nothing") + func backAtFirstStep() { + let state = OnboardingState() + state.back() + #expect(state.currentStep == .welcome) + } + + @Test("Step titles are correct") + func stepTitles() { + #expect(OnboardingState.Step.welcome.title == "Welcome") + #expect(OnboardingState.Step.authenticate.title == "Connect Last.fm") + #expect(OnboardingState.Step.selectApp.title == "Select App") + #expect(OnboardingState.Step.preferences.title == "Preferences") + } + + @Test("Step isFirst property") + func stepIsFirst() { + #expect(OnboardingState.Step.welcome.isFirst == true) + #expect(OnboardingState.Step.authenticate.isFirst == false) + #expect(OnboardingState.Step.selectApp.isFirst == false) + #expect(OnboardingState.Step.preferences.isFirst == false) + } + + @Test("Step isLast property") + func stepIsLast() { + #expect(OnboardingState.Step.welcome.isLast == false) + #expect(OnboardingState.Step.authenticate.isLast == false) + #expect(OnboardingState.Step.selectApp.isLast == false) + #expect(OnboardingState.Step.preferences.isLast == true) + } + + @Test("canProceed returns true for welcome regardless of auth") + func canProceedWelcome() { + let state = OnboardingState() + let auth = AuthState() + auth.isAuthenticated = false + #expect(state.canProceed(authState: auth) == true) + } + + @Test("canProceed returns false for authenticate when not authenticated") + func canProceedAuthNotAuthenticated() { + let state = OnboardingState() + state.next() // authenticate + let auth = AuthState() + auth.isAuthenticated = false + #expect(state.canProceed(authState: auth) == false) + } + + @Test("canProceed returns true for authenticate when authenticated") + func canProceedAuthAuthenticated() { + let state = OnboardingState() + state.next() // authenticate + let auth = AuthState() + auth.isAuthenticated = true + #expect(state.canProceed(authState: auth) == true) + } +} diff --git a/scrobbleTests/PreferencesManagerTests.swift b/scrobbleTests/PreferencesManagerTests.swift new file mode 100644 index 0000000..873bfe3 --- /dev/null +++ b/scrobbleTests/PreferencesManagerTests.swift @@ -0,0 +1,94 @@ +import Testing +@testable import scrobble + +@Suite("PreferencesManager Tests", .serialized) +struct PreferencesManagerTests { + + private func cleanupDefaults() { + let keys = [ + "lastFmUsername", "lastFmPassword", "numberOfFriendsDisplayed", + "numberOfFriendsRecentTracksDisplayed", "trackCompletionPercentageBeforeScrobble", + "maxTrackCompletionScrobbleDelay", "useMaxTrackCompletionScrobbleDelay", + "mediaAppSource", "enableCustomScrobbler", "blueskyHandle", + "enableLastFm", "launchAtLogin", "selectedMusicAppBundleId" + ] + for key in keys { + UserDefaults.standard.removeObject(forKey: key) + } + } + + @Test("Default username is empty") + func defaultUsername() { + cleanupDefaults() + let manager = PreferencesManager() + #expect(manager.username == "") + } + + @Test("Setting username persists to UserDefaults") + func usernamePersistence() { + cleanupDefaults() + let manager = PreferencesManager() + manager.username = "testuser" + #expect(UserDefaults.standard.string(forKey: "lastFmUsername") == "testuser") + cleanupDefaults() + } + + @Test("Default numberOfFriendsDisplayed is 10") + func defaultFriendsDisplayed() { + cleanupDefaults() + let manager = PreferencesManager() + #expect(manager.numberOfFriendsDisplayed == 10) + } + + @Test("Default trackCompletionPercentageBeforeScrobble is 50") + func defaultCompletionPercentage() { + cleanupDefaults() + let manager = PreferencesManager() + #expect(manager.trackCompletionPercentageBeforeScrobble == 50) + } + + @Test("Default maxTrackCompletionScrobbleDelay is 240") + func defaultMaxDelay() { + cleanupDefaults() + let manager = PreferencesManager() + #expect(manager.maxTrackCompletionScrobbleDelay == 240) + } + + @Test("Default selectedMusicApp is Apple Music") + func defaultSelectedApp() { + cleanupDefaults() + let manager = PreferencesManager() + #expect(manager.selectedMusicApp.bundleId == "com.apple.Music") + } + + @Test("Setting selectedMusicApp updates mediaAppSource") + func selectedMusicAppUpdatesMediaAppSource() { + cleanupDefaults() + let manager = PreferencesManager() + let spotify = SupportedMusicApp.allApps.first(where: { $0.bundleId == "com.spotify.client" })! + manager.selectedMusicApp = spotify + #expect(manager.mediaAppSource == "Spotify") + cleanupDefaults() + } + + @Test("Default enableLastFm is true") + func defaultEnableLastFm() { + cleanupDefaults() + let manager = PreferencesManager() + #expect(manager.enableLastFm == true) + } + + @Test("Default enableCustomScrobbler is false") + func defaultEnableCustomScrobbler() { + cleanupDefaults() + let manager = PreferencesManager() + #expect(manager.enableCustomScrobbler == false) + } + + @Test("Default launchAtLogin is false") + func defaultLaunchAtLogin() { + cleanupDefaults() + let manager = PreferencesManager() + #expect(manager.launchAtLogin == false) + } +} diff --git a/scrobbleTests/ScrobbleDelayTests.swift b/scrobbleTests/ScrobbleDelayTests.swift new file mode 100644 index 0000000..7db49ca --- /dev/null +++ b/scrobbleTests/ScrobbleDelayTests.swift @@ -0,0 +1,54 @@ +import Testing +@testable import scrobble + +@Suite("Scrobble Delay Tests") +struct ScrobbleDelayTests { + + @Test("Returns nil for tracks under 30 seconds") + func shortTrackReturnsNil() { + let delay = calculateScrobbleDelay(trackDuration: 25, completionPercentage: 50, useMaxDelay: false, maxDelay: nil) + #expect(delay == nil) + } + + @Test("Returns nil for exactly 30-second tracks") + func exactlyThirtySecondsReturnsNil() { + let delay = calculateScrobbleDelay(trackDuration: 30, completionPercentage: 50, useMaxDelay: false, maxDelay: nil) + #expect(delay == nil) + } + + @Test("50% of a 200-second track is 100 seconds") + func fiftyPercentDelay() { + let delay = calculateScrobbleDelay(trackDuration: 200, completionPercentage: 50, useMaxDelay: false, maxDelay: nil) + #expect(delay == 100.0) + } + + @Test("75% of a 200-second track is 150 seconds") + func seventyFivePercentDelay() { + let delay = calculateScrobbleDelay(trackDuration: 200, completionPercentage: 75, useMaxDelay: false, maxDelay: nil) + #expect(delay == 150.0) + } + + @Test("Max delay caps the scrobble delay") + func maxDelayCaps() { + let delay = calculateScrobbleDelay(trackDuration: 600, completionPercentage: 50, useMaxDelay: true, maxDelay: 240) + #expect(delay == 240.0) + } + + @Test("Max delay does not affect shorter delays") + func maxDelayNoEffectOnShorterDelay() { + let delay = calculateScrobbleDelay(trackDuration: 200, completionPercentage: 50, useMaxDelay: true, maxDelay: 240) + #expect(delay == 100.0) + } + + @Test("Max delay disabled uses full percentage delay") + func maxDelayDisabled() { + let delay = calculateScrobbleDelay(trackDuration: 600, completionPercentage: 50, useMaxDelay: false, maxDelay: 240) + #expect(delay == 300.0) + } + + @Test("100% completion equals full duration") + func fullCompletionPercentage() { + let delay = calculateScrobbleDelay(trackDuration: 180, completionPercentage: 100, useMaxDelay: false, maxDelay: nil) + #expect(delay == 180.0) + } +} diff --git a/scrobbleTests/SupportedMusicAppTests.swift b/scrobbleTests/SupportedMusicAppTests.swift new file mode 100644 index 0000000..a083c38 --- /dev/null +++ b/scrobbleTests/SupportedMusicAppTests.swift @@ -0,0 +1,105 @@ +import Testing +@testable import scrobble + +@Suite("SupportedMusicApp Tests") +struct SupportedMusicAppTests { + + @Test("allApps contains expected number of apps") + func allAppsCount() { + #expect(SupportedMusicApp.allApps.count == 4) + } + + @Test("findApp by known bundle IDs returns correct apps") + func findAppByKnownBundleId() { + let appleMusic = SupportedMusicApp.findApp(byBundleId: "com.apple.Music") + #expect(appleMusic != nil) + #expect(appleMusic?.displayName == "Apple Music") + + let spotify = SupportedMusicApp.findApp(byBundleId: "com.spotify.client") + #expect(spotify != nil) + #expect(spotify?.displayName == "Spotify") + + let safari = SupportedMusicApp.findApp(byBundleId: "com.apple.safari") + #expect(safari != nil) + #expect(safari?.displayName == "Safari") + + let anyApp = SupportedMusicApp.findApp(byBundleId: "any") + #expect(anyApp != nil) + #expect(anyApp?.displayName == "Any App") + } + + @Test("findApp by unknown bundle ID returns nil") + func findAppByUnknownBundleId() { + let result = SupportedMusicApp.findApp(byBundleId: "com.unknown.app") + #expect(result == nil) + } + + @Test("findApp by display name") + func findAppByDisplayName() { + let appleMusic = SupportedMusicApp.findApp(byName: "Apple Music") + #expect(appleMusic?.bundleId == "com.apple.Music") + + let spotify = SupportedMusicApp.findApp(byName: "Spotify") + #expect(spotify?.bundleId == "com.spotify.client") + } + + @Test("findApp by alternative name") + func findAppByAlternativeName() { + let music = SupportedMusicApp.findApp(byName: "Music.app") + #expect(music?.bundleId == "com.apple.Music") + + let spotifyMac = SupportedMusicApp.findApp(byName: "Spotify for Mac") + #expect(spotifyMac?.bundleId == "com.spotify.client") + } + + @Test("findApp by name is case insensitive") + func findAppByNameCaseInsensitive() { + let upper = SupportedMusicApp.findApp(byName: "APPLE MUSIC") + #expect(upper?.bundleId == "com.apple.Music") + + let lower = SupportedMusicApp.findApp(byName: "spotify") + #expect(lower?.bundleId == "com.spotify.client") + } + + @Test("findApp by unknown name returns nil") + func findAppByUnknownName() { + let result = SupportedMusicApp.findApp(byName: "Tidal") + #expect(result == nil) + } + + @Test("Each app has non-empty properties") + func appPropertiesNonEmpty() { + for app in SupportedMusicApp.allApps { + #expect(!app.bundleId.isEmpty) + #expect(!app.displayName.isEmpty) + #expect(!app.icon.isEmpty) + } + } + + @Test("Apple Music is first in allApps") + func appleMusicIsFirst() { + #expect(SupportedMusicApp.allApps.first?.bundleId == "com.apple.Music") + } + + @Test("Any App has empty alternativeNames") + func anyAppEmptyAlternativeNames() { + let anyApp = SupportedMusicApp.findApp(byBundleId: "any") + #expect(anyApp?.alternativeNames.isEmpty == true) + } + + @Test("Each app has unique bundle ID") + func uniqueBundleIds() { + let bundleIds = SupportedMusicApp.allApps.map(\.bundleId) + #expect(Set(bundleIds).count == bundleIds.count) + } + + @Test("SupportedMusicApp round-trips through Codable") + func codableRoundTrip() throws { + let app = SupportedMusicApp.allApps.first! + let data = try JSONEncoder().encode(app) + let decoded = try JSONDecoder().decode(SupportedMusicApp.self, from: data) + #expect(decoded.bundleId == app.bundleId) + #expect(decoded.displayName == app.displayName) + #expect(decoded.icon == app.icon) + } +} diff --git a/scrobbleTests/UpdateCheckerTests.swift b/scrobbleTests/UpdateCheckerTests.swift new file mode 100644 index 0000000..4ff05f6 --- /dev/null +++ b/scrobbleTests/UpdateCheckerTests.swift @@ -0,0 +1,70 @@ +import Testing +@testable import scrobble + +@Suite("UpdateChecker Tests") +struct UpdateCheckerTests { + + @Test("No update available when version info is nil") + func nilVersionNoUpdate() { + let checker = UpdateChecker() + #expect(checker.updateAvailable == false) + } + + @Test("Update available when latest version is higher") + func higherVersionAvailable() { + let checker = UpdateChecker() + checker.latestVersion = "99.0" + checker.latestBuildNumber = "1" + #expect(checker.updateAvailable == true) + } + + @Test("Update available when same version but higher build") + func higherBuildAvailable() { + let checker = UpdateChecker() + let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" + checker.latestVersion = currentVersion + checker.latestBuildNumber = "9999" + #expect(checker.updateAvailable == true) + } + + @Test("No update when version is lower") + func lowerVersionNoUpdate() { + let checker = UpdateChecker() + checker.latestVersion = "0.1" + checker.latestBuildNumber = "99" + #expect(checker.updateAvailable == false) + } + + @Test("No update when same version and same build") + func sameVersionSameBuild() { + let checker = UpdateChecker() + let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" + let currentBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0" + checker.latestVersion = currentVersion + checker.latestBuildNumber = currentBuild + #expect(checker.updateAvailable == false) + } + + @Test("isChecking defaults to false") + func isCheckingDefault() { + let checker = UpdateChecker() + #expect(checker.isChecking == false) + } + + @Test("error defaults to nil") + func errorDefault() { + let checker = UpdateChecker() + #expect(checker.error == nil) + } + + @Test("GitHubRelease decodes correctly") + func githubReleaseDecode() throws { + let json = """ + {"tag_name": "v1.6.7", "html_url": "https://github.com/test/test/releases/v1.6.7"} + """ + let data = json.data(using: .utf8)! + let release = try JSONDecoder().decode(GitHubRelease.self, from: data) + #expect(release.tagName == "v1.6.7") + #expect(release.htmlURL == "https://github.com/test/test/releases/v1.6.7") + } +}