Skip to content

Commit 911c0d7

Browse files
committed
lifecycle
1 parent 39c9fe6 commit 911c0d7

3 files changed

Lines changed: 124 additions & 2 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
attachSessionLifecycle,
2323
decideStartRoute,
2424
handleConnectIntercept,
25+
resolveStartRoute,
2526
tearDownSession,
2627
} from '@/main/session-lifecycle'
2728
import { createLauncherShortcutManager } from '@/main/shortcuts'
@@ -48,6 +49,7 @@ function main(): void {
4849
const preloadPath = join(__dirname, 'preload.cjs')
4950

5051
let mainWindow: BrowserWindow | null = null
52+
let mainWindowCreation: Promise<void> | null = null
5153
let loadHealth: LoadHealthHandle | null = null
5254
let tray: TrayHandle | null = null
5355
let updater: UpdaterHandle | null = null
@@ -132,8 +134,29 @@ function main(): void {
132134
}
133135

134136
async function createAndLoadMainWindow(): Promise<void> {
137+
if (mainWindowCreation) {
138+
await mainWindowCreation
139+
return
140+
}
141+
const pending = performCreateAndLoadMainWindow()
142+
mainWindowCreation = pending
143+
try {
144+
await pending
145+
} finally {
146+
if (mainWindowCreation === pending) {
147+
mainWindowCreation = null
148+
}
149+
}
150+
}
151+
152+
async function performCreateAndLoadMainWindow(): Promise<void> {
135153
const origin = appOrigin()
136154
const ses = configureSessionForOrigin(origin)
155+
const requestedRoute = decideStartRoute(config.get('lastRoute'))
156+
const route = await resolveStartRoute(ses, origin, requestedRoute)
157+
if (route !== requestedRoute) {
158+
config.set('lastRoute', route)
159+
}
137160
const win = createMainWindow({
138161
config,
139162
events,
@@ -163,7 +186,7 @@ function main(): void {
163186
})
164187
loadHealth = attachLoadHealth(win, {
165188
offlinePagePath: OFFLINE_PAGE,
166-
getStartUrl: () => `${appOrigin()}${decideStartRoute(config.get('lastRoute'))}`,
189+
getStartUrl: () => `${appOrigin()}${route}`,
167190
isOnline: () => net.isOnline(),
168191
events,
169192
})
@@ -178,7 +201,6 @@ function main(): void {
178201
onReauthRequested: () => void authFlow.beginLoginHandoff(),
179202
})
180203
loadHealth.startWatchdog()
181-
const route = decideStartRoute(config.get('lastRoute'))
182204
// Fire-and-forget: the window and all its handlers are wired synchronously
183205
// above, so callers get a usable window immediately and the app menu and
184206
// updater never wait on the remote page's load (load-health surfaces any

apps/desktop/src/main/session-lifecycle.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isLogoutNavigation,
99
isSessionCookieName,
1010
probeSession,
11+
resolveStartRoute,
1112
tearDownSession,
1213
} from '@/main/session-lifecycle'
1314

@@ -58,6 +59,53 @@ describe('decideStartRoute', () => {
5859
})
5960
})
6061

