Skip to content
Merged
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
7 changes: 7 additions & 0 deletions desktop/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function registerIpc(opts: {
session: () => SessionInfo;
signOut: () => Promise<void>;
accountUrl: () => string;
shellAction: (action: string) => void;
}): void {
const navigate = opts.navigate;
ipcMain.on("capscreen:bootstrap", (e) => {
Expand All @@ -42,6 +43,12 @@ export function registerIpc(opts: {
void opts.signOut();
});

// Only the shell's data: pages (gate, boot error) may trigger these.
ipcMain.on("capscreen:shell-action", (e, action: unknown) => {
if (typeof action !== "string" || !e.senderFrame?.url.startsWith("data:")) return;
opts.shellAction(action);
});

ipcMain.handle("capscreen:open-account", async () => {
const url = opts.accountUrl();
if (url) await shell.openExternal(url);
Expand Down
33 changes: 19 additions & 14 deletions desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,20 +101,6 @@ function createWindow(): void {
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (e, url) => {
// Buttons on the sign-in gate.
const gateAction = /^about:blank#(signin|signup|reopen|cancel)$/.exec(url)?.[1];
if (gateAction) {
e.preventDefault();
void handleGateAction(gateAction);
return;
}
// Buttons on the boot error page.
if (url === "about:blank#retry" || url === "about:blank#logs") {
e.preventDefault();
if (url.endsWith("#logs")) void shell.openPath(userData("logs"));
else void mainWindow?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(splashHtml())}`).then(start);
return;
}
const allowed = url.startsWith(APP_ORIGIN) || (runtime && url.startsWith(runtime.frontendUrl)) || url.startsWith("data:");
if (!allowed) {
e.preventDefault();
Expand Down Expand Up @@ -267,6 +253,24 @@ function requireSignIn(): Promise<void> {
return new Promise<void>((resolve) => (gateResolve = resolve));
}

/** Buttons on the shell pages (splash.ts), sent over IPC. */
function handleShellAction(action: string): void {
switch (action) {
case "signin":
case "signup":
case "reopen":
case "cancel":
void handleGateAction(action);
break;
case "logs":
void shell.openPath(userData("logs"));
break;
case "retry":
void showPage(splashHtml()).then(start);
break;
}
}

async function handleGateAction(action: string): Promise<void> {
if (action === "reopen" && pendingSignIn) {
await shell.openExternal(pendingSignIn.handle.authorizeUrl);
Expand Down Expand Up @@ -364,6 +368,7 @@ void app.whenReady().then(() => {
},
signOut: signOutAndGate,
accountUrl: () => AUTH_URL,
shellAction: handleShellAction,
});
buildMenu();
buildTray();
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ contextBridge.exposeInMainWorld("capscreenDesktop", {
printToPdf: (opts: unknown) => ipcRenderer.invoke("capscreen:print-pdf", opts),
signOut: () => ipcRenderer.invoke("capscreen:sign-out"),
openAccount: () => ipcRenderer.invoke("capscreen:open-account"),
// Buttons on the shell's own pages (sign-in gate, boot error); ignored from the app UI.
shellAction: (action: string) => ipcRenderer.send("capscreen:shell-action", action),
onNavigate: (cb: (route: string) => void) => {
const listener = (_e: unknown, route: string) => cb(route);
ipcRenderer.on("capscreen:navigate", listener);
Expand Down
28 changes: 21 additions & 7 deletions desktop/src/splash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ const BASE_CSS = `
a.btn.primary { background: var(--fg); color: var(--bg); border-color: var(--fg); }
`;

/**
* Buttons carry data-action; clicks go to main.ts over the preload bridge.
* (Navigating to about:blank#… doesn't work: Chromium commits it without
* emitting will-navigate, so the window just went blank.)
*/
const ACTIONS_SCRIPT = `<script>
document.addEventListener("click", (e) => {
const el = e.target instanceof Element ? e.target.closest("[data-action]") : null;
if (!el) return;
e.preventDefault();
window.capscreenDesktop?.shellAction(el.getAttribute("data-action"));
});
</script>`;

export function splashHtml(): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>CapScreen</title><style>${BASE_CSS}</style></head>
<body><div class="card">${markSvg()}<h1>CapScreen</h1><div class="spinner"></div>
Expand All @@ -53,29 +67,29 @@ export function errorHtml(message: string, logsDir: string): string {
<p>One of the local services failed to launch.</p>
<pre>${escapeHtml(message)}</pre>
<p style="font-size:12px">Logs: ${escapeHtml(logsDir)}</p>
<div class="row"><a class="btn" href="about:blank#logs">Open logs</a><a class="btn primary" href="about:blank#retry">Retry</a></div>
</div></body></html>`;
<div class="row"><a class="btn" href="#" data-action="logs">Open logs</a><a class="btn primary" href="#" data-action="retry">Retry</a></div>
</div>${ACTIONS_SCRIPT}</body></html>`;
}

export type SignInView = { state: "idle" } | { state: "waiting" } | { state: "error"; message: string };

/**
* Gate shown until the user signs in. Buttons navigate to about:blank#… and
* main.ts intercepts them (the page itself has no privileges).
* Gate shown until the user signs in. Buttons send their data-action to
* main.ts (the page itself has no privileges).
*/
export function signInHtml(view: SignInView): string {
const body =
view.state === "waiting"
? `<div class="spinner"></div>
<p>Finish signing in in your browser.</p>
<p style="font-size:12px">Didn't see a browser window?</p>
<div class="row"><a class="btn" href="about:blank#reopen">Open sign-in page again</a><a class="btn" href="about:blank#cancel">Cancel</a></div>`
<div class="row"><a class="btn" href="#" data-action="reopen">Open sign-in page again</a><a class="btn" href="#" data-action="cancel">Cancel</a></div>`
: `<p>Sign in with your CapScreen account to continue.</p>
<p style="font-size:12px">Your account controls access only. Cap tables, documents and screenings stay on this computer.</p>
${view.state === "error" ? `<pre>${escapeHtml(view.message)}</pre>` : ""}
<div class="row"><a class="btn primary" href="about:blank#signin">Sign in with browser</a><a class="btn" href="about:blank#signup">Create account</a></div>`;
<div class="row"><a class="btn primary" href="#" data-action="signin">Sign in with browser</a><a class="btn" href="#" data-action="signup">Create account</a></div>`;
return `<!doctype html><html><head><meta charset="utf-8"><title>CapScreen</title><style>${BASE_CSS}</style></head>
<body><div class="card">${markSvg()}<h1>Welcome to CapScreen</h1>${body}</div></body></html>`;
<body><div class="card">${markSvg()}<h1>Welcome to CapScreen</h1>${body}</div>${ACTIONS_SCRIPT}</body></html>`;
}

/** Shown in the browser tab after it hands the sign-in back to the app. */
Expand Down
Loading