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
3 changes: 2 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,14 @@ symphonia = { version = "0.5", default-features = false, features = ["mkv", "ogg
# serves Opus-in-WebM for the large majority of tracks at `high` — and as the *only* audio
# offered for many of them, so preferring AAC is not an alternative.
opus = "0.3.1"
# Was macOS-only (the session encryption key); account slot ids need it on every platform now.
rand = "0.8"

[target.'cfg(target_os = "macos")'.dependencies]
aes-gcm = "0.10"
block2 = "0.6.2"
objc2 = "0.6.4"
objc2-foundation = { version = "0.3.2", features = ["NSData", "NSDictionary", "NSGeometry", "NSObject", "NSString", "NSValue", "objc2-core-foundation"] }
rand = "0.8"

[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.61.3", features = [
Expand Down
851 changes: 764 additions & 87 deletions src-tauri/src/lib.rs

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion src/datasource/DataSource.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
AccountOption,
GoogleAccountOption,
ArtistNotificationLevel,
BrowsePage,
BrowseShelf,
Expand Down Expand Up @@ -91,10 +92,30 @@ export abstract class DataSource {
): Promise<void>;
/** Abandons a sign-in still waiting on the user. No-op once it has moved past that. */
cancelSignIn?(): Promise<void>;
signOut?(): Promise<void>;
/**
* Signs out of the active account. Returns whether another stored account (see
* `listGoogleAccounts`) took over automatically — false means fully signed out.
*/
signOut?(): Promise<boolean>;
/** Channels available on the signed-in account. Absent when the source has no such notion. */
listAccounts?(): Promise<AccountOption[]>;
selectAccount?(id: string): Promise<void>;
/**
* Every Google account with a stored session on this device — distinct from `listAccounts`,
* which lists channels/brand accounts *within* one login. `signIn` both signs in for the
* first time and adds an additional account: re-authenticating an account already stored
* refreshes it in place instead of duplicating it. Absent when the source has no notion of
* more than one stored login at a time.
*/
listGoogleAccounts?(): Promise<GoogleAccountOption[]>;
/** Makes a stored Google account active without signing in again. */
switchGoogleAccount?(id: string): Promise<void>;
/**
* Forgets one stored Google account. "unchanged" means a non-active account was removed and
* nothing about the current session changed; "switched" means the active one was removed and
* another stored account took over; "signed-out" means it was the last one.
*/
removeGoogleAccount?(id: string): Promise<"unchanged" | "switched" | "signed-out">;
getCachedLibrary?(): Promise<LibrarySnapshot | null>;
/**
* `onError` reports a *background* refresh failure — the one case the return value cannot
Expand Down
17 changes: 16 additions & 1 deletion src/datasource/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ export type AuthStage = "browser" | "session" | "library";
* share a progress shape and are told apart by `kind` — which decides both the wording and
* whether backing out is possible.
*/
export type AuthFlow = "sign-in" | "account-switch";
export type AuthFlow = "sign-in" | "account-switch" | "google-account-switch";

export interface AuthProgress {
flow: AuthFlow;
Expand All @@ -241,6 +241,21 @@ export interface AccountOption {
isActive: boolean;
}

/**
* One stored Google login, as offered by the Google account switcher.
*
* Distinct from `AccountOption`: that is a channel/brand account *within* one Google login,
* this is a separate login (a different person, or the same person's other email) — switching
* between these is what issue-tracker requests for "multiple accounts" mean.
*/
export interface GoogleAccountOption {
/** Opaque and stable across reloads; pass back to switchGoogleAccount/removeGoogleAccount. */
id: string;
name: string;
artworkUrl?: string;
isActive: boolean;
}

export interface LibrarySnapshot {
account: AccountProfile;
albums: Album[];
Expand Down
39 changes: 39 additions & 0 deletions src/datasource/youtube/YouTubeMusicDataSource.check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Self-check for `removeAccountOutcome`, the one piece of the multi-account switch/remove flow
* that lives on the TypeScript side rather than in Rust's `YoutubeAccountStore` (see its own
* tests in src-tauri/src/lib.rs). It decides what `LibraryController.removeGoogleAccount` shows
* the user: nothing, a fallback account's library, or signed-out.
*/
export {};

import { removeAccountOutcome } from "./YouTubeMusicDataSource";

function check(condition: boolean, message: string): void {
if (!condition) throw new Error(`FAILED: ${message}`);
}

// Removing an account that was not active must not disturb the current session at all.
check(
removeAccountOutcome("cookie-a", "cookie-a") === "unchanged",
"same cookie before and after means the removed account was not the active one",
);

// Removing the active account with another stored one falls back to it.
check(
removeAccountOutcome("cookie-a", "cookie-b") === "switched",
"a different, non-null cookie afterward means another stored account took over",
);

// Removing the last account leaves nothing active.
check(
removeAccountOutcome("cookie-a", null) === "signed-out",
"no cookie afterward means that was the last stored account",
);

// Removing the only account while never having been signed in is still a no-op, not a crash.
check(
removeAccountOutcome(null, null) === "unchanged",
"null before and after must not be read as switching to 'no one'",
);

console.log("YouTubeMusicDataSource.removeAccountOutcome: ok");
156 changes: 152 additions & 4 deletions src/datasource/youtube/YouTubeMusicDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { mintPoToken, warmPoToken } from "./poToken";
import { AuthExpiredError, DataSource, type StreamData } from "../DataSource";
import type {
AccountOption,
GoogleAccountOption,
ArtistNotificationLevel,
BrowseLink,
BrowsePage,
Expand Down Expand Up @@ -314,6 +315,34 @@ interface SignInResult {
cookie: string;
/** False when this is the same Google account recovering a lapsed session. */
accountChanged: boolean;
/** Opaque handle for switchGoogleAccount / removeGoogleAccount / the profile-capture call. */
slotId: string;
}

/** Mirrors `YoutubeAccountSummary` in src-tauri/src/lib.rs. */
interface StoredGoogleAccount {
slotId: string;
name: string | null;
avatarUrl: string | null;
isActive: boolean;
}

/**
* What removing a stored Google account should do to the current session, given the cookie that
* was active beforehand and whatever the backend reports is active afterward.
*
* Exported (rather than kept private to `removeGoogleAccount`) purely so
* `YouTubeMusicDataSource.check.ts` can exercise this branch directly — this is the one piece of
* logic in the multi-account flow the TypeScript side owns rather than delegating to Rust's
* `YoutubeAccountStore`, and it decides whether the UI silently does nothing, refreshes for a
* fallback account, or shows signed-out.
*/
export function removeAccountOutcome(
previousCookie: string | null,
newActiveCookie: string | null,
): "unchanged" | "switched" | "signed-out" {
if (newActiveCookie === previousCookie) return "unchanged";
return newActiveCookie ? "switched" : "signed-out";
}

const BROWSE_ITEM_TYPES = new Set(["song", "video", "album", "playlist", "artist"]);
Expand Down Expand Up @@ -2990,7 +3019,7 @@ export class YouTubeMusicDataSource extends DataSource {
});
// Unbounded: this resolves when the person finishes signing in, not on a timer.
onStage?.("browser");
const { cookie, accountChanged } = await invoke<SignInResult>("sign_in_youtube_music");
const { cookie, accountChanged, slotId } = await invoke<SignInResult>("sign_in_youtube_music");
this.musicCookie = cookie;
onStage?.("session");
logInternalInfo("YouTubeMusicDataSource.signIn command completed", {
Expand All @@ -3003,10 +3032,16 @@ export class YouTubeMusicDataSource extends DataSource {
* one: the cached library stays, the chosen channel stays, and only the clients holding the
* old cookie are rebuilt. Clearing regardless is what turned every lapsed session into a
* full resync behind the sign-in overlay — for an account whose cache was still correct.
*
* This is also "add another Google account" — the Rust side stores whatever account this
* turns out to be as an *additional* slot rather than replacing one, so from here on out the
* only difference between a first sign-in, a lapsed-session renewal, and adding a second
* account is this one flag.
*/
if (!accountChanged) {
this.resetMusicClients();
await this.getMusicClient();
void this.captureAccountProfile(slotId);
logInternalInfo("YouTubeMusicDataSource.signIn success (session renewed)");
return;
}
Expand All @@ -3020,9 +3055,41 @@ export class YouTubeMusicDataSource extends DataSource {
}
this.resetMusicSessionSelection();
await this.getMusicClient();
void this.captureAccountProfile(slotId);
logInternalInfo("YouTubeMusicDataSource.signIn success");
}

/**
* Labels a stored account with its own display name/avatar, for the account switcher.
*
* Best-effort and fire-and-forget from every call site: this is cosmetic (a missing label
* falls back to "YouTube Music" — see `listGoogleAccounts`), never worth failing or delaying
* a sign-in over. Rust has no way to ask YouTube for this itself, so it is captured here,
* right after whichever call just proved this client is authenticated as that account.
*/
private async captureAccountProfile(slotId: string): Promise<void> {
try {
const client = await this.getWebClient();
const accountItems = await client.account.getInfo(true) as Array<{
account_name?: { toString(): string };
account_photo?: unknown;
}>;
// accounts_list puts the signed-in person's own channel first — see getAccountCandidates,
// which relies on the same ordering.
const owner = accountItems[0];
if (!owner) return;
await invoke("update_youtube_music_account_profile", {
slotId,
name: owner.account_name?.toString() ?? null,
avatarUrl: selectArtworkUrl(collectArtworkCandidates(owner.account_photo)) ?? null,
});
} catch (error) {
logInternalWarn("YouTubeMusicDataSource.captureAccountProfile failed", {
error: error instanceof Error ? error.message : String(error),
});
}
}

/**
* Closes the sign-in window, which is what the backend's poll loop treats as a cancellation.
*
Expand All @@ -3041,19 +3108,100 @@ export class YouTubeMusicDataSource extends DataSource {
}
}

async signOut(): Promise<void> {
/**
* Signs out of whichever Google account is currently active.
*
* Returns whether another stored account took over — with more than one signed in, removing
* the active one falls back to another rather than dropping to fully signed out, the same way
* removing your active account from a browser falls back to another one still signed in there.
* `LibraryController` uses this to decide whether to show signed-out or refresh the library
* for whoever is now active.
*/
async signOut(): Promise<boolean> {
logInternalInfo("YouTubeMusicDataSource.signOut start");
await invoke("delete_youtube_music_cookie");
const fallbackCookie = await invoke<string | null>("delete_youtube_music_cookie");
try {
await clearCache();
} catch (error) {
logInternalWarn("YouTubeMusicDataSource.signOut cache clear failed", {
error: error instanceof Error ? error.message : String(error),
});
}
this.musicCookie = null;
this.musicCookie = fallbackCookie;
this.resetMusicSessionSelection();
this.resetMusicClients();
if (fallbackCookie) {
await this.getMusicClient();
logInternalInfo("YouTubeMusicDataSource.signOut success (fell back to another stored account)");
return true;
}
logInternalInfo("YouTubeMusicDataSource.signOut success");
return false;
}

async listGoogleAccounts(): Promise<GoogleAccountOption[]> {
const accounts = await invoke<StoredGoogleAccount[]>("list_youtube_music_accounts");
return accounts.map((account) => ({
id: account.slotId,
name: account.name || "YouTube Music",
artworkUrl: account.avatarUrl ?? undefined,
isActive: account.isActive,
}));
}

/** Makes a stored Google account active. Unlike signIn, never opens a browser window. */
async switchGoogleAccount(id: string): Promise<void> {
logInternalInfo("YouTubeMusicDataSource.switchGoogleAccount start");
const cookie = await invoke<string | null>("switch_youtube_music_account", { slotId: id });
this.musicCookie = cookie;
this.resetMusicSessionSelection();
this.resetMusicClients();
try {
await clearCache();
} catch (error) {
logInternalWarn("YouTubeMusicDataSource.switchGoogleAccount cache clear failed", {
error: error instanceof Error ? error.message : String(error),
});
}
await this.getMusicClient();
logInternalInfo("YouTubeMusicDataSource.switchGoogleAccount success");
}

/**
* Forgets one stored Google account.
*
* The three outcomes are what `LibraryController` needs to know to update the UI correctly:
* removing an account that was not active changes nothing about the current session at all,
* removing the active one either falls back to another stored account or, if that was the
* last one, leaves nothing signed in.
*/
async removeGoogleAccount(id: string): Promise<"unchanged" | "switched" | "signed-out"> {
logInternalInfo("YouTubeMusicDataSource.removeGoogleAccount start");
const previousCookie = this.musicCookie;
const newActiveCookie = await invoke<string | null>("remove_youtube_music_account", { slotId: id });
const outcome = removeAccountOutcome(previousCookie, newActiveCookie);
if (outcome === "unchanged") {
logInternalInfo("YouTubeMusicDataSource.removeGoogleAccount unchanged");
return outcome;
}

this.musicCookie = newActiveCookie;
this.resetMusicSessionSelection();
this.resetMusicClients();
try {
await clearCache();
} catch (error) {
logInternalWarn("YouTubeMusicDataSource.removeGoogleAccount cache clear failed", {
error: error instanceof Error ? error.message : String(error),
});
}
if (outcome === "signed-out") {
logInternalInfo("YouTubeMusicDataSource.removeGoogleAccount success (signed out)");
return outcome;
}
await this.getMusicClient();
logInternalInfo("YouTubeMusicDataSource.removeGoogleAccount success (switched)");
return "switched";
}

getCachedLibrary(): Promise<LibrarySnapshot | null> {
Expand Down
Loading
Loading