Skip to content

Commit 4dae3ec

Browse files
authored
Merge pull request #11 from tshmieldev/tshmieldev/mobile-save
Saving a key on a phone failed with "no active window"
2 parents a0abffc + 7b855fd commit 4dae3ec

2 files changed

Lines changed: 77 additions & 9 deletions

File tree

‎src/popup/App.tsx‎

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type SettingsPatch,
1111
} from '../common/settings';
1212
import { extensionsPage } from '../common/build';
13+
import { errorMessage } from '../common/errors';
1314
import { GeneralPanel, type GeneralTab } from './GeneralPanel';
1415
import { ListSheet, type ListKey } from './ListSheet';
1516
import { ModelBrowser } from './ModelBrowser';
@@ -50,6 +51,33 @@ export async function readerTab() {
5051
.sort((a, b) => (b.lastAccessed ?? 0) - (a.lastAccessed ?? 0))[0]?.url;
5152
}
5253

54+
/** 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.');
79+
}
80+
5381
function normalize(settings: Settings): Settings {
5482
const lines = (values: readonly string[], author = false) => [
5583
...new Set(
@@ -236,15 +264,9 @@ export function App() {
236264
throw new Error('Use an HTTPS base URL without credentials, query or fragment.');
237265
}
238266
}
239-
// Every origin these settings will call: the chat endpoint, or the
240-
// classifier's provider and whoever describes its images. Already-granted
241-
// origins resolve without a prompt. Called from the submit gesture, before
242-
// any await (required by Chrome).
243-
const wanted = apiOrigins(next);
244-
if (wanted.length) {
245-
const granted = await chrome.permissions.request({ origins: [...wanted] });
246-
if (!granted) throw new Error('Permission to reach the provider was not granted.');
247-
}
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));
248270
// Only changed fields are sent; unrelated updates from a tab aren't overwritten.
249271
const patch: SettingsPatch = Object.fromEntries(
250272
Object.entries(next).filter(([key, value]) => !same(value, saved[key as keyof Settings])),

‎tests/permissions.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, expect, it, vi } from 'vitest';
3+
import { ensureOrigins } from '../src/popup/App';
4+
5+
function mockPermissions(granted: string[], request?: () => Promise<boolean>) {
6+
const requested: string[][] = [];
7+
vi.stubGlobal('chrome', {
8+
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[] }) => {
13+
requested.push(origins);
14+
return request ? request() : true;
15+
}),
16+
},
17+
});
18+
return requested;
19+
}
20+
afterEach(() => vi.unstubAllGlobals());
21+
22+
it('never shows a prompt for origins the browser already allows', async () => {
23+
// Kiwi opens the popup as a tab with no window to draw a prompt in, so a
24+
// 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();
27+
expect(requested).toEqual([]);
28+
});
29+
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/*']);
33+
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');
37+
});
38+
39+
it('explains a prompt the browser cannot show, naming the host', async () => {
40+
mockPermissions([], async () => {
41+
throw new Error('No active window.');
42+
});
43+
await expect(ensureOrigins(['https://ai-gateway.vercel.sh/*'])).rejects.toThrow(
44+
/cannot ask for permission to reach ai-gateway\.vercel\.sh/,
45+
);
46+
});

0 commit comments

Comments
 (0)