experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(session): extract shared session logic to app/lib

+517
+140
app/lib/session.d.ts
··· 1 + /** 2 + * Session snapshot — shared pure logic for save/restore. 3 + * 4 + * Contains type definitions, URL extraction, parameter sanitization, 5 + * snapshot validation, crash detection heuristics, and restore 6 + * sequencing/ordering logic. 7 + * 8 + * @module session 9 + */ 10 + 11 + // ============================================================================ 12 + // Type definitions 13 + // ============================================================================ 14 + 15 + export interface Rectangle { 16 + x: number; 17 + y: number; 18 + width: number; 19 + height: number; 20 + } 21 + 22 + export interface WindowContext { 23 + mode: string | null; 24 + modeMetadata: Record<string, unknown> | null; 25 + } 26 + 27 + export interface WindowDescriptor { 28 + url: string; 29 + key: string | undefined; 30 + title: string; 31 + bounds: Rectangle; 32 + source: string; 33 + params: Record<string, unknown>; 34 + zOrder: number; 35 + focused: boolean; 36 + context?: WindowContext; 37 + } 38 + 39 + export interface SessionMetadata { 40 + lastSaveReason: string; 41 + lastSaveAt: number; 42 + windowCount: number; 43 + cleanShutdown: boolean; 44 + crashCount: number; 45 + lastRestoreAt?: number; 46 + restoreCount?: number; 47 + } 48 + 49 + export interface SessionSnapshot { 50 + version: 1; 51 + createdAt: number; 52 + reason: 'before-quit' | 'autosave' | 'manual'; 53 + windows: WindowDescriptor[]; 54 + } 55 + 56 + // ============================================================================ 57 + // Constants 58 + // ============================================================================ 59 + 60 + export declare const MAX_CRASH_COUNT: number; 61 + export declare const RESTORE_STRIP_KEYS: Set<string>; 62 + export declare const SYSTEM_WINDOW_URLS: string[]; 63 + 64 + // ============================================================================ 65 + // URL extraction 66 + // ============================================================================ 67 + 68 + export declare function extractRealUrl(url: string): string; 69 + export declare function extractCanvasBounds(url: string): Rectangle | null; 70 + 71 + // ============================================================================ 72 + // Parameter sanitization 73 + // ============================================================================ 74 + 75 + export declare function sanitizeParams(params: Record<string, unknown>): Record<string, unknown>; 76 + export declare function stripRestoreParams(params: Record<string, unknown>): Record<string, unknown>; 77 + 78 + // ============================================================================ 79 + // Snapshot validation 80 + // ============================================================================ 81 + 82 + export declare function isValidDescriptor(descriptor: WindowDescriptor): boolean; 83 + export declare function isSystemWindowUrl(url: string): boolean; 84 + export declare function validateSnapshot(snapshot: SessionSnapshot): { validWindows: WindowDescriptor[] } | null; 85 + 86 + // ============================================================================ 87 + // Crash detection heuristics 88 + // ============================================================================ 89 + 90 + export declare function determineCrashAction( 91 + wasCleanShutdown: boolean, 92 + crashCount: number 93 + ): 'restore' | 'ask' | 'skip'; 94 + 95 + export declare function computeCrashCount( 96 + wasCleanShutdown: boolean, 97 + previousCrashCount: number 98 + ): number; 99 + 100 + // ============================================================================ 101 + // Restore sequencing / ordering 102 + // ============================================================================ 103 + 104 + export declare function sortWindowsByZOrder(windows: WindowDescriptor[]): WindowDescriptor[]; 105 + 106 + export declare function computeRestoreBounds( 107 + bounds: Rectangle, 108 + isOnScreen: (x: number, y: number) => boolean 109 + ): { width: number; height: number; x?: number; y?: number }; 110 + 111 + export declare function buildRestoreOptions( 112 + descriptor: WindowDescriptor, 113 + isOnScreen: (x: number, y: number) => boolean 114 + ): Record<string, unknown>; 115 + 116 + // ============================================================================ 117 + // Restore result analysis 118 + // ============================================================================ 119 + 120 + export declare function isPartialRestore(result: { 121 + restored: number; 122 + failed: number; 123 + total: number; 124 + }): boolean; 125 + 126 + // ============================================================================ 127 + // Snapshot construction helpers 128 + // ============================================================================ 129 + 130 + export declare function buildSnapshot( 131 + windows: WindowDescriptor[], 132 + reason: 'before-quit' | 'autosave' | 'manual' 133 + ): SessionSnapshot; 134 + 135 + export declare function buildMetadata( 136 + reason: 'before-quit' | 'autosave' | 'manual', 137 + windowCount: number, 138 + existing?: Partial<SessionMetadata>, 139 + opts?: { cleanShutdown?: boolean } 140 + ): SessionMetadata;
+375
app/lib/session.js
··· 1 + /** 2 + * Session snapshot — shared pure logic for save/restore. 3 + * 4 + * Contains type definitions, URL extraction, parameter sanitization, 5 + * snapshot validation, crash detection heuristics, and restore 6 + * sequencing/ordering logic. All functions are pure — no BrowserWindow, 7 + * no database, no Electron imports. 8 + * 9 + * Backend-specific operations (window enumeration, window creation, 10 + * database persistence, dialog display) remain in each backend's 11 + * session implementation. 12 + * 13 + * @module session 14 + */ 15 + 16 + // ============================================================================ 17 + // Constants 18 + // ============================================================================ 19 + 20 + /** 21 + * Maximum consecutive crash count before auto-skipping restore. 22 + * After this many crashes, the app starts fresh without asking. 23 + * @type {number} 24 + */ 25 + export const MAX_CRASH_COUNT = 3; 26 + 27 + /** 28 + * Keys stripped from window params during restore. 29 + * These are internal bookkeeping or context-dependent values that should be 30 + * re-derived from the URL and current environment during restore: 31 + * - x/y/width/height: stale positions from original creation (bounds are authoritative) 32 + * - role: saved role can trigger hasNonContentRole in window-open, preventing 33 + * canvas mode for web pages that had role 'workspace' 34 + * - escapeMode: affects role detection (escapeMode:'navigate' -> role:'workspace') 35 + * which also disables canvas mode for web pages 36 + * - address: internal bookkeeping param, overwritten by window-open anyway 37 + * - transient/parentWindowId: session state, not applicable to restored windows 38 + * @type {Set<string>} 39 + */ 40 + export const RESTORE_STRIP_KEYS = new Set([ 41 + 'x', 'y', 'width', 'height', 42 + 'role', 'escapeMode', 'address', 43 + 'transient', 'parentWindowId', 44 + ]); 45 + 46 + /** 47 + * URLs that identify internal system windows (should be excluded from snapshots). 48 + * @type {string[]} 49 + */ 50 + export const SYSTEM_WINDOW_URLS = [ 51 + 'peek://app/background.html', 52 + 'peek://app/extension-host.html', 53 + ]; 54 + 55 + // ============================================================================ 56 + // URL extraction 57 + // ============================================================================ 58 + 59 + /** 60 + * Extract the real URL from a page container URL. 61 + * Page containers use peek://app/page/index.html?url=<actual-url> 62 + * Returns the actual URL, or the original URL if not a container. 63 + * 64 + * @param {string} url - The possibly-rewritten peek:// URL 65 + * @returns {string} The actual URL 66 + */ 67 + export function extractRealUrl(url) { 68 + if (url.startsWith('peek://app/page')) { 69 + try { 70 + const parsed = new URL(url); 71 + const actualUrl = parsed.searchParams.get('url'); 72 + if (actualUrl) { 73 + return actualUrl; 74 + } 75 + } catch { 76 + // If URL parsing fails, keep the original 77 + } 78 + } 79 + return url; 80 + } 81 + 82 + /** 83 + * Extract webview bounds from a canvas page container URL. 84 + * Canvas pages encode the visible webview position/size in URL params. 85 + * Returns null if the URL is not a canvas page or params are missing. 86 + * 87 + * @param {string} url - The peek:// container URL 88 + * @returns {{ x: number, y: number, width: number, height: number } | null} 89 + */ 90 + export function extractCanvasBounds(url) { 91 + if (!url.startsWith('peek://app/page')) return null; 92 + try { 93 + const parsed = new URL(url); 94 + const x = parsed.searchParams.get('x'); 95 + const y = parsed.searchParams.get('y'); 96 + const width = parsed.searchParams.get('width'); 97 + const height = parsed.searchParams.get('height'); 98 + if (x && y && width && height) { 99 + return { 100 + x: parseInt(x, 10), 101 + y: parseInt(y, 10), 102 + width: parseInt(width, 10), 103 + height: parseInt(height, 10), 104 + }; 105 + } 106 + } catch { 107 + // If URL parsing fails, skip 108 + } 109 + return null; 110 + } 111 + 112 + // ============================================================================ 113 + // Parameter sanitization 114 + // ============================================================================ 115 + 116 + /** 117 + * Sanitize params to only include serializable values. 118 + * Filters out functions, complex objects, and other non-JSON-safe types. 119 + * Mirrors the logic in ipc.ts window-list handler. 120 + * 121 + * @param {Record<string, unknown>} params - Raw window params 122 + * @returns {Record<string, unknown>} Params safe for JSON serialization 123 + */ 124 + export function sanitizeParams(params) { 125 + const safe = {}; 126 + for (const [key, value] of Object.entries(params)) { 127 + const type = typeof value; 128 + if (type === 'string' || type === 'number' || type === 'boolean' || value === null) { 129 + safe[key] = value; 130 + } else if (Array.isArray(value)) { 131 + if (value.every(v => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null)) { 132 + safe[key] = value; 133 + } 134 + } 135 + // Skip functions, objects, etc. that may not serialize safely 136 + } 137 + return safe; 138 + } 139 + 140 + /** 141 + * Strip internal bookkeeping keys from params for restore. 142 + * Returns a new object without the keys that should be re-derived 143 + * from the URL and environment during window restoration. 144 + * 145 + * @param {Record<string, unknown>} params - Saved window params 146 + * @returns {Record<string, unknown>} Cleaned params for window-open 147 + */ 148 + export function stripRestoreParams(params) { 149 + const clean = {}; 150 + for (const [key, value] of Object.entries(params)) { 151 + if (!RESTORE_STRIP_KEYS.has(key)) { 152 + clean[key] = value; 153 + } 154 + } 155 + return clean; 156 + } 157 + 158 + // ============================================================================ 159 + // Snapshot validation 160 + // ============================================================================ 161 + 162 + /** 163 + * Validate that a window descriptor has the required fields for restore. 164 + * Returns false if the descriptor is invalid and should be skipped. 165 + * 166 + * @param {WindowDescriptor} descriptor 167 + * @returns {boolean} 168 + */ 169 + export function isValidDescriptor(descriptor) { 170 + // Must have a URL 171 + if (!descriptor.url || typeof descriptor.url !== 'string' || descriptor.url.trim() === '') { 172 + return false; 173 + } 174 + return true; 175 + } 176 + 177 + /** 178 + * Check if a URL belongs to an internal system window. 179 + * 180 + * @param {string} url - The window URL to check 181 + * @returns {boolean} True if this is a system window URL 182 + */ 183 + export function isSystemWindowUrl(url) { 184 + return SYSTEM_WINDOW_URLS.some(sysUrl => url.includes(sysUrl)); 185 + } 186 + 187 + /** 188 + * Validate a session snapshot and return valid window descriptors. 189 + * Checks version, filters invalid descriptors, returns null if nothing to restore. 190 + * 191 + * @param {SessionSnapshot} snapshot - The raw parsed snapshot 192 + * @returns {{ validWindows: WindowDescriptor[] } | null} Valid windows or null 193 + */ 194 + export function validateSnapshot(snapshot) { 195 + // Validate version 196 + if (!snapshot || snapshot.version !== 1) { 197 + return null; 198 + } 199 + 200 + // Check if there are windows to restore 201 + if (!snapshot.windows || snapshot.windows.length === 0) { 202 + return null; 203 + } 204 + 205 + // Filter out invalid descriptors 206 + const validWindows = snapshot.windows.filter(d => isValidDescriptor(d)); 207 + 208 + if (validWindows.length === 0) { 209 + return null; 210 + } 211 + 212 + return { validWindows }; 213 + } 214 + 215 + // ============================================================================ 216 + // Crash detection heuristics 217 + // ============================================================================ 218 + 219 + /** 220 + * Determine whether crash recovery dialog should be shown. 221 + * Only shown after repeated crashes (crashCount > 0 with unclean shutdown). 222 + * A single unclean shutdown (e.g. Ctrl+C, power loss) silently restores. 223 + * 224 + * @param {boolean} wasCleanShutdown - Whether last session shut down cleanly 225 + * @param {number} crashCount - Number of consecutive crashes 226 + * @returns {'restore' | 'ask' | 'skip'} Action to take 227 + */ 228 + export function determineCrashAction(wasCleanShutdown, crashCount) { 229 + if (wasCleanShutdown || crashCount === 0) { 230 + return 'restore'; 231 + } 232 + if (crashCount > MAX_CRASH_COUNT) { 233 + return 'skip'; 234 + } 235 + return 'ask'; 236 + } 237 + 238 + /** 239 + * Compute updated crash count based on previous shutdown state. 240 + * If the previous session was not clean, increments the crash count. 241 + * 242 + * @param {boolean} wasCleanShutdown - Previous session's clean shutdown flag 243 + * @param {number} previousCrashCount - Previous crash count 244 + * @returns {number} Updated crash count 245 + */ 246 + export function computeCrashCount(wasCleanShutdown, previousCrashCount) { 247 + if (wasCleanShutdown === false) { 248 + return previousCrashCount + 1; 249 + } 250 + return previousCrashCount; 251 + } 252 + 253 + // ============================================================================ 254 + // Restore sequencing / ordering 255 + // ============================================================================ 256 + 257 + /** 258 + * Sort window descriptors by z-order for restore sequencing. 259 + * Higher zOrder = later in stack = opened later. 260 + * Returns a new sorted array (does not mutate input). 261 + * 262 + * @param {WindowDescriptor[]} windows - Window descriptors to sort 263 + * @returns {WindowDescriptor[]} Sorted descriptors (back-to-front) 264 + */ 265 + export function sortWindowsByZOrder(windows) { 266 + return [...windows].sort((a, b) => b.zOrder - a.zOrder); 267 + } 268 + 269 + /** 270 + * Compute restore bounds for a window descriptor. 271 + * Calculates the center point and returns bounds options for window-open. 272 + * If the center point is off-screen (as determined by the caller's 273 + * isOnScreen callback), x/y are omitted so the backend can center the window. 274 + * 275 + * @param {{ x: number, y: number, width: number, height: number }} bounds - Saved window bounds 276 + * @param {(x: number, y: number) => boolean} isOnScreen - Callback to check if a point is on screen 277 + * @returns {{ width: number, height: number, x?: number, y?: number }} 278 + */ 279 + export function computeRestoreBounds(bounds, isOnScreen) { 280 + const centerX = bounds.x + Math.round(bounds.width / 2); 281 + const centerY = bounds.y + Math.round(bounds.height / 2); 282 + 283 + const result = { 284 + width: bounds.width, 285 + height: bounds.height, 286 + }; 287 + 288 + if (isOnScreen(centerX, centerY)) { 289 + result.x = bounds.x; 290 + result.y = bounds.y; 291 + } 292 + 293 + return result; 294 + } 295 + 296 + /** 297 + * Build restore options for a window from its descriptor. 298 + * Combines cleaned params with computed bounds and key. 299 + * 300 + * @param {WindowDescriptor} descriptor - The window descriptor 301 + * @param {(x: number, y: number) => boolean} isOnScreen - Screen bounds checker 302 + * @returns {Record<string, unknown>} Options object for window-open 303 + */ 304 + export function buildRestoreOptions(descriptor, isOnScreen) { 305 + const cleanParams = stripRestoreParams(descriptor.params); 306 + const boundsOptions = computeRestoreBounds(descriptor.bounds, isOnScreen); 307 + 308 + const options = { 309 + ...cleanParams, 310 + ...boundsOptions, 311 + }; 312 + 313 + if (descriptor.key) { 314 + options.key = descriptor.key; 315 + } 316 + 317 + return options; 318 + } 319 + 320 + // ============================================================================ 321 + // Restore result analysis 322 + // ============================================================================ 323 + 324 + /** 325 + * Check if a restore result indicates a problematic partial restore. 326 + * Returns true if more than half the windows failed (and there were 327 + * enough windows for this to be meaningful). 328 + * 329 + * @param {{ restored: number, failed: number, total: number }} result 330 + * @returns {boolean} 331 + */ 332 + export function isPartialRestore(result) { 333 + return result.failed > 0 && result.total > 2 && result.failed > result.total / 2; 334 + } 335 + 336 + // ============================================================================ 337 + // Snapshot construction helpers 338 + // ============================================================================ 339 + 340 + /** 341 + * Build a SessionSnapshot object from an array of window descriptors. 342 + * 343 + * @param {WindowDescriptor[]} windows - Collected window descriptors 344 + * @param {'before-quit' | 'autosave' | 'manual'} reason - Why the snapshot is being taken 345 + * @returns {SessionSnapshot} 346 + */ 347 + export function buildSnapshot(windows, reason) { 348 + return { 349 + version: 1, 350 + createdAt: Date.now(), 351 + reason, 352 + windows, 353 + }; 354 + } 355 + 356 + /** 357 + * Build a SessionMetadata object, preserving fields from existing metadata. 358 + * 359 + * @param {'before-quit' | 'autosave' | 'manual'} reason 360 + * @param {number} windowCount 361 + * @param {Partial<SessionMetadata>} [existing] - Previous metadata to merge 362 + * @param {{ cleanShutdown?: boolean }} [opts] 363 + * @returns {SessionMetadata} 364 + */ 365 + export function buildMetadata(reason, windowCount, existing, opts) { 366 + return { 367 + lastSaveReason: reason, 368 + lastSaveAt: Date.now(), 369 + windowCount, 370 + cleanShutdown: opts?.cleanShutdown !== false, 371 + crashCount: existing?.crashCount ?? 0, 372 + lastRestoreAt: existing?.lastRestoreAt, 373 + restoreCount: existing?.restoreCount, 374 + }; 375 + }
+2
backend/electron/session.ts
··· 9 9 * Captures all visible user windows and writes to extension_settings 10 10 * using synchronous better-sqlite3 APIs (safe for before-quit handler). 11 11 * On startup, reads the snapshot and recreates windows. 12 + * 13 + * NOTE: Pure logic shared copy lives in app/lib/session.js for frontend/Tauri use. 12 14 */ 13 15 14 16 import { BrowserWindow, dialog, screen } from 'electron';