From 188666e2392ae54ac5ca792e01cf66e62f7b9343 Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Thu, 29 Jan 2026 16:07:14 -0800 Subject: [PATCH 1/8] release actions --- .github/workflows/release.yml | 118 +++++- scrobble.xcodeproj/project.pbxproj | 32 ++ scrobble/Models/OnboardingState.swift | 73 ++++ .../OnboardingAppSelectionView.swift | 177 +++++++++ .../Views/Onboarding/OnboardingAuthView.swift | 341 ++++++++++++++++++ .../Onboarding/OnboardingContainerView.swift | 141 ++++++++ .../OnboardingPreferencesView.swift | 142 ++++++++ .../Onboarding/OnboardingWelcomeView.swift | 94 +++++ scrobble/scrobbleApp.swift | 54 ++- 9 files changed, 1155 insertions(+), 17 deletions(-) create mode 100644 scrobble/Models/OnboardingState.swift create mode 100644 scrobble/Views/Onboarding/OnboardingAppSelectionView.swift create mode 100644 scrobble/Views/Onboarding/OnboardingAuthView.swift create mode 100644 scrobble/Views/Onboarding/OnboardingContainerView.swift create mode 100644 scrobble/Views/Onboarding/OnboardingPreferencesView.swift create mode 100644 scrobble/Views/Onboarding/OnboardingWelcomeView.swift 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..949e83b 100644 --- a/scrobble.xcodeproj/project.pbxproj +++ b/scrobble.xcodeproj/project.pbxproj @@ -49,6 +49,12 @@ A6F2FEEF2EE79E47003826F7 /* Secrets.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = A6F2FEED2EE79E47003826F7 /* Secrets.xcconfig */; }; A6F2FEF22EE7A429003826F7 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F2FEF12EE7A429003826F7 /* Logger.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 */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -111,6 +117,12 @@ A6F2FEED2EE79E47003826F7 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; A6F2FEF12EE7A429003826F7 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -203,6 +215,7 @@ A6F2FEE72EE79A20003826F7 /* Views */ = { isa = PBXGroup; children = ( + A6ONBOARD000D /* Onboarding */, A62299162EE7D46100592239 /* ErrorMessageView.swift */, A64B5FAB2E7E09D2000F1BAE /* AppSelectionView.swift */, A63A2B182D2717A1000BC92E /* FriendsView.swift */, @@ -221,6 +234,18 @@ path = Views; sourceTree = ""; }; + A6ONBOARD000D /* Onboarding */ = { + isa = PBXGroup; + children = ( + A6ONBOARD0004 /* OnboardingContainerView.swift */, + A6ONBOARD0006 /* OnboardingWelcomeView.swift */, + A6ONBOARD0008 /* OnboardingAuthView.swift */, + A6ONBOARD000A /* OnboardingAppSelectionView.swift */, + A6ONBOARD000C /* OnboardingPreferencesView.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; A6F2FEE82EE79A26003826F7 /* Models */ = { isa = PBXGroup; children = ( @@ -229,6 +254,7 @@ A64304C52D52FBA1001998B6 /* AppState.swift */, A6F2FEEB2EE79C16003826F7 /* FriendsModel.swift */, A6F2131D2C8C10AB00E1D23B /* PreferencesManager.swift */, + A6ONBOARD0002 /* OnboardingState.swift */, ); path = Models; sourceTree = ""; @@ -361,6 +387,12 @@ 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; }; diff --git a/scrobble/Models/OnboardingState.swift b/scrobble/Models/OnboardingState.swift new file mode 100644 index 0000000..3172c52 --- /dev/null +++ b/scrobble/Models/OnboardingState.swift @@ -0,0 +1,73 @@ +// +// OnboardingState.swift +// scrobble +// +// Created by Claude on 1/12/26. +// + +import Foundation +import SwiftUI +import Observation + +@Observable +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 + + 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 } + currentStep = Step.allCases[currentIndex + 1] + } + + func back() { + guard let currentIndex = Step.allCases.firstIndex(of: currentStep), + currentIndex > 0 else { return } + 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 = UserDefaults.standard.string(forKey: "lastfm_session_key") != nil + return !completed && !hasSession + } +} diff --git a/scrobble/Views/Onboarding/OnboardingAppSelectionView.swift b/scrobble/Views/Onboarding/OnboardingAppSelectionView.swift new file mode 100644 index 0000000..859fb1f --- /dev/null +++ b/scrobble/Views/Onboarding/OnboardingAppSelectionView.swift @@ -0,0 +1,177 @@ +// +// 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, 32) + } + + // 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, 40) + + // Running apps indicator + if !runningApps.isEmpty { + HStack(spacing: 4) { + Circle() + .fill(.green) + .frame(width: 6, height: 6) + 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, 16) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? Color.accentColor : Color.clear) + } + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .strokeBorder(isSelected ? Color.clear : Color.secondary.opacity(0.2), lineWidth: 1) + } + } + .buttonStyle(.plain) + } +} + +#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..ec9fe14 --- /dev/null +++ b/scrobble/Views/Onboarding/OnboardingAuthView.swift @@ -0,0 +1,341 @@ +// +// 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: 60)) + .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, 32) + } + + 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) + .scaleEffect(1.2) + + 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: 60)) + .foregroundStyle(.green) + + 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, 32) + + 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..1ee6828 --- /dev/null +++ b/scrobble/Views/Onboarding/OnboardingContainerView.swift @@ -0,0 +1,141 @@ +// +// 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: 8) { + 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, 8) + + // Step title + Text(onboardingState.currentStep.title) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 16) + + // Content area + ZStack { + switch onboardingState.currentStep { + case .welcome: + OnboardingWelcomeView() + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + + case .authenticate: + OnboardingAuthView() + .environment(scrobbler) + .environment(authState) + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + + case .selectApp: + OnboardingAppSelectionView() + .environment(preferencesManager) + .environment(scrobbler) + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + + case .preferences: + OnboardingPreferencesView() + .environment(preferencesManager) + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + } + } + .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 func stepColor(for step: OnboardingState.Step) -> Color { + if step.rawValue < onboardingState.currentStep.rawValue { + return .accentColor + } else if step == onboardingState.currentStep { + return .accentColor + } else { + return .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) +} diff --git a/scrobble/Views/Onboarding/OnboardingPreferencesView.swift b/scrobble/Views/Onboarding/OnboardingPreferencesView.swift new file mode 100644 index 0000000..8cbb04a --- /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, 32) + } + + // 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, 32) + + 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..bdfad0f --- /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("Welcome to Scrobble") + .font(.largeTitle) + .fontWeight(.bold) + + Text("Track your music listening across Last.fm") + .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, 32) + .padding(.top, 8) + + Spacer() + + Text("Let's get you 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/scrobbleApp.swift b/scrobble/scrobbleApp.swift index 0299f20..ce55cf4 100644 --- a/scrobble/scrobbleApp.swift +++ b/scrobble/scrobbleApp.swift @@ -17,7 +17,8 @@ struct scrobbleApp: App { @State private var appState = AppState() @State private var authState: AuthState @State private var updateChecker = UpdateChecker() - + @State private var hasCheckedOnboarding = false + private var cancellables = Set() init() { @@ -52,13 +53,13 @@ struct scrobbleApp: App { var body: some Scene { MenuBarExtra { VStack(spacing: 10) { - ContentView() + ContentView() .environment(scrobbler) .environment(preferencesManager) .environment(authState) - + Divider() - + MenuButtonsView() .environment(authState) .environment(appState) @@ -67,8 +68,11 @@ struct scrobbleApp: App { .containerBackground( .ultraThinMaterial, for: .window ) - .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) - + .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) + .background { + OnboardingLauncher(hasCheckedOnboarding: $hasCheckedOnboarding) + } + } label: { Image(systemName: "music.note") } @@ -141,8 +145,42 @@ 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 +struct OnboardingLauncher: View { + @Environment(\.openWindow) private var openWindow + @Binding var hasCheckedOnboarding: Bool + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .onAppear { + guard !hasCheckedOnboarding else { return } + hasCheckedOnboarding = true + + if OnboardingState.needsOnboarding { + // Small delay to ensure app is fully initialized + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + openWindow(id: "onboarding") + NSApp.activate(ignoringOtherApps: true) + } + } + } } } From 858a3307ac402d7643c1da9e5a3d2406d55eb264 Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Fri, 30 Jan 2026 14:57:36 -0800 Subject: [PATCH 2/8] pjt --- scrobble.xcodeproj/project.pbxproj | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/scrobble.xcodeproj/project.pbxproj b/scrobble.xcodeproj/project.pbxproj index 949e83b..8dd3397 100644 --- a/scrobble.xcodeproj/project.pbxproj +++ b/scrobble.xcodeproj/project.pbxproj @@ -234,18 +234,6 @@ path = Views; sourceTree = ""; }; - A6ONBOARD000D /* Onboarding */ = { - isa = PBXGroup; - children = ( - A6ONBOARD0004 /* OnboardingContainerView.swift */, - A6ONBOARD0006 /* OnboardingWelcomeView.swift */, - A6ONBOARD0008 /* OnboardingAuthView.swift */, - A6ONBOARD000A /* OnboardingAppSelectionView.swift */, - A6ONBOARD000C /* OnboardingPreferencesView.swift */, - ); - path = Onboarding; - sourceTree = ""; - }; A6F2FEE82EE79A26003826F7 /* Models */ = { isa = PBXGroup; children = ( @@ -276,6 +264,18 @@ 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 = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -535,7 +535,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; @@ -585,7 +585,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; From 24538c1276ca8c0fb048f3c8d081ff02a22afaea Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Thu, 19 Feb 2026 23:32:11 -0800 Subject: [PATCH 3/8] imp --- scrobble/Utils/GlassEffect+Compat.swift | 22 +++++++++++++------ scrobble/Views/MenuButtonsView.swift | 1 + .../Onboarding/OnboardingContainerView.swift | 2 ++ .../Onboarding/OnboardingWelcomeView.swift | 6 ++--- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/scrobble/Utils/GlassEffect+Compat.swift b/scrobble/Utils/GlassEffect+Compat.swift index 2ec962d..5598a97 100644 --- a/scrobble/Utils/GlassEffect+Compat.swift +++ b/scrobble/Utils/GlassEffect+Compat.swift @@ -54,20 +54,28 @@ extension View { // 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: 6) + .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: 6)) + .opacity(configuration.isPressed ? 0.85 : 1.0) } } diff --git a/scrobble/Views/MenuButtonsView.swift b/scrobble/Views/MenuButtonsView.swift index 079c8e1..325b34e 100644 --- a/scrobble/Views/MenuButtonsView.swift +++ b/scrobble/Views/MenuButtonsView.swift @@ -64,4 +64,5 @@ struct MenuButtonsView: View { #Preview { MenuButtonsView() + .environment(AppState()) } diff --git a/scrobble/Views/Onboarding/OnboardingContainerView.swift b/scrobble/Views/Onboarding/OnboardingContainerView.swift index 1ee6828..99082c1 100644 --- a/scrobble/Views/Onboarding/OnboardingContainerView.swift +++ b/scrobble/Views/Onboarding/OnboardingContainerView.swift @@ -138,4 +138,6 @@ struct OnboardingContainerView: View { .environment(prefManager) .environment(Scrobbler(lastFmManager: lastFmManager, preferencesManager: prefManager)) .environment(authState) + .padding(100) + } diff --git a/scrobble/Views/Onboarding/OnboardingWelcomeView.swift b/scrobble/Views/Onboarding/OnboardingWelcomeView.swift index bdfad0f..5bc4d94 100644 --- a/scrobble/Views/Onboarding/OnboardingWelcomeView.swift +++ b/scrobble/Views/Onboarding/OnboardingWelcomeView.swift @@ -21,11 +21,11 @@ struct OnboardingWelcomeView: View { // Welcome text VStack(spacing: 8) { - Text("Welcome to Scrobble") + Text("scrobble") .font(.largeTitle) .fontWeight(.bold) - Text("Track your music listening across Last.fm") + Text("track your desktop music listening with last.fm compatilble services") .font(.title3) .foregroundStyle(.secondary) } @@ -55,7 +55,7 @@ struct OnboardingWelcomeView: View { Spacer() - Text("Let's get you set up in just a few steps.") + Text("Get set up in just a few steps.") .font(.subheadline) .foregroundStyle(.secondary) .padding(.bottom, 8) From 3069a93708728dac63b71d69afe0a4765279578f Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Fri, 20 Feb 2026 13:52:49 -0800 Subject: [PATCH 4/8] clean up views --- scrobble.xcodeproj/project.pbxproj | 4 + scrobble/Models/OnboardingState.swift | 3 + scrobble/Utils/DesignTokens.swift | 57 +++++ scrobble/Utils/GlassEffect+Compat.swift | 79 ++++-- scrobble/Views/AppSelectionView.swift | 69 +++--- scrobble/Views/ErrorMessageView.swift | 26 +- scrobble/Views/FriendCardView.swift | 45 ++-- scrobble/Views/FriendsView.swift | 25 +- scrobble/Views/LastFMAuthSheetView.swift | 20 +- scrobble/Views/LastFMWebAuthView.swift | 44 +--- scrobble/Views/LastFMWebAuthViewLegacy.swift | 10 +- .../Views/LaunchAtLoginSettingsView.swift | 36 ++- scrobble/Views/MenuButtonsView.swift | 34 +-- .../OnboardingAppSelectionView.swift | 18 +- .../Views/Onboarding/OnboardingAuthView.swift | 11 +- .../Onboarding/OnboardingContainerView.swift | 48 ++-- .../OnboardingPreferencesView.swift | 4 +- .../Onboarding/OnboardingWelcomeView.swift | 4 +- scrobble/Views/PreferencesView.swift | 231 ++++++------------ scrobble/Views/ScrobblingServicesView.swift | 4 +- scrobble/Views/ScrobblingView.swift | 226 +++-------------- scrobble/Views/ServicesStatusView.swift | 33 ++- scrobble/Views/UpdateView.swift | 35 +-- scrobble/scrobbleApp.swift | 97 +++----- 24 files changed, 475 insertions(+), 688 deletions(-) create mode 100644 scrobble/Utils/DesignTokens.swift diff --git a/scrobble.xcodeproj/project.pbxproj b/scrobble.xcodeproj/project.pbxproj index 8dd3397..d87f15d 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 */; }; @@ -99,6 +100,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 = ""; }; @@ -260,6 +262,7 @@ children = ( A6F2FEF12EE7A429003826F7 /* Logger.swift */, A6COMPAT012EFBAA0000002 /* GlassEffect+Compat.swift */, + A6DESIGN012EFBAA0000002 /* DesignTokens.swift */, ); path = Utils; sourceTree = ""; @@ -353,6 +356,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 */, diff --git a/scrobble/Models/OnboardingState.swift b/scrobble/Models/OnboardingState.swift index 3172c52..4631679 100644 --- a/scrobble/Models/OnboardingState.swift +++ b/scrobble/Models/OnboardingState.swift @@ -31,6 +31,7 @@ class OnboardingState { } var currentStep: Step = .welcome + var isGoingForward: Bool = true func canProceed(authState: AuthState) -> Bool { switch currentStep { @@ -48,12 +49,14 @@ class OnboardingState { 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] } 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 5598a97..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,6 +54,33 @@ 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 lightweight fallback for glass buttons. @@ -65,7 +94,7 @@ struct CompatGlassButtonStyleLegacy: ButtonStyle { .padding(.horizontal, 10) .padding(.vertical, 6) .background { - RoundedRectangle(cornerRadius: 6) + RoundedRectangle(cornerRadius: DesignTokens.cornerRadiusSmall) .fill( isSelected ? Color.accentColor.opacity(0.2) @@ -74,7 +103,7 @@ struct CompatGlassButtonStyleLegacy: ButtonStyle { : Color.primary.opacity(0.04) ) } - .contentShape(RoundedRectangle(cornerRadius: 6)) + .contentShape(RoundedRectangle(cornerRadius: DesignTokens.cornerRadiusSmall)) .opacity(configuration.isPressed ? 0.85 : 1.0) } } @@ -82,13 +111,17 @@ struct CompatGlassButtonStyleLegacy: ButtonStyle { // 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/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 325b34e..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) } diff --git a/scrobble/Views/Onboarding/OnboardingAppSelectionView.swift b/scrobble/Views/Onboarding/OnboardingAppSelectionView.swift index 859fb1f..07ff838 100644 --- a/scrobble/Views/Onboarding/OnboardingAppSelectionView.swift +++ b/scrobble/Views/Onboarding/OnboardingAppSelectionView.swift @@ -30,7 +30,7 @@ struct OnboardingAppSelectionView: View { .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .padding(.horizontal, 32) + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) } // App selection grid @@ -49,7 +49,7 @@ struct OnboardingAppSelectionView: View { } } } - .padding(.horizontal, 40) + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) // Running apps indicator if !runningApps.isEmpty { @@ -57,6 +57,7 @@ struct OnboardingAppSelectionView: View { Circle() .fill(.green) .frame(width: 6, height: 6) + .accessibilityHidden(true) Text("Running: \(runningApps.map { $0.displayName }.joined(separator: ", "))") .font(.caption) .foregroundStyle(.secondary) @@ -144,19 +145,20 @@ struct OnboardingAppButton: View { .foregroundStyle(isSelected ? .white : .primary) } .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .padding(.horizontal, 12) + .padding(.vertical, DesignTokens.spacingLarge) + .padding(.horizontal, DesignTokens.spacingSection) .background { - RoundedRectangle(cornerRadius: 12) - .fill(isSelected ? Color.accentColor : Color.clear) + RoundedRectangle(cornerRadius: DesignTokens.cornerRadiusLarge) + .fill(isSelected ? Color.accentColor : Color.primary.opacity(0.04)) } - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) .overlay { - RoundedRectangle(cornerRadius: 12) + 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 : []) } } diff --git a/scrobble/Views/Onboarding/OnboardingAuthView.swift b/scrobble/Views/Onboarding/OnboardingAuthView.swift index ec9fe14..2b8d8bf 100644 --- a/scrobble/Views/Onboarding/OnboardingAuthView.swift +++ b/scrobble/Views/Onboarding/OnboardingAuthView.swift @@ -40,7 +40,7 @@ struct OnboardingAuthView: View { Spacer() Image(systemName: "link.circle.fill") - .font(.system(size: 60)) + .font(.system(size: DesignTokens.onboardingIconSize)) .foregroundStyle(Color.accentColor) VStack(spacing: 8) { @@ -52,7 +52,7 @@ struct OnboardingAuthView: View { .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .padding(.horizontal, 32) + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) } Button { @@ -96,7 +96,7 @@ struct OnboardingAuthView: View { VStack(spacing: 16) { ProgressView() .progressViewStyle(.circular) - .scaleEffect(1.2) + .controlSize(.large) Text("Completing authentication...") .font(.headline) @@ -124,8 +124,9 @@ struct OnboardingAuthView: View { Spacer() Image(systemName: "checkmark.circle.fill") - .font(.system(size: 60)) + .font(.system(size: DesignTokens.onboardingIconSize)) .foregroundStyle(.green) + .accessibilityLabel("Successfully connected") VStack(spacing: 8) { Text("Connected!") @@ -144,7 +145,7 @@ struct OnboardingAuthView: View { .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .padding(.horizontal, 32) + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) Spacer() } diff --git a/scrobble/Views/Onboarding/OnboardingContainerView.swift b/scrobble/Views/Onboarding/OnboardingContainerView.swift index 99082c1..00a6c66 100644 --- a/scrobble/Views/Onboarding/OnboardingContainerView.swift +++ b/scrobble/Views/Onboarding/OnboardingContainerView.swift @@ -17,7 +17,7 @@ struct OnboardingContainerView: View { var body: some View { VStack(spacing: 0) { // Progress indicator - HStack(spacing: 8) { + HStack(spacing: DesignTokens.spacingDefault) { ForEach(OnboardingState.Step.allCases, id: \.self) { step in Circle() .fill(stepColor(for: step)) @@ -26,49 +26,39 @@ struct OnboardingContainerView: View { } } .padding(.top, 20) - .padding(.bottom, 8) + .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(.caption) + .font(.subheadline) .foregroundStyle(.secondary) - .padding(.bottom, 16) + .padding(.bottom, DesignTokens.spacingLarge) // Content area ZStack { switch onboardingState.currentStep { case .welcome: OnboardingWelcomeView() - .transition(.asymmetric( - insertion: .move(edge: .trailing).combined(with: .opacity), - removal: .move(edge: .leading).combined(with: .opacity) - )) + .transition(stepTransition) case .authenticate: OnboardingAuthView() .environment(scrobbler) .environment(authState) - .transition(.asymmetric( - insertion: .move(edge: .trailing).combined(with: .opacity), - removal: .move(edge: .leading).combined(with: .opacity) - )) + .transition(stepTransition) case .selectApp: OnboardingAppSelectionView() .environment(preferencesManager) .environment(scrobbler) - .transition(.asymmetric( - insertion: .move(edge: .trailing).combined(with: .opacity), - removal: .move(edge: .leading).combined(with: .opacity) - )) + .transition(stepTransition) case .preferences: OnboardingPreferencesView() .environment(preferencesManager) - .transition(.asymmetric( - insertion: .move(edge: .trailing).combined(with: .opacity), - removal: .move(edge: .leading).combined(with: .opacity) - )) + .transition(stepTransition) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -108,14 +98,17 @@ struct OnboardingContainerView: View { .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 { - if step.rawValue < onboardingState.currentStep.rawValue { - return .accentColor - } else if step == onboardingState.currentStep { - return .accentColor - } else { - return .secondary.opacity(0.3) - } + step.rawValue <= onboardingState.currentStep.rawValue + ? .accentColor + : .secondary.opacity(0.3) } private func completeOnboarding() { @@ -139,5 +132,4 @@ struct OnboardingContainerView: View { .environment(Scrobbler(lastFmManager: lastFmManager, preferencesManager: prefManager)) .environment(authState) .padding(100) - } diff --git a/scrobble/Views/Onboarding/OnboardingPreferencesView.swift b/scrobble/Views/Onboarding/OnboardingPreferencesView.swift index 8cbb04a..8a5cf68 100644 --- a/scrobble/Views/Onboarding/OnboardingPreferencesView.swift +++ b/scrobble/Views/Onboarding/OnboardingPreferencesView.swift @@ -34,7 +34,7 @@ struct OnboardingPreferencesView: View { .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .padding(.horizontal, 32) + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) } // Settings form @@ -86,7 +86,7 @@ struct OnboardingPreferencesView: View { .foregroundStyle(.tertiary) } } - .padding(.horizontal, 32) + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) Spacer() diff --git a/scrobble/Views/Onboarding/OnboardingWelcomeView.swift b/scrobble/Views/Onboarding/OnboardingWelcomeView.swift index 5bc4d94..15e0bf2 100644 --- a/scrobble/Views/Onboarding/OnboardingWelcomeView.swift +++ b/scrobble/Views/Onboarding/OnboardingWelcomeView.swift @@ -25,7 +25,7 @@ struct OnboardingWelcomeView: View { .font(.largeTitle) .fontWeight(.bold) - Text("track your desktop music listening with last.fm compatilble services") + Text("Track your desktop music listening with Last.fm compatible services") .font(.title3) .foregroundStyle(.secondary) } @@ -50,7 +50,7 @@ struct OnboardingWelcomeView: View { description: "Quick access without cluttering your dock" ) } - .padding(.horizontal, 32) + .padding(.horizontal, DesignTokens.contentPaddingHorizontal) .padding(.top, 8) Spacer() 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 ce55cf4..b377a01 100644 --- a/scrobble/scrobbleApp.swift +++ b/scrobble/scrobbleApp.swift @@ -6,7 +6,6 @@ // import SwiftUI -import Combine import Observation @main @@ -19,40 +18,25 @@ struct scrobbleApp: App { @State private var updateChecker = UpdateChecker() @State private var hasCheckedOnboarding = false - private var cancellables = Set() - 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) { + VStack(spacing: DesignTokens.spacingDefault) { ContentView() .environment(scrobbler) .environment(preferencesManager) @@ -64,7 +48,7 @@ struct scrobbleApp: App { .environment(authState) .environment(appState) } - .padding(8) + .padding(DesignTokens.spacingDefault) .containerBackground( .ultraThinMaterial, for: .window ) @@ -72,12 +56,11 @@ struct scrobbleApp: App { .background { OnboardingLauncher(hasCheckedOnboarding: $hasCheckedOnboarding) } - } label: { Image(systemName: "music.note") + .accessibilityLabel("Scrobble") } .menuBarExtraStyle(.window) - WindowGroup("Scrobbler", id: "scrobbler") { ContentView() @@ -87,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 { @@ -157,11 +133,13 @@ struct scrobbleApp: App { .windowResizability(.contentSize) .defaultPosition(.center) } + } // MARK: - Onboarding Launcher -/// Helper view that checks if onboarding should be shown and opens the window +/// 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 @@ -169,16 +147,14 @@ struct OnboardingLauncher: View { var body: some View { Color.clear .frame(width: 0, height: 0) - .onAppear { + .task { guard !hasCheckedOnboarding else { return } hasCheckedOnboarding = true if OnboardingState.needsOnboarding { - // Small delay to ensure app is fully initialized - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - openWindow(id: "onboarding") - NSApp.activate(ignoringOtherApps: true) - } + try? await Task.sleep(for: .milliseconds(500)) + openWindow(id: "onboarding") + NSApp.activate() } } } @@ -186,7 +162,6 @@ struct OnboardingLauncher: View { class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { - // Setup URL event handling for Last.fm authentication callbacks NSAppleEventManager.shared().setEventHandler( self, @@ -195,37 +170,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, From a990893d52e352271e52948768e77a0ec1d802a7 Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Fri, 20 Feb 2026 14:25:28 -0800 Subject: [PATCH 5/8] add tests --- scrobble.xcodeproj/project.pbxproj | 147 ++++++++++++ .../xcshareddata/xcschemes/scrobble.xcscheme | 78 +++++++ .../xcschemes/scrobbleTests.xcscheme | 70 ++++++ .../xcschemes/xcschememanagement.plist | 5 + scrobble/LastFmManagerDesktop.swift | 30 ++- scrobble/Scrobbler.swift | 11 + scrobbleTests/AuthStateTests.swift | 115 ++++++++++ scrobbleTests/CodableTests.swift | 212 ++++++++++++++++++ scrobbleTests/CryptoUtilsTests.swift | 60 +++++ scrobbleTests/DesignTokensTests.swift | 43 ++++ scrobbleTests/OnboardingStateTests.swift | 127 +++++++++++ scrobbleTests/PreferencesManagerTests.swift | 94 ++++++++ scrobbleTests/ScrobbleDelayTests.swift | 54 +++++ scrobbleTests/SupportedMusicAppTests.swift | 105 +++++++++ scrobbleTests/UpdateCheckerTests.swift | 70 ++++++ 15 files changed, 1209 insertions(+), 12 deletions(-) create mode 100644 scrobble.xcodeproj/xcshareddata/xcschemes/scrobble.xcscheme create mode 100644 scrobble.xcodeproj/xcshareddata/xcschemes/scrobbleTests.xcscheme create mode 100644 scrobbleTests/AuthStateTests.swift create mode 100644 scrobbleTests/CodableTests.swift create mode 100644 scrobbleTests/CryptoUtilsTests.swift create mode 100644 scrobbleTests/DesignTokensTests.swift create mode 100644 scrobbleTests/OnboardingStateTests.swift create mode 100644 scrobbleTests/PreferencesManagerTests.swift create mode 100644 scrobbleTests/ScrobbleDelayTests.swift create mode 100644 scrobbleTests/SupportedMusicAppTests.swift create mode 100644 scrobbleTests/UpdateCheckerTests.swift diff --git a/scrobble.xcodeproj/project.pbxproj b/scrobble.xcodeproj/project.pbxproj index d87f15d..eeb3b45 100644 --- a/scrobble.xcodeproj/project.pbxproj +++ b/scrobble.xcodeproj/project.pbxproj @@ -56,6 +56,15 @@ 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 */ @@ -72,6 +81,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 = ""; }; @@ -125,6 +144,16 @@ 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 */ @@ -138,6 +167,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A6TESTFB0001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -154,6 +190,7 @@ isa = PBXGroup; children = ( A6F212FE2C8C021800E1D23B /* scrobble */, + A6TESTGR0001 /* scrobbleTests */, A6F212FD2C8C021800E1D23B /* Products */, A6F213142C8C05E800E1D23B /* Frameworks */, A6F2FEEE2EE79E47003826F7 /* Configs */, @@ -164,6 +201,7 @@ isa = PBXGroup; children = ( A6F212FC2C8C021800E1D23B /* scrobble.app */, + A6TESTFR000A /* scrobbleTests.xctest */, ); name = Products; sourceTree = ""; @@ -279,6 +317,22 @@ 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 */ @@ -300,6 +354,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 */ @@ -314,6 +385,10 @@ CreatedOnToolsVersion = 15.4; LastSwiftMigration = 2600; }; + A6TESTNT0001 = { + CreatedOnToolsVersion = 15.4; + TestTargetID = A6F212FB2C8C021800E1D23B; + }; }; }; buildConfigurationList = A6F212F72C8C021800E1D23B /* Build configuration list for PBXProject "scrobble" */; @@ -333,6 +408,7 @@ projectRoot = ""; targets = ( A6F212FB2C8C021800E1D23B /* scrobble */, + A6TESTNT0001 /* scrobbleTests */, ); }; /* End PBXProject section */ @@ -400,8 +476,32 @@ ); 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; @@ -626,6 +726,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 */ @@ -647,6 +785,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..667c962 100644 --- a/scrobble/LastFmManagerDesktop.swift +++ b/scrobble/LastFmManagerDesktop.swift @@ -26,6 +26,23 @@ struct Secrets { } } +// 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 { @@ -612,18 +629,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() + return createLastFmSignature(parameters: parameters, secret: apiSecret) } } 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/scrobbleTests/AuthStateTests.swift b/scrobbleTests/AuthStateTests.swift new file mode 100644 index 0000000..e2592a7 --- /dev/null +++ b/scrobbleTests/AuthStateTests.swift @@ -0,0 +1,115 @@ +import Testing +@testable import scrobble + +@Suite("AuthState Tests", .serialized) +struct AuthStateTests { + + private func cleanUserDefaults() { + UserDefaults.standard.removeObject(forKey: "lastfm_session_key") + } + + @Test("Initial state without session key") + func initialStateNoSession() { + cleanUserDefaults() + 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") + func initialStateWithSession() { + UserDefaults.standard.set("test-session-key", forKey: "lastfm_session_key") + let state = AuthState() + #expect(state.isAuthenticated == true) + cleanUserDefaults() + } + + @Test("startAuth resets error and flags") + func startAuth() { + cleanUserDefaults() + 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() { + cleanUserDefaults() + 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() { + cleanUserDefaults() + 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() { + UserDefaults.standard.set("test-key", forKey: "lastfm_session_key") + let state = AuthState() + #expect(state.isAuthenticated == true) + state.signOut() + #expect(state.isAuthenticated == false) + #expect(state.authError == nil) + #expect(UserDefaults.standard.string(forKey: "lastfm_session_key") == nil) + } + + @Test("completeAuth failure message is correct") + func completeAuthFailureMessage() { + cleanUserDefaults() + let state = AuthState() + state.completeAuth(success: false) + #expect(state.authError == "Authentication failed or was cancelled") + } + + @Test("State transition: start then complete success") + func stateTransitionSuccess() { + cleanUserDefaults() + 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() { + cleanUserDefaults() + 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() { + cleanUserDefaults() + 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..a81ba15 --- /dev/null +++ b/scrobbleTests/OnboardingStateTests.swift @@ -0,0 +1,127 @@ +import Testing +@testable import scrobble + +@Suite("OnboardingState Tests") +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") + } +} From 998722a1109221ca154f82e8f578eb78cab94d64 Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Sat, 21 Mar 2026 13:35:31 -0700 Subject: [PATCH 6/8] store session key in keychain --- scrobble.xcodeproj/project.pbxproj | 4 ++ scrobble/LastFmManagerDesktop.swift | 95 ++++++++++++++++++++------- scrobble/Models/AuthState.swift | 7 +- scrobble/Models/OnboardingState.swift | 3 +- scrobble/Utils/KeychainHelper.swift | 61 +++++++++++++++++ scrobbleTests/AuthStateTests.swift | 39 +++++++---- 6 files changed, 169 insertions(+), 40 deletions(-) create mode 100644 scrobble/Utils/KeychainHelper.swift diff --git a/scrobble.xcodeproj/project.pbxproj b/scrobble.xcodeproj/project.pbxproj index eeb3b45..a5ea16b 100644 --- a/scrobble.xcodeproj/project.pbxproj +++ b/scrobble.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 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 */; }; @@ -137,6 +138,7 @@ 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 = ""; }; @@ -299,6 +301,7 @@ isa = PBXGroup; children = ( A6F2FEF12EE7A429003826F7 /* Logger.swift */, + A6KEYCHAIN02 /* KeychainHelper.swift */, A6COMPAT012EFBAA0000002 /* GlassEffect+Compat.swift */, A6DESIGN012EFBAA0000002 /* DesignTokens.swift */, ); @@ -461,6 +464,7 @@ 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 */, diff --git a/scrobble/LastFmManagerDesktop.swift b/scrobble/LastFmManagerDesktop.swift index 667c962..d1749b8 100644 --- a/scrobble/LastFmManagerDesktop.swift +++ b/scrobble/LastFmManagerDesktop.swift @@ -164,9 +164,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 } @@ -177,30 +188,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) .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) @@ -268,11 +307,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) @@ -282,6 +324,7 @@ class LastFmDesktopManager: LastFmManagerType { authState.isAuthenticating = false authState.showingAuthSheet = false authState.authError = nil + authState.isAuthenticated = true authStatus = .authenticated authenticationSubject.send(()) @@ -307,8 +350,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)") } @@ -319,7 +362,7 @@ 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.. 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] = [ @@ -435,7 +483,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 { @@ -493,8 +541,8 @@ class LastFmDesktopManager: LastFmManagerType { try await Task.sleep(nanoseconds: 3_000_000_000) 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)) @@ -665,7 +713,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..76bdf92 100644 --- a/scrobble/Models/AuthState.swift +++ b/scrobble/Models/AuthState.swift @@ -18,8 +18,9 @@ class AuthState { 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() { @@ -39,6 +40,8 @@ class AuthState { } 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 index 4631679..e2f21a4 100644 --- a/scrobble/Models/OnboardingState.swift +++ b/scrobble/Models/OnboardingState.swift @@ -70,7 +70,8 @@ class OnboardingState { static var needsOnboarding: Bool { let completed = UserDefaults.standard.bool(forKey: "hasCompletedOnboarding") - let hasSession = UserDefaults.standard.string(forKey: "lastfm_session_key") != nil + let hasSession = KeychainHelper.load(key: "lastfm_session_key") != nil + || UserDefaults.standard.string(forKey: "lastfm_session_key") != nil return !completed && !hasSession } } 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/scrobbleTests/AuthStateTests.swift b/scrobbleTests/AuthStateTests.swift index e2592a7..fbb7c8b 100644 --- a/scrobbleTests/AuthStateTests.swift +++ b/scrobbleTests/AuthStateTests.swift @@ -4,13 +4,15 @@ import Testing @Suite("AuthState Tests", .serialized) struct AuthStateTests { - private func cleanUserDefaults() { + 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() { - cleanUserDefaults() + cleanAuth() let state = AuthState() #expect(state.isAuthenticated == false) #expect(state.isAuthenticating == false) @@ -18,17 +20,26 @@ struct AuthStateTests { #expect(state.authError == nil) } - @Test("Initial state with session key") + @Test("Initial state with session key in Keychain") func initialStateWithSession() { - UserDefaults.standard.set("test-session-key", forKey: "lastfm_session_key") + _ = KeychainHelper.save(key: "lastfm_session_key", value: "test-session-key") let state = AuthState() #expect(state.isAuthenticated == true) - cleanUserDefaults() + 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() { - cleanUserDefaults() + cleanAuth() let state = AuthState() state.authError = "some error" state.startAuth() @@ -39,7 +50,7 @@ struct AuthStateTests { @Test("completeAuth with success sets authenticated") func completeAuthSuccess() { - cleanUserDefaults() + cleanAuth() let state = AuthState() state.showingAuthSheet = true state.isAuthenticating = true @@ -52,7 +63,7 @@ struct AuthStateTests { @Test("completeAuth with failure sets error") func completeAuthFailure() { - cleanUserDefaults() + cleanAuth() let state = AuthState() state.completeAuth(success: false) #expect(state.isAuthenticated == false) @@ -62,18 +73,18 @@ struct AuthStateTests { @Test("signOut clears session key and state") func signOut() { - UserDefaults.standard.set("test-key", forKey: "lastfm_session_key") + _ = 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(UserDefaults.standard.string(forKey: "lastfm_session_key") == nil) + #expect(KeychainHelper.load(key: "lastfm_session_key") == nil) } @Test("completeAuth failure message is correct") func completeAuthFailureMessage() { - cleanUserDefaults() + cleanAuth() let state = AuthState() state.completeAuth(success: false) #expect(state.authError == "Authentication failed or was cancelled") @@ -81,7 +92,7 @@ struct AuthStateTests { @Test("State transition: start then complete success") func stateTransitionSuccess() { - cleanUserDefaults() + cleanAuth() let state = AuthState() #expect(state.isAuthenticated == false) state.startAuth() @@ -91,7 +102,7 @@ struct AuthStateTests { @Test("State transition: failure then retry success") func stateTransitionFailureThenSuccess() { - cleanUserDefaults() + cleanAuth() let state = AuthState() state.startAuth() state.completeAuth(success: false) @@ -105,7 +116,7 @@ struct AuthStateTests { @Test("signOut after successful auth") func signOutAfterAuth() { - cleanUserDefaults() + cleanAuth() let state = AuthState() state.completeAuth(success: true) #expect(state.isAuthenticated == true) From cf745cb512e9a3a895e6a79315260916ec07a25c Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Sat, 21 Mar 2026 13:40:08 -0700 Subject: [PATCH 7/8] clean imports fix concurrency related issues --- scrobble/LastFmManagerDesktop.swift | 59 +++++++++------------------ scrobble/Models/AuthState.swift | 13 +++--- scrobble/Models/OnboardingState.swift | 3 +- scrobble/scrobbleApp.swift | 1 + 4 files changed, 26 insertions(+), 50 deletions(-) diff --git a/scrobble/LastFmManagerDesktop.swift b/scrobble/LastFmManagerDesktop.swift index d1749b8..9a068ab 100644 --- a/scrobble/LastFmManagerDesktop.swift +++ b/scrobble/LastFmManagerDesktop.swift @@ -5,24 +5,17 @@ // 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 ?? "" } } @@ -88,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 @@ -203,7 +181,7 @@ class LastFmDesktopManager: LastFmManagerType { } makeRequest(parameters: parameters) - .receive(on: DispatchQueue.main) + .receive(on: RunLoop.main) .sink( receiveCompletion: { [weak self] completion in if case .failure(let error) = completion { @@ -277,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) @@ -369,9 +347,9 @@ class LastFmDesktopManager: LastFmManagerType { do { // Calculate delay with exponential backoff (0s, 2s, 4s) if attempt > 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 result = try await getSessionAsync(token: token) @@ -411,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) } } } @@ -538,7 +517,7 @@ 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 result = try await self.getSessionAsync(token: token) @@ -558,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, @@ -677,7 +656,7 @@ class LastFmDesktopManager: LastFmManagerType { } private func createSignature(parameters: [String: Any]) -> String { - return createLastFmSignature(parameters: parameters, secret: apiSecret) + createLastFmSignature(parameters: parameters, secret: apiSecret) } } diff --git a/scrobble/Models/AuthState.swift b/scrobble/Models/AuthState.swift index 76bdf92..df6b379 100644 --- a/scrobble/Models/AuthState.swift +++ b/scrobble/Models/AuthState.swift @@ -5,31 +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 (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 @@ -38,7 +36,7 @@ class AuthState { authError = "Authentication failed or was cancelled" } } - + func signOut() { KeychainHelper.delete(key: "lastfm_session_key") KeychainHelper.delete(key: "lastfm_username") @@ -47,4 +45,3 @@ class AuthState { authError = nil } } - diff --git a/scrobble/Models/OnboardingState.swift b/scrobble/Models/OnboardingState.swift index e2f21a4..24632b9 100644 --- a/scrobble/Models/OnboardingState.swift +++ b/scrobble/Models/OnboardingState.swift @@ -5,11 +5,10 @@ // Created by Claude on 1/12/26. // -import Foundation import SwiftUI -import Observation @Observable +@MainActor class OnboardingState { enum Step: Int, CaseIterable { case welcome diff --git a/scrobble/scrobbleApp.swift b/scrobble/scrobbleApp.swift index b377a01..362b307 100644 --- a/scrobble/scrobbleApp.swift +++ b/scrobble/scrobbleApp.swift @@ -160,6 +160,7 @@ struct OnboardingLauncher: View { } } +@MainActor class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { // Setup URL event handling for Last.fm authentication callbacks From ee40ecb7babc3b70f51e26e8bb65879afde3004e Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Sat, 21 Mar 2026 14:18:10 -0700 Subject: [PATCH 8/8] test ci --- .github/workflows/build.yml | 4 +++ scrobble/scrobbleApp.swift | 34 ++++++++++++------------ scrobbleTests/AuthStateTests.swift | 1 + scrobbleTests/OnboardingStateTests.swift | 1 + 4 files changed, 23 insertions(+), 17 deletions(-) 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/scrobble/scrobbleApp.swift b/scrobble/scrobbleApp.swift index 362b307..0d040c0 100644 --- a/scrobble/scrobbleApp.swift +++ b/scrobble/scrobbleApp.swift @@ -37,25 +37,25 @@ struct scrobbleApp: App { var body: some Scene { MenuBarExtra { VStack(spacing: DesignTokens.spacingDefault) { - ContentView() - .environment(scrobbler) - .environment(preferencesManager) - .environment(authState) + ContentView() + .environment(scrobbler) + .environment(preferencesManager) + .environment(authState) - Divider() + Divider() - MenuButtonsView() - .environment(authState) - .environment(appState) - } - .padding(DesignTokens.spacingDefault) - .containerBackground( - .ultraThinMaterial, for: .window - ) - .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) - .background { - OnboardingLauncher(hasCheckedOnboarding: $hasCheckedOnboarding) - } + 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") diff --git a/scrobbleTests/AuthStateTests.swift b/scrobbleTests/AuthStateTests.swift index fbb7c8b..031bcc2 100644 --- a/scrobbleTests/AuthStateTests.swift +++ b/scrobbleTests/AuthStateTests.swift @@ -2,6 +2,7 @@ import Testing @testable import scrobble @Suite("AuthState Tests", .serialized) +@MainActor struct AuthStateTests { private func cleanAuth() { diff --git a/scrobbleTests/OnboardingStateTests.swift b/scrobbleTests/OnboardingStateTests.swift index a81ba15..f28128f 100644 --- a/scrobbleTests/OnboardingStateTests.swift +++ b/scrobbleTests/OnboardingStateTests.swift @@ -2,6 +2,7 @@ import Testing @testable import scrobble @Suite("OnboardingState Tests") +@MainActor struct OnboardingStateTests { @Test("Initial state is welcome")