Skip to content

Commit 87cb74e

Browse files
committed
fix: keep the permission prompt inside the user's gesture on Firefox
From the review on #11. Firefox allows permissions.request only while the user's gesture is live, and any earlier await in the handler ends it. The "no active window" fix awaited permissions.contains first, which would have broken every save that needs a prompt on Firefox desktop: Vercel, TypeSafe, and custom endpoints. The granted origins are now read once when the popup opens (and again on every grant or revocation), the comparison is synchronous, and the prompt is the first thing the save handler awaits. A grant that covers every site counts as covering the origin.
1 parent 28eab48 commit 87cb74e

2 files changed

Lines changed: 87 additions & 44 deletions

File tree

‎src/popup/App.tsx‎

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -51,31 +51,64 @@ export async function readerTab() {
5151
.sort((a, b) => (b.lastAccessed ?? 0) - (a.lastAccessed ?? 0))[0]?.url;
5252
}
5353

54+
/** Whether a granted pattern covers an origin. Sharp's own patterns are all
55+
* `https://host/*`, so equality does it, plus the two that cover everything. */
56+
const covers = (granted: string, origin: string) =>
57+
granted === origin ||
58+
granted === '<all_urls>' ||
59+
granted === 'https://*/*' ||
60+
granted === '*://*/*';
61+
5462
/** Makes sure the browser lets Sharp reach these origins, asking only for the
55-
* ones not yet granted. The prompt needs a browser window, and a popup opened
56-
* as a tab (Kiwi, and Firefox on a phone) has none: asking for an origin the
57-
* manifest already covers would fail there with "no active window" and block
58-
* the save for nothing. Must be called from a user gesture. */
59-
export async function ensureOrigins(origins: readonly string[]) {
60-
const missing = (
61-
await Promise.all(
62-
origins.map(async (origin) =>
63-
(await chrome.permissions.contains({ origins: [origin] })) ? null : origin,
64-
),
65-
)
66-
).filter((origin): origin is string => origin !== null);
67-
if (!missing.length) return;
68-
const granted = await chrome.permissions.request({ origins: missing }).catch((error: unknown) => {
69-
// The browser has nowhere to draw the prompt. Say what to do, not what
70-
// went wrong inside.
71-
if (/window/i.test(errorMessage(error))) {
72-
throw new Error(
73-
`This browser cannot ask for permission to reach ${missing.map(host).join(', ')} from here. OpenRouter needs no extra permission, so it works on phones; the other providers need a desktop browser.`,
74-
);
75-
}
76-
throw error;
77-
});
78-
if (!granted) throw new Error('Permission to reach the provider was not granted.');
63+
* ones not yet granted. Two rules meet here. The prompt needs a browser
64+
* window, and a popup opened as a tab (Kiwi, and Firefox on a phone) has none,
65+
* so asking for an origin the manifest already covers would fail there with
66+
* "no active window" and block the save for nothing. And Firefox allows the
67+
* prompt only while the user's gesture is live, which any `await` ends. So the
68+
* granted set is read from a cache filled when the popup opened, the comparison
69+
* is synchronous, and `permissions.request` is the first thing awaited. */
70+
export function ensureOrigins(origins: readonly string[], granted: readonly string[]) {
71+
const missing = origins.filter((origin) => !granted.some((have) => covers(have, origin)));
72+
if (!missing.length) return Promise.resolve();
73+
return chrome.permissions
74+
.request({ origins: missing })
75+
.catch((error: unknown) => {
76+
// The browser has nowhere to draw the prompt. Say what to do, not what
77+
// went wrong inside.
78+
if (/window/i.test(errorMessage(error))) {
79+
throw new Error(
80+
`This browser cannot ask for permission to reach ${missing.map(host).join(', ')} from here. OpenRouter needs no extra permission, so it works on phones; the other providers need a desktop browser.`,
81+
);
82+
}
83+
throw error;
84+
})
85+
.then((ok) => {
86+
if (!ok) throw new Error('Permission to reach the provider was not granted.');
87+
});
88+
}
89+
90+
/** Every origin the browser has granted, kept current. Read once when the popup
91+
* opens, then on every grant or revocation, so a save can compare without
92+
* waiting on anything. */
93+
export function useGrantedOrigins() {
94+
const [granted, setGranted] = useState<readonly string[]>([]);
95+
useEffect(() => {
96+
let live = true;
97+
const load = () =>
98+
chrome.permissions.getAll().then((all) => {
99+
if (live) setGranted(all.origins ?? []);
100+
}, noop);
101+
void load();
102+
// Not every browser fires these; the load at open is what matters.
103+
chrome.permissions.onAdded?.addListener(load);
104+
chrome.permissions.onRemoved?.addListener(load);
105+
return () => {
106+
live = false;
107+
chrome.permissions.onAdded?.removeListener(load);
108+
chrome.permissions.onRemoved?.removeListener(load);
109+
};
110+
}, []);
111+
return granted;
79112
}
80113

81114
function normalize(settings: Settings): Settings {
@@ -109,6 +142,7 @@ export function App() {
109142
const [site, setSite] = useState<Site | null>(null);
110143
const [busy, setBusy] = useState(false);
111144
const [missing, setMissing] = useState<readonly string[]>([]);
145+
const granted = useGrantedOrigins();
112146

113147
/** Keep an open popup honest about edits made from the timeline, without
114148
* discarding fields the reader is still editing here. */
@@ -264,9 +298,10 @@ export function App() {
264298
throw new Error('Use an HTTPS base URL without credentials, query or fragment.');
265299
}
266300
}
267-
// Every origin these settings will call. Called from the submit gesture,
268-
// before any other await (required by Chrome).
269-
await ensureOrigins(apiOrigins(next));
301+
// Every origin these settings will call. The first await in this handler:
302+
// Firefox drops the user's gesture, and with it the right to prompt, at
303+
// any earlier one.
304+
await ensureOrigins(apiOrigins(next), granted);
270305
// Only changed fields are sent; unrelated updates from a tab aren't overwritten.
271306
const patch: SettingsPatch = Object.fromEntries(
272307
Object.entries(next).filter(([key, value]) => !same(value, saved[key as keyof Settings])),

‎tests/permissions.test.ts‎

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,13 @@
22
import { afterEach, expect, it, vi } from 'vitest';
33
import { ensureOrigins } from '../src/popup/App';
44

5-
function mockPermissions(granted: string[], request?: () => Promise<boolean>) {
5+
function mockRequest(request?: () => Promise<boolean>) {
66
const requested: string[][] = [];
77
vi.stubGlobal('chrome', {
88
permissions: {
9-
contains: vi.fn(async ({ origins }: { origins: string[] }) =>
10-
origins.every((origin) => granted.includes(origin)),
11-
),
12-
request: vi.fn(async ({ origins }: { origins: string[] }) => {
9+
request: vi.fn(({ origins }: { origins: string[] }) => {
1310
requested.push(origins);
14-
return request ? request() : true;
11+
return request ? request() : Promise.resolve(true);
1512
}),
1613
},
1714
});
@@ -22,25 +19,36 @@ afterEach(() => vi.unstubAllGlobals());
2219
it('never shows a prompt for origins the browser already allows', async () => {
2320
// Kiwi opens the popup as a tab with no window to draw a prompt in, so a
2421
// request for OpenRouter, granted at install, would fail and block the save.
25-
const requested = mockPermissions(['https://openrouter.ai/*']);
26-
await expect(ensureOrigins(['https://openrouter.ai/*'])).resolves.toBeUndefined();
22+
const requested = mockRequest();
23+
await expect(
24+
ensureOrigins(['https://openrouter.ai/*'], ['https://x.com/*', 'https://openrouter.ai/*']),
25+
).resolves.toBeUndefined();
26+
expect(requested).toEqual([]);
27+
// A grant that covers every site covers this one too.
28+
await ensureOrigins(['https://api.typesafe.ai/*'], ['https://*/*']);
2729
expect(requested).toEqual([]);
2830
});
2931

30-
it('asks only for what is missing, and fails the save if that is refused', async () => {
31-
let requested = mockPermissions(['https://openrouter.ai/*']);
32-
await ensureOrigins(['https://openrouter.ai/*', 'https://ai-gateway.vercel.sh/*']);
32+
it('asks only for what is missing, synchronously, while the gesture is still live', async () => {
33+
// Firefox refuses a prompt after any await in the handler, so the request
34+
// must already be in flight before ensureOrigins yields.
35+
const requested = mockRequest();
36+
const pending = ensureOrigins(
37+
['https://openrouter.ai/*', 'https://ai-gateway.vercel.sh/*'],
38+
['https://openrouter.ai/*'],
39+
);
3340
expect(requested).toEqual([['https://ai-gateway.vercel.sh/*']]);
34-
35-
requested = mockPermissions([], async () => false);
36-
await expect(ensureOrigins(['https://api.typesafe.ai/*'])).rejects.toThrow('not granted');
41+
await pending;
3742
});
3843

39-
it('explains a prompt the browser cannot show, naming the host', async () => {
40-
mockPermissions([], async () => {
44+
it('fails the save when the prompt is refused, and explains one that cannot be shown', async () => {
45+
mockRequest(async () => false);
46+
await expect(ensureOrigins(['https://api.typesafe.ai/*'], [])).rejects.toThrow('not granted');
47+
48+
mockRequest(async () => {
4149
throw new Error('No active window.');
4250
});
43-
await expect(ensureOrigins(['https://ai-gateway.vercel.sh/*'])).rejects.toThrow(
51+
await expect(ensureOrigins(['https://ai-gateway.vercel.sh/*'], [])).rejects.toThrow(
4452
/cannot ask for permission to reach ai-gateway\.vercel\.sh/,
4553
);
4654
});

0 commit comments

Comments
 (0)