diff --git a/electron/auth-session-ipc.ts b/electron/auth-session-ipc.ts index 0c2ed2ac..c216b90a 100644 --- a/electron/auth-session-ipc.ts +++ b/electron/auth-session-ipc.ts @@ -117,8 +117,8 @@ export function registerAuthSessionIpc(): AuthSessionStore { }); ipcMain.handle("youtube-auth:set-session", (event, next: unknown) => { - if (!fromApp(event)) return null; - return youtubeStore.set(next); + if (!fromApp(event)) return false; + return youtubeStore.persist(next); }); return store; diff --git a/electron/auth-session-store.test.ts b/electron/auth-session-store.test.ts index 1f7a56ba..ba9ae06d 100644 --- a/electron/auth-session-store.test.ts +++ b/electron/auth-session-store.test.ts @@ -86,6 +86,15 @@ describe("AuthSessionStore", () => { expect(fs.existsSync(file)).toBe(false); }); + it("says whether a session reached the disk", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + expect(createStore().persist(session("rt_1"))).toBe(true); + expect(createStore().persist({ accessToken: "a" })).toBe(false); + fs.rmSync(file, { force: true }); + fs.mkdirSync(file, { recursive: true }); + expect(createStore().persist(session("rt_2"))).toBe(false); + }); + it("rejects malformed sessions instead of storing them", () => { const store = createStore(); expect(store.set({ accessToken: "a" })).toBeNull(); diff --git a/electron/auth-session-store.ts b/electron/auth-session-store.ts index 571f636a..f952ce27 100644 --- a/electron/auth-session-store.ts +++ b/electron/auth-session-store.ts @@ -111,6 +111,12 @@ export class AuthSessionStore { return session; } + persist(next: unknown): boolean { + const session = isStoredAuthSession(next) ? next : null; + if (next !== null && !session) return false; + return this.commit(session); + } + onChange(listener: AuthSessionListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -232,10 +238,10 @@ export class AuthSessionStore { }); } - private commit(next: StoredAuthSession | null, origin?: number): void { + private commit(next: StoredAuthSession | null, origin?: number): boolean { this.session = next; this.loaded = true; - this.write(next); + const written = this.write(next); for (const listener of this.listeners) { try { listener(next, origin); @@ -243,6 +249,7 @@ export class AuthSessionStore { console.error("[auth] session listener failed:", err); } } + return written; } private read(): StoredAuthSession | null { @@ -271,11 +278,11 @@ export class AuthSessionStore { } } - private write(next: StoredAuthSession | null): void { + private write(next: StoredAuthSession | null): boolean { try { if (!next) { fs.rmSync(this.filePath, { force: true }); - return; + return true; } const json = JSON.stringify(next); const encrypted = this.cipher.available(); @@ -298,8 +305,10 @@ export class AuthSessionStore { fs.writeFileSync(this.filePath, contents, { mode: 0o600 }); fs.rmSync(tempPath, { force: true }); } + return true; } catch (err) { console.error("[auth] failed to persist session:", describeError(err)); + return false; } } } diff --git a/electron/preload.ts b/electron/preload.ts index 27e5f364..6314f673 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -107,7 +107,7 @@ export interface LinerElectronApi { get: () => StoredAuthSession | null; set: ( next: { accessToken: string; refreshToken: string } | null, - ) => Promise; + ) => Promise; }; onDeeplink: (cb: (target: DeeplinkTarget) => void) => () => void; diagnoseNetwork: (hosts: string[]) => Promise; diff --git a/src/shared/youtube/auth/youtubeAuthSession.test.ts b/src/shared/youtube/auth/youtubeAuthSession.test.ts index 6129e955..9911399c 100644 --- a/src/shared/youtube/auth/youtubeAuthSession.test.ts +++ b/src/shared/youtube/auth/youtubeAuthSession.test.ts @@ -3,6 +3,15 @@ import type { YoutubeAuthTokens } from "./tvTokens"; const STORAGE_KEY = "liner_youtube_tv_session"; +const deviceFlow = vi.hoisted(() => ({ poll: vi.fn() })); +vi.mock("./tvOauthClient", () => ({ + getTvOauthClient: async () => ({ clientId: "id", clientSecret: "secret" }), +})); +vi.mock("./tvDeviceFlow", () => ({ + requestTvDeviceCode: vi.fn(), + pollTvDeviceToken: deviceFlow.poll, +})); + const tokens = (refreshToken: string): YoutubeAuthTokens => ({ accessToken: `access_${refreshToken}`, refreshToken, @@ -10,13 +19,14 @@ const tokens = (refreshToken: string): YoutubeAuthTokens => ({ expiresAt: Date.now() + 3_600_000, }); -function installBridge(stored: YoutubeAuthTokens | null) { +function installBridge(stored: YoutubeAuthTokens | null, writable = true) { const bridge = { stored, get: vi.fn(() => bridge.stored), set: vi.fn(async (next: YoutubeAuthTokens | null) => { + if (!writable) return false; bridge.stored = next; - return next; + return true; }), }; (window as unknown as { linerElectron: unknown }).linerElectron = { @@ -49,13 +59,41 @@ describe("youtube auth session backed by the main process", () => { expect(bridge.set).toHaveBeenCalledWith( expect.objectContaining({ refreshToken: "rt_legacy" }), ); - expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + await vi.waitFor(() => + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(), + ); const restarted = await loadModule(); expect(restarted.getYoutubeAuthTokens()?.refreshToken).toBe("rt_legacy"); expect(bridge.set).toHaveBeenCalledTimes(1); }); + it("keeps the plain copy when main can't store it, and retries on the next launch", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens("rt_legacy"))); + const bridge = installBridge(null, false); + const auth = await loadModule(); + expect(auth.getYoutubeAuthTokens()?.refreshToken).toBe("rt_legacy"); + await vi.waitFor(() => expect(bridge.set).toHaveBeenCalledTimes(1)); + await Promise.resolve(); + expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull(); + + const restarted = await loadModule(); + expect(restarted.getYoutubeAuthTokens()?.refreshToken).toBe("rt_legacy"); + expect(bridge.set).toHaveBeenCalledTimes(2); + }); + + it("fails the sign-in when main can't store the new tokens", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + deviceFlow.poll.mockResolvedValueOnce(tokens("rt_new")); + installBridge(null, false); + const auth = await loadModule(); + await expect(auth.completeYoutubeTvSignIn({} as never)).rejects.toThrow( + /couldn't save/i, + ); + expect(auth.isYoutubeSignedIn()).toBe(false); + }); + it("signs out through main without touching localStorage", async () => { vi.stubGlobal( "fetch", diff --git a/src/shared/youtube/auth/youtubeAuthSession.ts b/src/shared/youtube/auth/youtubeAuthSession.ts index 1d80b189..a1f9ac86 100644 --- a/src/shared/youtube/auth/youtubeAuthSession.ts +++ b/src/shared/youtube/auth/youtubeAuthSession.ts @@ -64,39 +64,56 @@ function load(): YoutubeAuthTokens | null { // older builds kept the tokens in plain localStorage: move them to main once const legacy = readLocal(); if (legacy) { - dropLocal(); - if (!stored && legacy.refreshToken) { - void bridge - .set(legacy) - .catch((err) => - console.error("[YouTube] saving the session failed:", err), - ); + if (stored || !legacy.refreshToken) { + dropLocal(); + } else { stored = legacy; + void persistInMain(bridge, legacy).then((saved) => { + if (saved) dropLocal(); + }); } } return stored; } -function save(next: YoutubeAuthTokens | null): void { +function persistInMain( + bridge: MainYoutubeAuthBridge, + next: YoutubeAuthTokens | null, +): Promise { + return bridge.set(next).then( + (saved) => { + if (!saved) console.error("[YouTube] saving the session failed"); + return saved; + }, + (err) => { + console.error("[YouTube] saving the session failed:", err); + return false; + }, + ); +} + +function save(next: YoutubeAuthTokens | null): Promise { session = next; const bridge = mainBridge(); + let saved: Promise; if (bridge) { - dropLocal(); - void bridge - .set(next) - .catch((err) => - console.error("[YouTube] saving the session failed:", err), - ); + if (!next) dropLocal(); + saved = persistInMain(bridge, next).then((ok) => { + if (ok) dropLocal(); + return ok; + }); } else { try { if (next) globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(next)); else globalThis.localStorage?.removeItem(STORAGE_KEY); } catch { - return; + return Promise.resolve(false); } + saved = Promise.resolve(true); } notifyChanged(); + return saved; } function storePageId(next: string | null): void { @@ -159,7 +176,11 @@ export async function completeYoutubeTvSignIn( signal, ); storePageId(null); - save(tokens); + if (!(await save(tokens))) { + session = null; + notifyChanged(); + throw new Error("Couldn't save the YouTube session on this device."); + } return tokens; } @@ -173,14 +194,14 @@ async function refresh( current.refreshToken, signal, ); - save(next); + void save(next); return next; } catch (error) { if ( error instanceof YoutubeAuthError && TERMINAL_REFRESH_ERRORS.has(error.code) ) - save(null); + void save(null); throw error; } } @@ -202,7 +223,7 @@ export async function getYoutubeAuthorization( export async function signOutYoutube(): Promise { const current = getYoutubeAuthTokens(); storePageId(null); - save(null); + void save(null); if (!current) return; await fetch( `${REVOKE_URL}?token=${encodeURIComponent(current.refreshToken)}`,