Compare commits
2
Commits
37079304c2
...
901940993c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
901940993c | ||
|
|
970d76a780 |
@@ -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 --------------------------------------------------------------
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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/<os>/`, 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -107,6 +107,7 @@ const api = {
|
||||
},
|
||||
app: {
|
||||
info: invoke('app:info'),
|
||||
platformCapabilities: invoke('platform:capabilities'),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+7
-134
@@ -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 });
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user