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")
+ }
+}