···11+/**
22+ * Session snapshot — shared pure logic for save/restore.
33+ *
44+ * Contains type definitions, URL extraction, parameter sanitization,
55+ * snapshot validation, crash detection heuristics, and restore
66+ * sequencing/ordering logic. All functions are pure — no BrowserWindow,
77+ * no database, no Electron imports.
88+ *
99+ * Backend-specific operations (window enumeration, window creation,
1010+ * database persistence, dialog display) remain in each backend's
1111+ * session implementation.
1212+ *
1313+ * @module session
1414+ */
1515+1616+// ============================================================================
1717+// Constants
1818+// ============================================================================
1919+2020+/**
2121+ * Maximum consecutive crash count before auto-skipping restore.
2222+ * After this many crashes, the app starts fresh without asking.
2323+ * @type {number}
2424+ */
2525+export const MAX_CRASH_COUNT = 3;
2626+2727+/**
2828+ * Keys stripped from window params during restore.
2929+ * These are internal bookkeeping or context-dependent values that should be
3030+ * re-derived from the URL and current environment during restore:
3131+ * - x/y/width/height: stale positions from original creation (bounds are authoritative)
3232+ * - role: saved role can trigger hasNonContentRole in window-open, preventing
3333+ * canvas mode for web pages that had role 'workspace'
3434+ * - escapeMode: affects role detection (escapeMode:'navigate' -> role:'workspace')
3535+ * which also disables canvas mode for web pages
3636+ * - address: internal bookkeeping param, overwritten by window-open anyway
3737+ * - transient/parentWindowId: session state, not applicable to restored windows
3838+ * @type {Set<string>}
3939+ */
4040+export const RESTORE_STRIP_KEYS = new Set([
4141+ 'x', 'y', 'width', 'height',
4242+ 'role', 'escapeMode', 'address',
4343+ 'transient', 'parentWindowId',
4444+]);
4545+4646+/**
4747+ * URLs that identify internal system windows (should be excluded from snapshots).
4848+ * @type {string[]}
4949+ */
5050+export const SYSTEM_WINDOW_URLS = [
5151+ 'peek://app/background.html',
5252+ 'peek://app/extension-host.html',
5353+];
5454+5555+// ============================================================================
5656+// URL extraction
5757+// ============================================================================
5858+5959+/**
6060+ * Extract the real URL from a page container URL.
6161+ * Page containers use peek://app/page/index.html?url=<actual-url>
6262+ * Returns the actual URL, or the original URL if not a container.
6363+ *
6464+ * @param {string} url - The possibly-rewritten peek:// URL
6565+ * @returns {string} The actual URL
6666+ */
6767+export function extractRealUrl(url) {
6868+ if (url.startsWith('peek://app/page')) {
6969+ try {
7070+ const parsed = new URL(url);
7171+ const actualUrl = parsed.searchParams.get('url');
7272+ if (actualUrl) {
7373+ return actualUrl;
7474+ }
7575+ } catch {
7676+ // If URL parsing fails, keep the original
7777+ }
7878+ }
7979+ return url;
8080+}
8181+8282+/**
8383+ * Extract webview bounds from a canvas page container URL.
8484+ * Canvas pages encode the visible webview position/size in URL params.
8585+ * Returns null if the URL is not a canvas page or params are missing.
8686+ *
8787+ * @param {string} url - The peek:// container URL
8888+ * @returns {{ x: number, y: number, width: number, height: number } | null}
8989+ */
9090+export function extractCanvasBounds(url) {
9191+ if (!url.startsWith('peek://app/page')) return null;
9292+ try {
9393+ const parsed = new URL(url);
9494+ const x = parsed.searchParams.get('x');
9595+ const y = parsed.searchParams.get('y');
9696+ const width = parsed.searchParams.get('width');
9797+ const height = parsed.searchParams.get('height');
9898+ if (x && y && width && height) {
9999+ return {
100100+ x: parseInt(x, 10),
101101+ y: parseInt(y, 10),
102102+ width: parseInt(width, 10),
103103+ height: parseInt(height, 10),
104104+ };
105105+ }
106106+ } catch {
107107+ // If URL parsing fails, skip
108108+ }
109109+ return null;
110110+}
111111+112112+// ============================================================================
113113+// Parameter sanitization
114114+// ============================================================================
115115+116116+/**
117117+ * Sanitize params to only include serializable values.
118118+ * Filters out functions, complex objects, and other non-JSON-safe types.
119119+ * Mirrors the logic in ipc.ts window-list handler.
120120+ *
121121+ * @param {Record<string, unknown>} params - Raw window params
122122+ * @returns {Record<string, unknown>} Params safe for JSON serialization
123123+ */
124124+export function sanitizeParams(params) {
125125+ const safe = {};
126126+ for (const [key, value] of Object.entries(params)) {
127127+ const type = typeof value;
128128+ if (type === 'string' || type === 'number' || type === 'boolean' || value === null) {
129129+ safe[key] = value;
130130+ } else if (Array.isArray(value)) {
131131+ if (value.every(v => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null)) {
132132+ safe[key] = value;
133133+ }
134134+ }
135135+ // Skip functions, objects, etc. that may not serialize safely
136136+ }
137137+ return safe;
138138+}
139139+140140+/**
141141+ * Strip internal bookkeeping keys from params for restore.
142142+ * Returns a new object without the keys that should be re-derived
143143+ * from the URL and environment during window restoration.
144144+ *
145145+ * @param {Record<string, unknown>} params - Saved window params
146146+ * @returns {Record<string, unknown>} Cleaned params for window-open
147147+ */
148148+export function stripRestoreParams(params) {
149149+ const clean = {};
150150+ for (const [key, value] of Object.entries(params)) {
151151+ if (!RESTORE_STRIP_KEYS.has(key)) {
152152+ clean[key] = value;
153153+ }
154154+ }
155155+ return clean;
156156+}
157157+158158+// ============================================================================
159159+// Snapshot validation
160160+// ============================================================================
161161+162162+/**
163163+ * Validate that a window descriptor has the required fields for restore.
164164+ * Returns false if the descriptor is invalid and should be skipped.
165165+ *
166166+ * @param {WindowDescriptor} descriptor
167167+ * @returns {boolean}
168168+ */
169169+export function isValidDescriptor(descriptor) {
170170+ // Must have a URL
171171+ if (!descriptor.url || typeof descriptor.url !== 'string' || descriptor.url.trim() === '') {
172172+ return false;
173173+ }
174174+ return true;
175175+}
176176+177177+/**
178178+ * Check if a URL belongs to an internal system window.
179179+ *
180180+ * @param {string} url - The window URL to check
181181+ * @returns {boolean} True if this is a system window URL
182182+ */
183183+export function isSystemWindowUrl(url) {
184184+ return SYSTEM_WINDOW_URLS.some(sysUrl => url.includes(sysUrl));
185185+}
186186+187187+/**
188188+ * Validate a session snapshot and return valid window descriptors.
189189+ * Checks version, filters invalid descriptors, returns null if nothing to restore.
190190+ *
191191+ * @param {SessionSnapshot} snapshot - The raw parsed snapshot
192192+ * @returns {{ validWindows: WindowDescriptor[] } | null} Valid windows or null
193193+ */
194194+export function validateSnapshot(snapshot) {
195195+ // Validate version
196196+ if (!snapshot || snapshot.version !== 1) {
197197+ return null;
198198+ }
199199+200200+ // Check if there are windows to restore
201201+ if (!snapshot.windows || snapshot.windows.length === 0) {
202202+ return null;
203203+ }
204204+205205+ // Filter out invalid descriptors
206206+ const validWindows = snapshot.windows.filter(d => isValidDescriptor(d));
207207+208208+ if (validWindows.length === 0) {
209209+ return null;
210210+ }
211211+212212+ return { validWindows };
213213+}
214214+215215+// ============================================================================
216216+// Crash detection heuristics
217217+// ============================================================================
218218+219219+/**
220220+ * Determine whether crash recovery dialog should be shown.
221221+ * Only shown after repeated crashes (crashCount > 0 with unclean shutdown).
222222+ * A single unclean shutdown (e.g. Ctrl+C, power loss) silently restores.
223223+ *
224224+ * @param {boolean} wasCleanShutdown - Whether last session shut down cleanly
225225+ * @param {number} crashCount - Number of consecutive crashes
226226+ * @returns {'restore' | 'ask' | 'skip'} Action to take
227227+ */
228228+export function determineCrashAction(wasCleanShutdown, crashCount) {
229229+ if (wasCleanShutdown || crashCount === 0) {
230230+ return 'restore';
231231+ }
232232+ if (crashCount > MAX_CRASH_COUNT) {
233233+ return 'skip';
234234+ }
235235+ return 'ask';
236236+}
237237+238238+/**
239239+ * Compute updated crash count based on previous shutdown state.
240240+ * If the previous session was not clean, increments the crash count.
241241+ *
242242+ * @param {boolean} wasCleanShutdown - Previous session's clean shutdown flag
243243+ * @param {number} previousCrashCount - Previous crash count
244244+ * @returns {number} Updated crash count
245245+ */
246246+export function computeCrashCount(wasCleanShutdown, previousCrashCount) {
247247+ if (wasCleanShutdown === false) {
248248+ return previousCrashCount + 1;
249249+ }
250250+ return previousCrashCount;
251251+}
252252+253253+// ============================================================================
254254+// Restore sequencing / ordering
255255+// ============================================================================
256256+257257+/**
258258+ * Sort window descriptors by z-order for restore sequencing.
259259+ * Higher zOrder = later in stack = opened later.
260260+ * Returns a new sorted array (does not mutate input).
261261+ *
262262+ * @param {WindowDescriptor[]} windows - Window descriptors to sort
263263+ * @returns {WindowDescriptor[]} Sorted descriptors (back-to-front)
264264+ */
265265+export function sortWindowsByZOrder(windows) {
266266+ return [...windows].sort((a, b) => b.zOrder - a.zOrder);
267267+}
268268+269269+/**
270270+ * Compute restore bounds for a window descriptor.
271271+ * Calculates the center point and returns bounds options for window-open.
272272+ * If the center point is off-screen (as determined by the caller's
273273+ * isOnScreen callback), x/y are omitted so the backend can center the window.
274274+ *
275275+ * @param {{ x: number, y: number, width: number, height: number }} bounds - Saved window bounds
276276+ * @param {(x: number, y: number) => boolean} isOnScreen - Callback to check if a point is on screen
277277+ * @returns {{ width: number, height: number, x?: number, y?: number }}
278278+ */
279279+export function computeRestoreBounds(bounds, isOnScreen) {
280280+ const centerX = bounds.x + Math.round(bounds.width / 2);
281281+ const centerY = bounds.y + Math.round(bounds.height / 2);
282282+283283+ const result = {
284284+ width: bounds.width,
285285+ height: bounds.height,
286286+ };
287287+288288+ if (isOnScreen(centerX, centerY)) {
289289+ result.x = bounds.x;
290290+ result.y = bounds.y;
291291+ }
292292+293293+ return result;
294294+}
295295+296296+/**
297297+ * Build restore options for a window from its descriptor.
298298+ * Combines cleaned params with computed bounds and key.
299299+ *
300300+ * @param {WindowDescriptor} descriptor - The window descriptor
301301+ * @param {(x: number, y: number) => boolean} isOnScreen - Screen bounds checker
302302+ * @returns {Record<string, unknown>} Options object for window-open
303303+ */
304304+export function buildRestoreOptions(descriptor, isOnScreen) {
305305+ const cleanParams = stripRestoreParams(descriptor.params);
306306+ const boundsOptions = computeRestoreBounds(descriptor.bounds, isOnScreen);
307307+308308+ const options = {
309309+ ...cleanParams,
310310+ ...boundsOptions,
311311+ };
312312+313313+ if (descriptor.key) {
314314+ options.key = descriptor.key;
315315+ }
316316+317317+ return options;
318318+}
319319+320320+// ============================================================================
321321+// Restore result analysis
322322+// ============================================================================
323323+324324+/**
325325+ * Check if a restore result indicates a problematic partial restore.
326326+ * Returns true if more than half the windows failed (and there were
327327+ * enough windows for this to be meaningful).
328328+ *
329329+ * @param {{ restored: number, failed: number, total: number }} result
330330+ * @returns {boolean}
331331+ */
332332+export function isPartialRestore(result) {
333333+ return result.failed > 0 && result.total > 2 && result.failed > result.total / 2;
334334+}
335335+336336+// ============================================================================
337337+// Snapshot construction helpers
338338+// ============================================================================
339339+340340+/**
341341+ * Build a SessionSnapshot object from an array of window descriptors.
342342+ *
343343+ * @param {WindowDescriptor[]} windows - Collected window descriptors
344344+ * @param {'before-quit' | 'autosave' | 'manual'} reason - Why the snapshot is being taken
345345+ * @returns {SessionSnapshot}
346346+ */
347347+export function buildSnapshot(windows, reason) {
348348+ return {
349349+ version: 1,
350350+ createdAt: Date.now(),
351351+ reason,
352352+ windows,
353353+ };
354354+}
355355+356356+/**
357357+ * Build a SessionMetadata object, preserving fields from existing metadata.
358358+ *
359359+ * @param {'before-quit' | 'autosave' | 'manual'} reason
360360+ * @param {number} windowCount
361361+ * @param {Partial<SessionMetadata>} [existing] - Previous metadata to merge
362362+ * @param {{ cleanShutdown?: boolean }} [opts]
363363+ * @returns {SessionMetadata}
364364+ */
365365+export function buildMetadata(reason, windowCount, existing, opts) {
366366+ return {
367367+ lastSaveReason: reason,
368368+ lastSaveAt: Date.now(),
369369+ windowCount,
370370+ cleanShutdown: opts?.cleanShutdown !== false,
371371+ crashCount: existing?.crashCount ?? 0,
372372+ lastRestoreAt: existing?.lastRestoreAt,
373373+ restoreCount: existing?.restoreCount,
374374+ };
375375+}
+2
backend/electron/session.ts
···99 * Captures all visible user windows and writes to extension_settings
1010 * using synchronous better-sqlite3 APIs (safe for before-quit handler).
1111 * On startup, reads the snapshot and recreates windows.
1212+ *
1313+ * NOTE: Pure logic shared copy lives in app/lib/session.js for frontend/Tauri use.
1214 */
13151416import { BrowserWindow, dialog, screen } from 'electron';