experiments in a post-browser web
10
fork

Configure Feed

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

fix(session): collapse restored windows to primary display when topology changed

Reconnecting / re-arranging displays between save and restore left
windows stranded on now-secondary screens. Saved bounds whose center
landed on the laptop (no longer primary) survived the existing
isPointOnScreen guard because the laptop was still attached, just
no longer primary — half the user's session would restore there.

Single-display restore policy (we don't try to remember multi-display
layouts yet): at restore, any saved window whose center isn't on the
current primary display gets re-centered on primary, with size
clamped to fit. Placement intent (`center: true`, `screenEdge`) is
cleared on rewrite so the explicit primary-centered coords reach
window-open instead of computePlacement re-deriving against the
cursor display.

Pure helper `collapseToPrimaryDisplay(descriptor, primaryWorkArea)`
takes the workArea as a parameter so unit tests don't need Electron's
screen module. Six new tests in session.test.ts cover: pass-through
on primary, re-center off-primary, intent-clear on rewrite, size
clamp, downstream interaction with buildRestoreBoundsOptions, and
the half-open primary-edge boundary.

+204 -2
+146
backend/electron/session.test.ts
··· 195 195 return out; 196 196 } 197 197 198 + /** 199 + * Collapse a window descriptor onto the primary display when its saved 200 + * center isn't already there. Mirrors session.ts collapseToPrimaryDisplay. 201 + */ 202 + function collapseToPrimaryDisplay( 203 + descriptor: { bounds: { x: number; y: number; width: number; height: number }; params: Record<string, unknown> }, 204 + primaryWorkArea: { x: number; y: number; width: number; height: number }, 205 + ): { bounds: { x: number; y: number; width: number; height: number }; params: Record<string, unknown> } { 206 + const cx = descriptor.bounds.x + Math.round(descriptor.bounds.width / 2); 207 + const cy = descriptor.bounds.y + Math.round(descriptor.bounds.height / 2); 208 + const onPrimary = 209 + cx >= primaryWorkArea.x && cx < primaryWorkArea.x + primaryWorkArea.width && 210 + cy >= primaryWorkArea.y && cy < primaryWorkArea.y + primaryWorkArea.height; 211 + if (onPrimary) return descriptor; 212 + 213 + const w = Math.min(descriptor.bounds.width, primaryWorkArea.width); 214 + const h = Math.min(descriptor.bounds.height, primaryWorkArea.height); 215 + const newBounds = { 216 + x: primaryWorkArea.x + Math.round((primaryWorkArea.width - w) / 2), 217 + y: primaryWorkArea.y + Math.round((primaryWorkArea.height - h) / 2), 218 + width: w, 219 + height: h, 220 + }; 221 + const newParams = { ...descriptor.params }; 222 + delete newParams.center; 223 + delete newParams.screenEdge; 224 + return { bounds: newBounds, params: newParams }; 225 + } 226 + 198 227 // ============================================================================ 199 228 // Database helper functions (replicate the SQL from session.ts exactly) 200 229 // ============================================================================ ··· 704 733 ); 705 734 assert.strictEqual(observedX, 500); // 100 + round(800/2) 706 735 assert.strictEqual(observedY, 500); // 200 + round(600/2) 736 + }); 737 + }); 738 + 739 + // ======================================================================== 740 + // collapseToPrimaryDisplay tests 741 + // 742 + // Single-display restore policy: a window whose saved center isn't on the 743 + // current primary display gets re-centered there, with placement intent 744 + // cleared so the rewritten coords are what window-open uses. 745 + // 746 + // Catches the regression where reconnecting an external display between 747 + // save and restore left ~half the windows on the laptop screen. 748 + // ======================================================================== 749 + 750 + describe('collapseToPrimaryDisplay', () => { 751 + // Simulated topology: external is primary at origin, laptop is at -1440,0 752 + // (typical macOS arrangement when external is set as the main display). 753 + const externalPrimaryWA = { x: 0, y: 0, width: 2560, height: 1440 }; 754 + const laptopBounds = { x: -1440, y: 0, width: 1440, height: 900 }; 755 + 756 + it('returns the descriptor unchanged when saved center is on primary', () => { 757 + const desc = { 758 + bounds: { x: 100, y: 100, width: 800, height: 600 }, 759 + params: { center: true, role: 'workspace' }, 760 + }; 761 + const out = collapseToPrimaryDisplay(desc, externalPrimaryWA); 762 + assert.strictEqual(out, desc); 763 + }); 764 + 765 + it('re-centers a window whose saved center lands on a non-primary display', () => { 766 + const desc = { 767 + bounds: { 768 + x: laptopBounds.x + 200, 769 + y: laptopBounds.y + 200, 770 + width: 800, 771 + height: 600, 772 + }, 773 + params: {} as Record<string, unknown>, 774 + }; 775 + const out = collapseToPrimaryDisplay(desc, externalPrimaryWA); 776 + assert.notStrictEqual(out, desc); 777 + assert.strictEqual(out.bounds.width, 800); 778 + assert.strictEqual(out.bounds.height, 600); 779 + // Centered horizontally + vertically on the primary workArea 780 + assert.strictEqual(out.bounds.x, Math.round((2560 - 800) / 2)); 781 + assert.strictEqual(out.bounds.y, Math.round((1440 - 600) / 2)); 782 + // Center of new bounds lies inside primary workArea 783 + const cx = out.bounds.x + out.bounds.width / 2; 784 + const cy = out.bounds.y + out.bounds.height / 2; 785 + assert.ok(cx >= 0 && cx < 2560); 786 + assert.ok(cy >= 0 && cy < 1440); 787 + }); 788 + 789 + it('clears placement intent (center + screenEdge) on rewrite', () => { 790 + const desc = { 791 + bounds: { 792 + x: laptopBounds.x + 200, 793 + y: laptopBounds.y + 200, 794 + width: 800, 795 + height: 600, 796 + }, 797 + params: { center: true, screenEdge: 'right', role: 'workspace' }, 798 + }; 799 + const out = collapseToPrimaryDisplay(desc, externalPrimaryWA); 800 + assert.strictEqual(out.params.center, undefined); 801 + assert.strictEqual(out.params.screenEdge, undefined); 802 + // Non-placement params survive 803 + assert.strictEqual(out.params.role, 'workspace'); 804 + }); 805 + 806 + it('clamps width/height when saved size exceeds primary workArea', () => { 807 + const desc = { 808 + bounds: { 809 + x: laptopBounds.x, 810 + y: laptopBounds.y, 811 + width: 5000, 812 + height: 3000, 813 + }, 814 + params: {} as Record<string, unknown>, 815 + }; 816 + const out = collapseToPrimaryDisplay(desc, externalPrimaryWA); 817 + assert.strictEqual(out.bounds.width, 2560); 818 + assert.strictEqual(out.bounds.height, 1440); 819 + assert.strictEqual(out.bounds.x, 0); 820 + assert.strictEqual(out.bounds.y, 0); 821 + }); 822 + 823 + it('downstream buildRestoreBoundsOptions on a collapsed descriptor returns explicit x/y on primary', () => { 824 + const desc = { 825 + bounds: { 826 + x: laptopBounds.x + 200, 827 + y: laptopBounds.y + 200, 828 + width: 800, 829 + height: 600, 830 + }, 831 + params: { center: true } as Record<string, unknown>, 832 + }; 833 + const collapsed = collapseToPrimaryDisplay(desc, externalPrimaryWA); 834 + const isPointOnScreen = (x: number, y: number) => 835 + x >= 0 && x < 2560 && y >= 0 && y < 1440; 836 + const opts = buildRestoreBoundsOptions(collapsed, isPointOnScreen); 837 + // After collapse, center: true is gone, so manual-bounds path runs and 838 + // window-open gets explicit primary-centered coords. 839 + assert.strictEqual(opts.x, Math.round((2560 - 800) / 2)); 840 + assert.strictEqual(opts.y, Math.round((1440 - 600) / 2)); 841 + assert.strictEqual(opts.width, 800); 842 + assert.strictEqual(opts.height, 600); 843 + }); 844 + 845 + it('center exactly at the rightmost workArea edge is treated as off-primary (half-open interval)', () => { 846 + // Center at x=2560 (workArea.x + workArea.width) should NOT count as on primary. 847 + const desc = { 848 + bounds: { x: 2160, y: 600, width: 800, height: 600 }, 849 + params: {} as Record<string, unknown>, 850 + }; 851 + const out = collapseToPrimaryDisplay(desc, externalPrimaryWA); 852 + assert.notStrictEqual(out, desc); 707 853 }); 708 854 }); 709 855
+58 -2
backend/electron/session.ts
··· 79 79 } 80 80 81 81 /** 82 + * Single-display restore policy: rewrite bounds + drop placement intent for 83 + * any saved window whose center isn't on the current primary display. 84 + * 85 + * Why: we don't (yet) try to remember multi-display layouts. After a topology 86 + * change between save and restore — display added, removed, or just re-ordered 87 + * — windows whose saved coords land on a non-primary display would otherwise 88 + * stay on that display, splitting the user's session across screens. This 89 + * function collapses everything onto primary at restore time, preserving 90 + * window size (clamped to fit) and centering inside primary's workArea. 91 + * 92 + * Returns a new descriptor when rewriting; returns the input untouched when 93 + * the saved center is already on primary. Placement intent (`center: true`, 94 + * `screenEdge`) is cleared on rewrite so the explicit bounds reach 95 + * `buildRestoreBoundsOptions` and aren't re-derived against the cursor display. 96 + * 97 + * Pure: takes the primary workArea as a parameter so it's unit-testable 98 + * without Electron's `screen` module. 99 + */ 100 + export function collapseToPrimaryDisplay( 101 + descriptor: { bounds: Electron.Rectangle; params: Record<string, unknown> }, 102 + primaryWorkArea: Electron.Rectangle, 103 + ): { bounds: Electron.Rectangle; params: Record<string, unknown> } { 104 + const cx = descriptor.bounds.x + Math.round(descriptor.bounds.width / 2); 105 + const cy = descriptor.bounds.y + Math.round(descriptor.bounds.height / 2); 106 + const onPrimary = 107 + cx >= primaryWorkArea.x && cx < primaryWorkArea.x + primaryWorkArea.width && 108 + cy >= primaryWorkArea.y && cy < primaryWorkArea.y + primaryWorkArea.height; 109 + if (onPrimary) return descriptor; 110 + 111 + const w = Math.min(descriptor.bounds.width, primaryWorkArea.width); 112 + const h = Math.min(descriptor.bounds.height, primaryWorkArea.height); 113 + const newBounds: Electron.Rectangle = { 114 + x: primaryWorkArea.x + Math.round((primaryWorkArea.width - w) / 2), 115 + y: primaryWorkArea.y + Math.round((primaryWorkArea.height - h) / 2), 116 + width: w, 117 + height: h, 118 + }; 119 + const newParams = { ...descriptor.params }; 120 + // Clear placement intent so the rewritten x/y is what window-open uses, 121 + // rather than computePlacement re-deriving against the cursor display. 122 + delete newParams.center; 123 + delete newParams.screenEdge; 124 + return { bounds: newBounds, params: newParams }; 125 + } 126 + 127 + /** 82 128 * Build the x/y/w/h options the restore path passes to window-open. 83 129 * 84 130 * If the descriptor declares a non-manual placement intent (`center: true` ··· 696 742 // Track which window was focused for later focus restoration 697 743 let focusedWindowId: number | null = null; 698 744 699 - for (const descriptor of sortedWindows) { 745 + // Single-display restore policy: every window goes to the current primary 746 + // display. Avoids the case where reconnecting/swapping displays between 747 + // save and restore leaves windows stranded on a now-secondary screen. 748 + const primaryWorkArea = screen.getPrimaryDisplay().workArea; 749 + 750 + for (const original of sortedWindows) { 700 751 try { 752 + const descriptor = (() => { 753 + const collapsed = collapseToPrimaryDisplay(original, primaryWorkArea); 754 + if (collapsed === original) return original; 755 + return { ...original, bounds: collapsed.bounds, params: collapsed.params }; 756 + })(); 701 757 const boundsOptions = buildRestoreBoundsOptions(descriptor, isPointOnScreen); 702 758 const centerX = descriptor.bounds.x + Math.round(descriptor.bounds.width / 2); 703 759 const centerY = descriptor.bounds.y + Math.round(descriptor.bounds.height / 2); ··· 771 827 } 772 828 } catch (error) { 773 829 result.failed++; 774 - console.error(`[session] Error restoring window ${descriptor.url}:`, error); 830 + console.error(`[session] Error restoring window ${original.url}:`, error); 775 831 } 776 832 } 777 833