62+
describe('resolveStartRoute', () => {
63+
it('restores an accessible saved workspace route', async () => {
64+
const session = sessionWithResponse(200, { workspace: { id: 'ws1' } })
65+
66+
await expect(resolveStartRoute(session, APP, '/workspace/ws1/home')).resolves.toBe(
67+
'/workspace/ws1/home'
68+
)
69+
expect(vi.mocked(session.fetch)).toHaveBeenCalledWith(
70+
`${APP}/api/workspaces/ws1/host-context`,
71+
expect.objectContaining({ cache: 'no-store' })
72+
)
73+
})
74+
75+
it('falls back to the workspace picker after confirmed access denial', async () => {
76+
const session = sessionWithResponse(403, { error: 'Workspace access denied' })
77+
78+
await expect(resolveStartRoute(session, APP, '/workspace/revoked/chat/c1')).resolves.toBe(
79+
'/workspace'
80+
)
81+
})
82+
83+
it('does not probe routes without a workspace id', async () => {
84+
const session = sessionWithResponse(200, {})
85+
86+
await expect(resolveStartRoute(session, APP, '/workspace')).resolves.toBe('/workspace')
87+
expect(session.fetch).not.toHaveBeenCalled()
88+
})
89+
90+
it('preserves the saved route on auth, server, and network failures', async () => {
91+
await expect(
92+
resolveStartRoute(sessionWithResponse(401, {}), APP, '/workspace/ws1/home')
93+
).resolves.toBe('/workspace/ws1/home')
94+
await expect(
95+
resolveStartRoute(sessionWithResponse(500, {}), APP, '/workspace/ws1/home')
96+
).resolves.toBe('/workspace/ws1/home')
97+
98+
const failing = {
99+
fetch: vi.fn(async () => {
100+
throw new Error('offline')
101+
}),
102+
} as unknown as Session
103+
await expect(resolveStartRoute(failing, APP, '/workspace/ws1/home')).resolves.toBe(
104+
'/workspace/ws1/home'
105+
)
106+
})
107+
})
108+
61109
describe('probeSession', () => {
62110
it('reports valid when a session or user is present', async () => {
63111
await expect(probeSession(sessionWithResponse(200, { user: { id: 'u1' } }), APP)).resolves.toBe(

apps/desktop/src/main/session-lifecycle.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const logger = createLogger('DesktopSessionLifecycle')
99

1010
const EXPIRY_PROMPT_COOLDOWN_MS = 30_000
1111
const SESSION_PROBE_TIMEOUT_MS = 5000
12+
const START_ROUTE_PROBE_TIMEOUT_MS = 1500
1213
const TEARDOWN_COOLDOWN_MS = 3000
1314

1415
const CLEARED_STORAGES = [
@@ -63,6 +64,57 @@ export function decideStartRoute(lastRoute: string | undefined): string {
6364
return '/workspace'
6465
}
6566

67+
function workspaceIdFromRoute(route: string): string | null {
68+
try {
69+
const pathname = new URL(route, 'https://internal.invalid').pathname
70+
const match = /^\/workspace\/([^/]+)/.exec(pathname)
71+
return match ? decodeURIComponent(match[1]) : null
72+
} catch {
73+
return null
74+
}
75+
}
76+
77+
/**
78+
* Validates a workspace-specific saved route before Desktop restores it.
79+
* Only a confirmed 403 discards the route; auth, network, timeout, and server
80+
* failures preserve normal web-app recovery instead of masquerading as a
81+
* revoked workspace.
82+
*/
83+
export async function resolveStartRoute(
84+
session: Session,
85+
origin: string,
86+
lastRoute: string | undefined,
87+
timeoutMs: number = START_ROUTE_PROBE_TIMEOUT_MS
88+
): Promise<string> {
89+
const route = decideStartRoute(lastRoute)
90+
const workspaceId = workspaceIdFromRoute(route)
91+
if (!workspaceId) {
92+
return route
93+
}
94+
95+
const controller = new AbortController()
96+
const timer = setTimeout(() => controller.abort(), timeoutMs)
97+
try {
98+
const response = await session.fetch(
99+
`${origin}/api/workspaces/${encodeURIComponent(workspaceId)}/host-context`,
100+
{
101+
signal: controller.signal,
102+
headers: { accept: 'application/json' },
103+
cache: 'no-store',
104+
}
105+
)
106+
if (response.status === 403) {
107+
logger.info('Saved workspace route is no longer accessible; opening workspace picker')
108+
return '/workspace'
109+
}
110+
return route
111+
} catch {
112+
return route
113+
} finally {
114+
clearTimeout(timer)
115+
}
116+
}
117+
66118
/**
67119
* Checks whether the partition currently holds a valid session by asking
68120
* better-auth's get-session endpoint with the partition's cookies. Network

0 commit comments

Comments
 (0)