Skip to content

Commit ddd0e69

Browse files
committed
web: sign in without a second window
A pop-up opened from a chain that begins in a webview is the first thing a strict browser (Safari, a phone) refuses. The editor now takes its own tab to the account site's sign-in and the sign-in returns to the same tab: - host.openAuth: in the browser the extension asks the page to navigate (levelcode.web.openAuthUrl, an embedder command) with the address the editor's own opener would have used; without the command it opens as before. The desktop is untouched. - web/main.js registers the command. It only goes to the account site's /ai/ pages: any extension in the page can call it, and it must not be an open redirect. - callback.html returns the tab to the editor when this tab left it (a sessionStorage mark), and otherwise says to close the tab and offers a link in. - At start-up the editor delivers a result the sign-in left in localStorage (a callback.html result written in the last two minutes: the one-time code lives for one), and ?signin=1 — the account site's "Open in browser" — starts the sign-in once and removes itself from the address. Tests: unit (host.openAuth; main.js's leaveForSignIn, drain and start-up, mutation-checked) and an end-to-end in headless Chrome (web/test/e2e-signin-tab.mjs): no second window, the verifier survives the reload, no repeat, no sign-in for a signed-in editor, a result waiting for a gone editor is delivered and an old one is dropped.
1 parent 97c8af9 commit ddd0e69

10 files changed

Lines changed: 589 additions & 9 deletions

File tree

‎extensions/levelcode-ai/host.js‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,35 @@ async function withReads(run) {
141141
return result;
142142
}
143143

144+
/* ----- sending the user to sign in ------------------------------------------------------------- */
145+
146+
/** Registered by the page (web/main.js); it takes the tab to the account site and the sign-in comes back to it. */
147+
const OPEN_AUTH_COMMAND = 'levelcode.web.openAuthUrl';
148+
149+
/**
150+
* Open the account site's sign-in page. The desktop hands it to the system browser, as ever. A browser
151+
* tab leaves the page for it: a pop-up opened from a chain that began in a webview is the first thing a
152+
* strict browser (Safari, a phone) refuses, and the sign-in returns to the editor in the same tab. The
153+
* page's entry script supplies the command that navigates; without it this falls back to openExternal.
154+
*
155+
* The string handed over is the one the editor's own opener would have opened — `encodeURI(uri.toString(true))`
156+
* (src/vs/editor/browser/services/openerService.ts) — so the server sees the same address whichever way
157+
* the sign-in was started, as it has from the desktop app all along.
158+
* @param {vscode.Uri} uri
159+
*/
160+
async function openAuth(uri) {
161+
if (isBrowser) {
162+
try {
163+
const known = await vscode.commands.getCommands(true);
164+
if (known.includes(OPEN_AUTH_COMMAND)) {
165+
await vscode.commands.executeCommand(OPEN_AUTH_COMMAND, encodeURI(uri.toString(true)));
166+
return;
167+
}
168+
} catch { /* fall through to the pop-up */ }
169+
}
170+
await vscode.env.openExternal(uri);
171+
}
172+
144173
/* ----- finding files ------------------------------------------------------------------------- */
145174

146175
/**
@@ -156,7 +185,7 @@ function findFiles(glob, exclude, max) {
156185

157186
module.exports = {
158187
isBrowser, caps,
159-
uriFor, readBytes, readText, readTextOrNull, stat, exists, isBinary, withReads, findFiles,
188+
uriFor, readBytes, readText, readTextOrNull, stat, exists, isBinary, withReads, findFiles, openAuth,
160189
// For tests.
161190
_path: path,
162191
};

‎web/ai-extension/copy.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Words that are true on the desktop and false in a tab, and what the browser build says instead.
1+
// What the desktop extension says or does that is not true in a tab, and what the browser build uses instead.
22
//
33
// The desktop extension's text is written for a Mac: keys in the OS keychain, nothing leaves "your
44
// machine". In a browser the same facts read differently (keys are sealed in this browser's
@@ -15,6 +15,10 @@ const REPLACEMENTS = [
1515
['extension.js',
1616
'Stored encrypted in your OS keychain — it never leaves your machine except to ',
1717
'Stored encrypted in this browser — it never leaves this browser except to '],
18+
// Sign-in leaves the tab for the account page and returns to it (host.openAuth); the desktop opens its browser.
19+
['extension.js',
20+
"\tdbg('account.signin', { provider: provider || 'browser', create: !!create });\n\tawait vscode.env.openExternal(vscode.Uri.parse(url));",
21+
"\tdbg('account.signin', { provider: provider || 'browser', create: !!create });\n\tawait host.openAuth(vscode.Uri.parse(url));"],
1822
['media/chat.html',
1923
'Bring your own API key, stored in your OS keychain.',
2024
'Bring your own API key, stored encrypted in this browser.'],

‎web/callback.css‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ main { display: grid; justify-items: center; gap: 8px; padding: 24px; text-align
55
.mark { width: 56px; height: 56px; color: #7C5CD6; margin-bottom: 8px; }
66
h1 { margin: 0; font-size: 20px; font-weight: 650; letter-spacing: -.01em; }
77
p { margin: 0; opacity: .72; }
8+
[hidden] { display: none !important; }
9+
a { color: inherit; text-underline-offset: 3px; }

‎web/callback.html‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
</svg>
1717
<h1 id="title">Finishing sign-in</h1>
1818
<p id="text">One moment.</p>
19+
<p id="more" hidden><a href="./">Open LevelCode in this tab</a></p>
1920
</main>
2021
<script src="/callback.js"></script>
2122
</body>

‎web/callback.js‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,25 @@
2222
var rest = params.toString();
2323
if (rest) { uri.query = rest; }
2424
if (fragment) { uri.fragment = fragment; }
25+
// `at` lets the editor tell this result from one left behind long ago; the URL it is revived from ignores it.
26+
uri.at = Date.now();
2527
localStorage.setItem('vscode-web.url-callbacks[' + id + ']', JSON.stringify(uri));
26-
say('You are signed in', 'You can close this tab and go back to LevelCode.');
27-
// Take the one-time code out of the address bar and history, then try to close the tab.
28+
// Take the one-time code out of the address bar and history.
2829
history.replaceState(null, '', location.pathname);
29-
setTimeout(function () { try { window.close(); } catch (e) { /* the text above still tells the user */ } }, 600);
30+
var home = new URL('./', location.href).href;
31+
var back = false;
32+
// Set by the editor when it took THIS tab to the sign-in (web/main.js); the editor clears it at start-up.
33+
try { back = sessionStorage.getItem('levelcode-web.return') === '1'; } catch (e) { /* no storage: the pop-up path */ }
34+
if (back) {
35+
// This tab left the editor for the sign-in: go back. The editor finds the result at start-up.
36+
say('You are signed in', 'Back to LevelCode.');
37+
location.replace(home);
38+
} else {
39+
say('You are signed in', 'You can close this tab and go back to LevelCode.');
40+
var more = document.getElementById('more');
41+
if (more) { more.hidden = false; more.querySelector('a').href = home; }
42+
setTimeout(function () { try { window.close(); } catch (e) { /* the text above still tells the user */ } }, 600);
43+
}
3044
} catch (e) {
3145
say('This link does not look right', String((e && e.message) || e));
3246
}

