Skip to content
Open
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
4 changes: 2 additions & 2 deletions electron/auth-session-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions electron/auth-session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 13 additions & 4 deletions electron/auth-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -232,17 +238,18 @@ 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);
} catch (err) {
console.error("[auth] session listener failed:", err);
}
}
return written;
}

private read(): StoredAuthSession | null {
Expand Down Expand Up @@ -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();
Expand All @@ -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;
}
}
}
2 changes: 1 addition & 1 deletion electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export interface LinerElectronApi {
get: () => StoredAuthSession | null;
set: (
next: { accessToken: string; refreshToken: string } | null,
) => Promise<StoredAuthSession | null>;
) => Promise<boolean>;
};
onDeeplink: (cb: (target: DeeplinkTarget) => void) => () => void;
diagnoseNetwork: (hosts: string[]) => Promise<MainNetResult>;
Expand Down
44 changes: 41 additions & 3 deletions src/shared/youtube/auth/youtubeAuthSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,30 @@ 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,
tokenType: "Bearer",
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 = {
Expand Down Expand Up @@ -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",
Expand Down
59 changes: 40 additions & 19 deletions src/shared/youtube/auth/youtubeAuthSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<boolean> {
session = next;
const bridge = mainBridge();
let saved: Promise<boolean>;
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 {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
}
Expand All @@ -202,7 +223,7 @@ export async function getYoutubeAuthorization(
export async function signOutYoutube(): Promise<void> {
const current = getYoutubeAuthTokens();
storePageId(null);
save(null);
void save(null);
if (!current) return;
await fetch(
`${REVOKE_URL}?token=${encodeURIComponent(current.refreshToken)}`,
Expand Down
Loading