diff --git a/scrobble.xcodeproj/project.pbxproj b/scrobble.xcodeproj/project.pbxproj index a5ea16b..ef0914b 100644 --- a/scrobble.xcodeproj/project.pbxproj +++ b/scrobble.xcodeproj/project.pbxproj @@ -33,9 +33,13 @@ A6A580A82E78DB7F0079DC91 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = A6A580A72E78DB7F0079DC91 /* MediaRemoteAdapter */; }; A6A580A92E78DB9F0079DC91 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = A6A580A72E78DB7F0079DC91 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; A6CC84552EE8470C00236D47 /* MarqueeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6CC84542EE8470C00236D47 /* MarqueeText.swift */; }; + A6HIG00001 /* AppSelectionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6HIG00002 /* AppSelectionButton.swift */; }; + A6HIG00003 /* FeatureRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6HIG00004 /* FeatureRow.swift */; }; + A6HIG00005 /* ServiceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6HIG00006 /* ServiceRow.swift */; }; + A6HIG00007 /* ServiceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6HIG00008 /* ServiceStatus.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 */; }; + A6DESIGN012EFBAA0000001 /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6DESIGN012EFBAA0000002 /* DesignTokens.swift */; }; A6F213002C8C021800E1D23B /* scrobbleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F212FF2C8C021800E1D23B /* scrobbleApp.swift */; }; A6F213022C8C021800E1D23B /* ScrobblingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F213012C8C021800E1D23B /* ScrobblingView.swift */; }; A6F213042C8C021900E1D23B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A6F213032C8C021900E1D23B /* Assets.xcassets */; }; @@ -49,8 +53,8 @@ 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 */; }; + A6KEYCHAIN01 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6KEYCHAIN02 /* KeychainHelper.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 */; }; @@ -68,6 +72,16 @@ A6TESTBF0009 /* CryptoUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0009 /* CryptoUtilsTests.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + A6TESTCI0001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A6F212F42C8C021800E1D23B /* Project object */; + proxyType = 1; + remoteGlobalIDString = A6F212FB2C8C021800E1D23B; + remoteInfo = scrobble; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ A6A580AA2E78DB9F0079DC91 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -82,16 +96,6 @@ }; /* 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 = ""; }; @@ -119,9 +123,13 @@ A6A580A22E78D8480079DC91 /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; }; A6A580A42E78D9340079DC91 /* NowPlayingFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingFetcher.swift; sourceTree = ""; }; A6CC84542EE8470C00236D47 /* MarqueeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarqueeText.swift; sourceTree = ""; }; + A6HIG00002 /* AppSelectionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSelectionButton.swift; sourceTree = ""; }; + A6HIG00004 /* FeatureRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureRow.swift; sourceTree = ""; }; + A6HIG00006 /* ServiceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceRow.swift; sourceTree = ""; }; + A6HIG00008 /* ServiceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceStatus.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 = ""; }; + A6DESIGN012EFBAA0000002 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.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 = ""; }; A6F213012C8C021800E1D23B /* ScrobblingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrobblingView.swift; sourceTree = ""; }; @@ -138,8 +146,8 @@ 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 = ""; }; + A6KEYCHAIN02 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.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 = ""; }; @@ -184,6 +192,9 @@ children = ( A622991F2EE80F2C00592239 /* LabeledStepper.swift */, A6CC84542EE8470C00236D47 /* MarqueeText.swift */, + A6HIG00002 /* AppSelectionButton.swift */, + A6HIG00004 /* FeatureRow.swift */, + A6HIG00006 /* ServiceRow.swift */, ); path = "View Components"; sourceTree = ""; @@ -283,6 +294,7 @@ A65671E92EEADB7300773C58 /* UpdateChecker.swift */, A64304C52D52FBA1001998B6 /* AppState.swift */, A6F2FEEB2EE79C16003826F7 /* FriendsModel.swift */, + A6HIG00008 /* ServiceStatus.swift */, A6F2131D2C8C10AB00E1D23B /* PreferencesManager.swift */, A6ONBOARD0002 /* OnboardingState.swift */, ); @@ -445,6 +457,10 @@ A62299172EE7D46100592239 /* ErrorMessageView.swift in Sources */, A64B5FAE2E7E0AF1000F1BAE /* SupportedMusicApp.swift in Sources */, A6CC84552EE8470C00236D47 /* MarqueeText.swift in Sources */, + A6HIG00001 /* AppSelectionButton.swift in Sources */, + A6HIG00003 /* FeatureRow.swift in Sources */, + A6HIG00005 /* ServiceRow.swift in Sources */, + A6HIG00007 /* ServiceStatus.swift in Sources */, A6F213022C8C021800E1D23B /* ScrobblingView.swift in Sources */, A6F954FC2EED236000501378 /* LaunchAtLoginSettingsView.swift in Sources */, A65671EC2EEADB9F00773C58 /* UpdateView.swift in Sources */, @@ -643,7 +659,7 @@ CODE_SIGN_ENTITLEMENTS = scrobble/scrobble.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"scrobble/Preview Content\""; ENABLE_APP_SANDBOX = NO; @@ -693,7 +709,7 @@ CODE_SIGN_ENTITLEMENTS = scrobble/scrobble.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"scrobble/Preview Content\""; ENABLE_APP_SANDBOX = NO; diff --git a/scrobble/BlueskyOAuthManager.swift b/scrobble/BlueskyOAuthManager.swift index 1701d21..fb61f22 100644 --- a/scrobble/BlueskyOAuthManager.swift +++ b/scrobble/BlueskyOAuthManager.swift @@ -352,7 +352,8 @@ extension BlueskyOAuthManager: WKNavigationDelegate { Log.debug("Detected successful OAuth redirect to Railway app - extracting cookies", category: .auth) // Likely completed OAuth flow, extract cookies - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + Task { @MainActor in + try? await Task.sleep(for: .seconds(1)) self.extractAndStoreCookies() } } else if url.contains("bsky.social") { diff --git a/scrobble/ContentView.swift b/scrobble/ContentView.swift index af43e4b..6bf1a22 100644 --- a/scrobble/ContentView.swift +++ b/scrobble/ContentView.swift @@ -13,27 +13,16 @@ struct ContentView: View { var body: some View { TabView { - ScrobblingView() - .tabItem { - Label("Scrobbling", systemImage: "music.note") - } - - AppSelectionView() - .tabItem { - Label("Apps", systemImage: "app.badge") - } - - // Use type-safe casting to check for desktop manager - if let desktopManager = scrobbler.lastFmManager as? LastFmDesktopManager { - FriendsView(lastFmManager: desktopManager) - .tabItem { - Label("Friends", systemImage: "person.2") - } - } else { + Tab("Scrobbling", systemImage: "music.note") { + ScrobblingView() + } + + Tab("Apps", systemImage: "app.badge") { + AppSelectionView() + } + + Tab("Friends", systemImage: "person.2") { FriendsView(lastFmManager: scrobbler.lastFmManager) - .tabItem { - Label("Friends", systemImage: "person.2") - } } } .tabViewStyle(.tabBarOnly) diff --git a/scrobble/CustomScrobblingService.swift b/scrobble/CustomScrobblingService.swift index 4dde1f9..c8accc7 100644 --- a/scrobble/CustomScrobblingService.swift +++ b/scrobble/CustomScrobblingService.swift @@ -26,38 +26,32 @@ class CustomScrobblingService: ScrobblingService { } var authStatus: AsyncStream { - AsyncStream { continuation in - // Yield initial state immediately - let initialState = Task { @MainActor in - return oauthManager.isAuthenticated - } - - Task { - let initial = await initialState.value - continuation.yield(initial) - } - - // Use a polling approach with reasonable interval instead of busy-wait observation - // Auth state changes are infrequent (user-initiated), so 1 second polling is fine - let monitoringTask = Task { @MainActor in - var previousState = oauthManager.isAuthenticated - - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { break } - - let newState = oauthManager.isAuthenticated - if newState != previousState { - continuation.yield(newState) - previousState = newState - } + let (stream, continuation) = AsyncStream.makeStream(of: Bool.self) + + // Use a polling approach with reasonable interval instead of busy-wait observation + // Auth state changes are infrequent (user-initiated), so 1 second polling is fine + let monitoringTask = Task { @MainActor in + var previousState = oauthManager.isAuthenticated + continuation.yield(previousState) + + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { break } + + let newState = oauthManager.isAuthenticated + if newState != previousState { + continuation.yield(newState) + previousState = newState } } + continuation.finish() + } - continuation.onTermination = { _ in - monitoringTask.cancel() - } + continuation.onTermination = { _ in + monitoringTask.cancel() } + + return stream } init(blueskyHandle: String) { @@ -67,22 +61,14 @@ class CustomScrobblingService: ScrobblingService { func authenticate() async throws -> Bool { await oauthManager.startAuthentication() - // Wait for authentication to complete - // We can just watch the stream we made! for await isAuth in authStatus { if isAuth { return true } - // Check for error state? - let error = await oauthManager.authError - if error != nil { - throw ScrobblerError.apiError(error ?? "Unknown error") - } - // Timeout or cancellation handling might be needed here practically - // but for this implementation we wait. - // Actually we should inspect `isAuthenticating`. - let authenticating = await oauthManager.isAuthenticating - if !authenticating && !isAuth { - return false // Finished but failed - } + if let error = oauthManager.authError { + throw ScrobblerError.apiError(error) + } + if !oauthManager.isAuthenticating && !isAuth { + return false + } } return false } @@ -95,8 +81,7 @@ class CustomScrobblingService: ScrobblingService { } func scrobble(artist: String, track: String, album: String) async throws -> Bool { - let isAuth = await isAuthenticated - guard isAuth else { + guard isAuthenticated else { Log.debug("Custom scrobbler: Not authenticated", category: .scrobble) throw ScrobblerError.authenticationRequired } @@ -112,8 +97,7 @@ class CustomScrobblingService: ScrobblingService { } func updateNowPlaying(artist: String, track: String, album: String) async throws -> Bool { - let isAuth = await isAuthenticated - guard isAuth else { + guard isAuthenticated else { Log.error("Custom scrobbler: Not authenticated for now playing update", category: .scrobble) throw ScrobblerError.authenticationRequired } diff --git a/scrobble/LastFmManager.swift b/scrobble/LastFmManager.swift index 3909793..9e4eec9 100644 --- a/scrobble/LastFmManager.swift +++ b/scrobble/LastFmManager.swift @@ -6,14 +6,13 @@ // import Foundation -import Combine @MainActor protocol LastFmManagerType { - func scrobble(artist: String, track: String, album: String) -> AnyPublisher - func updateNowPlaying(artist: String, track: String, album: String) -> AnyPublisher - func getFriends(page: Int, limit: Int) -> AnyPublisher<[Friend], Error> - func getRecentTracks(for username: String, page: Int, limit: Int) -> AnyPublisher<[RecentTracksResponse.RecentTracks.Track], Error> + func scrobble(artist: String, track: String, album: String) async throws -> Bool + func updateNowPlaying(artist: String, track: String, album: String) async throws -> Bool + func getFriends(page: Int, limit: Int) async throws -> [Friend] + func getRecentTracks(for username: String, page: Int, limit: Int) async throws -> [RecentTracksResponse.RecentTracks.Track] } enum ScrobblerError: Error, LocalizedError { @@ -23,7 +22,10 @@ enum ScrobblerError: Error, LocalizedError { case noData case invalidURL case authenticationRequired - + + static let authenticationFailed = ScrobblerError.apiError("Authentication failed") + static let authorizationCancelled = ScrobblerError.apiError("Authorization was cancelled by user") + var errorDescription: String? { switch self { case .noSessionKey: @@ -163,7 +165,6 @@ struct Friend: Codable { } } - let mockFriend: Friend = Friend(name: "test", realname: "Test User", url: "https://example.com", image: [Friend.Image(text: "https://example.com/image.jpg", size: "medium")], country: "USA", playcount: "100", registered: Friend.Registered(unixtime: "1234567890"), subscriber: "0") diff --git a/scrobble/LastFmManagerDesktop.swift b/scrobble/LastFmManagerDesktop.swift index 9a068ab..923d2de 100644 --- a/scrobble/LastFmManagerDesktop.swift +++ b/scrobble/LastFmManagerDesktop.swift @@ -41,8 +41,15 @@ func createLastFmSignature(parameters: [String: Any], secret: String) -> String class LastFmDesktopManager: LastFmManagerType { let apiKey: String // Made public for WebKit auth view private let apiSecret: String - private let username: String + private let initialUsername: String private let password: String // Kept for API compatibility but not used + + /// Resolves the effective username: prefers Keychain, falls back to UserDefaults backup, then init value + private var effectiveUsername: String { + KeychainHelper.load(key: "lastfm_username") + ?? UserDefaults.standard.string(forKey: "lastfm_username_backup") + ?? initialUsername + } private var sessionKey: String? private var isAuthenticated = false private var authenticationSubject = PassthroughSubject() @@ -94,7 +101,7 @@ class LastFmDesktopManager: LastFmManagerType { init(apiKey: String, apiSecret: String, username: String, password: String = "", authState: AuthState) { self.apiKey = apiKey self.apiSecret = apiSecret - self.username = username + self.initialUsername = username self.password = password self.authState = authState @@ -142,19 +149,20 @@ class LastFmDesktopManager: LastFmManagerType { } private func checkSavedAuth() { - // Try Keychain first, fall back to UserDefaults for migration + // Try Keychain first, fall back to UserDefaults. + // UserDefaults fallback is kept because Keychain ACLs can break + // between Xcode dev builds when the app is re-signed. 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) + } else if let fallbackSessionKey = UserDefaults.standard.string(forKey: "lastfm_session_key") { + Log.debug("Keychain miss, recovering session from UserDefaults", category: .auth) + self.sessionKey = fallbackSessionKey + // Try to save back to Keychain for next time + _ = KeychainHelper.save(key: "lastfm_session_key", value: fallbackSessionKey) + if !initialUsername.isEmpty { + _ = KeychainHelper.save(key: "lastfm_username", value: initialUsername) } - Log.debug("Migrated session key from UserDefaults to Keychain", category: .auth) validateSavedSession() } else { authStatus = .needsAuth @@ -167,8 +175,8 @@ class LastFmDesktopManager: LastFmManagerType { return } - // Use stored username from Keychain if the init username is empty - let validationUser = !username.isEmpty ? username : (KeychainHelper.load(key: "lastfm_username") ?? "") + // Use effective username (prefers Keychain, falls back to init value) + let validationUser = effectiveUsername // Test the session with a simple API call var parameters: [String: String] = [ @@ -180,47 +188,43 @@ class LastFmDesktopManager: LastFmManagerType { parameters["user"] = validationUser } - makeRequest(parameters: parameters) - .receive(on: RunLoop.main) - .sink( - receiveCompletion: { [weak self] completion in - 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 - } + let signature = createSignature(parameters: parameters) + parameters["api_sig"] = signature + parameters["format"] = "json" - 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) + Task { + do { + _ = try await makeRequestAsync(parameters: parameters) + Log.debug("Saved session validated successfully", category: .auth) + self.isAuthenticated = true + self.authStatus = .authenticated + self.authState.isAuthenticated = true + } catch { + // Only clear the session for explicit auth errors from Last.fm + let isAuthError: Bool + if let scrobblerError = error as? ScrobblerError, + case .apiError(let message) = scrobblerError { + 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 { + Log.debug("Session validation failed (transient), keeping session: \(error.localizedDescription)", category: .auth) self.isAuthenticated = true self.authStatus = .authenticated self.authState.isAuthenticated = true } - ) - .store(in: &cancellables) + } + } } func startAuth() { @@ -290,8 +294,11 @@ class LastFmDesktopManager: LastFmManagerType { self.sessionKey = sessionKey _ = KeychainHelper.save(key: "lastfm_session_key", value: sessionKey) - if let username = username, !username.isEmpty { + // Keep UserDefaults as fallback in case Keychain ACL breaks after re-signing + UserDefaults.standard.set(sessionKey, forKey: "lastfm_session_key") + if let username, !username.isEmpty { _ = KeychainHelper.save(key: "lastfm_username", value: username) + UserDefaults.standard.set(username, forKey: "lastfm_username_backup") } self.isAuthenticated = true @@ -491,50 +498,11 @@ class LastFmDesktopManager: LastFmManagerType { return data } - // MARK: - Legacy Combine API Methods (kept for compatibility) - - private func getToken() -> AnyPublisher { - let parameters: [String: Any] = [ - "method": "auth.getToken", - "api_key": apiKey - ] - - let signature = createSignature(parameters: parameters) - var allParameters = parameters - allParameters["api_sig"] = signature - allParameters["format"] = "json" - - return makeRequest(parameters: allParameters) - .decode(type: TokenResponse.self, decoder: JSONDecoder()) - .map { $0.token } - .eraseToAnyPublisher() - } - - private func getSession(token: String) -> AnyPublisher { - Log.debug("Requesting session with token: \(token)", category: .auth) - // Small delay to allow Last.fm to process the authorization - return Future { promise in - Task { - do { - // Wait 3 seconds before requesting the session (legacy behavior) - try await Task.sleep(for: .seconds(3)) - Log.debug("Making session request after delay...", category: .auth) + // MARK: - Public API - 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)) - } - } - }.eraseToAnyPublisher() - } - - // MARK: - Public API (matching original LastFmManager) - - func scrobble(artist: String, track: String, album: String) -> AnyPublisher { + func scrobble(artist: String, track: String, album: String) async throws -> Bool { guard let sessionKey = self.sessionKey else { - return Fail(error: ScrobblerError.noSessionKey).eraseToAnyPublisher() + throw ScrobblerError.noSessionKey } let timestamp = Int(Date.now.timeIntervalSince1970) @@ -547,22 +515,21 @@ class LastFmDesktopManager: LastFmManagerType { "api_key": apiKey, "sk": sessionKey ] - + let signature = createSignature(parameters: parameters) parameters["api_sig"] = signature parameters["format"] = "json" - - return makeRequest(parameters: parameters) - .decode(type: ScrobbleResponse.self, decoder: JSONDecoder()) - .map { $0.scrobbles.attr.accepted == 1 } - .eraseToAnyPublisher() + + let data = try await makeRequestAsync(parameters: parameters) + let response = try JSONDecoder().decode(ScrobbleResponse.self, from: data) + return response.scrobbles.attr.accepted == 1 } - - func updateNowPlaying(artist: String, track: String, album: String) -> AnyPublisher { + + func updateNowPlaying(artist: String, track: String, album: String) async throws -> Bool { guard let sessionKey = self.sessionKey else { - return Fail(error: ScrobblerError.noSessionKey).eraseToAnyPublisher() + throw ScrobblerError.noSessionKey } - + var parameters: [String: String] = [ "method": "track.updateNowPlaying", "artist": artist, @@ -571,43 +538,41 @@ class LastFmDesktopManager: LastFmManagerType { "api_key": apiKey, "sk": sessionKey ] - + let signature = createSignature(parameters: parameters) parameters["api_sig"] = signature parameters["format"] = "json" - - return makeRequest(parameters: parameters) - .map { _ in true } - .eraseToAnyPublisher() + + _ = try await makeRequestAsync(parameters: parameters) + return true } - - func getFriends(page: Int = 1, limit: Int = 50) -> AnyPublisher<[Friend], Error> { + + func getFriends(page: Int = 1, limit: Int = 50) async throws -> [Friend] { guard let sessionKey = self.sessionKey else { - return Fail(error: ScrobblerError.noSessionKey).eraseToAnyPublisher() + throw ScrobblerError.noSessionKey } var parameters: [String: String] = [ "method": "user.getFriends", - "user": username, + "user": effectiveUsername, "api_key": apiKey, "sk": sessionKey, "page": String(page), "limit": String(limit), ] - + let signature = createSignature(parameters: parameters) parameters["api_sig"] = signature parameters["format"] = "json" - + Log.debug("Getting friends with parameters: \(parameters)", category: .general) - - return makeRequest(parameters: parameters) - .decode(type: FriendsResponse.self, decoder: JSONDecoder()) - .map { $0.friends.user } - .eraseToAnyPublisher() + + let data = try await makeRequestAsync(parameters: parameters) + let response = try JSONDecoder().decode(FriendsResponse.self, from: data) + return response.friends.user } - - func getRecentTracks(for username: String, page: Int = 1, limit: Int = 50) -> AnyPublisher<[RecentTracksResponse.RecentTracks.Track], Error> { + + func getRecentTracks(for username: String, page: Int = 1, limit: Int = 50) async throws -> [RecentTracksResponse.RecentTracks.Track] { var parameters: [String: String] = [ "method": "user.getRecentTracks", "user": username, @@ -615,46 +580,18 @@ class LastFmDesktopManager: LastFmManagerType { "page": String(page), "limit": String(limit), ] - + let signature = createSignature(parameters: parameters) parameters["api_sig"] = signature parameters["format"] = "json" - - return makeRequest(parameters: parameters) - .decode(type: RecentTracksResponse.self, decoder: JSONDecoder()) - .map { $0.recenttracks.track } - .eraseToAnyPublisher() + + let data = try await makeRequestAsync(parameters: parameters) + let response = try JSONDecoder().decode(RecentTracksResponse.self, from: data) + return response.recenttracks.track } // MARK: - Helper Methods - private func makeRequest(parameters: [String: Any]) -> AnyPublisher { - let baseURL = "https://ws.audioscrobbler.com/2.0/" - var components = URLComponents(string: baseURL)! - components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: "\($0.value)") } - - guard let url = components.url else { - return Fail(error: ScrobblerError.invalidURL).eraseToAnyPublisher() - } - - var request = URLRequest(url: url) - if parameters["method"] as? String == "track.scrobble" || - parameters["method"] as? String == "track.updateNowPlaying" { - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - } - - return URLSession.shared.dataTaskPublisher(for: request) - .map(\.data) - .tryMap { data in - if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data) { - throw ScrobblerError.apiError(errorResponse.message) - } - return data - } - .eraseToAnyPublisher() - } - private func createSignature(parameters: [String: Any]) -> String { createLastFmSignature(parameters: parameters, secret: apiSecret) } @@ -678,11 +615,6 @@ private struct LastFmErrorResponse: Codable { let error: Int let message: String } - -extension ScrobblerError { - static let authorizationCancelled = ScrobblerError.apiError("Authorization was cancelled by user") -} - extension LastFmDesktopManager { func logout() { // Cancel any pending auth operations @@ -694,6 +626,8 @@ extension LastFmDesktopManager { currentAuthToken = "" // This also clears UserDefaults pending token KeychainHelper.delete(key: "lastfm_session_key") KeychainHelper.delete(key: "lastfm_username") + UserDefaults.standard.removeObject(forKey: "lastfm_session_key") + UserDefaults.standard.removeObject(forKey: "lastfm_username_backup") authState.signOut() authStatus = .needsAuth diff --git a/scrobble/Models/AppState.swift b/scrobble/Models/AppState.swift index 4c93875..a4ce64b 100644 --- a/scrobble/Models/AppState.swift +++ b/scrobble/Models/AppState.swift @@ -7,11 +7,11 @@ import Foundation import SwiftUI -import Combine import Observation @Observable +@MainActor class AppState { var isMainWindowVisible = false var isPreferencesVisible = false diff --git a/scrobble/Models/AuthState.swift b/scrobble/Models/AuthState.swift index df6b379..4937f89 100644 --- a/scrobble/Models/AuthState.swift +++ b/scrobble/Models/AuthState.swift @@ -41,6 +41,7 @@ class AuthState { KeychainHelper.delete(key: "lastfm_session_key") KeychainHelper.delete(key: "lastfm_username") UserDefaults.standard.removeObject(forKey: "lastfm_session_key") + UserDefaults.standard.removeObject(forKey: "lastfm_username_backup") isAuthenticated = false authError = nil } diff --git a/scrobble/Models/FriendsModel.swift b/scrobble/Models/FriendsModel.swift index 4ce054e..0130135 100644 --- a/scrobble/Models/FriendsModel.swift +++ b/scrobble/Models/FriendsModel.swift @@ -6,7 +6,6 @@ // import Foundation -import Combine import Observation @Observable @@ -16,80 +15,101 @@ class FriendsModel { var friendTracks: [String: [RecentTracksResponse.RecentTracks.Track]] = [:] var isLoading = false var errorMessage: String? - + var preferencesManager: PreferencesManager? - + private var lastFmManager: LastFmManagerType - private var cancellables = Set() - + private var lastFetchedAt: Date? + private var loadTask: Task? + private static let cacheTTL: TimeInterval = 60 + init(lastFmManager: LastFmManagerType) { self.lastFmManager = lastFmManager } - + + /// Update the manager reference without triggering a fetch. + func setLastFmManager(_ manager: LastFmManagerType) { + self.lastFmManager = manager + } + + /// Update the manager and force a fresh fetch. func updateLastFmManager(_ manager: LastFmManagerType) { self.lastFmManager = manager loadFriends() } - + + /// Called by pull-to-refresh and manual refresh button — always fetches. func refreshData() { loadFriends() } - - // Using a separate method to keep init lightweight - func loadFriends() { + + /// Called by onAppear — skips fetch if cache is fresh. + func loadIfNeeded() { + if let lastFetchedAt, Date.now.timeIntervalSince(lastFetchedAt) < Self.cacheTTL, !friends.isEmpty { + Log.debug("FriendsModel: Cache still fresh, skipping fetch", category: .general) + return + } + loadFriends() + } + + private func loadFriends() { + loadTask?.cancel() Log.debug("FriendsModel: Loading friends...", category: .general) isLoading = true errorMessage = nil - + let limit = preferencesManager?.numberOfFriendsDisplayed ?? 10 Log.debug("FriendsModel: Fetching \(limit) friends", category: .general) - - lastFmManager.getFriends(page: 1, limit: limit) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in - guard let self = self else { return } - self.isLoading = false - - if case .failure(let error) = completion { - Log.error("FriendsModel: Error loading friends: \(error)", category: .general) - // Check for specific error about missing parameters - if error.localizedDescription.contains("Missing parameter") { - self.errorMessage = "Configuration error: Missing username or API key." - } else { - self.errorMessage = error.localizedDescription - } - } - }, receiveValue: { [weak self] friends in - guard let self = self else { return } + + loadTask = Task { + do { + let friends = try await lastFmManager.getFriends(page: 1, limit: limit) + guard !Task.isCancelled else { return } Log.debug("FriendsModel: Loaded \(friends.count) friends", category: .general) self.friends = friends - self.loadRecentTracksForFriends() - }) - .store(in: &cancellables) + self.lastFetchedAt = Date.now + self.isLoading = false + await self.loadRecentTracksForFriends() + } catch is CancellationError { + // Normal — view disappeared or new load started + } catch { + guard !Task.isCancelled else { return } + Log.error("FriendsModel: Error loading friends: \(error)", category: .general) + self.isLoading = false + if error.localizedDescription.contains("Missing parameter") { + self.errorMessage = "Configuration error: Missing username or API key." + } else { + self.errorMessage = error.localizedDescription + } + } + } } - - private func loadRecentTracksForFriends() { + + private func loadRecentTracksForFriends() async { guard !friends.isEmpty else { return } Log.debug("FriendsModel: Loading recent tracks for \(friends.count) friends", category: .general) - - // Clear old tracks just in case, or keep them? - // Better to update incrementally or clear? Let's keep existing if refreshing. - - // get limit from pref manager + let limit = preferencesManager?.numberOfFriendsRecentTracksDisplayed ?? 3 - - - for friend in friends { - lastFmManager.getRecentTracks(for: friend.name, page: 1, limit: limit) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in - if case .failure(let error) = completion { - Log.error("FriendsModel: Error loading tracks for \(friend.name): \(error)", category: .general) + + await withTaskGroup(of: (String, [RecentTracksResponse.RecentTracks.Track]?).self) { group in + for friend in friends { + let name = friend.name + group.addTask { + do { + let tracks = try await self.lastFmManager.getRecentTracks(for: name, page: 1, limit: limit) + return (name, tracks) + } catch { + Log.error("FriendsModel: Error loading tracks for \(name): \(error)", category: .general) + return (name, nil) } - }, receiveValue: { [weak self] tracks in - self?.friendTracks[friend.name] = tracks - }) - .store(in: &cancellables) + } + } + + for await (name, tracks) in group { + if let tracks { + self.friendTracks[name] = tracks + } + } } } } diff --git a/scrobble/Models/OnboardingState.swift b/scrobble/Models/OnboardingState.swift index 24632b9..2064477 100644 --- a/scrobble/Models/OnboardingState.swift +++ b/scrobble/Models/OnboardingState.swift @@ -68,9 +68,6 @@ class OnboardingState { } static var needsOnboarding: Bool { - let completed = UserDefaults.standard.bool(forKey: "hasCompletedOnboarding") - let hasSession = KeychainHelper.load(key: "lastfm_session_key") != nil - || UserDefaults.standard.string(forKey: "lastfm_session_key") != nil - return !completed && !hasSession + !hasCompletedOnboarding } } diff --git a/scrobble/Models/PreferencesManager.swift b/scrobble/Models/PreferencesManager.swift index 4cad825..5aaec07 100644 --- a/scrobble/Models/PreferencesManager.swift +++ b/scrobble/Models/PreferencesManager.swift @@ -7,138 +7,224 @@ import Foundation import AppKit - import Observation +/// Wraps a UserDefaults-backed value with @Observable change tracking. +/// This avoids relying on `didSet` which can break with the @Observable macro. +@propertyWrapper +struct DefaultsBacked { + let key: String + let defaultValue: Value + let store: UserDefaults + + init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) { + self.key = key + self.defaultValue = wrappedValue + self.store = store + } + + var wrappedValue: Value { + get { + store.object(forKey: key).flatMap { $0 as? Value } ?? defaultValue + } + set { + store.set(newValue, forKey: key) + } + } +} + @Observable +@MainActor class PreferencesManager { // Standard access for secrets var apiKey: String { Secrets.lastFmApiKey } var apiSecret: String { Secrets.lastFmApiSecret } - - var username: String = "" { - didSet { UserDefaults.standard.set(username, forKey: "lastFmUsername") } - } - - var password: String = "" { - didSet { UserDefaults.standard.set(password, forKey: "lastFmPassword") } - } - - var numberOfFriendsDisplayed: Int = 10 { - didSet { UserDefaults.standard.set(numberOfFriendsDisplayed, forKey: "numberOfFriendsDisplayed")} - } - - var numberOfFriendsRecentTracksDisplayed: Int = 5 { - didSet { UserDefaults.standard.set(numberOfFriendsRecentTracksDisplayed, forKey: "numberOfFriendsRecentTracksDisplayed")} - } - - var selectedMusicApp: SupportedMusicApp = SupportedMusicApp.allApps.first(where: { $0.bundleId == "com.apple.Music" })! { - didSet { - UserDefaults.standard.set(selectedMusicApp.bundleId, forKey: "selectedMusicAppBundleId") - if _isInitialized { - mediaAppSource = selectedMusicApp.displayName + + // Password is no longer stored — kept for API compatibility only + var password: String = "" + + @ObservationIgnored @DefaultsBacked("lastFmUsername") + private var _username: String = "" + var username: String { + get { + access(keyPath: \.username) + return _username + } + set { + withMutation(keyPath: \.username) { + _username = newValue } } } - - // Scrobble configuration - var trackCompletionPercentageBeforeScrobble: Int = 50 { - didSet { UserDefaults.standard.set(trackCompletionPercentageBeforeScrobble, forKey: "trackCompletionPercentageBeforeScrobble") } + + @ObservationIgnored @DefaultsBacked("numberOfFriendsDisplayed") + private var _numberOfFriendsDisplayed: Int = 10 + var numberOfFriendsDisplayed: Int { + get { + access(keyPath: \.numberOfFriendsDisplayed) + return _numberOfFriendsDisplayed + } + set { + withMutation(keyPath: \.numberOfFriendsDisplayed) { + _numberOfFriendsDisplayed = newValue + } + } } - - // default max delay of 240 seconds (4 minutes) - - var useMaxTrackCompletionScrobbleDelay: Bool = true { - didSet { - UserDefaults.standard.set(useMaxTrackCompletionScrobbleDelay, forKey: "useMaxTrackCompletionScrobbleDelay") + + @ObservationIgnored @DefaultsBacked("numberOfFriendsRecentTracksDisplayed") + private var _numberOfFriendsRecentTracksDisplayed: Int = 5 + var numberOfFriendsRecentTracksDisplayed: Int { + get { + access(keyPath: \.numberOfFriendsRecentTracksDisplayed) + return _numberOfFriendsRecentTracksDisplayed + } + set { + withMutation(keyPath: \.numberOfFriendsRecentTracksDisplayed) { + _numberOfFriendsRecentTracksDisplayed = newValue + } } } - var maxTrackCompletionScrobbleDelay: Int? = 240 { - didSet { UserDefaults.standard.set(maxTrackCompletionScrobbleDelay, forKey: "maxTrackCompletionScrobbleDelay") } + + @ObservationIgnored @DefaultsBacked("trackCompletionPercentageBeforeScrobble") + private var _trackCompletionPercentageBeforeScrobble: Int = 50 + var trackCompletionPercentageBeforeScrobble: Int { + get { + access(keyPath: \.trackCompletionPercentageBeforeScrobble) + return _trackCompletionPercentageBeforeScrobble + } + set { + withMutation(keyPath: \.trackCompletionPercentageBeforeScrobble) { + _trackCompletionPercentageBeforeScrobble = newValue + } + } } - - // Keep for backwards compatibility - var mediaAppSource: String = "Apple Music" { - didSet { UserDefaults.standard.set(mediaAppSource, forKey: "mediaAppSource") } + + @ObservationIgnored @DefaultsBacked("useMaxTrackCompletionScrobbleDelay") + private var _useMaxTrackCompletionScrobbleDelay: Bool = true + var useMaxTrackCompletionScrobbleDelay: Bool { + get { + access(keyPath: \.useMaxTrackCompletionScrobbleDelay) + return _useMaxTrackCompletionScrobbleDelay + } + set { + withMutation(keyPath: \.useMaxTrackCompletionScrobbleDelay) { + _useMaxTrackCompletionScrobbleDelay = newValue + } + } } - - // Custom scrobbler settings - var enableCustomScrobbler: Bool = false { - didSet { UserDefaults.standard.set(enableCustomScrobbler, forKey: "enableCustomScrobbler") } + + @ObservationIgnored @DefaultsBacked("maxTrackCompletionScrobbleDelay") + private var _maxTrackCompletionScrobbleDelay: Int = 240 + var maxTrackCompletionScrobbleDelay: Int { + get { + access(keyPath: \.maxTrackCompletionScrobbleDelay) + return _maxTrackCompletionScrobbleDelay + } + set { + withMutation(keyPath: \.maxTrackCompletionScrobbleDelay) { + _maxTrackCompletionScrobbleDelay = newValue + } + } } - - var blueskyHandle: String = "" { - didSet { UserDefaults.standard.set(blueskyHandle, forKey: "blueskyHandle") } + + @ObservationIgnored @DefaultsBacked("mediaAppSource") + private var _mediaAppSource: String = "Apple Music" + var mediaAppSource: String { + get { + access(keyPath: \.mediaAppSource) + return _mediaAppSource + } + set { + withMutation(keyPath: \.mediaAppSource) { + _mediaAppSource = newValue + } + } + } + + @ObservationIgnored @DefaultsBacked("enableCustomScrobbler") + private var _enableCustomScrobbler: Bool = false + var enableCustomScrobbler: Bool { + get { + access(keyPath: \.enableCustomScrobbler) + return _enableCustomScrobbler + } + set { + withMutation(keyPath: \.enableCustomScrobbler) { + _enableCustomScrobbler = newValue + } + } + } + + @ObservationIgnored @DefaultsBacked("blueskyHandle") + private var _blueskyHandle: String = "" + var blueskyHandle: String { + get { + access(keyPath: \.blueskyHandle) + return _blueskyHandle + } + set { + withMutation(keyPath: \.blueskyHandle) { + _blueskyHandle = newValue + } + } + } + + @ObservationIgnored @DefaultsBacked("enableLastFm") + private var _enableLastFm: Bool = true + var enableLastFm: Bool { + get { + access(keyPath: \.enableLastFm) + return _enableLastFm + } + set { + withMutation(keyPath: \.enableLastFm) { + _enableLastFm = newValue + } + } } - - // Last.fm settings - var enableLastFm: Bool = true { - didSet { UserDefaults.standard.set(enableLastFm, forKey: "enableLastFm") } + + @ObservationIgnored @DefaultsBacked("launchAtLogin") + private var _launchAtLogin: Bool = false + var launchAtLogin: Bool { + get { + access(keyPath: \.launchAtLogin) + return _launchAtLogin + } + set { + withMutation(keyPath: \.launchAtLogin) { + _launchAtLogin = newValue + } + } } - - // App settings - var launchAtLogin: Bool = false { - didSet { - UserDefaults.standard.set(launchAtLogin, forKey: "launchAtLogin") + + @ObservationIgnored @DefaultsBacked("selectedMusicAppBundleId") + private var _selectedMusicAppBundleId: String = "com.apple.Music" + var selectedMusicApp: SupportedMusicApp { + get { + access(keyPath: \.selectedMusicApp) + return SupportedMusicApp.findApp(byBundleId: _selectedMusicAppBundleId) + ?? SupportedMusicApp.allApps.first(where: { $0.bundleId == "com.apple.Music" })! + } + set { + withMutation(keyPath: \.selectedMusicApp) { + _selectedMusicAppBundleId = newValue.bundleId + _mediaAppSource = newValue.displayName + } } } - - private var _isInitialized = false - + init() { - self.username = UserDefaults.standard.string(forKey: "lastFmUsername") ?? "" - self.password = UserDefaults.standard.string(forKey: "lastFmPassword") ?? "" - - let savedShown = UserDefaults.standard.integer(forKey: "numberOfFriendsDisplayed") - if savedShown > 0 { - self.numberOfFriendsDisplayed = savedShown - } - - let savedRecentTracks = UserDefaults.standard.integer(forKey: "numberOfFriendsRecentTracksDisplayed") - if savedRecentTracks > 0 { - self.numberOfFriendsRecentTracksDisplayed = savedRecentTracks - } - - let savedCompletionPercentage = UserDefaults.standard.integer(forKey: "trackCompletionPercentageBeforeScrobble") - if savedCompletionPercentage > 0 { - self.trackCompletionPercentageBeforeScrobble = savedCompletionPercentage - } - - let savedMaxDelay = UserDefaults.standard.integer(forKey: "maxTrackCompletionScrobbleDelay") - if savedMaxDelay > 0 { - self.maxTrackCompletionScrobbleDelay = savedMaxDelay - } - - self.mediaAppSource = UserDefaults.standard.string(forKey: "mediaAppSource") ?? "Apple Music" - - self.enableCustomScrobbler = UserDefaults.standard.bool(forKey: "enableCustomScrobbler") - self.blueskyHandle = UserDefaults.standard.string(forKey: "blueskyHandle") ?? "" - - self.launchAtLogin = UserDefaults.standard.bool(forKey: "launchAtLogin") - - // Initialize Last.fm settings - if UserDefaults.standard.object(forKey: "enableLastFm") != nil { - self.enableLastFm = UserDefaults.standard.bool(forKey: "enableLastFm") - } - - let savedBundleId = UserDefaults.standard.string(forKey: "selectedMusicAppBundleId") ?? "com.apple.Music" - self.selectedMusicApp = SupportedMusicApp.findApp(byBundleId: savedBundleId) ?? SupportedMusicApp.allApps.first(where: { $0.bundleId == "com.apple.Music" })! - - // Sync mediaAppSource - if mediaAppSource != selectedMusicApp.displayName { - mediaAppSource = selectedMusicApp.displayName - } - - _isInitialized = true - } - + // Clean up legacy password storage + UserDefaults.standard.removeObject(forKey: "lastFmPassword") + } + func showPreferences() { if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "preferences" }) { window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } } - + func showMainWindow() { if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "main" }) { window.makeKeyAndOrderFront(nil) diff --git a/scrobble/Models/ServiceStatus.swift b/scrobble/Models/ServiceStatus.swift new file mode 100644 index 0000000..766464c --- /dev/null +++ b/scrobble/Models/ServiceStatus.swift @@ -0,0 +1,44 @@ +// +// ServiceStatus.swift +// scrobble +// + +import SwiftUI + +enum ServiceStatus { + case connected + case notConnected + case failed + case checking + case disabled + + var label: String { + switch self { + case .connected: "Connected" + case .notConnected: "Not connected" + case .failed: "Auth failed" + case .checking: "Checking..." + case .disabled: "Disabled" + } + } + + var icon: String { + switch self { + case .connected: "checkmark.seal.fill" + case .notConnected: "exclamationmark.triangle.fill" + case .failed: "xmark.octagon.fill" + case .checking: "hourglass" + case .disabled: "minus.circle.fill" + } + } + + var color: Color { + switch self { + case .connected: .green + case .notConnected: .yellow + case .failed: .red + case .checking: .secondary + case .disabled: .secondary + } + } +} diff --git a/scrobble/Models/UpdateChecker.swift b/scrobble/Models/UpdateChecker.swift index d6479a5..295f8bc 100644 --- a/scrobble/Models/UpdateChecker.swift +++ b/scrobble/Models/UpdateChecker.swift @@ -8,6 +8,7 @@ import Foundation @Observable +@MainActor final class UpdateChecker { var latestVersion: String? var latestBuildNumber: String? @@ -46,7 +47,7 @@ final class UpdateChecker { let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases/latest")! let (data, _) = try await URLSession.shared.data(from: url) let release = try JSONDecoder().decode(GitHubRelease.self, from: data) - print(release) + Log.debug("Fetched release: \(release.tagName)", category: .general) // Parse "v1.6.4" -> version "1.6", build "4" let tag = release.tagName.trimmingPrefix("v") diff --git a/scrobble/NowPlayingFetcher.swift b/scrobble/NowPlayingFetcher.swift index 4e93025..8e6cc23 100644 --- a/scrobble/NowPlayingFetcher.swift +++ b/scrobble/NowPlayingFetcher.swift @@ -21,6 +21,7 @@ struct KnownMusicAppNames: Hashable { static let safari = ("Safari" ,"Safari.app" ,"safari" , "Safari.app (Safari)") } +@MainActor final class NowPlayingFetcher { private var mediaController: MediaController @@ -111,28 +112,28 @@ final class NowPlayingFetcher { func reloadWithNewBundleId(_ bundleId: String?) { Log.debug("Reloading MediaController with bundleId: \(bundleId ?? "nil (any app)")", category: .scrobble) - + // More aggressive cleanup - wait longer for complete termination - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - guard let self = self else { return } - + Task { [weak self] in + try? await Task.sleep(for: .milliseconds(300)) + guard let self else { return } + Log.debug("Creating new MediaController instance", category: .scrobble) - + // Create completely new controller instance self.mediaController = MediaController(bundleIdentifier: bundleId) - + // Reapply handlers self.setupTrackInfoHandler() - + // Start listening self.setupAndStart() - + Log.debug("MediaController reloaded and started for app: \(self.currentTargetApp?.displayName ?? "any")", category: .scrobble) - + // Force an immediate check after a brief delay - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - Log.debug("Current state after reload: \(self.getCurrentState())", category: .scrobble) - } + try? await Task.sleep(for: .milliseconds(500)) + Log.debug("Current state after reload: \(self.getCurrentState())", category: .scrobble) } } diff --git a/scrobble/Scrobbler.swift b/scrobble/Scrobbler.swift index cd91320..c0fbff2 100644 --- a/scrobble/Scrobbler.swift +++ b/scrobble/Scrobbler.swift @@ -6,13 +6,11 @@ // import Foundation -import Combine import MediaPlayer import MusicKit import ScriptingBridge import CommonCrypto import Network -import Network import AppKit import Observation @@ -48,10 +46,11 @@ func calculateScrobbleDelay(trackDuration: Double, completionPercentage: Int, us } @Observable +@MainActor class Scrobbler { let lastFmManager: LastFmManagerType private var scrobblingServices: [ScrobblingService] = [] - @MainActor var servicesLastUpdated = Date() // This will trigger UI updates when services change + var servicesLastUpdated = Date() // This will trigger UI updates when services change var currentTrack: String = "No track playing" var currentArtwork: NSImage? = nil @@ -59,11 +58,19 @@ class Scrobbler { var lastScrobbledTrack: String = "" var errorMessage: String? var musicAppStatus: String = "Connecting to Music app..." + /// Briefly set to true when a scrobble succeeds, then reset after a delay + var showScrobbleSuccess: Bool = false + /// Number of scrobbles waiting to be retried + var pendingRetryCount: Int { failedScrobbles.count } - private var cancellables = Set() private var lastScrobbleTime: Date? private let minimumScrobbleInterval: TimeInterval = 30 private var pollTask: Task? + private var retryTask: Task? + + /// Queue of failed scrobbles to retry + private var failedScrobbles: [(artist: String, title: String, album: String, timestamp: Date)] = [] + private static let maxRetryQueueSize = 50 private var currentTrackStartTime: Date? private var currentTrackDuration: TimeInterval? @@ -91,10 +98,8 @@ class Scrobbler { self.preferencesManager = preferencesManager - // Initialize scrobbling services on main actor - Task { @MainActor in - self.setupScrobblingServices(preferencesManager: preferencesManager) - } + // Initialize scrobbling services + self.setupScrobblingServices(preferencesManager: preferencesManager) // Monitor preferences changes for scrobbling service settings if let prefManager = preferencesManager { @@ -120,7 +125,7 @@ class Scrobbler { startPolling() // Initial check for now playing - Task { @MainActor in + Task { await checkNowPlaying() } } @@ -138,7 +143,7 @@ class Scrobbler { } onChange: { [weak self] in Log.debug("Scrobbler: Preferences changed, scheduling refresh") Task { @MainActor [weak self] in - guard let self = self else { return } + guard let self else { return } self.refreshScrobblingServices() // Re-register observation self.startPreferencesObservation(prefManager) @@ -146,7 +151,6 @@ class Scrobbler { } } - @MainActor private func setupScrobblingServices(preferencesManager: PreferencesManager?) { // Cancel any existing auth monitoring tasks before creating new ones for task in authMonitoringTasks { @@ -172,7 +176,7 @@ class Scrobbler { // Subscribe to authentication state changes for all services for service in scrobblingServices { - let task = Task { @MainActor in + let task = Task { for await _ in service.authStatus { guard !Task.isCancelled else { break } Log.debug("Service auth state changed: \(service.serviceName)", category: .auth) @@ -183,15 +187,12 @@ class Scrobbler { } // Trigger UI update - Task { @MainActor in - self.servicesLastUpdated = Date() - } + self.servicesLastUpdated = Date() Log.debug("Initialized \(scrobblingServices.count) scrobbling services") } // Method to refresh services when preferences change - @MainActor func refreshScrobblingServices() { setupScrobblingServices(preferencesManager: preferencesManager) } @@ -206,10 +207,8 @@ class Scrobbler { Log.debug("Scrobbler switching to target app: \(app.displayName)", category: .scrobble) // Clear current track display immediately - Task { @MainActor in - self.currentTrack = "Switching to \(app.displayName)..." - self.currentArtwork = nil - } + self.currentTrack = "Switching to \(app.displayName)..." + self.currentArtwork = nil // Switch the fetcher _mediaRemoteFetcher?.setTargetApp(app) @@ -218,7 +217,7 @@ class Scrobbler { musicAppStatus = "Connected to \(app.displayName)" // Schedule multiple checks to ensure we catch the new app's state - Task { @MainActor in + Task { try? await Task.sleep(for: .seconds(0.5)) Log.debug("First check after app switch", category: .scrobble) await checkNowPlaying() @@ -268,7 +267,7 @@ class Scrobbler { @objc private func handleSystemWake(_ notification: Notification) { Log.debug("System wake detected, checking playback state", category: .scrobble) - Task { @MainActor in + Task { await checkNowPlaying() } } @@ -294,7 +293,7 @@ class Scrobbler { } private func startPolling() { - pollTask = Task { @MainActor in + pollTask = Task { while !Task.isCancelled { try? await Task.sleep(for: .seconds(10)) guard !Task.isCancelled else { break } @@ -304,12 +303,11 @@ class Scrobbler { } @objc private func handleMusicPlayerNotification(_ notification: Notification) { - Task { @MainActor in + Task { await checkNowPlaying() } } - @MainActor private func checkNowPlaying() async { Log.debug("Checking now playing...", category: .scrobble) @@ -393,11 +391,11 @@ class Scrobbler { // Mark this session as scrobbled hasScrobbledCurrentSession = true - Task { @MainActor in + Task { // Scrobble to all enabled services in parallel await withTaskGroup(of: (String, Bool).self) { group in for service in self.scrobblingServices { - let serviceName = service.serviceName // Capture on main actor + let serviceName = service.serviceName group.addTask { do { let result = try await service.scrobble(artist: artist, track: title, album: album) @@ -408,7 +406,7 @@ class Scrobbler { } } } - + var successes: [String] = [] var failures: [String] = [] @@ -420,22 +418,31 @@ class Scrobbler { } } - // Capture results before passing to MainActor - let successList = successes - let failureList = failures - - await MainActor.run { - self.isScrobbling = false + self.isScrobbling = false - if !successList.isEmpty { - self.lastScrobbledTrack = "\(artist) - \(title)" - Log.debug("Successfully scrobbled to: \(successList.joined(separator: ", "))", category: .scrobble) + if !successes.isEmpty { + self.lastScrobbledTrack = "\(artist) - \(title)" + self.showScrobbleSuccess = true + Log.debug("Successfully scrobbled to: \(successes.joined(separator: ", "))", category: .scrobble) + // Reset success indicator after 2 seconds + Task { + try? await Task.sleep(for: .seconds(2)) + self.showScrobbleSuccess = false } + // Successful scrobble — try to flush retry queue + self.processRetryQueue() + } + + if !failures.isEmpty { + let failureNames = failures.joined(separator: ", ") + Log.error("Failed to scrobble to: \(failureNames)", category: .scrobble) - if !failureList.isEmpty { - let failureNames = failureList.joined(separator: ", ") + // If ALL services failed, queue for retry + if successes.isEmpty { + self.queueFailedScrobble(artist: artist, title: title, album: album) + self.errorMessage = "Failed to scrobble — will retry (\(self.failedScrobbles.count) queued)" + } else { self.errorMessage = "Failed to scrobble to: \(failureNames)" - Log.error("Failed to scrobble to: \(failureNames)", category: .scrobble) } } } @@ -478,10 +485,14 @@ class Scrobbler { // Use Task.sleep instead of Timer for modern Swift concurrency scrobbleTask = Task { - try? await Task.sleep(for: .seconds(scrobbleDelay)) - guard !Task.isCancelled else { return } - Log.debug("Scrobble timer fired", category: .scrobble) - scrobbleTrack(artist: artist, title: title, album: album) + do { + try await Task.sleep(for: .seconds(scrobbleDelay)) + Log.debug("Scrobble timer fired", category: .scrobble) + scrobbleTrack(artist: artist, title: title, album: album) + } catch { + // Cancelled — track changed or stopped, do nothing + Log.debug("Scrobble timer cancelled", category: .scrobble) + } } } else { Log.error("Could not get track duration", category: .scrobble) @@ -497,12 +508,11 @@ class Scrobbler { hasScrobbledCurrentSession = false } - @MainActor private func updateNowPlaying(artist: String, title: String, album: String) async { // Update now playing for all enabled services await withTaskGroup(of: (String, Bool).self) { group in for service in self.scrobblingServices { - let serviceName = service.serviceName // Capture on main actor + let serviceName = service.serviceName group.addTask { do { let result = try await service.updateNowPlaying(artist: artist, track: title, album: album) @@ -535,11 +545,63 @@ class Scrobbler { } } - deinit { + // MARK: - Retry Queue + + private func queueFailedScrobble(artist: String, title: String, album: String) { + let entry = (artist: artist, title: title, album: album, timestamp: Date.now) + + // Cap the queue to prevent unbounded growth + if failedScrobbles.count >= Self.maxRetryQueueSize { + failedScrobbles.removeFirst() + } + failedScrobbles.append(entry) + Log.debug("Queued failed scrobble: \(artist) - \(title) (\(failedScrobbles.count) in queue)", category: .scrobble) + } + + private func processRetryQueue() { + guard !failedScrobbles.isEmpty else { return } + + let pending = failedScrobbles + failedScrobbles.removeAll() + Log.debug("Retrying \(pending.count) queued scrobbles", category: .scrobble) + + retryTask?.cancel() + retryTask = Task { + for entry in pending { + guard !Task.isCancelled else { + // Re-queue remaining entries + failedScrobbles.append(contentsOf: pending.suffix(from: pending.firstIndex(where: { $0.timestamp == entry.timestamp }) ?? pending.endIndex)) + break + } + + var anySuccess = false + for service in self.scrobblingServices { + do { + let result = try await service.scrobble(artist: entry.artist, track: entry.title, album: entry.album) + if result { anySuccess = true } + } catch { + Log.error("Retry scrobble failed for \(service.serviceName): \(error)", category: .scrobble) + } + } + + if anySuccess { + Log.debug("Retry succeeded: \(entry.artist) - \(entry.title)", category: .scrobble) + } else { + // Re-queue if all services still failing + queueFailedScrobble(artist: entry.artist, title: entry.title, album: entry.album) + } + + // Small delay between retries to avoid hammering the API + try? await Task.sleep(for: .seconds(1)) + } + } + } + + isolated deinit { DistributedNotificationCenter.default().removeObserver(self) pollTask?.cancel() scrobbleTask?.cancel() - // Cancel all auth monitoring tasks + retryTask?.cancel() for task in authMonitoringTasks { task.cancel() } diff --git a/scrobble/ScrobblingService.swift b/scrobble/ScrobblingService.swift index f24530e..9cb041e 100644 --- a/scrobble/ScrobblingService.swift +++ b/scrobble/ScrobblingService.swift @@ -84,44 +84,12 @@ class LastFmServiceAdapter: ScrobblingService { func scrobble(artist: String, track: String, album: String) async throws -> Bool { Log.debug("Last.fm: Scrobbling \(artist) - \(track)", category: .scrobble) - - return try await withCheckedThrowingContinuation { continuation in - var cancellable: AnyCancellable? - cancellable = lastFmManager.scrobble(artist: artist, track: track, album: album) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - break // Wait for value - case .failure(let error): - continuation.resume(throwing: error) - cancellable = nil - } - }, receiveValue: { success in - continuation.resume(returning: success) - cancellable = nil - }) - } + return try await lastFmManager.scrobble(artist: artist, track: track, album: album) } func updateNowPlaying(artist: String, track: String, album: String) async throws -> Bool { Log.debug("Last.fm: Updating now playing \(artist) - \(track)", category: .scrobble) - - return try await withCheckedThrowingContinuation { continuation in - var cancellable: AnyCancellable? - cancellable = lastFmManager.updateNowPlaying(artist: artist, track: track, album: album) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - break - case .failure(let error): - continuation.resume(throwing: error) - cancellable = nil - } - }, receiveValue: { success in - continuation.resume(returning: success) - cancellable = nil - }) - } + return try await lastFmManager.updateNowPlaying(artist: artist, track: track, album: album) } func authenticate() async throws -> Bool { @@ -143,8 +111,4 @@ class LastFmServiceAdapter: ScrobblingService { desktopManager.logout() } } -} - -extension ScrobblerError { - static let authenticationFailed = ScrobblerError.apiError("Authentication failed") -} +} \ No newline at end of file diff --git a/scrobble/SupportedMusicApp.swift b/scrobble/SupportedMusicApp.swift index c66d0a6..d7639c4 100644 --- a/scrobble/SupportedMusicApp.swift +++ b/scrobble/SupportedMusicApp.swift @@ -8,7 +8,7 @@ import Foundation struct SupportedMusicApp: Identifiable, Hashable, Codable { - let id = UUID() + var id: String { bundleId } let bundleId: String let displayName: String let alternativeNames: [String] diff --git a/scrobble/Utils/DesignTokens.swift b/scrobble/Utils/DesignTokens.swift index 0359305..1c3e893 100644 --- a/scrobble/Utils/DesignTokens.swift +++ b/scrobble/Utils/DesignTokens.swift @@ -50,6 +50,9 @@ enum DesignTokens { /// Track artwork thumbnail in lists static let artworkSizeThumbnail: CGFloat = 40 + /// Small inline track artwork in friend cards + static let artworkSizeInline: CGFloat = 24 + // MARK: - Onboarding /// SF Symbol icon size for onboarding step headers diff --git a/scrobble/Utils/KeychainHelper.swift b/scrobble/Utils/KeychainHelper.swift index 2a7698c..e61085d 100644 --- a/scrobble/Utils/KeychainHelper.swift +++ b/scrobble/Utils/KeychainHelper.swift @@ -31,6 +31,9 @@ enum KeychainHelper { ] let status = SecItemAdd(addQuery as CFDictionary, nil) + if status != errSecSuccess { + Log.error("Keychain save failed for \(key): \(status)", category: .auth) + } return status == errSecSuccess } @@ -46,6 +49,10 @@ enum KeychainHelper { var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) + if status != errSecSuccess && status != errSecItemNotFound { + Log.error("Keychain load failed for \(key): \(status)", category: .auth) + } + guard status == errSecSuccess, let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } @@ -56,6 +63,9 @@ enum KeychainHelper { kSecAttrService as String: service, kSecAttrAccount as String: key ] - SecItemDelete(query as CFDictionary) + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + Log.error("Keychain delete failed for \(key): \(status)", category: .auth) + } } } diff --git a/scrobble/View Components/AppSelectionButton.swift b/scrobble/View Components/AppSelectionButton.swift new file mode 100644 index 0000000..3648eb3 --- /dev/null +++ b/scrobble/View Components/AppSelectionButton.swift @@ -0,0 +1,45 @@ +// +// AppSelectionButton.swift +// scrobble +// + +import SwiftUI + +struct AppSelectionButton: View { + let app: SupportedMusicApp + let isSelected: Bool + let isRunning: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + 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) + .bold() + .foregroundStyle(isSelected ? .white : .secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, DesignTokens.spacingSection) + .padding(.horizontal, DesignTokens.spacingDefault) + } + .compatGlassButtonStyle(selected: isSelected) + .accessibilityLabel("\(app.displayName)\(isRunning ? ", running" : "")") + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} diff --git a/scrobble/View Components/FeatureRow.swift b/scrobble/View Components/FeatureRow.swift new file mode 100644 index 0000000..cf3a0d5 --- /dev/null +++ b/scrobble/View Components/FeatureRow.swift @@ -0,0 +1,30 @@ +// +// FeatureRow.swift +// scrobble +// + +import SwiftUI + +struct FeatureRow: View { + let icon: String + let title: String + let description: String + + var body: some View { + HStack(alignment: .top, spacing: DesignTokens.spacingSection) { + 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) + } + } + } +} diff --git a/scrobble/View Components/MarqueeText.swift b/scrobble/View Components/MarqueeText.swift index 15766fa..ba4f3ef 100644 --- a/scrobble/View Components/MarqueeText.swift +++ b/scrobble/View Components/MarqueeText.swift @@ -11,15 +11,15 @@ struct MarqueeRenderer: TextRenderer { var offset: Double var containerWidth: Double var fadeWidth: Double = 20 - + var animatableData: Double { get { offset } set { offset = newValue } } - + func draw(layout: Text.Layout, in context: inout GraphicsContext) { let textWidth = layout.first?.typographicBounds.width ?? 0 - + // Edge fade masks let leftFade = GraphicsContext.Shading.linearGradient( Gradient(colors: [.clear, .white]), @@ -31,10 +31,10 @@ struct MarqueeRenderer: TextRenderer { startPoint: CGPoint(x: containerWidth - fadeWidth, y: 0), endPoint: CGPoint(x: containerWidth, y: 0) ) - + context.clipToLayer { ctx in ctx.fill(Path(CGRect(x: 0, y: 0, width: containerWidth, height: 1000)), with: .color(.white)) - + // Apply fades only when text is scrolling past edges if offset < 0 { ctx.fill(Path(CGRect(x: 0, y: 0, width: fadeWidth, height: 1000)), with: leftFade) @@ -43,7 +43,7 @@ struct MarqueeRenderer: TextRenderer { ctx.fill(Path(CGRect(x: containerWidth - fadeWidth, y: 0, width: fadeWidth, height: 1000)), with: rightFade) } } - + for line in layout { for run in line { for glyph in run { @@ -59,35 +59,48 @@ struct MarqueeRenderer: TextRenderer { struct MarqueeText: View { let text: String var font: Font = .body - var containerWidth: Double = 200 var speed: Double = 30 // points per second var pauseDuration: Double = 1.5 @State private var offset: Double = 0 @State private var textWidth: Double = 0 + @State private var containerWidth: Double = 0 @State private var animationTask: Task? @State private var isHovering = false private var needsScrolling: Bool { - textWidth > containerWidth + containerWidth > 0 && textWidth > containerWidth } var body: some View { + // Hidden text reserves the correct line height in layout, + // truncated so it never pushes the container wider. Text(text) .font(font) .lineLimit(1) - .fixedSize() - .background(GeometryReader { geo in - Color.clear - .onAppear { textWidth = geo.size.width } - .onChange(of: text) { - // Update width when text changes - textWidth = geo.size.width - } - }) - .textRenderer(MarqueeRenderer(offset: offset, containerWidth: containerWidth)) - .frame(width: containerWidth, alignment: .leading) - .clipped() + .truncationMode(.tail) + .hidden() + .frame(maxWidth: .infinity, alignment: .leading) + .overlay { + GeometryReader { geo in + // Actual marquee text, rendered in an overlay so + // .fixedSize() can't influence the parent's width. + Text(text) + .font(font) + .lineLimit(1) + .fixedSize() + .background(GeometryReader { textGeo in + Color.clear + .onAppear { textWidth = textGeo.size.width } + .onChange(of: text) { textWidth = textGeo.size.width } + }) + .textRenderer(MarqueeRenderer(offset: offset, containerWidth: geo.size.width)) + .frame(width: geo.size.width, alignment: .leading) + .clipped() + .onAppear { containerWidth = geo.size.width } + .onChange(of: geo.size.width) { _, w in containerWidth = w } + } + } .onHover { hovering in isHovering = hovering if hovering && needsScrolling { @@ -102,7 +115,6 @@ struct MarqueeText: View { } private func startAnimation() { - // Cancel any existing animation first animationTask?.cancel() guard needsScrolling else { return } @@ -112,25 +124,20 @@ struct MarqueeText: View { animationTask = Task { @MainActor in while !Task.isCancelled && isHovering { - // Reset to start withAnimation(.easeOut(duration: 0.2)) { offset = 0 } - // Brief pause at start try? await Task.sleep(for: .seconds(pauseDuration)) guard !Task.isCancelled && isHovering else { break } - // Scroll left withAnimation(.linear(duration: scrollDuration)) { offset = -scrollDistance } - // Wait for scroll animation to complete + pause at end try? await Task.sleep(for: .seconds(scrollDuration + pauseDuration)) } - // Reset position when done if !isHovering { withAnimation(.easeOut(duration: 0.2)) { offset = 0 @@ -145,14 +152,19 @@ struct MarqueeText: View { } } - - - - #Preview { - MarqueeText( - text: "Some Really Long Song Title That Needs to Scroll", - font: .headline, - containerWidth: 200 - ) + VStack(alignment: .leading, spacing: 12) { + MarqueeText( + text: "Short text", + font: .body + ) + MarqueeText( + text: "Some Really Long Song Title That Definitely Needs to Scroll On Hover", + font: .headline + ) + Text("Regular text below for spacing comparison") + .font(.body) + } + .frame(width: 250) + .padding() } diff --git a/scrobble/View Components/ServiceRow.swift b/scrobble/View Components/ServiceRow.swift new file mode 100644 index 0000000..700b3c9 --- /dev/null +++ b/scrobble/View Components/ServiceRow.swift @@ -0,0 +1,41 @@ +// +// ServiceRow.swift +// scrobble +// + +import SwiftUI + +struct ServiceRow: View { + let title: String + @Binding var isEnabled: Bool + let status: ServiceStatus + @ViewBuilder let detailContent: Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(title) + .font(.headline) + .lineLimit(1) + .fixedSize() + .foregroundStyle(.primary) + 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) + } + } + } +} diff --git a/scrobble/Views/AppSelectionView.swift b/scrobble/Views/AppSelectionView.swift index 002e4d1..d3dfa2b 100644 --- a/scrobble/Views/AppSelectionView.swift +++ b/scrobble/Views/AppSelectionView.swift @@ -52,7 +52,7 @@ struct AppSelectionView: View { .foregroundStyle(.secondary) Text(runningApps.map { $0.displayName }.joined(separator: ", ")) - .font(.caption2) + .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal) @@ -62,20 +62,17 @@ struct AppSelectionView: View { .padding(.horizontal) VStack(alignment: .leading, spacing: DesignTokens.spacingDefault) { - HStack { - Image(systemName: "info.circle") - .foregroundStyle(Color.accentColor) - Text("Current Selection") - .font(.subheadline) - .fontWeight(.medium) - } + Label("Current Selection", systemImage: "info.circle") + .font(.subheadline) + .bold() + .foregroundStyle(Color.accentColor) HStack(alignment: .firstTextBaseline, spacing: DesignTokens.spacingTight) { Image(systemName: preferencesManager.selectedMusicApp.icon) .foregroundStyle(Color.accentColor) .aspectRatio(contentMode: .fill) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 2) { // tight title/subtitle pair Text(preferencesManager.selectedMusicApp.displayName) .font(.body) .fontWeight(.medium) @@ -127,45 +124,6 @@ struct AppSelectionView: View { } } -struct AppSelectionButton: View { - let app: SupportedMusicApp - let isSelected: Bool - let isRunning: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - 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) - .foregroundStyle(isSelected ? .white : .secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, DesignTokens.spacingSection) - .padding(.horizontal, DesignTokens.spacingDefault) - } - .compatGlassButtonStyle(selected: isSelected) - .accessibilityLabel("\(app.displayName)\(isRunning ? ", running" : "")") - .accessibilityAddTraits(isSelected ? .isSelected : []) - } -} - #Preview { let prefManager = PreferencesManager() let authState = AuthState() diff --git a/scrobble/Views/FriendCardView.swift b/scrobble/Views/FriendCardView.swift index 353172a..9df632d 100644 --- a/scrobble/Views/FriendCardView.swift +++ b/scrobble/Views/FriendCardView.swift @@ -48,7 +48,7 @@ struct FriendCardView: View { } if recentTracks.isEmpty { - Text("No recent tracks") + Label("No recent tracks", systemImage: "music.note.slash") .font(.caption) .foregroundStyle(.secondary) } else { @@ -68,8 +68,8 @@ struct FriendCardView: View { } placeholder: { Color.gray.opacity(0.2) } - .frame(width: 24, height: 24) - .clipShape(RoundedRectangle(cornerRadius: 4)) + .frame(width: DesignTokens.artworkSizeInline, height: DesignTokens.artworkSizeInline) + .clipShape(RoundedRectangle(cornerRadius: DesignTokens.cornerRadiusSmall)) VStack(alignment: .leading) { Text("\(track.artist.text) - \(track.name)") @@ -95,7 +95,6 @@ struct FriendCardView: View { } Spacer() } - .contentShape(Rectangle()) } } } @@ -113,5 +112,17 @@ struct FriendCardView: View { } #Preview { - FriendCardView(friend: mockFriend, recentTracks: []) + FriendCardView( + friend: Friend( + name: "test", + realname: "Test User", + url: "https://example.com", + image: [Friend.Image(text: "https://example.com/image.jpg", size: "medium")], + country: "USA", + playcount: "100", + registered: Friend.Registered(unixtime: "1234567890"), + subscriber: "0" + ), + recentTracks: [] + ) } diff --git a/scrobble/Views/FriendsView.swift b/scrobble/Views/FriendsView.swift index ebc0a72..13bcc20 100644 --- a/scrobble/Views/FriendsView.swift +++ b/scrobble/Views/FriendsView.swift @@ -37,8 +37,11 @@ struct FriendsView: View { ProgressView() .progressViewStyle(.circular) } else if model.friends.isEmpty { - Text("No friends found") - .foregroundStyle(.secondary) + ContentUnavailableView( + "No Friends Found", + systemImage: "person.2.slash", + description: Text("Connect with friends on Last.fm to see their activity here.") + ) } else { ScrollView { LazyVStack(spacing: DesignTokens.spacingLarge) { @@ -68,7 +71,8 @@ struct FriendsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { model.preferencesManager = preferencesManager - model.updateLastFmManager(scrobbler.lastFmManager) + model.setLastFmManager(scrobbler.lastFmManager) + model.loadIfNeeded() } .onChange(of: preferencesManager.numberOfFriendsDisplayed) { _, _ in model.refreshData() diff --git a/scrobble/Views/MenuButtonsView.swift b/scrobble/Views/MenuButtonsView.swift index 0f00b64..c6e340d 100644 --- a/scrobble/Views/MenuButtonsView.swift +++ b/scrobble/Views/MenuButtonsView.swift @@ -24,7 +24,7 @@ struct MenuButtonsView: View { } label: { Label("Window", systemImage: "rectangle.expand.vertical") .foregroundStyle(.tertiary) - .font(.caption2) + .font(.caption) } .compatGlassButtonStyle() .accessibilityLabel("Open window") @@ -34,7 +34,7 @@ struct MenuButtonsView: View { SettingsLink { Label("Settings", systemImage: "gearshape") .foregroundStyle(.tertiary) - .font(.caption2) + .font(.caption) } .compatGlassButtonStyle() @@ -45,7 +45,7 @@ struct MenuButtonsView: View { } label: { Label("Quit", systemImage: "xmark.circle") .foregroundStyle(.quaternary) - .font(.caption2) + .font(.caption) } .compatGlassButtonStyle() .accessibilityLabel("Quit Scrobble") diff --git a/scrobble/Views/Onboarding/OnboardingContainerView.swift b/scrobble/Views/Onboarding/OnboardingContainerView.swift index 00a6c66..f5d4498 100644 --- a/scrobble/Views/Onboarding/OnboardingContainerView.swift +++ b/scrobble/Views/Onboarding/OnboardingContainerView.swift @@ -9,6 +9,7 @@ import SwiftUI struct OnboardingContainerView: View { @Environment(\.dismissWindow) private var dismissWindow + @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(PreferencesManager.self) var preferencesManager @Environment(Scrobbler.self) var scrobbler @Environment(AuthState.self) var authState @@ -99,7 +100,10 @@ struct OnboardingContainerView: View { } private var stepTransition: AnyTransition { - .asymmetric( + if reduceMotion { + return .opacity + } + return .asymmetric( insertion: .move(edge: onboardingState.isGoingForward ? .trailing : .leading).combined(with: .opacity), removal: .move(edge: onboardingState.isGoingForward ? .leading : .trailing).combined(with: .opacity) ) diff --git a/scrobble/Views/Onboarding/OnboardingPreferencesView.swift b/scrobble/Views/Onboarding/OnboardingPreferencesView.swift index 8a5cf68..1d69780 100644 --- a/scrobble/Views/Onboarding/OnboardingPreferencesView.swift +++ b/scrobble/Views/Onboarding/OnboardingPreferencesView.swift @@ -58,10 +58,7 @@ struct OnboardingPreferencesView: View { Toggle("Cap scrobble delay", isOn: $preferencesManager.useMaxTrackCompletionScrobbleDelay) if preferencesManager.useMaxTrackCompletionScrobbleDelay { - Picker("Max delay", selection: Binding( - get: { preferencesManager.maxTrackCompletionScrobbleDelay ?? 240 }, - set: { preferencesManager.maxTrackCompletionScrobbleDelay = $0 } - )) { + Picker("Max delay", selection: $preferencesManager.maxTrackCompletionScrobbleDelay) { ForEach(maxDelayOptions, id: \.self) { seconds in Text("\(seconds / 60) minutes").tag(seconds) } diff --git a/scrobble/Views/Onboarding/OnboardingWelcomeView.swift b/scrobble/Views/Onboarding/OnboardingWelcomeView.swift index 15e0bf2..5aaf75f 100644 --- a/scrobble/Views/Onboarding/OnboardingWelcomeView.swift +++ b/scrobble/Views/Onboarding/OnboardingWelcomeView.swift @@ -9,7 +9,7 @@ import SwiftUI struct OnboardingWelcomeView: View { var body: some View { - VStack(spacing: 24) { + VStack(spacing: DesignTokens.spacingLarge) { Spacer() // App icon @@ -20,7 +20,7 @@ struct OnboardingWelcomeView: View { .shadow(color: .black.opacity(0.2), radius: 10, y: 5) // Welcome text - VStack(spacing: 8) { + VStack(spacing: DesignTokens.spacingDefault) { Text("scrobble") .font(.largeTitle) .fontWeight(.bold) @@ -31,7 +31,7 @@ struct OnboardingWelcomeView: View { } // Features list - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: DesignTokens.spacingLarge) { FeatureRow( icon: "music.note.list", title: "Automatic Scrobbling", @@ -64,30 +64,6 @@ struct OnboardingWelcomeView: View { } } -struct FeatureRow: View { - let icon: String - let title: String - let description: String - - var body: some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: icon) - .font(.title2) - .foregroundStyle(Color.accentColor) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.headline) - - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - } -} - #Preview { OnboardingWelcomeView() .frame(width: 500, height: 450) diff --git a/scrobble/Views/PreferencesView.swift b/scrobble/Views/PreferencesView.swift index 9fc3eb7..86957e0 100644 --- a/scrobble/Views/PreferencesView.swift +++ b/scrobble/Views/PreferencesView.swift @@ -63,10 +63,10 @@ struct PreferencesView: View { HStack { Text("scrobble v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")") .font(.headline) - .fontWeight(.bold) + .bold() Text("[build \(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown")]") - .font(.caption2) + .font(.caption) .foregroundStyle(.tertiary) Spacer() @@ -76,14 +76,14 @@ struct PreferencesView: View { HStack { Text("copyright \u{00A9} 2025-2026 COMPUTER DATA") - .font(.caption2) + .font(.caption) .foregroundStyle(.secondary) Spacer() Link("GitHub", destination: URL(string: "https://github.com/bretth18/scrobble")!) - .font(.caption2) + .font(.caption) Link("License", destination: URL(string: "https://github.com/bretth18/scrobble/blob/main/LICENSE")!) - .font(.caption2) + .font(.caption) } } diff --git a/scrobble/Views/ScrobblingServicesView.swift b/scrobble/Views/ScrobblingServicesView.swift index 7dd37cb..b5adb32 100644 --- a/scrobble/Views/ScrobblingServicesView.swift +++ b/scrobble/Views/ScrobblingServicesView.swift @@ -125,79 +125,6 @@ struct ScrobblingServicesView: View { } } -enum ServiceStatus { - case connected - case notConnected - case failed - case checking - case disabled - - var label: String { - switch self { - case .connected: return "Connected" - case .notConnected: return "Not connected" - case .failed: return "Auth failed" - case .checking: return "Checking..." - case .disabled: return "Disabled" - } - } - - var icon: String { - switch self { - case .connected: return "checkmark.seal.fill" - case .notConnected: return "exclamationmark.triangle.fill" - case .failed: return "xmark.octagon.fill" - case .checking: return "hourglass" - case .disabled: return "minus.circle.fill" - } - } - - var color: Color { - switch self { - case .connected: return .green - case .notConnected: return .yellow - case .failed: return .red - case .checking: return .secondary - case .disabled: return .secondary - } - } -} - -struct ServiceRow: View { - let title: String - @Binding var isEnabled: Bool - let status: ServiceStatus - @ViewBuilder let detailContent: Content - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(title) - .font(.headline) - .lineLimit(1) - .fixedSize() - .foregroundStyle(.primary) - 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) - } - } - } -} - #Preview { let prefManager = PreferencesManager() let authState = AuthState() diff --git a/scrobble/Views/ScrobblingView.swift b/scrobble/Views/ScrobblingView.swift index e9b7a8f..90e4b60 100644 --- a/scrobble/Views/ScrobblingView.swift +++ b/scrobble/Views/ScrobblingView.swift @@ -19,7 +19,7 @@ struct ScrobblingView: View { Text( "v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")" ) - .font(.caption2) + .font(.caption) .foregroundStyle(.tertiary) } .padding(.horizontal) @@ -31,7 +31,7 @@ struct ScrobblingView: View { "monitoring:", systemImage: preferencesManager.selectedMusicApp.icon ) - .font(.caption2.monospaced()) + .font(.caption.monospaced()) .foregroundStyle(.tertiary) Spacer() Text( @@ -47,7 +47,7 @@ struct ScrobblingView: View { // Scrobbling Services Status VStack(alignment: .leading, spacing: DesignTokens.spacingDefault) { Text("scrobbling to:") - .font(.caption2.monospaced()) + .font(.caption.monospaced()) .foregroundStyle(.tertiary) ServicesStatusView() @@ -66,8 +66,7 @@ struct ScrobblingView: View { MarqueeText( text: scrobbler.currentTrack, - font: .body, - containerWidth: 200 + font: .body ) if let currentArtwork = scrobbler.currentArtwork { @@ -95,17 +94,25 @@ struct ScrobblingView: View { .accessibilityHidden(true) } - Text("Last Scrobbled:") - .font(.subheadline) - .foregroundStyle(.tertiary) + HStack(spacing: DesignTokens.spacingTight) { + Text("Last Scrobbled:") + .font(.subheadline) + .foregroundStyle(.tertiary) - HStack { - MarqueeText( - text: scrobbler.lastScrobbledTrack, - font: .body, - containerWidth: 200 - ) + if scrobbler.showScrobbleSuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.caption) + .transition(.opacity) + .accessibilityLabel("Scrobble succeeded") + } } + .animation(.easeInOut(duration: 0.3), value: scrobbler.showScrobbleSuccess) + + MarqueeText( + text: scrobbler.lastScrobbledTrack, + font: .body + ) } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/scrobble/Views/ServicesStatusView.swift b/scrobble/Views/ServicesStatusView.swift index 689e8dd..c00290d 100644 --- a/scrobble/Views/ServicesStatusView.swift +++ b/scrobble/Views/ServicesStatusView.swift @@ -39,7 +39,7 @@ struct ServicesStatusView: View { if !service.isAuthenticated { Text("not authenticated") - .font(.caption2) + .font(.caption) .foregroundStyle(.tertiary) } } diff --git a/scrobble/Views/UpdateView.swift b/scrobble/Views/UpdateView.swift index b282c8f..3bcf08f 100644 --- a/scrobble/Views/UpdateView.swift +++ b/scrobble/Views/UpdateView.swift @@ -21,7 +21,7 @@ struct UpdateSettingsView: View { .foregroundStyle(.secondary) Text(updateChecker.currentVersionString) - .font(.caption2) + .font(.caption) .foregroundStyle(.tertiary) Spacer() @@ -45,7 +45,7 @@ struct UpdateSettingsView: View { if let error = updateChecker.error { Text(error.localizedDescription) - .font(.caption2) + .font(.caption) .foregroundStyle(.red) } diff --git a/scrobble/scrobbleApp.swift b/scrobble/scrobbleApp.swift index 0d040c0..28f64bc 100644 --- a/scrobble/scrobbleApp.swift +++ b/scrobble/scrobbleApp.swift @@ -11,7 +11,7 @@ import Observation @main struct scrobbleApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @State private var preferencesManager = PreferencesManager() + @State private var preferencesManager: PreferencesManager @State private var scrobbler: Scrobbler @State private var appState = AppState() @State private var authState: AuthState @@ -34,6 +34,16 @@ struct scrobbleApp: App { _scrobbler = State(initialValue: Scrobbler(lastFmManager: lastFmManager, preferencesManager: prefManager)) } + private var menuBarIcon: String { + if scrobbler.errorMessage != nil { + "music.note.tv" + } else if scrobbler.currentTrack != "No track playing" { + "music.note" + } else { + "music.note" + } + } + var body: some Scene { MenuBarExtra { VStack(spacing: DesignTokens.spacingDefault) { @@ -57,7 +67,8 @@ struct scrobbleApp: App { OnboardingLauncher(hasCheckedOnboarding: $hasCheckedOnboarding) } } label: { - Image(systemName: "music.note") + Image(systemName: menuBarIcon) + .symbolEffect(.pulse, isActive: scrobbler.isScrobbling) .accessibilityLabel("Scrobble") } .menuBarExtraStyle(.window) diff --git a/scrobbleTests/CodableTests.swift b/scrobbleTests/CodableTests.swift index f9f69dc..b7095ec 100644 --- a/scrobbleTests/CodableTests.swift +++ b/scrobbleTests/CodableTests.swift @@ -194,11 +194,21 @@ struct CodableTests { #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("Friend has expected values") + func friendValues() { + let friend = Friend( + name: "test", + realname: "Test User", + url: "https://example.com", + image: [Friend.Image(text: "https://example.com/image.jpg", size: "medium")], + country: "USA", + playcount: "100", + registered: Friend.Registered(unixtime: "1234567890"), + subscriber: "0" + ) + #expect(friend.name == "test") + #expect(friend.realname == "Test User") + #expect(friend.country == "USA") } @Test("SupportedMusicApp encoding excludes id field") diff --git a/scrobbleTests/PreferencesManagerTests.swift b/scrobbleTests/PreferencesManagerTests.swift index 873bfe3..42a8602 100644 --- a/scrobbleTests/PreferencesManagerTests.swift +++ b/scrobbleTests/PreferencesManagerTests.swift @@ -2,6 +2,7 @@ import Testing @testable import scrobble @Suite("PreferencesManager Tests", .serialized) +@MainActor struct PreferencesManagerTests { private func cleanupDefaults() { diff --git a/scrobbleTests/UpdateCheckerTests.swift b/scrobbleTests/UpdateCheckerTests.swift index 4ff05f6..15172b8 100644 --- a/scrobbleTests/UpdateCheckerTests.swift +++ b/scrobbleTests/UpdateCheckerTests.swift @@ -2,6 +2,7 @@ import Testing @testable import scrobble @Suite("UpdateChecker Tests") +@MainActor struct UpdateCheckerTests { @Test("No update available when version info is nil")