diff --git a/README.md b/README.md index b3c9cb0..b4972e4 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Glimpse supports several window style flags that can be combined freely: | `floating` | Always on top of other windows | | `transparent` | Clear window background — HTML body needs `background: transparent` | | `clickThrough` | Window ignores all mouse events | +| `kiosk` | Open borderless on the active display and request shortcut inhibition while the window is open | | `noDock` | No dock icon (macOS) — window works normally but the app doesn't appear in the dock or app switcher | Common combinations: @@ -77,6 +78,25 @@ Common combinations: - **Custom dialog**: `frameless: true` — clean UI with no system chrome - **Overlay**: `frameless + transparent` — shaped widgets that float over content - **Companion widget**: `frameless + transparent + floating + clickThrough` — visual-only overlays that don't interfere with the desktop +- **Kiosk prompt**: `kiosk: true` — fill the active display, keep focus, and ask the OS to route common system shortcuts to the window + +## Kiosk Mode + +`kiosk: true` opens a borderless window on the active display, sizes it to that display, focuses the WebView for normal typing, and asks the operating system or compositor to inhibit common window-switching shortcuts while the window is open. + +```js +const win = open('', { + kiosk: true +}); + +win.on('kiosk', ({ active, reason }) => { + console.log(active, reason); +}); +``` + +Kiosk mode is explicit and best-effort. `active: true` means the backend installed its shortcut-control mechanism; `active: false` means kiosk was disabled, deferred, or unavailable in that backend. macOS uses the screen containing the pointer plus AppKit presentation options. Linux native uses the pointer's monitor, an active keyboard grab on X11, and layer-shell keyboard exclusivity plus GDK shortcut inhibition on Wayland where the compositor supports it. Windows uses the screen containing the pointer plus a low-level keyboard hook for common switch-away shortcuts. The Linux Chromium fallback passes Chromium's `--kiosk` flag and may open fullscreen, but cannot reliably inhibit OS-level shortcuts and reports `active: false`. + +When `kiosk` is set, Glimpse treats it as a screen-owning mode. The Node wrapper ignores `width`, `height`, `x`, `y`, `followCursor`, `followMode`, `cursorAnchor`, and `cursorOffset`, and emits a `GLIMPSE_KIOSK_IGNORED_OPTIONS` warning if any are provided. ## Follow Cursor @@ -106,7 +126,7 @@ const win = open(` The window tracks the cursor in real-time across all screens. `followCursor` implies `floating` — the window stays on top automatically. -**Platform support:** Follow cursor works on macOS and Windows. On Linux with the native backend, it requires Hyprland (via IPC socket). The Chromium CDP backend also supports X11 (via `xdotool`). Other Wayland compositors without the Chromium backend will emit a warning and silently ignore `followCursor`. +**Platform support:** Follow cursor works on macOS and Windows. On Linux with the native backend, it requires Hyprland (via IPC socket). Runtime `win.followCursor(true)` on Linux native also requires the window to have been launched with a layer-shell mode such as `followCursor`, `floating`, `clickThrough`, `kiosk`, or an explicit position. The Chromium CDP backend also supports X11 (via `xdotool`). Other Wayland compositors without the Chromium backend will emit a warning and silently ignore `followCursor`. You can also toggle tracking dynamically after the window is open: @@ -186,19 +206,20 @@ const win = open('...', { | Option | Type | Default | Description | |--------|------|---------|-------------| -| `width` | number | `800` | Window width in pixels | -| `height` | number | `600` | Window height in pixels | +| `width` | number | `800` | Window width in pixels (ignored with `kiosk`) | +| `height` | number | `600` | Window height in pixels (ignored with `kiosk`) | | `title` | string | `"Glimpse"` | Title bar text (ignored when frameless) | -| `x` | number | — | Horizontal screen position (omit to center) | -| `y` | number | — | Vertical screen position (omit to center) | +| `x` | number | — | Horizontal screen position (omit to center; ignored with `kiosk`) | +| `y` | number | — | Vertical screen position (omit to center; ignored with `kiosk`) | | `frameless` | boolean | `false` | Remove the title bar | | `floating` | boolean | `false` | Always on top of other windows | | `transparent` | boolean | `false` | Transparent window background | | `clickThrough` | boolean | `false` | Window ignores all mouse events | -| `followCursor` | boolean | `false` | Track cursor position in real-time | -| `followMode` | string | `"snap"` | Follow animation: `snap` (instant) or `spring` (elastic with overshoot) | -| `cursorAnchor` | string | — | Snap point around cursor: `top-left`, `top-right`, `right`, `bottom-right`, `bottom-left`, `left` | -| `cursorOffset` | `{ x?, y? }` | `{ x: 20, y: -20 }` | Pixel offset from cursor (or fine-tuning on top of `cursorAnchor`) | +| `kiosk` | boolean | `false` | Fill the active display and request system shortcut inhibition | +| `followCursor` | boolean | `false` | Track cursor position in real-time (ignored with `kiosk`) | +| `followMode` | string | `"snap"` | Follow animation: `snap` (instant) or `spring` (elastic with overshoot; ignored with `kiosk`) | +| `cursorAnchor` | string | — | Snap point around cursor: `top-left`, `top-right`, `right`, `bottom-right`, `bottom-left`, `left` (ignored with `kiosk`) | +| `cursorOffset` | `{ x?, y? }` | `{ x: 20, y: -20 }` | Pixel offset from cursor (or fine-tuning on top of `cursorAnchor`; ignored with `kiosk`) | | `openLinks` | boolean | `false` | Open clicked `http`/`https` links in the system browser (macOS only) | | `openLinksApp` | string | — | App bundle path for opening links, e.g. `"/Applications/Firefox.app"` (macOS only) | | `hidden` | boolean | `false` | Start hidden (prewarm mode) — load HTML in the background, reveal with `win.show()` | @@ -285,6 +306,7 @@ if (supportsFollowCursor()) { | `ready` | `info: object` | WebView loaded — includes screen, appearance, and cursor info | | `message` | `data: object` | Message sent from the page via `window.glimpse.send(data)` | | `info` | `info: object` | Fresh system info (response to `.getInfo()`) | +| `kiosk` | `{ active, reason }` | Kiosk shortcut-control state changed or was reported | | `click` | — | Menu bar icon clicked (status item mode only) | | `closed` | — | Window was closed (by user or via `.close()`) | | `error` | `Error` | Process error or malformed protocol line | @@ -321,6 +343,12 @@ win.followCursor(true, 'top-right', 'spring'); // spring physics win.followCursor(false); // detach ``` +**`win.kiosk(enabled?)`** — Enable or disable kiosk shortcut control at runtime. Enabling also applies kiosk geometry where supported; disabling releases shortcut control, but backends may leave the window's current size/style in place. +```js +win.kiosk(true); +win.kiosk(false); +``` + **`win.info`** — Getter for the last-known system info. Available after `ready`. ```js const { width, height } = win.info.screen; @@ -378,6 +406,12 @@ Glimpse uses a newline-delimited JSON (JSON Lines) protocol over stdin/stdout. E {"type":"follow-cursor","enabled":false} ``` +**Kiosk** — Toggle kiosk shortcut control. +```json +{"type":"kiosk","enabled":true} +{"type":"kiosk","enabled":false} +``` + **Load File** — Load a local HTML file by absolute path. ```json {"type":"file","path":"/path/to/page.html"} @@ -431,6 +465,11 @@ Glimpse uses a newline-delimited JSON (JSON Lines) protocol over stdin/stdout. E {"type":"click"} ``` +**Kiosk** — Kiosk shortcut-control state or backend support changed. +```json +{"type":"kiosk","active":true,"reason":"system shortcut inhibition granted"} +``` + **Closed** — Window closed. ```json {"type":"closed"} @@ -459,20 +498,21 @@ npx glimpseui page.html --frameless --transparent | Flag | Default | Description | |------|---------|-------------| -| `--width N` | `800` | Window width in pixels | -| `--height N` | `600` | Window height in pixels | +| `--width N` | `800` | Window width in pixels (ignored with `--kiosk`) | +| `--height N` | `600` | Window height in pixels (ignored with `--kiosk`) | | `--title STR` | `"Glimpse"` | Window title bar text | -| `--x N` | — | Horizontal screen position | -| `--y N` | — | Vertical screen position | +| `--x N` | — | Horizontal screen position (ignored with `--kiosk`) | +| `--y N` | — | Vertical screen position (ignored with `--kiosk`) | | `--frameless` | off | Remove the title bar | | `--floating` | off | Always on top | | `--transparent` | off | Transparent background | | `--click-through` | off | Mouse passes through | -| `--follow-cursor` | off | Track cursor position | -| `--follow-mode MODE` | `snap` | `snap` (instant) or `spring` (elastic) | -| `--cursor-anchor POS` | — | Snap point: `top-left`, `top-right`, `right`, `bottom-right`, `bottom-left`, `left` | -| `--cursor-offset-x N` | `20` | Horizontal cursor offset | -| `--cursor-offset-y N` | `-20` | Vertical cursor offset | +| `--kiosk` | off | Fill the active display and request shortcut inhibition | +| `--follow-cursor` | off | Track cursor position (ignored with `--kiosk`) | +| `--follow-mode MODE` | `snap` | `snap` (instant) or `spring` (elastic; ignored with `--kiosk`) | +| `--cursor-anchor POS` | — | Snap point: `top-left`, `top-right`, `right`, `bottom-right`, `bottom-left`, `left` (ignored with `--kiosk`) | +| `--cursor-offset-x N` | `20` | Horizontal cursor offset (ignored with `--kiosk`) | +| `--cursor-offset-y N` | `-20` | Vertical cursor offset (ignored with `--kiosk`) | | `--open-links` | off | Open `http`/`https` links in system browser (macOS) | | `--open-links-app PATH` | — | Open links in a specific app (macOS) | | `--status-item` | off | Menu bar mode instead of window (macOS) | @@ -572,6 +612,7 @@ The core protocol and Node.js API are identical across platforms. Some features | Window modes (frameless, floating, transparent, click-through) | ✅ | ✅ | ✅ | ✅ | | Follow cursor | ✅ | Hyprland only | Hyprland + X11 | ✅ | | Spring physics (follow mode) | ✅ | ✅ (Hyprland) | ✅ | ✅ | +| Kiosk shortcut inhibition | ✅ | X11 grab; Wayland compositor-dependent | — | Best effort | | Status item (menu bar / tray) | ✅ | — | ✅ | — | | Open links externally | ✅ | — | ✅ | — | | Hidden / prewarm | ✅ | ✅ | ✅ | ✅ | diff --git a/bin/glimpse.mjs b/bin/glimpse.mjs index 68a157e..b4b5e1f 100755 --- a/bin/glimpse.mjs +++ b/bin/glimpse.mjs @@ -17,6 +17,7 @@ for (let i = 0; i < args.length; i++) { else if (arg === '--floating') { flags.floating = true; } else if (arg === '--transparent') { flags.transparent = true; } else if (arg === '--click-through') { flags.clickThrough = true; } + else if (arg === '--kiosk') { flags.kiosk = true; } else if (arg === '--follow-cursor') { flags.followCursor = true; } else if (arg === '--auto-close') { flags.autoClose = true; } else if (arg === '--width' && args[i + 1]) { flags.width = parseInt(args[++i]); } @@ -44,23 +45,24 @@ Usage: glimpseui --demo Show a demo window Options: - --width Window width (default: 800) - --height Window height (default: 600) + --width Window width (ignored with --kiosk) + --height Window height (ignored with --kiosk) --title Window title (default: "Glimpse") --frameless No title bar --floating Always on top --transparent Transparent background --click-through Mouse passes through - --follow-cursor Window follows cursor - --follow-mode Follow mode: snap (default) or spring - --cursor-anchor Snap point: top-left, top-right, right, bottom-right, bottom-left, left - --cursor-offset-x Cursor X offset (default: 20) - --cursor-offset-y Cursor Y offset (default: -20) + --kiosk Request kiosk mode and inhibit system shortcuts + --follow-cursor Window follows cursor (ignored with --kiosk) + --follow-mode Follow mode: snap (default) or spring (ignored with --kiosk) + --cursor-anchor Snap point (ignored with --kiosk) + --cursor-offset-x Cursor X offset (ignored with --kiosk) + --cursor-offset-y Cursor Y offset (ignored with --kiosk) --open-links Open http/https links in default browser --open-links-app Open http/https links in a specific browser app (full path) --auto-close Close after first window.glimpse.send() - --x Window X position - --y Window Y position + --x Window X position (ignored with --kiosk) + --y Window Y position (ignored with --kiosk) --demo Show a demo window --help, -h Show this help `); @@ -125,8 +127,10 @@ async function main() { html = DEMO_HTML; flags.frameless = flags.frameless ?? true; flags.transparent = flags.transparent ?? true; - flags.width = flags.width ?? 380; - flags.height = flags.height ?? 320; + if (!flags.kiosk) { + flags.width = flags.width ?? 380; + flags.height = flags.height ?? 320; + } } else if (positional.length > 0) { // Load from file const file = resolve(positional[0]); diff --git a/native/windows/Glimpse.Windows.csproj b/native/windows/Glimpse.Windows.csproj index c308e27..baafe89 100644 --- a/native/windows/Glimpse.Windows.csproj +++ b/native/windows/Glimpse.Windows.csproj @@ -2,6 +2,7 @@ Exe net8.0-windows + true LatestMajor true enable diff --git a/native/windows/Program.cs b/native/windows/Program.cs index 33d672a..3598aa1 100644 --- a/native/windows/Program.cs +++ b/native/windows/Program.cs @@ -32,14 +32,31 @@ sealed class GlimpseHost : IDisposable private const int SwpNoSize = 0x1; private const int HwndTopmost = -1; private const int HwndNotopmost = -2; + private const int WhKeyboardLl = 13; + private const int WmKeydown = 0x0100; + private const int WmSysKeydown = 0x0104; + private const int LlkhfAltdown = 0x20; + private const int VkTab = 0x09; + private const int VkEscape = 0x1B; + private const int VkSpace = 0x20; + private const int VkShift = 0x10; + private const int VkControl = 0x11; + private const int VkMenu = 0x12; + private const int VkF4 = 0x73; + private const int VkLWin = 0x5B; + private const int VkRWin = 0x5C; private readonly Config _config; private readonly WebView2 _webView; private readonly System.Windows.Forms.Timer _cursorTimer; + private readonly LowLevelKeyboardProc _keyboardProc; private bool _closed; private bool _hidden; private bool _initialized; private bool _followCursorEnabled; + private bool _kioskRequested; + private bool _kioskActive; + private IntPtr _keyboardHook; private string? _cursorAnchor; private int _cursorOffsetX; private int _cursorOffsetY; @@ -56,6 +73,7 @@ public GlimpseHost(Config config) _config = config; _hidden = config.Hidden; _followCursorEnabled = config.FollowCursor; + _kioskRequested = config.Kiosk; _cursorAnchor = config.CursorAnchor; _cursorOffsetX = config.CursorOffsetX; _cursorOffsetY = config.CursorOffsetY; @@ -66,11 +84,11 @@ public GlimpseHost(Config config) Text = config.Title, StartPosition = FormStartPosition.Manual, ClientSize = new Size(config.Width, config.Height), - TopMost = config.Floating || config.FollowCursor, + TopMost = config.Floating || config.FollowCursor || config.Kiosk, ShowInTaskbar = false }; - if (config.Frameless || config.Transparent) + if (config.Frameless || config.Transparent || config.Kiosk) { Form.FormBorderStyle = FormBorderStyle.None; } @@ -82,7 +100,11 @@ public GlimpseHost(Config config) Form.TransparencyKey = Color.Magenta; } - if (config.X is not null && config.Y is not null) + if (config.Kiosk) + { + ApplyKioskWindowBounds(); + } + else if (config.X is not null && config.Y is not null) { Form.Location = new Point(config.X.Value, config.Y.Value); } @@ -111,12 +133,17 @@ public GlimpseHost(Config config) { ActivateVisibleWindow(); } + if (_kioskRequested) + { + SetKioskMode(true); + } }; Form.FormClosing += (_, _) => CloseOnce(); Form.HandleCreated += (_, _) => ApplyExtendedStyles(); _cursorTimer = new System.Windows.Forms.Timer { Interval = 16 }; _cursorTimer.Tick += (_, _) => UpdateFollowCursor(); + _keyboardProc = KeyboardHookCallback; _ = Task.Run(ReadCommandsAsync); } @@ -267,6 +294,10 @@ private void HandleCommand(JsonElement json) ActivateVisibleWindow(); _webView.Focus(); } + if (_kioskRequested) + { + SetKioskMode(true); + } break; case "close": CloseOnce(); @@ -282,7 +313,7 @@ private void HandleCommand(JsonElement json) _followMode = modeNode.GetString() ?? "snap"; InitializeSpringState(); } - Form.TopMost = _followCursorEnabled || _config.Floating; + Form.TopMost = _kioskRequested || _followCursorEnabled || _config.Floating; EnsureTopMost(); if (_followCursorEnabled) { @@ -296,6 +327,10 @@ private void HandleCommand(JsonElement json) PushCursorTip(); } break; + case "kiosk": + _kioskRequested = !json.TryGetProperty("enabled", out var kioskNode) || kioskNode.GetBoolean(); + SetKioskMode(_kioskRequested); + break; } } @@ -464,7 +499,7 @@ private void ShowPassiveWindow() private void EnsureTopMost() { if (!Form.IsHandleCreated) return; - var shouldBeTopMost = _followCursorEnabled || _config.Floating; + var shouldBeTopMost = _kioskRequested || _followCursorEnabled || _config.Floating; var hWndInsertAfter = shouldBeTopMost ? new IntPtr(HwndTopmost) : new IntPtr(HwndNotopmost); SetWindowPos(Form.Handle, hWndInsertAfter, 0, 0, 0, 0, SwpNoMove | SwpNoSize); } @@ -482,13 +517,105 @@ private void ApplyExtendedStyles() { style &= ~WsExTransparent; } - if (_config.Floating || _config.FollowCursor) + if (_config.Floating || _config.FollowCursor || _kioskRequested) { style |= WsExTopmost; } SetWindowLong(Form.Handle, GwlExStyle, style); } + private void SetKioskMode(bool enabled, bool emit = true) + { + _kioskRequested = enabled; + if (enabled) + { + ApplyKioskWindowBounds(); + Form.TopMost = true; + EnsureTopMost(); + ActivateVisibleWindow(); + _webView.Focus(); + InstallKeyboardHook(); + _kioskActive = _keyboardHook != IntPtr.Zero; + if (emit) + { + WriteJson(new + { + type = "kiosk", + active = _kioskActive, + reason = _kioskActive + ? "low-level keyboard hook enabled" + : "failed to install low-level keyboard hook" + }); + } + } + else + { + RemoveKeyboardHook(); + _kioskActive = false; + Form.TopMost = _followCursorEnabled || _config.Floating; + EnsureTopMost(); + if (emit) + { + WriteJson(new { type = "kiosk", active = false, reason = "kiosk mode disabled" }); + } + } + } + + private void ApplyKioskWindowBounds() + { + var screen = Screen.FromPoint(Cursor.Position); + Form.FormBorderStyle = FormBorderStyle.None; + Form.WindowState = FormWindowState.Normal; + Form.Bounds = screen.Bounds; + } + + private void InstallKeyboardHook() + { + if (_keyboardHook != IntPtr.Zero) return; + _keyboardHook = SetWindowsHookEx(WhKeyboardLl, _keyboardProc, GetModuleHandle(null), 0); + } + + private void RemoveKeyboardHook() + { + if (_keyboardHook == IntPtr.Zero) return; + UnhookWindowsHookEx(_keyboardHook); + _keyboardHook = IntPtr.Zero; + } + + private IntPtr KeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam) + { + if (nCode >= 0 && _kioskRequested && (wParam == new IntPtr(WmKeydown) || wParam == new IntPtr(WmSysKeydown))) + { + var data = Marshal.PtrToStructure(lParam); + if (ShouldBlockKioskKey(data)) + { + return new IntPtr(1); + } + } + + return CallNextHookEx(_keyboardHook, nCode, wParam, lParam); + } + + private static bool ShouldBlockKioskKey(KbdLlHookStruct data) + { + var vk = (int)data.VkCode; + var alt = (data.Flags & LlkhfAltdown) != 0 || IsKeyDown(VkMenu); + var ctrl = IsKeyDown(VkControl); + var shift = IsKeyDown(VkShift); + + if (vk == VkLWin || vk == VkRWin) return true; + if (alt && (vk == VkTab || vk == VkEscape || vk == VkF4 || vk == VkSpace)) return true; + if (ctrl && vk == VkEscape) return true; + if (ctrl && shift && vk == VkEscape) return true; + + return false; + } + + private static bool IsKeyDown(int virtualKey) + { + return (GetAsyncKeyState(virtualKey) & unchecked((short)0x8000)) != 0; + } + private static object GetCursor() { var point = Cursor.Position; @@ -585,6 +712,10 @@ private void CloseOnce() { if (_closed) return; _closed = true; + if (_kioskActive || _keyboardHook != IntPtr.Zero) + { + SetKioskMode(false, false); + } WriteJson(new { type = "closed" }); Console.Out.Flush(); _cursorTimer.Stop(); @@ -605,6 +736,7 @@ private void CloseOnce() public void Dispose() { + RemoveKeyboardHook(); _cursorTimer.Dispose(); _webView.Dispose(); Form.Dispose(); @@ -630,6 +762,33 @@ public void Dispose() [DllImport("Shcore.dll")] private static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY); + + private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential)] + private struct KbdLlHookStruct + { + public uint VkCode; + public uint ScanCode; + public uint Flags; + public uint Time; + public IntPtr DwExtraInfo; + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool UnhookWindowsHookEx(IntPtr hhk); + + [DllImport("user32.dll")] + private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern short GetAsyncKeyState(int vKey); + + [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern IntPtr GetModuleHandle(string? lpModuleName); } sealed class Config @@ -641,6 +800,7 @@ sealed class Config public bool Floating { get; init; } public bool Transparent { get; init; } public bool ClickThrough { get; init; } + public bool Kiosk { get; init; } public bool FollowCursor { get; init; } public bool Hidden { get; init; } public bool AutoClose { get; init; } @@ -665,6 +825,7 @@ public static Config Parse(string[] args) case "--floating": case "--transparent": case "--click-through": + case "--kiosk": case "--follow-cursor": case "--hidden": case "--auto-close": @@ -696,6 +857,7 @@ public static Config Parse(string[] args) Floating = flags.Contains("--floating"), Transparent = flags.Contains("--transparent"), ClickThrough = flags.Contains("--click-through"), + Kiosk = flags.Contains("--kiosk"), FollowCursor = flags.Contains("--follow-cursor"), Hidden = flags.Contains("--hidden"), AutoClose = flags.Contains("--auto-close"), diff --git a/package.json b/package.json index 625b07f..6680682 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build:linux": "node scripts/build.mjs linux", "build:windows": "node scripts/build.mjs win32", "postinstall": "node scripts/postinstall.mjs", - "test": "node test/test.mjs && node test/open-links.test.mjs && node test/test-status-item.mjs", + "test": "node test/test.mjs && node test/open-links.test.mjs && node test/kiosk.test.mjs && node test/test-status-item.mjs", "test:platform": "node test/platform.mjs", "publish:check": "./scripts/publish.sh --dry-run", "publish:npm": "./scripts/publish.sh" diff --git a/skills/glimpse/SKILL.md b/skills/glimpse/SKILL.md index 4a5e5c0..b32b143 100644 --- a/skills/glimpse/SKILL.md +++ b/skills/glimpse/SKILL.md @@ -69,6 +69,7 @@ win.close(); // close window floating: true, // always on top transparent: true, // transparent window background clickThrough: true, // mouse passes through window + kiosk: true, // active-display fullscreen plus shortcut inhibition followCursor: true, // window follows mouse cursor followMode: 'spring', // 'snap' (instant, default) or 'spring' (elastic) cursorAnchor: 'top-right', // snap point: top-left, top-right, right, bottom-right, bottom-left, left @@ -495,3 +496,4 @@ Things you can build with Glimpse that you might not have considered (use `win.i - **Use `file:///` for ESM imports** — On Windows, Node.js ESM requires `file:///C:/...` URLs for absolute paths. Bare paths like `C:/...` fail with `ERR_UNSUPPORTED_ESM_URL_SCHEME` - **Use `addEventListener` instead of inline `onclick`** — Inline event handlers (`onclick="..."`) can be unreliable in WebView2. Always use `document.getElementById('x').addEventListener('click', fn)` instead - **Use `floating: true` for sequential prompts** — When opening multiple `prompt()` calls in sequence, subsequent windows may appear behind other windows. Setting `floating: true` ensures they stay on top +- **Use `kiosk: true` sparingly** — It opens borderless on the active display and requests OS/compositor shortcut inhibition while the window is open; check the `kiosk` event and always provide an obvious completion or close path diff --git a/src/chromium-backend.mjs b/src/chromium-backend.mjs index 3a3f5fd..8249fdb 100644 --- a/src/chromium-backend.mjs +++ b/src/chromium-backend.mjs @@ -38,6 +38,7 @@ function parseArgs() { floating: false, transparent: false, clickThrough: false, + kiosk: false, followCursor: false, followMode: 'snap', cursorAnchor: null, @@ -64,6 +65,7 @@ function parseArgs() { case '--floating': config.floating = true; break; case '--transparent': config.transparent = true; break; case '--click-through': config.clickThrough = true; break; + case '--kiosk': config.kiosk = true; break; case '--follow-cursor': config.followCursor = true; break; case '--follow-mode': config.followMode = args[++i] || 'snap'; break; case '--cursor-anchor': config.cursorAnchor = args[++i] || null; break; @@ -568,6 +570,7 @@ async function main() { ]; if (config.transparent) chromeArgs.push('--enable-transparent-visuals'); + if (config.kiosk) chromeArgs.push('--kiosk'); // Initial position if (config.followCursor) { @@ -760,12 +763,26 @@ async function main() { xWindowId = findChromeWindow(cdp.pid, config.width, config.height); if (!xWindowId) { log('Could not find Chrome X11 window for mode application'); + if (config.kiosk) { + emitEvent({ + type: 'kiosk', + active: false, + reason: 'Chromium kiosk window requested, but OS-level shortcut inhibition is not available in this backend', + }); + } return; } if (config.frameless) xSetFrameless(xWindowId); - if (config.floating || config.followCursor) xSetAbove(xWindowId); + if (config.floating || config.followCursor || config.kiosk) xSetAbove(xWindowId); if (config.clickThrough) xSetClickThrough(xWindowId); + if (config.kiosk) { + emitEvent({ + type: 'kiosk', + active: false, + reason: 'Chromium kiosk window requested, but OS-level shortcut inhibition is not available in this backend', + }); + } if (config.hidden) { xWindowMove(xWindowId, -10000, -10000); @@ -1078,6 +1095,19 @@ async function main() { break; } + case 'kiosk': { + config.kiosk = msg.enabled !== false; + if (xWindowId && config.kiosk) xSetAbove(xWindowId); + emitEvent({ + type: 'kiosk', + active: false, + reason: config.kiosk + ? 'Chromium backend cannot inhibit OS-level shortcuts after launch' + : 'kiosk mode disabled', + }); + break; + } + default: log(`Unknown command: ${msg.type}`); } diff --git a/src/glimpse.mjs b/src/glimpse.mjs index 290681a..0a09e5f 100644 --- a/src/glimpse.mjs +++ b/src/glimpse.mjs @@ -67,6 +67,19 @@ export function getNativeHostInfo() { export { getFollowCursorSupport, supportsFollowCursor }; +function kioskIgnoredOptions(options) { + const ignored = []; + if (options.width != null) ignored.push('width'); + if (options.height != null) ignored.push('height'); + if (options.x != null) ignored.push('x'); + if (options.y != null) ignored.push('y'); + if (options.followCursor) ignored.push('followCursor'); + if (options.followMode != null) ignored.push('followMode'); + if (options.cursorAnchor != null) ignored.push('cursorAnchor'); + if (options.cursorOffset?.x != null || options.cursorOffset?.y != null) ignored.push('cursorOffset'); + return ignored; +} + class GlimpseWindow extends EventEmitter { #proc; #closed = false; @@ -113,6 +126,12 @@ class GlimpseWindow extends EventEmitter { case 'click': this.emit('click'); break; + case 'kiosk': + this.emit('kiosk', { + active: Boolean(msg.active), + reason: msg.reason ?? null, + }); + break; case 'closed': if (!this.#closed) { this.#closed = true; @@ -185,6 +204,10 @@ class GlimpseWindow extends EventEmitter { if (mode !== undefined) msg.mode = mode; this.#write(msg); } + + kiosk(enabled = true) { + this.#write({ type: 'kiosk', enabled }); + } } function ensureBinary() { @@ -209,16 +232,27 @@ function ensureBinary() { export function open(html, options = {}) { const host = ensureBinary(); + const kiosk = Boolean(options.kiosk); + + if (kiosk) { + const ignored = kioskIgnoredOptions(options); + if (ignored.length > 0) { + process.emitWarning(`kiosk ignores option(s): ${ignored.join(', ')}`, { + code: 'GLIMPSE_KIOSK_IGNORED_OPTIONS', + }); + } + } const args = []; - if (options.width != null) args.push('--width', String(options.width)); - if (options.height != null) args.push('--height', String(options.height)); + if (!kiosk && options.width != null) args.push('--width', String(options.width)); + if (!kiosk && options.height != null) args.push('--height', String(options.height)); if (options.title != null) args.push('--title', options.title); if (options.frameless) args.push('--frameless'); if (options.floating) args.push('--floating'); if (options.transparent) args.push('--transparent'); if (options.clickThrough) args.push('--click-through'); + if (kiosk) args.push('--kiosk'); if (options.noDock) args.push('--no-dock'); if (options.hidden) args.push('--hidden'); if (options.autoClose) args.push('--auto-close'); @@ -229,20 +263,20 @@ export function open(html, options = {}) { if (options.openLinksApp && supportsOpenLinks) args.push('--open-links-app', options.openLinksApp); // Follow cursor — gated by capability - if (options.followCursor && supportsFollowCursor()) { + if (!kiosk && options.followCursor && supportsFollowCursor()) { args.push('--follow-cursor'); - } else if (options.followCursor) { + } else if (!kiosk && options.followCursor) { const { reason } = getFollowCursorSupport(); process.emitWarning(`followCursor disabled: ${reason}`, { code: 'GLIMPSE_FOLLOW_CURSOR_UNSUPPORTED' }); } - if (options.x != null) args.push(`--x=${options.x}`); - if (options.y != null) args.push(`--y=${options.y}`); + if (!kiosk && options.x != null) args.push(`--x=${options.x}`); + if (!kiosk && options.y != null) args.push(`--y=${options.y}`); - if (options.cursorOffset?.x != null) args.push(`--cursor-offset-x=${options.cursorOffset.x}`); - if (options.cursorOffset?.y != null) args.push(`--cursor-offset-y=${options.cursorOffset.y}`); - if (options.cursorAnchor) args.push('--cursor-anchor', options.cursorAnchor); - if (options.followMode != null) args.push('--follow-mode', options.followMode); + if (!kiosk && options.cursorOffset?.x != null) args.push(`--cursor-offset-x=${options.cursorOffset.x}`); + if (!kiosk && options.cursorOffset?.y != null) args.push(`--cursor-offset-y=${options.cursorOffset.y}`); + if (!kiosk && options.cursorAnchor) args.push('--cursor-anchor', options.cursorAnchor); + if (!kiosk && options.followMode != null) args.push('--follow-mode', options.followMode); const spawnArgs = [...(host.extraArgs || []), ...args]; const proc = spawn(host.path, spawnArgs, { diff --git a/src/glimpse.swift b/src/glimpse.swift index 7944d3c..5b4e51d 100644 --- a/src/glimpse.swift +++ b/src/glimpse.swift @@ -18,12 +18,19 @@ func log(_ message: String) { // MARK: - System Info +func screenContainingMouse() -> NSScreen? { + let mouse = NSEvent.mouseLocation + return NSScreen.screens.first { screen in + screen.frame.contains(mouse) + } ?? NSScreen.main +} + func getSystemInfo() -> [String: Any] { let mouse = NSEvent.mouseLocation - // Main screen + // Active screen var screenInfo: [String: Any] = [:] - if let screen = NSScreen.main { + if let screen = screenContainingMouse() { let f = screen.frame let v = screen.visibleFrame screenInfo = [ @@ -139,6 +146,7 @@ struct Config { var openLinksApp: String? = nil var statusItem: Bool = false var noDock: Bool = false + var kiosk: Bool = false } func parseArgs() -> Config { @@ -178,6 +186,8 @@ func parseArgs() -> Config { if i < args.count, let v = Int(args[i]) { config.cursorOffsetY = v } case "--click-through": config.clickThrough = true + case "--kiosk": + config.kiosk = true case "--hidden": config.hidden = true case "--auto-close": @@ -367,6 +377,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScri var popover: NSPopover? var popoverViewController: StatusItemViewController? + // Kiosk mode state — AppKit presentation options must be restored on exit. + var previousPresentationOptions: NSApplication.PresentationOptions? + var previousActivationPolicy: NSApplication.ActivationPolicy? + var kioskRequested: Bool = false + var kioskActive: Bool = false + nonisolated init(config: Config) { self.config = config } @@ -379,8 +395,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScri hidden = config.hidden cursorAnchor = config.cursorAnchor followMode = config.followMode + kioskRequested = config.kiosk setupWindow() setupWebView() + if kioskRequested && !hidden { + setKioskMode(true) + } if config.followCursor { if followMode == "spring" { // Initialize spring position from the window position set by setupWindow() @@ -432,8 +452,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScri } private func setupWindow() { - let rect = NSRect(x: 0, y: 0, width: config.width, height: config.height) - let styleMask: NSWindow.StyleMask = config.frameless + let rect = config.kiosk + ? (screenContainingMouse()?.frame ?? NSRect(x: 0, y: 0, width: config.width, height: config.height)) + : NSRect(x: 0, y: 0, width: config.width, height: config.height) + let styleMask: NSWindow.StyleMask = (config.frameless || config.kiosk) ? [.borderless] : [.titled, .closable, .miniaturizable, .resizable] window = GlimpsePanel( @@ -443,10 +465,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScri defer: false ) window.title = config.title - if config.frameless { + if config.frameless && !config.kiosk { window.isMovableByWindowBackground = true } - if config.floating || config.followCursor { + if config.kiosk { + window.level = .screenSaver + window.collectionBehavior.formUnion([.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]) + } else if config.floating || config.followCursor { window.level = .floating } if config.clickThrough { @@ -456,7 +481,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScri window.isOpaque = false window.backgroundColor = .clear } - if config.followCursor { + if config.kiosk { + window.setFrame(rect, display: false) + } else if config.followCursor { let mouse = NSEvent.mouseLocation if let anchor = cursorAnchor, let base = anchorPosition(mouse: mouse, windowSize: NSSize(width: config.width, height: config.height), anchor: anchor) { @@ -677,6 +704,92 @@ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScri } } + // MARK: - Kiosk Mode + + func baseActivationPolicy() -> NSApplication.ActivationPolicy { + return (config.statusItem || config.clickThrough || config.hidden || config.noDock) ? .accessory : .regular + } + + func applyKioskWindowFrame() { + guard let screen = screenContainingMouse() else { return } + window.styleMask = [.borderless] + window.setFrame(screen.frame, display: true) + } + + func setKioskMode(_ enabled: Bool, emit: Bool = true) { + kioskRequested = enabled + guard !config.statusItem else { + if emit { + writeToStdout([ + "type": "kiosk", + "active": false, + "reason": "kiosk mode is not supported in status-item mode", + ]) + } + return + } + + if enabled { + if previousPresentationOptions == nil { + previousPresentationOptions = NSApp.presentationOptions + } + if previousActivationPolicy == nil { + previousActivationPolicy = baseActivationPolicy() + } + + NSApp.setActivationPolicy(.regular) + + var options = NSApp.presentationOptions + options.formUnion([ + .hideDock, + .hideMenuBar, + .disableProcessSwitching, + .disableForceQuit, + .disableSessionTermination, + .disableHideApplication, + ]) + NSApp.presentationOptions = options + + applyKioskWindowFrame() + window.level = .screenSaver + window.collectionBehavior.formUnion([.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]) + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(webView) + NSApp.activate(ignoringOtherApps: true) + kioskActive = true + + if emit { + writeToStdout([ + "type": "kiosk", + "active": true, + "reason": "AppKit presentation options enabled", + ]) + } + } else { + if let previousPresentationOptions { + NSApp.presentationOptions = previousPresentationOptions + self.previousPresentationOptions = nil + } + if let previousActivationPolicy { + NSApp.setActivationPolicy(previousActivationPolicy) + self.previousActivationPolicy = nil + } + + kioskActive = false + if !config.floating && !config.followCursor { + window.level = .normal + } + + if emit { + writeToStdout([ + "type": "kiosk", + "active": false, + "reason": "kiosk mode disabled", + ]) + } + } + } + // MARK: - Stdin Reader private func startStdinReader() { @@ -774,6 +887,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScri } else { webView.evaluateJavaScript("window.glimpse.cursorTip = null", completionHandler: nil) } + case "kiosk": + if hidden && (json["enabled"] as? Bool ?? true) { + kioskRequested = true + writeToStdout([ + "type": "kiosk", + "active": false, + "reason": "kiosk mode deferred until window is shown", + ]) + return + } + setKioskMode(json["enabled"] as? Bool ?? true) case "file": guard let path = json["path"] as? String else { log("file command: missing path field") @@ -811,6 +935,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScri window.makeKeyAndOrderFront(nil) window.makeFirstResponder(webView) NSApp.activate(ignoringOtherApps: true) + if kioskRequested || kioskActive { + setKioskMode(true) + } } case "title": guard let title = json["title"] as? String else { @@ -840,6 +967,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScri } func closeAndExit() { + if kioskActive { + setKioskMode(false, emit: false) + } if config.statusItem, let item = nsStatusItem { NSStatusBar.system.removeStatusItem(item) nsStatusItem = nil @@ -929,6 +1059,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScri } func windowWillClose(_ notification: Notification) { + if kioskActive { + setKioskMode(false, emit: false) + } writeToStdout(["type": "closed"]) exit(0) } @@ -940,5 +1073,5 @@ let config = parseArgs() let app = NSApplication.shared let delegate = AppDelegate(config: config) app.delegate = delegate -app.setActivationPolicy((config.statusItem || config.clickThrough || config.hidden || config.noDock) ? .accessory : .regular) +app.setActivationPolicy((config.statusItem || (!config.kiosk && (config.clickThrough || config.hidden || config.noDock))) ? .accessory : .regular) app.run() diff --git a/src/linux/src/main.rs b/src/linux/src/main.rs index 6d356b8..9566356 100644 --- a/src/linux/src/main.rs +++ b/src/linux/src/main.rs @@ -4,6 +4,7 @@ mod hyprland; mod io; mod protocol; mod sysinfo; +mod x11; use std::cell::RefCell; use std::rc::Rc; @@ -13,9 +14,12 @@ use std::time::Duration; use base64::Engine; use clap::Parser; -use glib::prelude::*; +use gdk4::prelude::*; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +use gtk4_layer_shell::{ + is_supported as layer_shell_is_supported, protocol_version as layer_shell_protocol_version, + Edge, KeyboardMode, Layer, LayerShell, +}; use webkit6::prelude::*; use protocol::{InboundMsg, OutboundMsg}; @@ -41,6 +45,8 @@ struct Args { transparent: bool, #[arg(long = "click-through")] click_through: bool, + #[arg(long)] + kiosk: bool, #[arg(long = "follow-cursor")] follow_cursor: bool, #[arg(long = "follow-mode", default_value = "snap")] @@ -60,11 +66,28 @@ struct Args { impl Args { fn effective_offset_x(&self) -> f64 { self.cursor_offset_x - .unwrap_or(if self.cursor_anchor.is_some() { 0.0 } else { 20.0 }) + .unwrap_or(if self.cursor_anchor.is_some() { + 0.0 + } else { + 20.0 + }) } fn effective_offset_y(&self) -> f64 { self.cursor_offset_y - .unwrap_or(if self.cursor_anchor.is_some() { 0.0 } else { -20.0 }) + .unwrap_or(if self.cursor_anchor.is_some() { + 0.0 + } else { + -20.0 + }) + } + + fn needs_layer_shell(&self) -> bool { + self.floating + || self.follow_cursor + || self.click_through + || self.kiosk + || self.x.is_some() + || self.y.is_some() } } @@ -111,27 +134,51 @@ fn main() { fn activate(app: >k4::Application, args: &Rc) { let display = gdk4::Display::default().unwrap(); + let layer_shell_enabled = + display.backend().is_wayland() && args.needs_layer_shell() && layer_shell_is_supported(); + let kiosk_monitor = if args.kiosk { + active_monitor(&display) + } else { + None + }; + let kiosk_bounds = kiosk_monitor.as_ref().map(|monitor| monitor.geometry()); + let initial_width = kiosk_bounds + .as_ref() + .map(|geom| geom.width()) + .unwrap_or(args.width); + let initial_height = kiosk_bounds + .as_ref() + .map(|geom| geom.height()) + .unwrap_or(args.height); let window = gtk4::ApplicationWindow::new(app); - window.init_layer_shell(); - window.set_layer(Layer::Overlay); - window.set_exclusive_zone(-1); + if layer_shell_enabled { + window.init_layer_shell(); + window.set_layer(Layer::Overlay); + window.set_exclusive_zone(-1); + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Left, true); + if args.kiosk { + window.set_anchor(Edge::Right, true); + window.set_anchor(Edge::Bottom, true); + } + } window.set_title(Some(&args.title)); - window.set_default_size(args.width, args.height); - - if args.click_through { - window.set_keyboard_mode(KeyboardMode::None); - } else { - window.set_keyboard_mode(KeyboardMode::OnDemand); + window.set_default_size(initial_width, initial_height); + if args.frameless || args.kiosk { + window.set_decorated(false); } - window.set_anchor(Edge::Top, true); - window.set_anchor(Edge::Left, true); + if layer_shell_enabled { + set_layer_keyboard_mode(&window, args, args.kiosk); + } let offset_x = args.effective_offset_x(); let offset_y = args.effective_offset_y(); - let initial_cursor = hyprland::current_cursor_pos().ok(); - let (init_x, init_y) = if args.follow_cursor { + let initial_cursor = current_cursor_position(&display); + let (init_x, init_y) = if let Some(bounds) = kiosk_bounds.as_ref() { + (bounds.x(), bounds.y()) + } else if args.follow_cursor { if let Some(cursor_pos) = initial_cursor { let (x, y) = cursor::compute_target( cursor_pos.x as f64, @@ -149,7 +196,11 @@ fn activate(app: >k4::Application, args: &Rc) { } else { resolve_initial_position(args, &display) }; - place_window_at_global_position(&window, &display, (init_x as f64, init_y as f64)); + if layer_shell_enabled && args.kiosk { + apply_kiosk_window_mode(&window, &display, layer_shell_enabled); + } else if layer_shell_enabled { + place_window_at_global_position(&window, &display, (init_x as f64, init_y as f64)); + } if args.transparent { let provider = gtk4::CssProvider::new(); @@ -187,24 +238,21 @@ fn activate(app: >k4::Application, args: &Rc) { let auto_close = args.auto_close; let app_for_msg = app.clone(); manager.register_script_message_handler("glimpse", None); - manager.connect_script_message_received( - Some("glimpse"), - move |_manager, value| { - let json_str = value.to_str(); - if let Ok(parsed) = serde_json::from_str::(&json_str) { - if parsed.get("__glimpse_close").and_then(|v| v.as_bool()) == Some(true) { - io::emit(&OutboundMsg::Closed); - app_for_msg.quit(); - return; - } - io::emit(&OutboundMsg::Message { data: parsed }); - if auto_close { - io::emit(&OutboundMsg::Closed); - app_for_msg.quit(); - } + manager.connect_script_message_received(Some("glimpse"), move |_manager, value| { + let json_str = value.to_str(); + if let Ok(parsed) = serde_json::from_str::(&json_str) { + if parsed.get("__glimpse_close").and_then(|v| v.as_bool()) == Some(true) { + io::emit(&OutboundMsg::Closed); + app_for_msg.quit(); + return; } - }, - ); + io::emit(&OutboundMsg::Message { data: parsed }); + if auto_close { + io::emit(&OutboundMsg::Closed); + app_for_msg.quit(); + } + } + }); // ── Ready event on page load ──────────────────────────────────────── let display_for_ready = display.clone(); @@ -214,6 +262,8 @@ fn activate(app: >k4::Application, args: &Rc) { let cursor_anchor = Rc::new(RefCell::new(args.cursor_anchor.clone())); let follow_mode = Rc::new(RefCell::new(args.follow_mode.clone())); let follow_enabled = Arc::new(AtomicBool::new(args.follow_cursor)); + let kiosk_enabled = Rc::new(RefCell::new(args.kiosk)); + let kiosk_signal_installed = Rc::new(RefCell::new(false)); let current_cursor = Rc::new(RefCell::new(initial_cursor)); let current_cursor_tip = Rc::new(RefCell::new(compute_cursor_tip_state( args, @@ -248,7 +298,7 @@ fn activate(app: >k4::Application, args: &Rc) { )))); let spring_animating = Rc::new(RefCell::new(false)); - if linux_follow_cursor_supported() { + if layer_shell_enabled && linux_follow_cursor_supported() { setup_cursor_tracking( &window, &display, @@ -274,6 +324,8 @@ fn activate(app: >k4::Application, args: &Rc) { let cursor_anchor_for_stdin = cursor_anchor.clone(); let follow_mode_for_stdin = follow_mode.clone(); let follow_enabled_for_stdin = follow_enabled.clone(); + let kiosk_enabled_for_stdin = kiosk_enabled.clone(); + let kiosk_signal_installed_for_stdin = kiosk_signal_installed.clone(); let current_cursor_for_stdin = current_cursor.clone(); let current_cursor_tip_for_stdin = current_cursor_tip.clone(); let spring_for_stdin = spring.clone(); @@ -293,6 +345,9 @@ fn activate(app: >k4::Application, args: &Rc) { &cursor_anchor_for_stdin, &follow_mode_for_stdin, &follow_enabled_for_stdin, + &kiosk_enabled_for_stdin, + &kiosk_signal_installed_for_stdin, + layer_shell_enabled, ¤t_cursor_for_stdin, ¤t_cursor_tip_for_stdin, offset_x, @@ -306,6 +361,219 @@ fn activate(app: >k4::Application, args: &Rc) { if !args.hidden { window.present(); + if args.kiosk { + apply_kiosk_window_mode(&window, &display, layer_shell_enabled); + webview.grab_focus(); + set_kiosk_mode( + &window, + args, + true, + layer_shell_enabled, + &kiosk_enabled, + &kiosk_signal_installed, + true, + ); + } else { + x11::set_keep_above(&window, args.floating); + } + } +} + +fn current_cursor_position(display: &gdk4::Display) -> Option { + if display.backend().is_x11() { + return x11::cursor_position(display); + } + + hyprland::current_cursor_pos().ok() +} + +fn active_monitor(display: &gdk4::Display) -> Option { + current_cursor_position(display) + .and_then(|cursor| monitor_at_global_position(display, cursor.x, cursor.y)) + .or_else(|| first_monitor(display)) +} + +fn apply_kiosk_window_mode( + window: >k4::ApplicationWindow, + display: &gdk4::Display, + layer_shell_enabled: bool, +) { + window.set_decorated(false); + + let Some(monitor) = active_monitor(display) else { + window.fullscreen(); + return; + }; + + let geom = monitor.geometry(); + window.set_default_size(geom.width(), geom.height()); + + if layer_shell_enabled { + window.set_monitor(Some(&monitor)); + window.set_anchor(Edge::Top, true); + window.set_anchor(Edge::Left, true); + window.set_anchor(Edge::Right, true); + window.set_anchor(Edge::Bottom, true); + window.set_margin(Edge::Top, 0); + window.set_margin(Edge::Left, 0); + window.set_margin(Edge::Right, 0); + window.set_margin(Edge::Bottom, 0); + } else { + window.fullscreen_on_monitor(&monitor); + } +} + +fn set_layer_keyboard_mode(window: >k4::ApplicationWindow, args: &Args, kiosk_enabled: bool) { + let mode = if kiosk_enabled && layer_shell_exclusive_keyboard_supported() { + KeyboardMode::Exclusive + } else if args.click_through { + KeyboardMode::None + } else { + KeyboardMode::OnDemand + }; + window.set_keyboard_mode(mode); +} + +fn layer_shell_exclusive_keyboard_supported() -> bool { + layer_shell_protocol_version() >= 4 +} + +fn toplevel_for_window(window: >k4::ApplicationWindow) -> Option { + window.surface().and_downcast::() +} + +fn install_kiosk_status_signal( + window: >k4::ApplicationWindow, + kiosk_enabled: &Rc>, + signal_installed: &Rc>, + layer_shell_keyboard_active: bool, +) { + if *signal_installed.borrow() { + return; + } + + let Some(toplevel) = toplevel_for_window(window) else { + return; + }; + + *signal_installed.borrow_mut() = true; + let kiosk_enabled = kiosk_enabled.clone(); + toplevel.connect_shortcuts_inhibited_notify(move |toplevel| { + if !*kiosk_enabled.borrow() { + return; + } + + let gdk_active = toplevel.is_shortcuts_inhibited(); + let active = gdk_active || layer_shell_keyboard_active; + let reason = if gdk_active { + "system shortcut inhibition granted" + } else if layer_shell_keyboard_active { + "layer-shell exclusive keyboard active; GDK shortcut status unavailable" + } else { + "system shortcut inhibition revoked by compositor" + }; + io::emit(&OutboundMsg::Kiosk { + active, + reason: Some(reason.to_string()), + }); + }); +} + +fn set_kiosk_mode( + window: >k4::ApplicationWindow, + args: &Args, + enabled: bool, + layer_shell_enabled: bool, + kiosk_enabled: &Rc>, + kiosk_signal_installed: &Rc>, + emit: bool, +) { + *kiosk_enabled.borrow_mut() = enabled; + + let layer_shell_keyboard_active = + enabled && layer_shell_enabled && layer_shell_exclusive_keyboard_supported(); + + if layer_shell_enabled { + set_layer_keyboard_mode(window, args, enabled); + } + + if enabled { + let display = gtk4::prelude::WidgetExt::display(window); + apply_kiosk_window_mode(window, &display, layer_shell_enabled); + } + + if x11::is_window(window) { + if enabled { + window.present(); + x11::set_keep_above(window, true); + } + + let result = x11::set_keyboard_grab(window, enabled); + let (active, reason) = match result { + Some(Ok(())) if enabled => (true, Some("X11 active keyboard grab enabled".to_string())), + Some(Ok(())) => (false, Some("kiosk mode disabled".to_string())), + Some(Err(reason)) => (false, Some(reason)), + None if enabled => (false, Some("X11 display unavailable".to_string())), + None => (false, Some("kiosk mode disabled".to_string())), + }; + + if !enabled { + x11::set_keep_above(window, args.floating || args.follow_cursor); + } + + if emit { + io::emit(&OutboundMsg::Kiosk { active, reason }); + } + return; + } + + install_kiosk_status_signal( + window, + kiosk_enabled, + kiosk_signal_installed, + layer_shell_keyboard_active, + ); + + let mut active = enabled; + let mut reason = if enabled { + Some("kiosk mode requested".to_string()) + } else { + Some("kiosk mode disabled".to_string()) + }; + + if let Some(toplevel) = toplevel_for_window(window) { + if enabled { + toplevel.inhibit_system_shortcuts(None::<&gdk4::Event>); + active = toplevel.is_shortcuts_inhibited(); + if active { + reason = Some("system shortcut inhibition granted".to_string()); + } else if layer_shell_keyboard_active { + active = true; + reason = Some( + "layer-shell exclusive keyboard active; GDK shortcut status pending" + .to_string(), + ); + } else { + reason = Some( + "system shortcut inhibition requested; waiting for compositor".to_string(), + ); + } + } else { + toplevel.restore_system_shortcuts(); + active = false; + } + } else if enabled && layer_shell_enabled { + reason = Some( + "layer-shell exclusive keyboard requested; compositor shortcut status unavailable" + .to_string(), + ); + } else if enabled { + active = false; + reason = Some("kiosk mode requires a realized window surface".to_string()); + } + + if emit { + io::emit(&OutboundMsg::Kiosk { active, reason }); } } @@ -377,8 +645,8 @@ fn place_window_at_global_position( let global_x = global_pos.0.round() as i32; let global_y = global_pos.1.round() as i32; - if let Some(monitor) = monitor_at_global_position(display, global_x, global_y) - .or_else(|| first_monitor(display)) + if let Some(monitor) = + monitor_at_global_position(display, global_x, global_y).or_else(|| first_monitor(display)) { let geom = monitor.geometry(); window.set_monitor(Some(&monitor)); @@ -458,16 +726,14 @@ fn setup_cursor_tracking( follow_enabled: &Arc, current_cursor: &Rc>>, ) { - let cursor_rx = match hyprland::spawn_cursor_poller( - follow_enabled.clone(), - Duration::from_millis(8), - ) { - Ok(rx) => rx, - Err(err) => { - eprintln!("[glimpse] failed to start Hyprland cursor tracker: {err}"); - return; - } - }; + let cursor_rx = + match hyprland::spawn_cursor_poller(follow_enabled.clone(), Duration::from_millis(8)) { + Ok(rx) => rx, + Err(err) => { + eprintln!("[glimpse] failed to start Hyprland cursor tracker: {err}"); + return; + } + }; let window = content_window.clone(); let display = display.clone(); @@ -534,6 +800,9 @@ fn handle_message( cursor_anchor: &Rc>>, follow_mode: &Rc>, follow_enabled: &Arc, + kiosk_enabled: &Rc>, + kiosk_signal_installed: &Rc>, + layer_shell_enabled: bool, current_cursor: &Rc>>, current_cursor_tip: &Rc>>, offset_x: f64, @@ -563,21 +832,69 @@ fn handle_message( *hidden.borrow_mut() = false; window.set_visible(true); window.present(); + if *kiosk_enabled.borrow() { + apply_kiosk_window_mode(window, display, layer_shell_enabled); + webview.grab_focus(); + set_kiosk_mode( + window, + args, + true, + layer_shell_enabled, + kiosk_enabled, + kiosk_signal_installed, + true, + ); + } else { + x11::set_keep_above(window, args.floating); + } } InboundMsg::Close => { + if *kiosk_enabled.borrow() { + set_kiosk_mode( + window, + args, + false, + layer_shell_enabled, + kiosk_enabled, + kiosk_signal_installed, + false, + ); + } io::emit(&OutboundMsg::Closed); std::process::exit(0); } InboundMsg::GetInfo => { - let live_cursor = hyprland::current_cursor_pos() - .ok() - .or(*current_cursor.borrow()); + let live_cursor = current_cursor_position(display).or(*current_cursor.borrow()); if let Some(cursor_pos) = live_cursor { *current_cursor.borrow_mut() = Some(cursor_pos); } let info = sysinfo::collect(display, live_cursor, *current_cursor_tip.borrow()); io::emit(&OutboundMsg::Info { info }); } + InboundMsg::Kiosk { enabled } => { + if enabled && *hidden.borrow() { + *kiosk_enabled.borrow_mut() = true; + io::emit(&OutboundMsg::Kiosk { + active: false, + reason: Some("kiosk mode deferred until window is shown".to_string()), + }); + return; + } + + if enabled { + apply_kiosk_window_mode(window, display, layer_shell_enabled); + webview.grab_focus(); + } + set_kiosk_mode( + window, + args, + enabled, + layer_shell_enabled, + kiosk_enabled, + kiosk_signal_installed, + true, + ); + } InboundMsg::FollowCursor { enabled, anchor, @@ -601,6 +918,15 @@ fn handle_message( } } + if enabled && !layer_shell_enabled { + follow_enabled.store(false, Ordering::Relaxed); + *spring_animating.borrow_mut() = false; + eprintln!( + "[glimpse] follow-cursor requires launch-time followCursor/floating/positioning on Linux native" + ); + return; + } + follow_enabled.store(enabled, Ordering::Relaxed); let tip = compute_cursor_tip_state( diff --git a/src/linux/src/protocol.rs b/src/linux/src/protocol.rs index d984661..11470dd 100644 --- a/src/linux/src/protocol.rs +++ b/src/linux/src/protocol.rs @@ -5,12 +5,24 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "kebab-case")] pub enum InboundMsg { - Html { html: String }, - Eval { js: String }, - File { path: String }, - Show { title: Option }, + Html { + html: String, + }, + Eval { + js: String, + }, + File { + path: String, + }, + Show { + title: Option, + }, Close, GetInfo, + Kiosk { + #[serde(default = "default_true")] + enabled: bool, + }, FollowCursor { #[serde(default = "default_true")] enabled: bool, @@ -39,6 +51,11 @@ pub enum OutboundMsg { Message { data: serde_json::Value, }, + Kiosk { + active: bool, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + }, Closed, } diff --git a/src/linux/src/sysinfo.rs b/src/linux/src/sysinfo.rs index 4e9cf7d..cfbf074 100644 --- a/src/linux/src/sysinfo.rs +++ b/src/linux/src/sysinfo.rs @@ -20,7 +20,20 @@ pub fn collect( } } - let primary = if let Some(first) = screens.first() { + let active = cursor_pos + .and_then(|cursor| { + screens.iter().find(|screen| { + let x = screen.x.unwrap_or(0); + let y = screen.y.unwrap_or(0); + cursor.x >= x + && cursor.x < x + screen.width + && cursor.y >= y + && cursor.y < y + screen.height + }) + }) + .or_else(|| screens.first()); + + let primary = if let Some(first) = active { ScreenInfo { x: None, y: None, diff --git a/src/linux/src/x11.rs b/src/linux/src/x11.rs new file mode 100644 index 0000000..3ed0df3 --- /dev/null +++ b/src/linux/src/x11.rs @@ -0,0 +1,266 @@ +use std::ffi::{c_char, c_int, c_long, c_uint, c_ulong, c_void, CString}; +use std::mem::ManuallyDrop; + +use gdk4::prelude::*; +use glib::translate::ToGlibPtr; +use gtk4::prelude::NativeExt; + +use crate::protocol; + +#[repr(C)] +struct XDisplay(c_void); + +#[repr(C)] +union XClientMessageData { + l: [c_long; 5], +} + +#[repr(C)] +struct XClientMessageEvent { + type_: c_int, + serial: c_ulong, + send_event: c_int, + display: *mut XDisplay, + window: c_ulong, + message_type: c_ulong, + format: c_int, + data: XClientMessageData, +} + +#[repr(C)] +union XEvent { + type_: c_int, + client_message: ManuallyDrop, +} + +#[link(name = "gtk-4")] +unsafe extern "C" { + fn gdk_x11_display_get_xdisplay(display: *mut c_void) -> *mut XDisplay; + fn gdk_x11_surface_get_xid(surface: *mut c_void) -> c_ulong; +} + +#[link(name = "X11")] +unsafe extern "C" { + fn XDefaultRootWindow(display: *mut XDisplay) -> c_ulong; + fn XFlush(display: *mut XDisplay) -> c_int; + fn XGrabKeyboard( + display: *mut XDisplay, + grab_window: c_ulong, + owner_events: c_int, + pointer_mode: c_int, + keyboard_mode: c_int, + time: c_ulong, + ) -> c_int; + fn XInternAtom( + display: *mut XDisplay, + atom_name: *const c_char, + only_if_exists: c_int, + ) -> c_ulong; + fn XQueryPointer( + display: *mut XDisplay, + window: c_ulong, + root_return: *mut c_ulong, + child_return: *mut c_ulong, + root_x_return: *mut c_int, + root_y_return: *mut c_int, + win_x_return: *mut c_int, + win_y_return: *mut c_int, + mask_return: *mut c_uint, + ) -> c_int; + fn XSendEvent( + display: *mut XDisplay, + window: c_ulong, + propagate: c_int, + event_mask: c_long, + event_send: *mut XEvent, + ) -> c_int; + fn XUngrabKeyboard(display: *mut XDisplay, time: c_ulong) -> c_int; +} + +const X_CURRENT_TIME: c_ulong = 0; +const X_GRAB_MODE_ASYNC: c_int = 1; +const X_GRAB_SUCCESS: c_int = 0; +const X_CLIENT_MESSAGE: c_int = 33; +const X_NET_WM_STATE_REMOVE: c_long = 0; +const X_NET_WM_STATE_ADD: c_long = 1; +const X_SUBSTRUCTURE_NOTIFY_MASK: c_long = 1 << 19; +const X_SUBSTRUCTURE_REDIRECT_MASK: c_long = 1 << 20; + +fn display_for_display(display: &gdk4::Display) -> Option<*mut XDisplay> { + if !display.backend().is_x11() { + return None; + } + + let display_ptr: *mut gdk4::ffi::GdkDisplay = display.to_glib_none().0; + let xdisplay = unsafe { gdk_x11_display_get_xdisplay(display_ptr.cast::()) }; + if xdisplay.is_null() { + None + } else { + Some(xdisplay) + } +} + +fn display_for_window(window: >k4::ApplicationWindow) -> Option<*mut XDisplay> { + let display = gtk4::prelude::WidgetExt::display(window); + display_for_display(&display) +} + +fn window_id(window: >k4::ApplicationWindow) -> Option { + let surface = window.surface()?; + let surface_ptr: *mut gdk4::ffi::GdkSurface = surface.to_glib_none().0; + let xid = unsafe { gdk_x11_surface_get_xid(surface_ptr.cast::()) }; + if xid == 0 { + None + } else { + Some(xid) + } +} + +fn atom(display: *mut XDisplay, name: &str) -> Option { + let name = CString::new(name).ok()?; + let atom = unsafe { XInternAtom(display, name.as_ptr(), 0) }; + if atom == 0 { + None + } else { + Some(atom) + } +} + +pub fn is_window(window: >k4::ApplicationWindow) -> bool { + display_for_window(window).is_some() +} + +pub fn set_keep_above(window: >k4::ApplicationWindow, enabled: bool) -> bool { + let Some(display) = display_for_window(window) else { + return false; + }; + let Some(xid) = window_id(window) else { + return false; + }; + let Some(state_atom) = atom(display, "_NET_WM_STATE") else { + return false; + }; + let Some(above_atom) = atom(display, "_NET_WM_STATE_ABOVE") else { + return false; + }; + + let action = if enabled { + X_NET_WM_STATE_ADD + } else { + X_NET_WM_STATE_REMOVE + }; + let mut event = XEvent { + client_message: ManuallyDrop::new(XClientMessageEvent { + type_: X_CLIENT_MESSAGE, + serial: 0, + send_event: 1, + display, + window: xid, + message_type: state_atom, + format: 32, + data: XClientMessageData { + l: [action, above_atom as c_long, 0, 1, 0], + }, + }), + }; + + let root = unsafe { XDefaultRootWindow(display) }; + let status = unsafe { + XSendEvent( + display, + root, + 0, + X_SUBSTRUCTURE_REDIRECT_MASK | X_SUBSTRUCTURE_NOTIFY_MASK, + &mut event, + ) + }; + unsafe { + XFlush(display); + } + + status != 0 +} + +pub fn set_keyboard_grab( + window: >k4::ApplicationWindow, + enabled: bool, +) -> Option> { + let display = display_for_window(window)?; + + if !enabled { + unsafe { + XUngrabKeyboard(display, X_CURRENT_TIME); + XFlush(display); + } + return Some(Ok(())); + } + + let Some(xid) = window_id(window) else { + return Some(Err( + "X11 keyboard grab requires a realized window".to_string() + )); + }; + + let status = unsafe { + XGrabKeyboard( + display, + xid, + 1, + X_GRAB_MODE_ASYNC, + X_GRAB_MODE_ASYNC, + X_CURRENT_TIME, + ) + }; + unsafe { + XFlush(display); + } + + if status == X_GRAB_SUCCESS { + Some(Ok(())) + } else { + Some(Err(grab_status_reason(status))) + } +} + +pub fn cursor_position(display: &gdk4::Display) -> Option { + let xdisplay = display_for_display(display)?; + let root = unsafe { XDefaultRootWindow(xdisplay) }; + let mut root_return = 0; + let mut child_return = 0; + let mut root_x = 0; + let mut root_y = 0; + let mut win_x = 0; + let mut win_y = 0; + let mut mask = 0; + let ok = unsafe { + XQueryPointer( + xdisplay, + root, + &mut root_return, + &mut child_return, + &mut root_x, + &mut root_y, + &mut win_x, + &mut win_y, + &mut mask, + ) + }; + if ok == 0 { + None + } else { + Some(protocol::CursorPos { + x: root_x, + y: root_y, + }) + } +} + +fn grab_status_reason(status: c_int) -> String { + match status { + 1 => "X11 keyboard grab failed: already grabbed".to_string(), + 2 => "X11 keyboard grab failed: invalid timestamp".to_string(), + 3 => "X11 keyboard grab failed: window is not viewable yet".to_string(), + 4 => "X11 keyboard grab failed: keyboard is frozen".to_string(), + other => format!("X11 keyboard grab failed with status {other}"), + } +} diff --git a/test/kiosk.test.mjs b/test/kiosk.test.mjs new file mode 100644 index 0000000..220d682 --- /dev/null +++ b/test/kiosk.test.mjs @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const TIMEOUT_MS = 10_000; + +const tmpDir = mkdtempSync(join(tmpdir(), 'glimpse-kiosk-')); +const argsPath = join(tmpDir, 'args.json'); +const commandsPath = join(tmpDir, 'commands.jsonl'); +const mockBinary = join(tmpDir, 'glimpse-mock'); +const warnings = []; +const originalEmitWarning = process.emitWarning; + +process.emitWarning = (warning, options) => { + const detail = warning instanceof Error ? warning : new Error(String(warning)); + if (options && typeof options === 'object') { + if (options.code != null) detail.code = options.code; + if (options.type != null) detail.name = options.type; + } + warnings.push(detail); +}; + +console.log('glimpse kiosk protocol regression test'); + +function pass(msg) { + console.log(` ✓ ${msg}`); +} + +function waitFor(emitter, event, timeoutMs = TIMEOUT_MS) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timeout waiting for '${event}' after ${timeoutMs}ms`)); + }, timeoutMs); + + emitter.once(event, (...args) => { + clearTimeout(timer); + resolve(args); + }); + + emitter.once('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +function writeMockBinary() { + const protocolReady = JSON.stringify({ + type: 'ready', + screen: { width: 800, height: 600, scaleFactor: 1, visibleX: 0, visibleY: 0, visibleWidth: 800, visibleHeight: 600 }, + screens: [], + appearance: { darkMode: false, accentColor: '#000000', reduceMotion: false, increaseContrast: false }, + cursor: { x: 0, y: 0 }, + }); + const scriptLines = [ + '#!/usr/bin/env node', + "const fs = require('node:fs');", + "const readline = require('node:readline');", + "const argsPath = process.env.GLIMPSE_KIOSK_ARGS;", + "const commandsPath = process.env.GLIMPSE_KIOSK_COMMANDS;", + "const args = process.argv.slice(2);", + "if (argsPath) fs.writeFileSync(argsPath, JSON.stringify(args));", + `process.stdout.write(${JSON.stringify(protocolReady)} + '\\n');`, + 'let sentFinalReady = false;', + 'const emit = (msg) => process.stdout.write(JSON.stringify(msg) + "\\n");', + 'const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });', + 'rl.on("line", (line) => {', + ' if (commandsPath) fs.appendFileSync(commandsPath, line + "\\n");', + ' let msg;', + ' try { msg = JSON.parse(line); } catch { return; }', + ' if (msg?.type === "html" && !sentFinalReady) {', + ` process.stdout.write(${JSON.stringify(protocolReady)} + '\\n');`, + ' sentFinalReady = true;', + ' if (args.includes("--kiosk")) emit({ type: "kiosk", active: true, reason: "mock kiosk active" });', + ' }', + ' if (msg?.type === "kiosk") {', + ' const active = msg.enabled !== false;', + ' emit({ type: "kiosk", active, reason: active ? "mock kiosk active" : "kiosk mode disabled" });', + ' }', + ' if (msg?.type === "close") {', + ' emit({ type: "closed" });', + ' process.exit(0);', + ' }', + '});', + ]; + + writeFileSync(mockBinary, scriptLines.join('\n')); + chmodSync(mockBinary, 0o755); +} + +try { + writeMockBinary(); + process.env.GLIMPSE_BINARY_PATH = mockBinary; + process.env.GLIMPSE_KIOSK_ARGS = argsPath; + process.env.GLIMPSE_KIOSK_COMMANDS = commandsPath; + + const { open } = await import('../src/glimpse.mjs'); + const win = open('kiosk', { + kiosk: true, + width: 320, + height: 200, + x: 10, + y: 20, + followCursor: true, + followMode: 'spring', + cursorAnchor: 'top-left', + cursorOffset: { x: 1, y: 2 }, + }); + const ready = waitFor(win, 'ready'); + const initialKiosk = waitFor(win, 'kiosk'); + + await ready; + const [initial] = await initialKiosk; + assert.deepEqual(initial, { active: true, reason: 'mock kiosk active' }); + pass('emits kiosk state from host protocol'); + + const args = JSON.parse(readFileSync(argsPath, 'utf8')); + assert.ok(args.includes('--kiosk')); + assert.ok(!args.includes('--width')); + assert.ok(!args.includes('--height')); + assert.ok(!args.some((arg) => arg.startsWith('--x='))); + assert.ok(!args.some((arg) => arg.startsWith('--y='))); + assert.ok(!args.includes('--follow-cursor')); + assert.ok(!args.includes('--follow-mode')); + assert.ok(!args.includes('--cursor-anchor')); + assert.ok(!args.some((arg) => arg.startsWith('--cursor-offset-'))); + assert.ok(warnings.some((warning) => warning.code === 'GLIMPSE_KIOSK_IGNORED_OPTIONS')); + pass('maps open({ kiosk: true }) to --kiosk and ignores window geometry'); + + const disabledEvent = waitFor(win, 'kiosk'); + win.kiosk(false); + const [disabled] = await disabledEvent; + assert.deepEqual(disabled, { active: false, reason: 'kiosk mode disabled' }); + + const commands = readFileSync(commandsPath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + assert.ok(commands.some((msg) => msg.type === 'kiosk' && msg.enabled === false)); + pass('sends runtime kiosk disable command'); + + win.close(); + await waitFor(win, 'closed'); + + console.log('\nkiosk protocol test passed'); +} finally { + process.emitWarning = originalEmitWarning; + rmSync(tmpDir, { recursive: true, force: true }); +}