Skip to content

Commit 1cb3df3

Browse files
authored
feat: account switcher (#75)
1 parent 12bf4f5 commit 1cb3df3

28 files changed

+3768
-175
lines changed

AGENTS.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,3 +343,66 @@ After each phase, briefly report:
343343
- ➡️ Next phase plan
344344

345345
This keeps the human informed and provides natural points to course-correct.
346+
347+
## Subagents (Context-Isolated Tasks)
348+
349+
VS Code's `#runSubagent` tool enables context-isolated task execution. Subagents run independently with their own context, preventing context confusion in complex tasks.
350+
351+
### When to Use Subagents
352+
353+
| Task Type | Use Subagent? | Rationale |
354+
|-----------|---------------|-----------|
355+
| Research API endpoints | ✅ Yes | Keeps raw JSON exploration out of main context |
356+
| Analyze unfamiliar code areas | ✅ Yes | Deep dives don't pollute main conversation |
357+
| Review a single file for patterns | ✅ Yes | Focused analysis, returns summary only |
358+
| Generate test fixtures | ✅ Yes | Boilerplate generation isolated from design discussion |
359+
| Simple edits to known files | ❌ No | Direct action is faster |
360+
| Multi-step refactoring | ❌ No | Needs continuous context across steps |
361+
| Tasks requiring user feedback | ❌ No | Subagents don't pause for input |
362+
363+
### Subagent Prompts for This Project
364+
365+
**API Research** — Explore an endpoint before implementing:
366+
```
367+
Analyze the YouTube Music API endpoint structure for #file:docs/api-discovery.md with #runSubagent.
368+
Focus on FEmusic_liked_playlists response format and identify all playlist item fields.
369+
Return a summary of the response structure suitable for writing a parser.
370+
```
371+
372+
**Code Pattern Analysis** — Understand existing patterns:
373+
```
374+
With #runSubagent, analyze #file:Core/Services/API/YTMusicClient.swift and identify:
375+
1. How authenticated requests are constructed
376+
2. Error handling patterns
377+
3. How parsers are invoked
378+
Return a concise pattern guide for adding a new endpoint method.
379+
```
380+
381+
**Parser Stub Generation** — Generate boilerplate:
382+
```
383+
Using #runSubagent, generate a Swift parser struct following the pattern in #file:Core/Services/API/Parsers/
384+
for parsing a "moods and genres" API response with categories containing playlists.
385+
Return only the struct definition with placeholder parsing logic.
386+
```
387+
388+
**Performance Audit** — Isolated deep dive:
389+
```
390+
With #runSubagent, audit #file:Views/macOS/LibraryView.swift for SwiftUI performance issues.
391+
Check for: await in ForEach, missing LazyVStack, inline image loading, excessive state updates.
392+
Return a prioritized list of issues with line numbers.
393+
```
394+
395+
### Subagent Best Practices
396+
397+
1. **Be specific in prompts** — Subagents don't have conversation history; include all necessary context
398+
2. **Request structured output** — Ask for summaries, lists, or code snippets that integrate cleanly
399+
3. **Use for exploration, not execution** — Subagents are great for research; keep edits in main context
400+
4. **Combine with file references** — Use `#file:path` to give subagents focused context
401+
5. **Review before integrating** — Subagent results join main context; verify accuracy first
402+
403+
### Anti-Patterns
404+
405+
- ❌ Using subagents for quick lookups (overhead not worth it)
406+
- ❌ Chaining multiple subagents (use main context for multi-step work)
407+
- ❌ Expecting subagents to remember previous subagent results
408+
- ❌ Using subagents for tasks requiring user clarification

App/KasetApp.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,12 @@ struct KasetApp: App {
5555
@State private var authService = AuthService()
5656
@State private var webKitManager = WebKitManager.shared
5757
@State private var playerService = PlayerService()
58-
@State private var ytMusicClient: YTMusicClient?
58+
@State private var sharedClient: any YTMusicClientProtocol
5959
@State private var notificationService: NotificationService?
6060
@State private var updaterService = UpdaterService()
6161
@State private var favoritesManager = FavoritesManager.shared
6262
@State private var likeStatusManager = SongLikeStatusManager.shared
63+
@State private var accountService: AccountService?
6364

6465
/// Triggers search field focus when set to true.
6566
@State private var searchFocusTrigger = false
@@ -87,11 +88,20 @@ struct KasetApp: App {
8788
player.setYTMusicClient(client)
8889
SongLikeStatusManager.shared.setClient(client)
8990

91+
// Create account service
92+
let account = AccountService(ytMusicClient: client, authService: auth)
93+
94+
// Wire up brand account provider so API requests use the correct account
95+
realClient.brandIdProvider = { [weak account] in
96+
account?.currentBrandId
97+
}
98+
9099
_authService = State(initialValue: auth)
91100
_webKitManager = State(initialValue: webkit)
92101
_playerService = State(initialValue: player)
93-
_ytMusicClient = State(initialValue: UITestConfig.isUITestMode ? nil : realClient)
102+
_sharedClient = State(initialValue: client)
94103
_notificationService = State(initialValue: NotificationService(playerService: player))
104+
_accountService = State(initialValue: account)
95105

96106
if UITestConfig.isUITestMode {
97107
DiagnosticsLogger.ui.info("App launched in UI Test mode")
@@ -105,12 +115,13 @@ struct KasetApp: App {
105115
Color.clear
106116
.frame(width: 1, height: 1)
107117
} else {
108-
MainWindow(navigationSelection: self.$navigationSelection)
118+
MainWindow(navigationSelection: self.$navigationSelection, client: self.sharedClient)
109119
.environment(self.authService)
110120
.environment(self.webKitManager)
111121
.environment(self.playerService)
112122
.environment(self.favoritesManager)
113123
.environment(self.likeStatusManager)
124+
.environment(self.accountService)
114125
.environment(\.searchFocusTrigger, self.$searchFocusTrigger)
115126
.environment(\.navigationSelection, self.$navigationSelection)
116127
.environment(\.showCommandBar, self.$showCommandBar)
@@ -121,6 +132,9 @@ struct KasetApp: App {
121132
// Check if user is already logged in from previous session
122133
await self.authService.checkLoginStatus()
123134

135+
// Fetch accounts after login check (for account switcher)
136+
await self.accountService?.fetchAccounts()
137+
124138
// Warm up Foundation Models in background
125139
await FoundationModelsService.shared.warmup()
126140
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// AccountsListResponse.swift
2+
// Kaset
3+
//
4+
// Created for account switcher feature.
5+
6+
import Foundation
7+
8+
/// Response containing the list of available user accounts.
9+
///
10+
/// This struct represents the parsed response from the YouTube Music accounts list API,
11+
/// containing all accounts (primary and brand) associated with the authenticated user.
12+
///
13+
/// ## Usage
14+
/// ```swift
15+
/// let response = AccountsListResponse(
16+
/// googleEmail: "user@gmail.com",
17+
/// accounts: [primaryAccount, brandAccount1, brandAccount2]
18+
/// )
19+
///
20+
/// if let selected = response.selectedAccount {
21+
/// DiagnosticsLogger.log("Current account: \(selected.name)")
22+
/// }
23+
///
24+
/// if response.hasMultipleAccounts {
25+
/// // Show account switcher UI
26+
/// }
27+
/// ```
28+
public struct AccountsListResponse: Sendable {
29+
// MARK: - Properties
30+
31+
/// The Google email address associated with the primary account.
32+
/// Extracted from the response header.
33+
public let googleEmail: String?
34+
35+
/// All available accounts (primary and brand accounts).
36+
public let accounts: [UserAccount]
37+
38+
// MARK: - Computed Properties
39+
40+
/// The currently selected/active account.
41+
/// Returns the first account where `isSelected` is true, or nil if none selected.
42+
public var selectedAccount: UserAccount? {
43+
self.accounts.first { $0.isSelected }
44+
}
45+
46+
/// Whether multiple accounts are available for switching.
47+
/// Returns `true` if more than one account exists.
48+
public var hasMultipleAccounts: Bool {
49+
self.accounts.count > 1
50+
}
51+
52+
// MARK: - Initialization
53+
54+
/// Creates a new AccountsListResponse.
55+
///
56+
/// - Parameters:
57+
/// - googleEmail: The Google email from the response header.
58+
/// - accounts: Array of parsed UserAccount objects.
59+
public init(googleEmail: String?, accounts: [UserAccount]) {
60+
self.googleEmail = googleEmail
61+
self.accounts = accounts
62+
}
63+
}

Core/Models/UserAccount.swift

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// UserAccount.swift
2+
// Kaset
3+
//
4+
// Created for account switcher feature.
5+
6+
import Foundation
7+
8+
/// Represents a YouTube Music user account (primary or brand account).
9+
///
10+
/// YouTube Music allows users to have multiple accounts:
11+
/// - **Primary account**: The main Google account (no brandId)
12+
/// - **Brand accounts**: Managed channel accounts associated with the primary account
13+
///
14+
/// ## API Response Mapping
15+
/// - `name`: From `accountName.runs[0].text`
16+
/// - `handle`: From `channelHandle.runs[0].text` (optional)
17+
/// - `brandId`: From `serviceEndpoint.selectActiveIdentityEndpoint.supportedTokens[].pageIdToken.pageId`
18+
/// - `thumbnailURL`: From `accountPhoto.thumbnails[0].url`
19+
/// - `isSelected`: From `isSelected`
20+
public struct UserAccount: Identifiable, Equatable, Sendable, Hashable {
21+
// MARK: - Properties
22+
23+
/// Unique identifier for the account.
24+
/// Uses `brandId` for brand accounts, "primary" for the main account.
25+
public let id: String
26+
27+
/// Display name of the account.
28+
public let name: String
29+
30+
/// Channel handle (e.g., "@username"), if available.
31+
public let handle: String?
32+
33+
/// Brand account identifier, nil for primary accounts.
34+
public let brandId: String?
35+
36+
/// URL for the account's profile photo thumbnail.
37+
public let thumbnailURL: URL?
38+
39+
/// Whether this account is currently selected/active.
40+
public let isSelected: Bool
41+
42+
// MARK: - Computed Properties
43+
44+
/// Returns `true` if this is the primary Google account (not a brand account).
45+
public var isPrimary: Bool {
46+
self.brandId == nil
47+
}
48+
49+
/// Human-readable label for the account type.
50+
/// Returns "Personal" for primary accounts, "Brand" for brand accounts.
51+
public var typeLabel: String {
52+
self.isPrimary ? "Personal" : "Brand"
53+
}
54+
55+
// MARK: - Initialization
56+
57+
/// Creates a new UserAccount instance.
58+
///
59+
/// - Parameters:
60+
/// - id: Unique identifier for the account.
61+
/// - name: Display name of the account.
62+
/// - handle: Optional channel handle.
63+
/// - brandId: Brand account identifier (nil for primary).
64+
/// - thumbnailURL: URL for the profile photo.
65+
/// - isSelected: Whether the account is currently active.
66+
public init(
67+
id: String,
68+
name: String,
69+
handle: String?,
70+
brandId: String?,
71+
thumbnailURL: URL?,
72+
isSelected: Bool
73+
) {
74+
self.id = id
75+
self.name = name
76+
self.handle = handle
77+
self.brandId = brandId
78+
self.thumbnailURL = thumbnailURL
79+
self.isSelected = isSelected
80+
}
81+
82+
// MARK: - Factory Methods
83+
84+
/// Creates a UserAccount from API response fields.
85+
///
86+
/// This factory method automatically determines the account ID based on whether
87+
/// a brandId is provided.
88+
///
89+
/// - Parameters:
90+
/// - name: Display name from `accountName.runs[0].text`.
91+
/// - handle: Optional handle from `channelHandle.runs[0].text`.
92+
/// - brandId: Brand ID from `pageIdToken.pageId`, nil for primary account.
93+
/// - thumbnailURL: Thumbnail URL from `accountPhoto.thumbnails[0].url`.
94+
/// - isSelected: Selection state from `isSelected`.
95+
/// - Returns: A configured UserAccount instance.
96+
public static func from(
97+
name: String,
98+
handle: String?,
99+
brandId: String?,
100+
thumbnailURL: URL?,
101+
isSelected: Bool
102+
) -> UserAccount {
103+
let accountId = brandId ?? "primary"
104+
return UserAccount(
105+
id: accountId,
106+
name: name,
107+
handle: handle,
108+
brandId: brandId,
109+
thumbnailURL: thumbnailURL,
110+
isSelected: isSelected
111+
)
112+
}
113+
}

Core/Services/API/APICache.swift

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,10 @@ final class APICache {
8989

9090
private static let logger = DiagnosticsLogger.api
9191

92-
/// Generates a stable, deterministic cache key from endpoint and request body.
92+
/// Generates a stable, deterministic cache key from endpoint, request body, and brand ID.
9393
/// Uses SHA256 hash of sorted JSON to ensure consistency.
94-
static func stableCacheKey(endpoint: String, body: [String: Any]) -> String {
94+
/// Including brandId ensures cache isolation between accounts.
95+
static func stableCacheKey(endpoint: String, body: [String: Any], brandId: String = "") -> String {
9596
// Use JSONSerialization with .sortedKeys for deterministic output
9697
// This is more efficient than custom recursive string building
9798
let jsonData: Data
@@ -104,7 +105,16 @@ final class APICache {
104105
// Return endpoint-only key with error marker to avoid collisions
105106
return "\(endpoint):serialization_error_\(body.count)"
106107
}
107-
let hash = SHA256.hash(data: jsonData)
108+
109+
// Include brand ID in hash to isolate cache between accounts
110+
var hashData = jsonData
111+
if !brandId.isEmpty {
112+
// Use NUL byte separator to avoid ambiguity between JSON and brandId bytes
113+
hashData.append(0)
114+
hashData.append(Data(brandId.utf8))
115+
}
116+
117+
let hash = SHA256.hash(data: hashData)
108118
let hashString = hash.prefix(16).compactMap { String(format: "%02x", $0) }.joined()
109119
return "\(endpoint):\(hashString)"
110120
}

0 commit comments

Comments
 (0)