Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 32 additions & 16 deletions scrobble.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -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 */; };
Expand All @@ -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;
Expand All @@ -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 = "<group>"; };
A61F9BD02E85E07200CC1661 /* BlueskyOAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueskyOAuthManager.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 = "<group>"; };
A6CC84542EE8470C00236D47 /* MarqueeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarqueeText.swift; sourceTree = "<group>"; };
A6HIG00002 /* AppSelectionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSelectionButton.swift; sourceTree = "<group>"; };
A6HIG00004 /* FeatureRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureRow.swift; sourceTree = "<group>"; };
A6HIG00006 /* ServiceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceRow.swift; sourceTree = "<group>"; };
A6HIG00008 /* ServiceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceStatus.swift; sourceTree = "<group>"; };
A6COMPAT012EFBAA0000002 /* GlassEffect+Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlassEffect+Compat.swift"; sourceTree = "<group>"; };
A6DESIGN012EFBAA0000002 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = "<group>"; };
A6COMPAT012EFBAA0000004 /* LastFMWebAuthViewLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastFMWebAuthViewLegacy.swift; sourceTree = "<group>"; };
A6DESIGN012EFBAA0000002 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = "<group>"; };
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 = "<group>"; };
A6F213012C8C021800E1D23B /* ScrobblingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrobblingView.swift; sourceTree = "<group>"; };
Expand All @@ -138,8 +146,8 @@
A6F2FEEB2EE79C16003826F7 /* FriendsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsModel.swift; sourceTree = "<group>"; };
A6F2FEED2EE79E47003826F7 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = "<group>"; };
A6F2FEF12EE7A429003826F7 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
A6KEYCHAIN02 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
A6F954FB2EED236000501378 /* LaunchAtLoginSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginSettingsView.swift; sourceTree = "<group>"; };
A6KEYCHAIN02 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
A6ONBOARD0002 /* OnboardingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingState.swift; sourceTree = "<group>"; };
A6ONBOARD0004 /* OnboardingContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingContainerView.swift; sourceTree = "<group>"; };
A6ONBOARD0006 /* OnboardingWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWelcomeView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -184,6 +192,9 @@
children = (
A622991F2EE80F2C00592239 /* LabeledStepper.swift */,
A6CC84542EE8470C00236D47 /* MarqueeText.swift */,
A6HIG00002 /* AppSelectionButton.swift */,
A6HIG00004 /* FeatureRow.swift */,
A6HIG00006 /* ServiceRow.swift */,
);
path = "View Components";
sourceTree = "<group>";
Expand Down Expand Up @@ -283,6 +294,7 @@
A65671E92EEADB7300773C58 /* UpdateChecker.swift */,
A64304C52D52FBA1001998B6 /* AppState.swift */,
A6F2FEEB2EE79C16003826F7 /* FriendsModel.swift */,
A6HIG00008 /* ServiceStatus.swift */,
A6F2131D2C8C10AB00E1D23B /* PreferencesManager.swift */,
A6ONBOARD0002 /* OnboardingState.swift */,
);
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion scrobble/BlueskyOAuthManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
29 changes: 9 additions & 20 deletions scrobble/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
76 changes: 30 additions & 46 deletions scrobble/CustomScrobblingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,38 +26,32 @@ class CustomScrobblingService: ScrobblingService {
}

var authStatus: AsyncStream<Bool> {
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) {
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
15 changes: 8 additions & 7 deletions scrobble/LastFmManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@
//

import Foundation
import Combine

@MainActor
protocol LastFmManagerType {
func scrobble(artist: String, track: String, album: String) -> AnyPublisher<Bool, Error>
func updateNowPlaying(artist: String, track: String, album: String) -> AnyPublisher<Bool, Error>
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 {
Expand All @@ -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:
Expand Down Expand Up @@ -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")



Expand Down
Loading
Loading