feat(shell): startup window mode, --fullscreen switch and F11 toggle (#1514)

Settings > General gains "Window on startup" (normal / maximized /
fullscreen), Electron only, mirrored into the main-process config by
SETTINGS_UPDATE and applied at the next window creation. `--fullscreen`
forces one fullscreen launch (consumed by the first window). F11 toggles
OS-level fullscreen through WINDOW:TOGGLE_FULLSCREEN — the exit path on
Windows/Linux where the title bar is hidden — and is skipped while the
player owns document.fullscreenElement.

attachWindowStateEvents tracks native and HTML fullscreen as two flags,
since Electron leaves only the HTML state when the window was already
natively fullscreen. macOS ignores the constructor `fullscreen` option on a
hidden window, so ready-to-show repeats the request after show(). Toggles
are decided by an observe-only, event-fed tracker
(native-fullscreen-transitions.ts), never against isFullScreen().

Closes #1455

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-03 19:49:41 +02:00
1 parent 078bd07d94
commit fa9084fca3
57 files changed
+1650 -8

No files matched your search

@@ -92,6 +92,93 @@ describe('WorkspaceKeyboardShortcutsService', () => {
input.remove();
});
describe('F11 window fullscreen toggle', () => {
const testWindow = window as unknown as {
electron?: Record<string, unknown>;
};
let toggleFullScreenWindow: jest.Mock;
function pressF11(
init: KeyboardEventInit = {},
target: EventTarget = document
): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
key: 'F11',
bubbles: true,
cancelable: true,
...init,
});
target.dispatchEvent(event);
return event;
}
function setHtmlFullscreenElement(element: Element | null): void {
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
value: element,
});
}
beforeEach(() => {
toggleFullScreenWindow = jest.fn().mockResolvedValue({
isMaximized: false,
isFullScreen: true,
});
testWindow.electron = { toggleFullScreenWindow };
setHtmlFullscreenElement(null);
});
afterEach(() => {
delete testWindow.electron;
setHtmlFullscreenElement(null);
});
it('toggles window fullscreen through the bridge and swallows the key', () => {
const event = pressF11();
expect(toggleFullScreenWindow).toHaveBeenCalledTimes(1);
expect(event.defaultPrevented).toBe(true);
expect(dialog.open).not.toHaveBeenCalled();
});
it('works while typing in an input — it is the exit from a fullscreen launch', () => {
const input = document.createElement('input');
document.body.appendChild(input);
pressF11({}, input);
expect(toggleFullScreenWindow).toHaveBeenCalledTimes(1);
input.remove();
});
it('leaves the key alone while the player owns HTML fullscreen', () => {
setHtmlFullscreenElement(document.createElement('div'));
const event = pressF11();
expect(toggleFullScreenWindow).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});
it('ignores modified and auto-repeated F11 presses', () => {
pressF11({ ctrlKey: true });
pressF11({ metaKey: true });
pressF11({ altKey: true });
pressF11({ shiftKey: true });
pressF11({ repeat: true });
expect(toggleFullScreenWindow).not.toHaveBeenCalled();
});
it('leaves F11 to the browser without a bridge', () => {
delete testWindow.electron;
const event = pressF11();
expect(event.defaultPrevented).toBe(false);
});
});
it('does not open duplicate dialogs while one is active', () => {
service.openShortcutsDialog();
service.openShortcutsDialog();
@@ -78,6 +78,10 @@ export class WorkspaceKeyboardShortcutsService {
}
private handleKeydown(event: KeyboardEvent): void {
if (this.handleWindowFullscreenToggle(event)) {
return;
}
if (
isTypingInInput(event) ||
!isKeyboardShortcutHelpTrigger(event) ||
@@ -90,6 +94,41 @@ export class WorkspaceKeyboardShortcutsService {
this.openShortcutsDialog();
}
/**
* F11 toggles OS-level window fullscreen in the desktop app. It is the
* way out of a fullscreen launch on Windows/Linux, where the title bar is
* hidden and the window controls hide themselves while fullscreen, so it
* is deliberately not gated by `isTypingInInput` — F11 is expected to
* work from any focus. While the player owns HTML-element fullscreen
* (`document.fullscreenElement`) its own F / Esc are in charge and F11
* must not yank OS fullscreen out from under it. Without a bridge (PWA)
* the browser keeps its own F11.
*/
private handleWindowFullscreenToggle(event: KeyboardEvent): boolean {
if (
event.key !== 'F11' ||
event.repeat ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.shiftKey
) {
return false;
}
const bridge = window.electron;
if (
typeof bridge?.toggleFullScreenWindow !== 'function' ||
document.fullscreenElement
) {
return false;
}
event.preventDefault();
void bridge.toggleFullScreenWindow().catch(() => undefined);
return true;
}
private getShortcutPlatform(): 'mac' | 'other' {
if (typeof navigator === 'undefined') {
return 'other';