From 2262b6d4cb40af25b0555f7cc6a7c44c90431911 Mon Sep 17 00:00:00 2001 From: loreofmin-ux <270651371+loreofmin-ux@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:10:45 +0100 Subject: [PATCH 1/2] fix(youtube): keep the tokens until main confirms they are on disk --- electron/auth-session-ipc.ts | 5 +- electron/auth-session-store.test.ts | 9 +++ electron/auth-session-store.ts | 18 ++++-- electron/preload.ts | 3 +- .../youtube/auth/youtubeAuthSession.test.ts | 45 +++++++++++++- src/shared/youtube/auth/youtubeAuthSession.ts | 59 +++++++++++++------ 6 files changed, 110 insertions(+), 29 deletions(-) diff --git a/electron/auth-session-ipc.ts b/electron/auth-session-ipc.ts index 0c2ed2ac..03c453e5 100644 --- a/electron/auth-session-ipc.ts +++ b/electron/auth-session-ipc.ts @@ -116,9 +116,10 @@ export function registerAuthSessionIpc(): AuthSessionStore { event.returnValue = fromApp(event) ? youtubeStore.get() : null; }); + // answers whether the tokens reached the disk, so the renderer never drops its only copy too early 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..e4c4c9a0 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 }); // a directory where the file should go: the write fails + 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..a178a934 100644 --- a/electron/auth-session-store.ts +++ b/electron/auth-session-store.ts @@ -111,6 +111,13 @@ export class AuthSessionStore { return session; } + /** like set(), but says whether the change reached the disk (false for a malformed 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 +239,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 +250,7 @@ export class AuthSessionStore { console.error("[auth] session listener failed:", err); } } + return written; } private read(): StoredAuthSession | null { @@ -271,11 +279,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 +306,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..82aec8a0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -105,9 +105,10 @@ export interface LinerElectronApi { }; youtubeAuthSession?: { get: () => StoredAuthSession | null; + /** resolves true once the tokens are on disk */ 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..08240358 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,15 @@ 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), + // main answers whether the tokens reached the disk 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 +60,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..18360c52 100644 --- a/src/shared/youtube/auth/youtubeAuthSession.ts +++ b/src/shared/youtube/auth/youtubeAuthSession.ts @@ -61,42 +61,58 @@ function load(): YoutubeAuthTokens | null { } catch (err) { console.error("[YouTube] reading the stored session failed:", err); } - // older builds kept the tokens in plain localStorage: move them to main once + // older builds kept the tokens in plain localStorage: move them to main once. + // the plain copy is only dropped once main has them on disk; otherwise the next launch retries 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; + }, + ); +} + +/** resolves whether the change was stored for the next launch */ +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), - ); + saved = persistInMain(bridge, next); } 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 +175,12 @@ export async function completeYoutubeTvSignIn( signal, ); storePageId(null); - save(tokens); + // a sign-in that can't be stored would silently sign out on the next launch: say so now + 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)}`, From 581905a503e6febfac962a4bff60a62280d5593f Mon Sep 17 00:00:00 2001 From: loreofmin-ux <270651371+loreofmin-ux@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:57:05 +0000 Subject: [PATCH 2/2] fix(youtube): drop the local copy only after main stores the session --- electron/auth-session-ipc.ts | 1 - electron/auth-session-store.test.ts | 2 +- electron/auth-session-store.ts | 1 - electron/preload.ts | 1 - src/shared/youtube/auth/youtubeAuthSession.test.ts | 1 - src/shared/youtube/auth/youtubeAuthSession.ts | 12 ++++++------ 6 files changed, 7 insertions(+), 11 deletions(-) diff --git a/electron/auth-session-ipc.ts b/electron/auth-session-ipc.ts index 03c453e5..c216b90a 100644 --- a/electron/auth-session-ipc.ts +++ b/electron/auth-session-ipc.ts @@ -116,7 +116,6 @@ export function registerAuthSessionIpc(): AuthSessionStore { event.returnValue = fromApp(event) ? youtubeStore.get() : null; }); - // answers whether the tokens reached the disk, so the renderer never drops its only copy too early ipcMain.handle("youtube-auth:set-session", (event, next: unknown) => { if (!fromApp(event)) return false; return youtubeStore.persist(next); diff --git a/electron/auth-session-store.test.ts b/electron/auth-session-store.test.ts index e4c4c9a0..ba9ae06d 100644 --- a/electron/auth-session-store.test.ts +++ b/electron/auth-session-store.test.ts @@ -91,7 +91,7 @@ describe("AuthSessionStore", () => { 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 }); // a directory where the file should go: the write fails + fs.mkdirSync(file, { recursive: true }); expect(createStore().persist(session("rt_2"))).toBe(false); }); diff --git a/electron/auth-session-store.ts b/electron/auth-session-store.ts index a178a934..f952ce27 100644 --- a/electron/auth-session-store.ts +++ b/electron/auth-session-store.ts @@ -111,7 +111,6 @@ export class AuthSessionStore { return session; } - /** like set(), but says whether the change reached the disk (false for a malformed session) */ persist(next: unknown): boolean { const session = isStoredAuthSession(next) ? next : null; if (next !== null && !session) return false; diff --git a/electron/preload.ts b/electron/preload.ts index 82aec8a0..6314f673 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -105,7 +105,6 @@ export interface LinerElectronApi { }; youtubeAuthSession?: { get: () => StoredAuthSession | null; - /** resolves true once the tokens are on disk */ set: ( next: { accessToken: string; refreshToken: string } | null, ) => Promise; diff --git a/src/shared/youtube/auth/youtubeAuthSession.test.ts b/src/shared/youtube/auth/youtubeAuthSession.test.ts index 08240358..9911399c 100644 --- a/src/shared/youtube/auth/youtubeAuthSession.test.ts +++ b/src/shared/youtube/auth/youtubeAuthSession.test.ts @@ -23,7 +23,6 @@ function installBridge(stored: YoutubeAuthTokens | null, writable = true) { const bridge = { stored, get: vi.fn(() => bridge.stored), - // main answers whether the tokens reached the disk set: vi.fn(async (next: YoutubeAuthTokens | null) => { if (!writable) return false; bridge.stored = next; diff --git a/src/shared/youtube/auth/youtubeAuthSession.ts b/src/shared/youtube/auth/youtubeAuthSession.ts index 18360c52..a1f9ac86 100644 --- a/src/shared/youtube/auth/youtubeAuthSession.ts +++ b/src/shared/youtube/auth/youtubeAuthSession.ts @@ -61,8 +61,7 @@ function load(): YoutubeAuthTokens | null { } catch (err) { console.error("[YouTube] reading the stored session failed:", err); } - // older builds kept the tokens in plain localStorage: move them to main once. - // the plain copy is only dropped once main has them on disk; otherwise the next launch retries + // older builds kept the tokens in plain localStorage: move them to main once const legacy = readLocal(); if (legacy) { if (stored || !legacy.refreshToken) { @@ -93,14 +92,16 @@ function persistInMain( ); } -/** resolves whether the change was stored for the next launch */ function save(next: YoutubeAuthTokens | null): Promise { session = next; const bridge = mainBridge(); let saved: Promise; if (bridge) { - dropLocal(); - saved = persistInMain(bridge, next); + if (!next) dropLocal(); + saved = persistInMain(bridge, next).then((ok) => { + if (ok) dropLocal(); + return ok; + }); } else { try { if (next) @@ -175,7 +176,6 @@ export async function completeYoutubeTvSignIn( signal, ); storePageId(null); - // a sign-in that can't be stored would silently sign out on the next launch: say so now if (!(await save(tokens))) { session = null; notifyChanged();