‎web/lib/config.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export function webConfig(o = {}) {
2121
// Where the page's own scripts and styles, the Code-OSS web build, and the extensions are mounted.
2222
// A release puts all three under one build-addressed prefix (/_/<id>) so they can be cached for
2323
// a year; the development server mounts them at the root, /static and /extensions.
24+
account,
2425
base: o.base || '',
2526
staticBase: o.staticBase || '/static',
2627
extensionsBase: o.extensionsBase || '/extensions',

‎web/main.js‎

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ const staticBase = trimSlash(new URL(config.staticBase || '/static', origin).hre
2323
// The workbench resolves every asset (css, nls, workers, codicon font) against this root.
2424
globalThis._VSCODE_FILE_ROOT = staticBase + '/out/';
2525

26+
// Declared up here, not beside the sign-in code: the block below awaits, and a const further down the module
27+
// is not initialised until it finishes.
28+
/** sessionStorage: this tab went to the account site's sign-in and is expected back (callback.js reads it). */
29+
const RETURN_KEY = 'levelcode-web.return';
30+
/** How long a sign-in result left in localStorage stays deliverable. The one-time code behind it lives for a minute. */
31+
const FRESH_MS = 2 * 60 * 1000;
32+
2633
const boot = createBootScreen();
2734

2835
try {
@@ -32,11 +39,18 @@ try {
3239

3340
const secrets = await createSecretStorage();
3441
const workspaceProvider = createWorkspaceProvider(URI, config);
42+
const callbacks = createUrlCallbackProvider(URI, config.callbackRoute || '/callback.html');
3543

3644
const workbench = create(document.body, {
3745
windowIndicator: { label: '$(globe) LevelCode Web', tooltip: 'LevelCode in your browser' },
3846
workspaceProvider,
39-
urlCallbackProvider: createUrlCallbackProvider(URI, config.callbackRoute || '/callback.html'),
47+
urlCallbackProvider: callbacks,
48+
commands: [{
49+
// The extension asks the page to take the tab to the account site's sign-in. A page opened by
50+
// script is what a strict browser blocks; leaving the tab and coming back (callback.js) is not.
51+
id: 'levelcode.web.openAuthUrl',
52+
handler: (url) => leaveForSignIn(url, config),
53+
}],
4054
secretStorageProvider: secrets,
4155
additionalBuiltinExtensions: (config.extensions || []).map((name) => ({
4256
scheme: location.protocol.slice(0, -1),
@@ -54,6 +68,7 @@ try {
5468
// create() resolves once the workbench has been constructed. The splash goes when its DOM exists.
5569
void workbench;
5670
boot.whenWorkbenchReady();
71+
startReturnFromSignIn(callbacks, URI);
5772
} catch (err) {
5873
console.error('[levelcode-web] failed to start', err);
5974
boot.fail(err);
@@ -69,6 +84,54 @@ function readConfig() {
6984

7085
function trimSlash(s) { return s.replace(/\/+$/, ''); }
7186

87+
/* ----- signing in ------------------------------------------------------------------------- */
88+
89+
/**
90+
* Take this tab to the account site's sign-in page. Only that site's /ai/ pages: this is a command any
91+
* extension in the page could call, and it must not be a way to send the tab anywhere else.
92+
*/
93+
function leaveForSignIn(url, cfg) {
94+
let target;
95+
try { target = new URL(String(url)); } catch { return; }
96+
let account;
97+
try { account = new URL(cfg.account); } catch { return; }
98+
if (target.origin !== account.origin || !target.pathname.startsWith('/ai/')) {
99+
console.warn('[levelcode-web] refused to navigate to', target.origin + target.pathname);
100+
return;
101+
}
102+
// callback.html is a page of this tab when this is set: it comes straight back instead of closing.
103+
try { sessionStorage.setItem(RETURN_KEY, '1'); } catch { /* the pop-up path still works without it */ }
104+
location.assign(target.href);
105+
}
106+
107+
/**
108+
* Two things can be waiting when the page starts, and both reach the extension as the "URL" the operating
109+
* system would have given it on the desktop:
110+
* - a sign-in that took this tab away and brought it back (callback.html) left its result in
111+
* localStorage; it is delivered now. Only a result written in the last two minutes is: an older one is
112+
* a code that cannot be exchanged any more, and is dropped;
113+
* - ?signin=1 (the account site's "Open in browser") asks for the sign-in itself, once. The parameter is
114+
* removed first, so a reload does not start it again.
115+
*/
116+
function startReturnFromSignIn(callbacks, URI) {
117+
const here = new URL(location.href);
118+
const launch = here.searchParams.get('signin') === '1';
119+
if (launch) {
120+
here.searchParams.delete('signin');
121+
try { history.replaceState(null, '', here.pathname + here.search + here.hash); } catch { /* cosmetic */ }
122+
}
123+
try { sessionStorage.removeItem(RETURN_KEY); } catch { /* nothing to clear */ }
124+
void callbacks.whenListening().then((listening) => {
125+
// With nobody listening a result would be taken out of storage and dropped; it stays for the next start.
126+
if (!listening) { return; }
127+
// Deliveries made before the extension has registered its handler are held by the workbench and
128+
// replayed to it (ExtensionUrlBootstrapHandler / uriBuffer), so there is nothing to wait for.
129+
const delivered = callbacks.drain(FRESH_MS);
130+
if (launch && delivered === 0) {
131+
callbacks.fire(URI.from({ scheme: 'levelcode', authority: 'levelcode.levelcode-ai', path: '/launch' }));
132+
}
133+
});
134+
}
72135
/** The URL decides the workspace, exactly as Code-OSS's web client does: ?folder= / ?workspace= / ?ew=true. */
73136
function createWorkspaceProvider(URI, cfg) {
74137
const params = new URL(location.href).searchParams;
@@ -139,6 +202,44 @@ function createUrlCallbackProvider(URI, callbackRoute) {
139202

140203
return {
141204
onCallback: (listener) => { listeners.add(listener); return { dispose: () => listeners.delete(listener) }; },
205+
/** Resolves true once the workbench's URL service is listening (it subscribes while it starts); false if it never did. */
206+
whenListening() {
207+
return new Promise((resolve) => {
208+
const t0 = Date.now();
209+
const tick = () => {
210+
if (listeners.size > 0) { resolve(true); }
211+
else if (Date.now() - t0 > 30000) { resolve(false); }
212+
else { setTimeout(tick, 100); }
213+
};
214+
tick();
215+
});
216+
},
217+
/**
218+
* Deliver results a sign-in left in localStorage that this page was not waiting for (it left and came
219+
* back). One not written within `maxAgeMs` is removed and not delivered. Returns how many were.
220+
*/
221+
drain(maxAgeMs) {
222+
const keys = [];
223+
for (let i = 0; i < localStorage.length; i++) {
224+
const k = localStorage.key(i);
225+
if (k && k.startsWith('vscode-web.url-callbacks[')) { keys.push(k); }
226+
}
227+
let n = 0;
228+
for (const k of keys) {
229+
const raw = localStorage.getItem(k);
230+
localStorage.removeItem(k);
231+
try {
232+
const data = JSON.parse(raw);
233+
if (typeof data.at === 'number' && Date.now() - data.at > maxAgeMs) { continue; }
234+
const uri = URI.revive(data);
235+
listeners.forEach((l) => l(uri));
236+
n++;
237+
} catch (e) { console.error(e); }
238+
}
239+
return n;
240+
},
241+
/** Hand the workbench a URL as if the OS had opened it. */
242+
fire(uri) { listeners.forEach((l) => l(uri)); },
142243
create(options = {}) {
143244
const id = ++nextId;
144245
const query = [`vscode-reqid=${id}`];

0 commit comments

Comments
 (0)