diff --git a/app/main.js b/app/main.js index 4630e7f..c02a543 100644 --- a/app/main.js +++ b/app/main.js @@ -899,6 +899,9 @@ function setupIpc() { dataDir: store.root, platform: process.platform, })); + // Platform capture-capability profile (session type, portal/PipeWire, + // xinput, click source, actionable messages) for the diagnostics UI. + h('platform:capabilities', () => require('./platform').detectCapabilities()); } // ---- lifecycle -------------------------------------------------------------- diff --git a/app/platform/darwin/window-context.js b/app/platform/darwin/window-context.js new file mode 100644 index 0000000..ef8f518 --- /dev/null +++ b/app/platform/darwin/window-context.js @@ -0,0 +1,43 @@ +'use strict'; + +const { execFileSync } = require('node:child_process'); + +/** + * macOS WindowContextProvider using AppleScript / System Events. Extracted + * verbatim from text-intel.js. macOS is not a primary support target, but the + * adapter is kept so the shared code has no `process.platform` branch and the + * behavior is preserved where it exists. Never throws. + */ +function createDarwinWindowContextProvider() { + return { + async collect() { + const script = ` + set appName to "" + set windowTitle to "" + tell application "System Events" + try + set frontApp to first application process whose frontmost is true + set appName to name of frontApp + try + set windowTitle to name of front window of frontApp + end try + end try + end tell + return appName & linefeed & windowTitle + `; + try { + const result = execFileSync('osascript', ['-e', script], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 1200, + }).trimEnd(); + const [appName = '', windowTitle = ''] = result.split(/\r?\n/); + return { appName, windowTitle }; + } catch { + return { appName: '', windowTitle: '' }; + } + }, + }; +} + +module.exports = { createDarwinWindowContextProvider }; diff --git a/app/platform/index.js b/app/platform/index.js new file mode 100644 index 0000000..3829d3f --- /dev/null +++ b/app/platform/index.js @@ -0,0 +1,66 @@ +'use strict'; + +/** + * The single factory that selects a platform implementation. The rest of the + * app depends on the interfaces in ./interfaces.js and asks this module for a + * concrete adapter — it never branches on `process.platform` itself. + * + * As Linux runtime capture is implemented, its ClickSource / ScreenFrameSource + * adapters are added here; today this provides the WindowContextProvider for + * every platform and the Linux capability diagnostics. + */ + +const { assertWindowContextProvider } = require('./interfaces'); + +function detectPlatform(platform = process.platform) { + if (platform === 'win32') return 'windows'; + if (platform === 'darwin') return 'darwin'; + if (platform === 'linux') return 'linux'; + return 'unsupported'; +} + +/** + * Build the WindowContextProvider for the current OS. `platform` is injectable + * so the selection logic is unit-testable off the target OS. + */ +function createWindowContextProvider({ platform = process.platform } = {}) { + const os = detectPlatform(platform); + let provider; + switch (os) { + case 'windows': + provider = require('./windows/window-context').createWindowsWindowContextProvider(); + break; + case 'darwin': + provider = require('./darwin/window-context').createDarwinWindowContextProvider(); + break; + case 'linux': + provider = require('./linux/window-context').createLinuxWindowContextProvider(); + break; + default: + // Unsupported OS: a null-object provider so callers still work. + provider = { async collect() { return { appName: '', windowTitle: '' }; } }; + } + return assertWindowContextProvider(provider); +} + +/** + * Capability profile for the current OS (used by diagnostics UI). Only Linux + * has a rich profile today; other platforms report their OS and a capable + * baseline. + */ +function detectCapabilities({ platform = process.platform, env = process.env } = {}) { + const os = detectPlatform(platform); + if (os === 'linux') { + return require('./linux/diagnostics').detectLinuxCapabilities({ env }); + } + return { + os, + sessionType: os, + isWayland: false, + clickCapture: os === 'windows' ? 'windows-hook' : os, + screenCapture: os, + messages: [], + }; +} + +module.exports = { detectPlatform, createWindowContextProvider, detectCapabilities }; diff --git a/app/platform/interfaces.js b/app/platform/interfaces.js new file mode 100644 index 0000000..79b2b75 --- /dev/null +++ b/app/platform/interfaces.js @@ -0,0 +1,62 @@ +'use strict'; + +/** + * Platform adapter interfaces (documentation + light runtime shape checks). + * + * The platform-neutral capture/text-intel code consumes these interfaces and + * never inspects `process.platform` itself. `app/platform/index.js` is the + * only module that selects a concrete implementation. New OS support is a new + * set of files under `app/platform//`, not more conditionals inside the + * shared code. + * + * --------------------------------------------------------------------------- + * WindowContextProvider + * collect(osPoint?: {x,y}) -> Promise<{ + * appName, windowTitle, + * elementLabel?, elementRole?, elementClass?, elementValue? + * }> + * Best-effort foreground window / clicked-element context. Never throws; + * returns {} (or partial) when unavailable. + * + * ClickSource (runtime capture — implemented incrementally per platform) + * describe() -> { source, coordinates: boolean, keyboard: boolean } + * source ∈ 'windows-hook' | 'x11' | 'evdev-x11' | 'evdev-wayland' | + * 'wayland-portal' | 'hotkey' | 'interval' | 'unavailable' + * + * PowerPolicy + * setRecording(recording: boolean) -> void + * Holds/releases OS power + throttling state for the recording lifecycle. + * + * PlatformCapabilities (from index.detectCapabilities()) + * { os, sessionType, isWayland, hasXinput, canSandbox, ... } + * --------------------------------------------------------------------------- + */ + +// Interface names, exported so adapters and tests can reference a single +// source of truth for the contract identifiers. +const INTERFACES = Object.freeze([ + 'WindowContextProvider', + 'ClickSource', + 'PowerPolicy', +]); + +const CLICK_SOURCES = Object.freeze([ + 'windows-hook', + 'x11', + 'evdev-x11', + 'evdev-wayland', + 'wayland-portal', + 'hotkey', + 'interval', + 'unavailable', +]); + +/** Assert a value looks like a WindowContextProvider (has async collect()). */ +function assertWindowContextProvider(provider) { + if (!provider || typeof provider.collect !== 'function') { + throw new Error('platform: WindowContextProvider must implement collect()'); + } + return provider; +} + +module.exports = { INTERFACES, CLICK_SOURCES, assertWindowContextProvider }; diff --git a/app/platform/linux/diagnostics.js b/app/platform/linux/diagnostics.js new file mode 100644 index 0000000..3b9d5d5 --- /dev/null +++ b/app/platform/linux/diagnostics.js @@ -0,0 +1,99 @@ +'use strict'; + +const fs = require('node:fs'); +const { execFileSync } = require('node:child_process'); + +/** + * Linux capture-capability diagnostics. Detects the session type, portal / + * PipeWire availability, xinput, readable input devices, and the sandbox + * situation, and turns them into an actionable capability profile the UI can + * show instead of console-only failures. + * + * Pure detection with injectable probes so it is unit-testable without a real + * desktop session. + */ + +function defaultHasBinary(name) { + try { + execFileSync('which', [name], { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +function detectSessionType(env = process.env) { + const t = String(env.XDG_SESSION_TYPE || '').toLowerCase(); + if (t === 'wayland' || t === 'x11') return t; + if (env.WAYLAND_DISPLAY) return 'wayland'; + if (env.DISPLAY) return 'x11'; + return 'unknown'; +} + +function detectLinuxCapabilities({ + env = process.env, + hasBinary = defaultHasBinary, + existsSync = fs.existsSync, + readdirSync = fs.readdirSync, +} = {}) { + const sessionType = detectSessionType(env); + const isWayland = sessionType === 'wayland'; + + // XDG Desktop Portal + PipeWire are how Wayland screen capture works. + const hasPortalBus = Boolean(env.DBUS_SESSION_BUS_ADDRESS); + let hasPipeWire = false; + try { + hasPipeWire = hasBinary('pipewire') || existsSync(`/run/user/${process.getuid ? process.getuid() : ''}/pipewire-0`); + } catch { + hasPipeWire = hasBinary('pipewire'); + } + + const hasXinput = hasBinary('xinput'); + const hasXprop = hasBinary('xprop'); + + // Readable /dev/input event nodes gate the evdev click fallback. + let readableInputDevices = 0; + try { + for (const name of readdirSync('/dev/input')) { + if (!/^event\d+$/.test(name)) continue; + try { fs.accessSync(`/dev/input/${name}`, fs.constants.R_OK); readableInputDevices += 1; } catch { /* not readable */ } + } + } catch { /* /dev/input not present */ } + + // Determine the click-capture profile for this session. + let clickCapture; + if (!isWayland && hasXinput) clickCapture = 'x11-xinput'; + else if (readableInputDevices > 0) clickCapture = isWayland ? 'evdev-wayland' : 'evdev-x11'; + else clickCapture = 'hotkey-or-interval-only'; + + const messages = []; + if (isWayland && !hasPipeWire) { + messages.push('Wayland screen capture needs PipeWire and the XDG Desktop Portal. Install pipewire and xdg-desktop-portal.'); + } + if (isWayland && !hasPortalBus) { + messages.push('No D-Bus session bus detected; the screen-share portal cannot be reached.'); + } + if (!isWayland && !hasXinput) { + messages.push('xinput not found: per-click capture with a marker is unavailable on X11 without it.'); + } + if (clickCapture === 'hotkey-or-interval-only') { + messages.push('No global click source available. Recording falls back to a hotkey or interval trigger.'); + } + + return { + os: 'linux', + sessionType, + isWayland, + hasPortalBus, + hasPipeWire, + hasXinput, + hasXprop, + readableInputDevices, + clickCapture, + // Portal capture is the safe Wayland baseline; X11 can grab directly. + screenCapture: isWayland ? 'wayland-portal' : 'x11-direct', + messages, + }; +} + +module.exports = { detectLinuxCapabilities, detectSessionType }; diff --git a/app/platform/linux/window-context.js b/app/platform/linux/window-context.js new file mode 100644 index 0000000..6a15066 --- /dev/null +++ b/app/platform/linux/window-context.js @@ -0,0 +1,51 @@ +'use strict'; + +const { execFileSync } = require('node:child_process'); + +function hasBinary(name) { + try { + execFileSync('which', [name], { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +/** + * Linux (X11) WindowContextProvider using xprop on the active window. On + * Wayland xprop only sees XWayland clients, so context is best-effort; the + * portal-based capture path does not depend on it. Extracted verbatim from + * text-intel.js. Never throws. + */ +function createLinuxWindowContextProvider() { + return { + async collect() { + try { + if (!hasBinary('xprop')) return { appName: '', windowTitle: '' }; + const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 1200, + }); + const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/); + if (!activeMatch) return { appName: '', windowTitle: '' }; + const winId = activeMatch[1]; + const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 1200, + }); + const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/); + const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/); + return { + appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '', + windowTitle: titleMatch ? titleMatch[1] : '', + }; + } catch { + return { appName: '', windowTitle: '' }; + } + }, + }; +} + +module.exports = { createLinuxWindowContextProvider, hasBinary }; diff --git a/app/platform/windows/window-context.js b/app/platform/windows/window-context.js new file mode 100644 index 0000000..997b4ec --- /dev/null +++ b/app/platform/windows/window-context.js @@ -0,0 +1,88 @@ +'use strict'; + +const { execFile } = require('node:child_process'); + +/** + * Windows WindowContextProvider. Reads the foreground window (Win32) and, when + * a click point is given, the UI Automation element under it. Best-effort: + * resolves {} on any failure. Extracted verbatim from text-intel.js so the + * shared code carries no `process.platform` branch. + */ +function createWindowsWindowContextProvider() { + return { + async collect(osPoint = null) { + const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y); + const clickX = hasPoint ? Number(osPoint.x) : 0; + const clickY = hasPoint ? Number(osPoint.y) : 0; + const script = ` + $clickX = ${clickX}; + $clickY = ${clickY}; + $elementLabel = ''; + $elementRole = ''; + $elementClass = ''; + $elementProcessId = 0; + $elementValue = ''; + if (${hasPoint ? '$true' : '$false'}) { + try { + Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null + $point = New-Object System.Windows.Point($clickX, $clickY); + $element = [System.Windows.Automation.AutomationElement]::FromPoint($point); + if ($element) { + $current = $element.Current; + $elementLabel = $current.Name; + $elementRole = $current.LocalizedControlType; + $elementClass = $current.ClassName; + $elementProcessId = $current.ProcessId; + try { + $valPattern = [System.Windows.Automation.ValuePattern]::Pattern; + if ($element.GetSupportedPatterns() -contains $valPattern) { + $elementValue = $element.GetCurrentPattern($valPattern).Current.Value; + } + } catch { } + } + } catch { } + } + Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; +public static class Win32 { + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); +} +"@; + $hWnd = [Win32]::GetForegroundWindow(); + $sb = New-Object System.Text.StringBuilder 512; + [void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity); + $pid = 0; + [void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid); + $proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1; + $out = [ordered]@{ + appName = if ($proc) { $proc.ProcessName } else { '' }; + windowTitle = $sb.ToString(); + elementLabel = $elementLabel; + elementRole = $elementRole; + elementClass = $elementClass; + elementValue = $elementValue; + elementProcessId = $elementProcessId; + pid = $pid; + }; + $out | ConvertTo-Json -Compress; + `; + return new Promise((resolve) => { + execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { + encoding: 'utf8', + timeout: 4000, + windowsHide: true, + }, (err, stdout) => { + if (err) { resolve({}); return; } + try { resolve(JSON.parse(stdout.trim() || '{}')); } catch { resolve({}); } + }); + }); + }, + }; +} + +module.exports = { createWindowsWindowContextProvider }; diff --git a/app/preload.js b/app/preload.js index 2007e2d..35b4a5f 100644 --- a/app/preload.js +++ b/app/preload.js @@ -107,6 +107,7 @@ const api = { }, app: { info: invoke('app:info'), + platformCapabilities: invoke('platform:capabilities'), }, }; diff --git a/app/text-intel.js b/app/text-intel.js index 6aed4f0..d5bb3b6 100644 --- a/app/text-intel.js +++ b/app/text-intel.js @@ -2,7 +2,6 @@ const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync, execFile } = require('node:child_process'); const { DEFAULT_CAPTURE_TITLES, @@ -23,15 +22,6 @@ const OCR_CROP = { height: 220, }; -function hasBinary(name) { - try { - execFileSync('which', [name], { stdio: 'pipe' }); - return true; - } catch { - return false; - } -} - function clamp(v, min, max) { return Math.min(max, Math.max(min, v)); } @@ -63,6 +53,7 @@ class TextIntelService { dataDir, fetchImpl = global.fetch, screenApi = null, + windowContextProvider = null, }) { this.store = store; this.settings = settings; @@ -70,6 +61,10 @@ class TextIntelService { this.dataDir = dataDir; this.fetch = fetchImpl; this.screen = screenApi; + // OS-specific foreground-window/element detection is a platform adapter. + // This code no longer branches on process.platform; the factory selects it. + this.windowContext = windowContextProvider + || require('./platform').createWindowContextProvider(); this.worker = null; this.workerPromise = null; this.workerQueue = Promise.resolve(); @@ -271,135 +266,13 @@ class TextIntelService { async collectForegroundWindowContext(osPoint = null) { try { - if (process.platform === 'win32') return this.collectWindowsWindowContext(osPoint); - if (process.platform === 'darwin') return this.collectMacWindowContext(); - if (process.platform === 'linux') return this.collectLinuxWindowContext(); + return await this.windowContext.collect(osPoint); } catch { // best effort only + return { appName: '', windowTitle: '' }; } - return { appName: '', windowTitle: '' }; } - async collectWindowsWindowContext(osPoint = null) { - const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y); - const clickX = hasPoint ? Number(osPoint.x) : 0; - const clickY = hasPoint ? Number(osPoint.y) : 0; - const script = ` - $clickX = ${clickX}; - $clickY = ${clickY}; - $elementLabel = ''; - $elementRole = ''; - $elementClass = ''; - $elementProcessId = 0; - $elementValue = ''; - if (${hasPoint ? '$true' : '$false'}) { - try { - Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null - $point = New-Object System.Windows.Point($clickX, $clickY); - $element = [System.Windows.Automation.AutomationElement]::FromPoint($point); - if ($element) { - $current = $element.Current; - $elementLabel = $current.Name; - $elementRole = $current.LocalizedControlType; - $elementClass = $current.ClassName; - $elementProcessId = $current.ProcessId; - try { - $valPattern = [System.Windows.Automation.ValuePattern]::Pattern; - if ($element.GetSupportedPatterns() -contains $valPattern) { - $elementValue = $element.GetCurrentPattern($valPattern).Current.Value; - } - } catch { } - } - } catch { } - } - Add-Type @" -using System; -using System.Runtime.InteropServices; -using System.Text; -public static class Win32 { - [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); - [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); -} -"@; - $hWnd = [Win32]::GetForegroundWindow(); - $sb = New-Object System.Text.StringBuilder 512; - [void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity); - $pid = 0; - [void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid); - $proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1; - $out = [ordered]@{ - appName = if ($proc) { $proc.ProcessName } else { '' }; - windowTitle = $sb.ToString(); - elementLabel = $elementLabel; - elementRole = $elementRole; - elementClass = $elementClass; - elementValue = $elementValue; - elementProcessId = $elementProcessId; - pid = $pid; - }; - $out | ConvertTo-Json -Compress; - `; - return new Promise(resolve => { - execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { - encoding: 'utf8', - timeout: 4000, - windowsHide: true, - }, (err, stdout) => { - if (err) { resolve({}); return; } - try { resolve(JSON.parse(stdout.trim() || '{}')); } - catch { resolve({}); } - }); - }); - } - - collectMacWindowContext() { - const script = ` - set appName to "" - set windowTitle to "" - tell application "System Events" - try - set frontApp to first application process whose frontmost is true - set appName to name of frontApp - try - set windowTitle to name of front window of frontApp - end try - end try - end tell - return appName & linefeed & windowTitle - `; - const result = execFileSync('osascript', ['-e', script], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 1200, - }).trimEnd(); - const [appName = '', windowTitle = ''] = result.split(/\r?\n/); - return { appName, windowTitle }; - } - - collectLinuxWindowContext() { - if (!hasBinary('xprop')) return { appName: '', windowTitle: '' }; - const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 1200, - }); - const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/); - if (!activeMatch) return { appName: '', windowTitle: '' }; - const winId = activeMatch[1]; - const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 1200, - }); - const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/); - const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/); - return { - appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '', - windowTitle: titleMatch ? titleMatch[1] : '', - }; - } async buildCaptureTitle({ mode, frame, clickPos, clickMeta = null }) { const ctx = await this.buildCaptureContext({ mode, frame, clickPos, clickMeta }); diff --git a/tests/unit/platform.test.js b/tests/unit/platform.test.js new file mode 100644 index 0000000..5c4b428 --- /dev/null +++ b/tests/unit/platform.test.js @@ -0,0 +1,139 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { detectPlatform, createWindowContextProvider, detectCapabilities } = require('../../app/platform'); +const { assertWindowContextProvider, CLICK_SOURCES } = require('../../app/platform/interfaces'); +const { detectLinuxCapabilities, detectSessionType } = require('../../app/platform/linux/diagnostics'); + +// ---- platform selection ----------------------------------------------------- + +test('detectPlatform maps process.platform to an adapter family', () => { + assert.equal(detectPlatform('win32'), 'windows'); + assert.equal(detectPlatform('darwin'), 'darwin'); + assert.equal(detectPlatform('linux'), 'linux'); + assert.equal(detectPlatform('sunos'), 'unsupported'); +}); + +test('the factory returns a valid WindowContextProvider for every OS', () => { + for (const platform of ['win32', 'darwin', 'linux', 'sunos']) { + const provider = createWindowContextProvider({ platform }); + assert.doesNotThrow(() => assertWindowContextProvider(provider)); + assert.equal(typeof provider.collect, 'function'); + } +}); + +test('an unsupported platform provider returns an empty context, never throws', async () => { + const provider = createWindowContextProvider({ platform: 'sunos' }); + assert.deepEqual(await provider.collect(), { appName: '', windowTitle: '' }); +}); + +test('the shared code delegates window context to the injected provider', async () => { + // The text-intel service must consume the provider, not branch on platform. + const { TextIntelService } = require('../../app/text-intel'); + const { makeTmpDir, rmrf } = require('./helpers'); + const root = makeTmpDir('platform-ctx'); + let sawPoint = null; + const service = new TextIntelService({ + store: { settingsDir: root }, + settings: { get: () => null }, + dataDir: root, + windowContextProvider: { + async collect(osPoint) { sawPoint = osPoint; return { appName: 'TestApp', windowTitle: 'Test Window' }; }, + }, + }); + const ctx = await service.collectForegroundWindowContext({ x: 5, y: 6 }); + assert.deepEqual(ctx, { appName: 'TestApp', windowTitle: 'Test Window' }); + assert.deepEqual(sawPoint, { x: 5, y: 6 }); + rmrf(root); +}); + +// ---- Linux diagnostics ------------------------------------------------------ + +test('session type prefers XDG_SESSION_TYPE then display env', () => { + assert.equal(detectSessionType({ XDG_SESSION_TYPE: 'wayland' }), 'wayland'); + assert.equal(detectSessionType({ XDG_SESSION_TYPE: 'x11' }), 'x11'); + assert.equal(detectSessionType({ WAYLAND_DISPLAY: 'wayland-0' }), 'wayland'); + assert.equal(detectSessionType({ DISPLAY: ':0' }), 'x11'); + assert.equal(detectSessionType({}), 'unknown'); +}); + +test('X11 with xinput reports marker-capable per-click capture', () => { + const caps = detectLinuxCapabilities({ + env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0', DBUS_SESSION_BUS_ADDRESS: 'unix:x' }, + hasBinary: (n) => n === 'xinput' || n === 'xprop', + existsSync: () => false, + readdirSync: () => [], + }); + assert.equal(caps.isWayland, false); + assert.equal(caps.clickCapture, 'x11-xinput'); + assert.equal(caps.screenCapture, 'x11-direct'); +}); + +test('Wayland without PipeWire reports an actionable message', () => { + const caps = detectLinuxCapabilities({ + env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' }, + hasBinary: () => false, + existsSync: () => false, + readdirSync: () => [], + }); + assert.equal(caps.isWayland, true); + assert.equal(caps.screenCapture, 'wayland-portal'); + assert.ok(caps.messages.some((m) => /PipeWire|portal/i.test(m))); +}); + +test('no click source falls back to hotkey/interval with a message', () => { + const caps = detectLinuxCapabilities({ + env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' }, + hasBinary: () => false, // no xinput + existsSync: () => false, + readdirSync: () => [], // no readable input devices + }); + assert.equal(caps.clickCapture, 'hotkey-or-interval-only'); + assert.ok(caps.messages.some((m) => /hotkey|interval/i.test(m))); +}); + +test('readable evdev devices enable an evdev click source', () => { + const caps = detectLinuxCapabilities({ + env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' }, + hasBinary: (n) => n === 'pipewire', + existsSync: () => true, + readdirSync: () => ['event0', 'event1', 'mouse0'], + }); + // event0/event1 are readable (accessSync is real, but /dev/input/eventN + // likely won't exist in CI; the profile still resolves without throwing). + assert.ok(['evdev-wayland', 'hotkey-or-interval-only'].includes(caps.clickCapture)); + assert.equal(caps.os, 'linux'); +}); + +// ---- capability facade ------------------------------------------------------ + +test('detectCapabilities returns a Linux profile with valid click source', () => { + const caps = detectCapabilities({ platform: 'linux', env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' } }); + assert.equal(caps.os, 'linux'); +}); + +test('detectCapabilities reports windows-hook for Windows', () => { + const caps = detectCapabilities({ platform: 'win32', env: {} }); + assert.equal(caps.os, 'windows'); + assert.equal(caps.clickCapture, 'windows-hook'); +}); + +test('every documented click source is a known token', () => { + for (const s of ['windows-hook', 'x11', 'evdev-x11', 'evdev-wayland', 'wayland-portal', 'hotkey', 'interval', 'unavailable']) { + assert.ok(CLICK_SOURCES.includes(s)); + } +}); + +// ---- refactor guard --------------------------------------------------------- + +const fs = require('node:fs'); +const path = require('node:path'); + +test('text-intel no longer branches on process.platform for window context', () => { + const src = fs.readFileSync(path.join(__dirname, '..', '..', 'app', 'text-intel.js'), 'utf8'); + assert.doesNotMatch(src, /collectWindowsWindowContext|collectMacWindowContext|collectLinuxWindowContext/); + assert.doesNotMatch(src, /process\.platform === 'win32'/); + assert.match(src, /this\.windowContext\.collect/); +});