diff --git a/src/lib/offscreen.test.ts b/src/lib/offscreen.test.ts new file mode 100644 index 0000000..fa16623 --- /dev/null +++ b/src/lib/offscreen.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ensureOffscreenDocument, hasOffscreenDocument } from './offscreen' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +const PARAMS = { + url: 'offscreen.html', + reasons: ['LOCAL_STORAGE' as chrome.offscreen.Reason], + justification: 'test', +} + +/** + * A chrome stub shaped like one of the version windows the manifest supports. + * `getContexts` arrived in Chrome 116; `hasDocument` has been callable since + * the offscreen API shipped in 109 but is undocumented, and has been + * discussed for removal — so "neither" is a real shape too. + */ +function stubChrome(opts: { + getContexts?: number | undefined + hasDocument?: boolean | undefined + createRejects?: unknown +}) { + const createDocument = vi.fn(async (_p: chrome.offscreen.CreateParameters) => { + if (opts.createRejects !== undefined) throw opts.createRejects + }) + const stub = { + runtime: { + ContextType: { OFFSCREEN_DOCUMENT: 'OFFSCREEN_DOCUMENT' }, + ...(opts.getContexts === undefined + ? {} + : { getContexts: vi.fn(async () => Array.from({ length: opts.getContexts! }, () => ({}))) }), + }, + offscreen: { + Reason: { LOCAL_STORAGE: 'LOCAL_STORAGE' }, + createDocument, + ...(opts.hasDocument === undefined + ? {} + : { hasDocument: vi.fn(async () => opts.hasDocument!) }), + }, + } + vi.stubGlobal('chrome', stub) + return { createDocument, stub } +} + +describe('hasOffscreenDocument', () => { + it('prefers getContexts where it exists (Chrome 116+)', async () => { + const { stub } = stubChrome({ getContexts: 1, hasDocument: false }) + await expect(hasOffscreenDocument()).resolves.toBe(true) + // The documented API wins even when the undocumented one disagrees. + expect(stub.offscreen.hasDocument).not.toHaveBeenCalled() + }) + + it('reports absence when getContexts returns nothing', async () => { + stubChrome({ getContexts: 0 }) + await expect(hasOffscreenDocument()).resolves.toBe(false) + }) + + // The 110-115 window the manifest still supports: no getContexts yet. + it('falls back to hasDocument when getContexts is missing', async () => { + const { stub } = stubChrome({ hasDocument: true }) + await expect(hasOffscreenDocument()).resolves.toBe(true) + expect(stub.offscreen.hasDocument).toHaveBeenCalled() + }) + + // Not "no": a caller that read undefined as false would create a second + // document and throw. + it('answers undefined when neither existence api is available', async () => { + stubChrome({}) + await expect(hasOffscreenDocument()).resolves.toBeUndefined() + }) +}) + +describe('ensureOffscreenDocument', () => { + it('creates the document when there is none', async () => { + const { createDocument } = stubChrome({ getContexts: 0 }) + await ensureOffscreenDocument(PARAMS) + expect(createDocument).toHaveBeenCalledWith(PARAMS) + }) + + it('does not create a second document when one exists', async () => { + const { createDocument } = stubChrome({ getContexts: 1 }) + await ensureOffscreenDocument(PARAMS) + expect(createDocument).not.toHaveBeenCalled() + }) + + // With no existence check available it must still try, and the attempt is + // what tells it the answer. + it('tries to create when existence is unknown', async () => { + const { createDocument } = stubChrome({}) + await ensureOffscreenDocument(PARAMS) + expect(createDocument).toHaveBeenCalled() + }) + + // Chrome's real message. Reaching this means the document exists, which is + // exactly what the caller asked for — sign-in must not fail here. + it('treats "only a single offscreen document" as success', async () => { + stubChrome({ + getContexts: 0, + createRejects: new Error('Only a single offscreen document may be created.'), + }) + await expect(ensureOffscreenDocument(PARAMS)).resolves.toBeUndefined() + }) + + it('survives that error with no existence api at all', async () => { + stubChrome({ createRejects: new Error('Only a single offscreen document may be created.') }) + await expect(ensureOffscreenDocument(PARAMS)).resolves.toBeUndefined() + }) + + it('propagates any other creation failure', async () => { + stubChrome({ getContexts: 0, createRejects: new Error('No such file: offscreen.html') }) + await expect(ensureOffscreenDocument(PARAMS)).rejects.toThrow(/No such file/) + }) +}) diff --git a/src/lib/offscreen.ts b/src/lib/offscreen.ts new file mode 100644 index 0000000..687acd5 --- /dev/null +++ b/src/lib/offscreen.ts @@ -0,0 +1,58 @@ +// Creating the offscreen document at most once, without betting sign-in on an +// API whose availability we cannot pin down. +// +// `chrome.offscreen.hasDocument()` has existed since the offscreen API shipped +// in Chrome 109, but it was marked `[nodoc]` in the Chromium IDL and only +// appeared in the published reference with Chrome 150 — so the "Chrome 150+" +// annotation is the date it was documented, not the date it became callable. +// Verified directly: on Chrome 149 it is a function, returns false before +// creation and true after. The reason it stayed undocumented is that the team +// was not committed to it (a per-extension existence check does not survive +// multiple offscreen documents, crbug.com/1339382), and removal has been +// discussed on chromium-extensions. +// +// So it is unsafe in both directions: unverifiable below 149, and a candidate +// for removal above it. This module therefore prefers the documented +// `chrome.runtime.getContexts()` (Chrome 116), falls back to `hasDocument()` +// for the 110-115 window the manifest still supports, and treats +// `createDocument`'s own "already exists" failure as the final authority when +// neither is usable. Sign-in is the one path that must not break on a version +// difference — it already did once, in v1.2.1. + +/** Chrome's message when a second offscreen document is requested. */ +const ALREADY_EXISTS = /single offscreen document/i + +/** + * Whether an offscreen document exists. `undefined` means neither existence + * API was available, so the caller must not treat the answer as "no". + */ +export async function hasOffscreenDocument(): Promise { + if (typeof chrome.runtime.getContexts === 'function') { + const contexts = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + }) + return contexts.length > 0 + } + if (typeof chrome.offscreen.hasDocument === 'function') { + return await chrome.offscreen.hasDocument() + } + return undefined +} + +/** + * Create the offscreen document unless one is already there. A lost race, or + * an existence check we could not run, both surface as the same + * createDocument error — which means the document exists, which is what the + * caller wanted. Any other failure is real and propagates. + */ +export async function ensureOffscreenDocument( + parameters: chrome.offscreen.CreateParameters, +): Promise { + if (await hasOffscreenDocument()) return + try { + await chrome.offscreen.createDocument(parameters) + } catch (err) { + if (!ALREADY_EXISTS.test(String(err))) throw err + console.debug('[substandard] offscreen document already existed', err) + } +} diff --git a/src/signin.ts b/src/signin.ts index 6d18c21..ac394b4 100644 --- a/src/signin.ts +++ b/src/signin.ts @@ -9,6 +9,7 @@ // level so their events revive the worker. import { AUTH_ERROR_KEY, callbackFromUpdate, oauthRedirectUri, replacedSession } from './lib/authflow' +import { ensureOffscreenDocument } from './lib/offscreen' import { getStoredSession, saveSessionMirror } from './lib/session-store' import type { OffscreenMsg, SessionInfo } from './lib/types' @@ -26,9 +27,8 @@ async function getPending(): Promise { return (await chrome.storage.session.get(PENDING_KEY))[PENDING_KEY] as PendingAuth | undefined } -async function ensureOffscreen(): Promise { - if (await chrome.offscreen.hasDocument()) return - await chrome.offscreen.createDocument({ +function ensureOffscreen(): Promise { + return ensureOffscreenDocument({ url: 'offscreen.html', reasons: [chrome.offscreen.Reason.LOCAL_STORAGE], justification: 'The AT Protocol OAuth client keeps its sign-in state in DOM storage',