diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 8a52ef91c04..1879af346e7 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,6 +1,6 @@ # Sim Desktop (macOS) -A thin Electron shell around the hosted Sim web app. The renderer loads the configured origin (default `https://sim.ai`) as a normal top-level page in a bundled, pinned Chromium — rendering is identical to Chrome of that version on every machine. No UI is re-implemented and no server stack is bundled. +A thin Electron shell around the hosted Sim web app. The renderer loads the configured origin (default `https://www.sim.ai` — the origin the server actually serves; the apex 301s there) as a normal top-level page in a bundled, pinned Chromium — rendering is identical to Chrome of that version on every machine. No UI is re-implemented and no server stack is bundled. ## Layout @@ -44,7 +44,7 @@ e2e/ # Playwright _electron smoke suite ```bash bun install # workspace root cd apps/desktop -bun run dev # bundle + launch against https://sim.ai +bun run dev # bundle + launch against https://www.sim.ai SIM_DESKTOP_ORIGIN=http://localhost:3000 bun run dev # against local sim ``` @@ -74,7 +74,7 @@ Deviations from the original plan doc (deliberate): ## Provider matrix (U5 spike — keep current) -Host lists live in `src/main/navigation.ts` (`SYSTEM_BROWSER_IDP_HOSTS`, `IN_WINDOW_IDP_HOSTS`). Verified so far: Google + Microsoft blocked (by policy, not spike); GitHub assumed lenient. **Before GA, run the spike**: GitHub sign-in, consumer-Microsoft, a sample of integration connects (Notion, Slack, Linear, Atlassian, Box, Dropbox), SSO, and Turnstile-on-signup in a packaged build, then update the lists and this section. +The host list lives in `src/main/navigation.ts` (`SYSTEM_BROWSER_IDP_HOSTS`) and now applies to **integration connects only** — sign-in always goes through the system-browser handoff whatever the IdP, so no provider needs to be classified as embed-tolerant for login. (The old `IN_WINDOW_IDP_HOSTS` list existed to keep GitHub sign-in in-window; embedding a login is a one-way door in a chrome-less shell and splits better-auth's OAuth state across two cookie jars, so it was removed.) Verified so far: Google + Microsoft block embedding by policy. **Before GA, run the spike** for connects: a sample of integration connects (Notion, Slack, Linear, Atlassian, Box, Dropbox) plus SSO and Turnstile-on-signup in a packaged build, then update the list and this section. ## Web-app coupling contract (audit on web-app changes) diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts index 18880124faa..e51d58dce54 100644 --- a/apps/desktop/scripts/build.ts +++ b/apps/desktop/scripts/build.ts @@ -7,7 +7,7 @@ const watch = process.argv.includes('--watch') // non-prod environment): SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai. // Baked into the bundle so it applies to fresh installs with no settings — // unlike the SIM_DESKTOP_ORIGIN env var, which only affects terminal-launched -// processes. Official builds leave it unset (default https://sim.ai). +// processes. Official builds leave it unset (default https://www.sim.ai). const bakedDefaultOrigin = process.env.SIM_DESKTOP_DEFAULT_ORIGIN ?? '' if ( bakedDefaultOrigin && diff --git a/apps/desktop/scripts/install-local.ts b/apps/desktop/scripts/install-local.ts index efd27652a4a..5298d751d6f 100644 --- a/apps/desktop/scripts/install-local.ts +++ b/apps/desktop/scripts/install-local.ts @@ -40,9 +40,11 @@ const CHANNEL_FLAGS: Record = { appId: 'ai.sim.desktop.staging', origin: 'https://www.staging.sim.ai', }, - // Bare sim.ai, matching official prod builds (config.ts) — www.sim.ai - // would land in a different cookie partition than a real install. - '--prod': { name: 'Sim', appId: 'ai.sim.desktop', origin: 'https://sim.ai' }, + // Origin left unset, exactly as official prod builds do, so this install + // resolves the same DEFAULT_ORIGIN (config.ts) and therefore the same cookie + // partition as a real one. Naming an origin here re-creates the drift that + // pinned prod to the apex while the server serves www. + '--prod': { name: 'Sim', appId: 'ai.sim.desktop' }, } const DEFAULT_IDENTITY: ChannelIdentity = { name: 'Sim', appId: 'ai.sim.desktop' } diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 7a0b0a5a8bd..0c679fd10b8 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -15,6 +15,13 @@ const logger = createLogger('DesktopConfig') * define replaces the env read at bundle time, so a packaged app never * consults the runtime environment for this. http is accepted only for * loopback origins (the localhost dev-server channel). + * + * This must be the origin the server actually *serves*, not a hostname that + * redirects to it. Every environment canonicalizes the apex to `www` (the load + * balancer 301s `sim.ai` → `www.sim.ai`), and the navigation guards compare + * origins for exact equality — so an apex origin here silently leaves every + * page off-origin, which reclassifies social login as an integration connect + * and strands the sign-in in the browser with no way back. */ function isValidBakedOrigin(origin: string | undefined): origin is string { if (!origin) return false @@ -29,7 +36,7 @@ function isValidBakedOrigin(origin: string | undefined): origin is string { export const DEFAULT_ORIGIN = isValidBakedOrigin(process.env.SIM_DESKTOP_DEFAULT_ORIGIN) ? process.env.SIM_DESKTOP_DEFAULT_ORIGIN - : 'https://sim.ai' + : 'https://www.sim.ai' /** * The environment a build is keyed to, derived from its baked default origin. diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts index 1fe85ab43d7..b6b5a20c9d3 100644 --- a/apps/desktop/src/main/menu.test.ts +++ b/apps/desktop/src/main/menu.test.ts @@ -74,6 +74,7 @@ describe('buildMenuTemplate', () => { expect(submenu(template, 'View').map((item) => item.label ?? item.role ?? item.type)).toEqual([ 'Toggle Sidebar', 'separator', + 'Back', 'Reload', 'separator', 'Actual Size', diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index 715a5e44bee..be2ed813a5f 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -55,6 +55,21 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] click: deps.toggleSidebar, }, { type: 'separator' }, + /** + * The shell has no browser chrome, so an in-window integration connect + * that leaves the app origin (the IdP's consent page) is otherwise a + * one-way door for anyone who decides not to finish it. + */ + { + label: 'Back', + accelerator: 'CmdOrCtrl+[', + click: withWindow((win) => { + const history = win.webContents.navigationHistory + if (history.canGoBack()) { + history.goBack() + } + }), + }, { label: 'Reload', accelerator: 'CmdOrCtrl+R', diff --git a/apps/desktop/src/main/navigation.test.ts b/apps/desktop/src/main/navigation.test.ts index 6edfe06592e..22ee2191aa0 100644 --- a/apps/desktop/src/main/navigation.test.ts +++ b/apps/desktop/src/main/navigation.test.ts @@ -13,7 +13,7 @@ import { openExternalSafe, } from '@/main/navigation' -const APP = 'https://sim.ai' +const APP = 'https://www.sim.ai' describe('classifyNavigation', () => { it('keeps same-origin navigation in-app', () => { @@ -65,12 +65,24 @@ describe('classifyNavigation', () => { ).toBe('idp-system-connect') }) - it('keeps verified-lenient IdPs in-window from the login surface', () => { + it('routes every sign-in to the system browser, including IdPs that tolerate embedding', () => { + // GitHub used to be kept in-window. Embedded, it is a one-way door (no + // browser chrome to back out of) and it splits better-auth's OAuth state + // cookie across two user agents, which comes back `state_mismatch`. expect( classifyNavigation('https://github.com/login/oauth/authorize?client_id=x', { appOrigin: APP, currentUrl: `${APP}/login`, }) + ).toBe('idp-system-login') + }) + + it('keeps the same IdP in-window when it is an integration connect', () => { + expect( + classifyNavigation('https://github.com/login/oauth/authorize?client_id=x', { + appOrigin: APP, + currentUrl: `${APP}/workspace/ws1/integrations/github`, + }) ).toBe('idp-in-window') }) diff --git a/apps/desktop/src/main/navigation.ts b/apps/desktop/src/main/navigation.ts index a89e37f50b9..c9224df5127 100644 --- a/apps/desktop/src/main/navigation.ts +++ b/apps/desktop/src/main/navigation.ts @@ -24,10 +24,10 @@ export interface NavigationContext { } /** - * IdP hosts that hard-block OAuth inside embedded user agents. Navigation to - * these hosts is cancelled and rerouted: from an auth surface the app starts - * the system-browser login handoff; from anywhere else it offers to finish the - * integration connect in the browser (tokens land server-side either way). + * IdP hosts that hard-block OAuth inside embedded user agents. An integration + * connect heading for one of these is cancelled and offered as a finish-in-the- + * browser flow instead (tokens land server-side either way). Sign-in never + * consults this list — see {@link classifyNavigation}. */ export const SYSTEM_BROWSER_IDP_HOSTS: readonly string[] = [ 'accounts.google.com', @@ -38,14 +38,6 @@ export const SYSTEM_BROWSER_IDP_HOSTS: readonly string[] = [ 'sts.windows.net', ] -/** - * IdP hosts verified lenient toward embedded user agents (the U5 provider - * matrix). Only consulted for navigations leaving an auth surface — everywhere - * else unknown hosts already stay in-window because same-window departures - * from workspace pages are OAuth connect flows in this app. - */ -export const IN_WINDOW_IDP_HOSTS: readonly string[] = ['github.com'] - const AUTH_SURFACE_PREFIXES: readonly string[] = [ '/login', '/signup', @@ -101,11 +93,21 @@ export function isAuthSurfacePath(pathname: string): boolean { * Classifies a top-level navigation (will-navigate / will-redirect). * * Same-window departures to non-origin hosts are OAuth flows in this app — - * regular external links always go through window.open — so unknown hosts stay - * in-window (lenient IdP assumption) except for the known embedded-blocking - * hosts, which are rerouted through the system browser. Departures from auth - * surfaces default to the system browser because SSO IdPs need real-browser - * device claims. + * regular external links always go through window.open. + * + * A departure from an auth surface is a *sign-in*, and sign-in always runs in + * the system browser (RFC 8252), whatever the IdP. Embedding one is a one-way + * door: the shell has no browser chrome, so a user who reaches the IdP and + * decides not to continue has no way back to the app. It also splits the flow + * across two cookie jars — better-auth binds its OAuth state cookie to the user + * agent that started the flow, so an in-window start that finishes anywhere + * else comes back `state_mismatch`. The handoff keeps the whole flow in one + * jar and hands a one-time token back over the loopback. + * + * Everything else is an integration connect from a workspace page: those stay + * in-window (the session cookie is already in this partition) unless the IdP + * hard-blocks embedded user agents, which is what {@link + * SYSTEM_BROWSER_IDP_HOSTS} enumerates. */ export function classifyNavigation(rawUrl: string, ctx: NavigationContext): MainNavigationAction { if (rawUrl === 'about:blank') { @@ -125,16 +127,13 @@ export function classifyNavigation(rawUrl: string, ctx: NavigationContext): Main const fromAuthSurface = current ? current.origin === ctx.appOrigin && isAuthSurfacePath(current.pathname) : false - if (matchesHostList(url.hostname, SYSTEM_BROWSER_IDP_HOSTS)) { - return fromAuthSurface ? 'idp-system-login' : 'idp-system-connect' + if (fromAuthSurface) { + return 'idp-system-login' } - if (matchesHostList(url.hostname, IN_WINDOW_IDP_HOSTS)) { - return 'idp-in-window' - } - if (current && current.origin !== ctx.appOrigin) { - return 'idp-in-window' + if (matchesHostList(url.hostname, SYSTEM_BROWSER_IDP_HOSTS)) { + return 'idp-system-connect' } - return fromAuthSurface ? 'idp-system-login' : 'idp-in-window' + return 'idp-in-window' } /**