mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 01:55:50 -07:00
fix(overlay): handle X11 display scaling across monitors (#193)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed X11/XWayland overlays being oversized and offset from mpv under fractional or mixed-monitor display scaling.
|
||||||
@@ -405,8 +405,9 @@ On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and othe
|
|||||||
|
|
||||||
SubMiner handles this automatically:
|
SubMiner handles this automatically:
|
||||||
|
|
||||||
- It launches its own window under XWayland (it sets `--ozone-platform-hint=x11`).
|
- It launches its own window under XWayland (it sets `--ozone-platform=x11`).
|
||||||
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too - Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11vk,x11egl,x11`) is applied. Only the window context is overridden; your `vo`/`gpu-api` and user shaders are left alone.
|
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too - Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11vk,x11egl,x11`) is applied. Only the window context is overridden; your `vo`/`gpu-api` and user shaders are left alone.
|
||||||
|
- Fractional and mixed-monitor display scaling is handled per screen when SubMiner maps XWayland mpv coordinates to the overlay.
|
||||||
- While mpv is windowed, the overlay is a managed X11 window owned by the tracked mpv window (`WM_TRANSIENT_FOR`), so it stays above mpv while other foreground X11/Xwayland apps can still cover both windows.
|
- While mpv is windowed, the overlay is a managed X11 window owned by the tracked mpv window (`WM_TRANSIENT_FOR`), so it stays above mpv while other foreground X11/Xwayland apps can still cover both windows.
|
||||||
- While tracked mpv is fullscreen, SubMiner swaps the visible overlay to a focusable-false X11 override-redirect window. That path can stay above the active fullscreen mpv window without requiring a KDE/KWin-specific rule, and SubMiner hides/releases it when mpv is no longer the active X11/Xwayland window.
|
- While tracked mpv is fullscreen, SubMiner swaps the visible overlay to a focusable-false X11 override-redirect window. That path can stay above the active fullscreen mpv window without requiring a KDE/KWin-specific rule, and SubMiner hides/releases it when mpv is no longer the active X11/Xwayland window.
|
||||||
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
|
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { shouldForceX11ElectronBackend } from './electron-backend';
|
import { resolveX11ElectronRelaunchArgs, shouldForceX11ElectronBackend } from './electron-backend';
|
||||||
|
|
||||||
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
|
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
|
||||||
const original = Object.getOwnPropertyDescriptor(process, 'platform');
|
const original = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||||
@@ -32,3 +32,70 @@ test('shouldForceX11ElectronBackend is false off Linux', () => {
|
|||||||
assert.equal(shouldForceX11ElectronBackend({}), false);
|
assert.equal(shouldForceX11ElectronBackend({}), false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveX11ElectronRelaunchArgs adds the raw X11 Ozone argument on unsupported Linux', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveX11ElectronRelaunchArgs(
|
||||||
|
['--start'],
|
||||||
|
{
|
||||||
|
DISPLAY: ':1',
|
||||||
|
WAYLAND_DISPLAY: 'wayland-0',
|
||||||
|
XDG_CURRENT_DESKTOP: 'KDE',
|
||||||
|
},
|
||||||
|
'linux',
|
||||||
|
),
|
||||||
|
['--start', '--ozone-platform=x11'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveX11ElectronRelaunchArgs avoids loops and preserves native Wayland backends', () => {
|
||||||
|
const kdeWayland = {
|
||||||
|
DISPLAY: ':1',
|
||||||
|
WAYLAND_DISPLAY: 'wayland-0',
|
||||||
|
XDG_CURRENT_DESKTOP: 'KDE',
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
resolveX11ElectronRelaunchArgs(['--start', '--ozone-platform=x11'], kdeWayland, 'linux'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveX11ElectronRelaunchArgs(
|
||||||
|
['--start'],
|
||||||
|
{ ...kdeWayland, HYPRLAND_INSTANCE_SIGNATURE: 'hypr' },
|
||||||
|
'linux',
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(resolveX11ElectronRelaunchArgs(['--start'], kdeWayland, 'darwin'), null);
|
||||||
|
assert.equal(
|
||||||
|
resolveX11ElectronRelaunchArgs(
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
...kdeWayland,
|
||||||
|
SUBMINER_APP_ARGC: '1',
|
||||||
|
SUBMINER_APP_ARG_0: '--start',
|
||||||
|
},
|
||||||
|
'linux',
|
||||||
|
)?.at(-1),
|
||||||
|
'--ozone-platform=x11',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveX11ElectronRelaunchArgs([], { ...kdeWayland, SUBMINER_X11_BOOTSTRAPPED: '1' }, 'linux'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveX11ElectronRelaunchArgs replaces an explicit unsupported Wayland argument', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveX11ElectronRelaunchArgs(
|
||||||
|
['--start', '--ozone-platform', 'wayland'],
|
||||||
|
{
|
||||||
|
DISPLAY: ':1',
|
||||||
|
WAYLAND_DISPLAY: 'wayland-0',
|
||||||
|
XDG_CURRENT_DESKTOP: 'KDE',
|
||||||
|
},
|
||||||
|
'linux',
|
||||||
|
),
|
||||||
|
['--start', '--ozone-platform=x11'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { isSupportedWaylandCompositor } from '../../shared/mpv-x11-backend';
|
|||||||
|
|
||||||
const logger = createLogger('core:electron-backend');
|
const logger = createLogger('core:electron-backend');
|
||||||
|
|
||||||
|
export const X11_ELECTRON_BOOTSTRAP_ENV = 'SUBMINER_X11_BOOTSTRAPPED';
|
||||||
|
const X11_ELECTRON_OZONE_ARG = '--ozone-platform=x11';
|
||||||
|
|
||||||
function getElectronOzonePlatformHint(env: NodeJS.ProcessEnv = process.env): string | null {
|
function getElectronOzonePlatformHint(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||||
const hint = env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
|
const hint = env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
|
||||||
if (hint) return hint;
|
if (hint) return hint;
|
||||||
@@ -24,11 +27,42 @@ function getElectronOzonePlatformHint(env: NodeJS.ProcessEnv = process.env): str
|
|||||||
* Electron Wayland backend is unsupported); the Hyprland/Sway case is left untouched so
|
* Electron Wayland backend is unsupported); the Hyprland/Sway case is left untouched so
|
||||||
* {@link enforceUnsupportedWaylandMode} can report it.
|
* {@link enforceUnsupportedWaylandMode} can report it.
|
||||||
*/
|
*/
|
||||||
export function shouldForceX11ElectronBackend(env: NodeJS.ProcessEnv = process.env): boolean {
|
export function shouldForceX11ElectronBackend(
|
||||||
if (process.platform !== 'linux') return false;
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): boolean {
|
||||||
|
if (platform !== 'linux') return false;
|
||||||
return !isSupportedWaylandCompositor(env);
|
return !isSupportedWaylandCompositor(env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveX11ElectronRelaunchArgs(
|
||||||
|
args: string[],
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): string[] | null {
|
||||||
|
if (!shouldForceX11ElectronBackend(env, platform)) return null;
|
||||||
|
if (env[X11_ELECTRON_BOOTSTRAP_ENV] === '1') return null;
|
||||||
|
|
||||||
|
const retainedArgs: string[] = [];
|
||||||
|
let alreadyForced = false;
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const arg = args[index];
|
||||||
|
if (arg === '--ozone-platform') {
|
||||||
|
const value = args[index + 1];
|
||||||
|
alreadyForced = value?.trim().toLowerCase() === 'x11';
|
||||||
|
if (value && !value.startsWith('--')) index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg?.startsWith('--ozone-platform=')) {
|
||||||
|
alreadyForced = arg.slice('--ozone-platform='.length).trim().toLowerCase() === 'x11';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg) retainedArgs.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return alreadyForced ? null : [...retainedArgs, X11_ELECTRON_OZONE_ARG];
|
||||||
|
}
|
||||||
|
|
||||||
export function forceX11Backend(args: CliArgs): void {
|
export function forceX11Backend(args: CliArgs): void {
|
||||||
if (!shouldStartApp(args)) return;
|
if (!shouldStartApp(args)) return;
|
||||||
if (!shouldForceX11ElectronBackend()) return;
|
if (!shouldForceX11ElectronBackend()) return;
|
||||||
|
|||||||
@@ -25,8 +25,24 @@ import {
|
|||||||
applyBackgroundBootstrapCommandLineSwitches,
|
applyBackgroundBootstrapCommandLineSwitches,
|
||||||
applyEarlyLinuxCommandLineSwitches,
|
applyEarlyLinuxCommandLineSwitches,
|
||||||
resolveLinuxPasswordStoreValue,
|
resolveLinuxPasswordStoreValue,
|
||||||
|
spawnDetachedApp,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
|
|
||||||
|
test('detached app launch policy stays in the startup runtime utilities', () => {
|
||||||
|
const entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8');
|
||||||
|
const runtimeSource = fs.readFileSync(
|
||||||
|
path.join(process.cwd(), 'src/main-entry-runtime.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(typeof spawnDetachedApp, 'function');
|
||||||
|
assert.doesNotMatch(entrySource, /function spawnDetachedApp/);
|
||||||
|
assert.match(
|
||||||
|
runtimeSource,
|
||||||
|
/child\.once\('error', \(error\) => \{\s*console\.error\([^;]*error\);\s*\}\);\s*child\.unref\(\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('background bootstrap exits through Electron so Chromium children shut down', () => {
|
test('background bootstrap exits through Electron so Chromium children shut down', () => {
|
||||||
const exitCodes: number[] = [];
|
const exitCodes: number[] = [];
|
||||||
exitBackgroundBootstrap({ exit: (code) => exitCodes.push(code) });
|
exitBackgroundBootstrap({ exit: (code) => exitCodes.push(code) });
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
||||||
import { resolveConfigDir } from './config/path-resolution';
|
import { resolveConfigDir } from './config/path-resolution';
|
||||||
|
import { resolveAppImageMountKeepaliveInvocation } from './main/appimage-mount-keepalive';
|
||||||
|
|
||||||
const BACKGROUND_ARG = '--background';
|
const BACKGROUND_ARG = '--background';
|
||||||
const START_ARG = '--start';
|
const START_ARG = '--start';
|
||||||
@@ -265,6 +267,25 @@ export function exitBackgroundBootstrap(app: BackgroundBootstrapAppLike): void {
|
|||||||
app.exit(0);
|
app.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function spawnDetachedApp(childArgs: string[], env: NodeJS.ProcessEnv): void {
|
||||||
|
const keepalive = resolveAppImageMountKeepaliveInvocation(env);
|
||||||
|
const child = keepalive
|
||||||
|
? spawn(keepalive.command, [...keepalive.args, ...childArgs], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
env,
|
||||||
|
})
|
||||||
|
: spawn(process.execPath, childArgs, {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
child.once('error', (error) => {
|
||||||
|
console.error('Failed to spawn detached SubMiner app:', error);
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
||||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||||
const args = parseCliArgs(argv);
|
const args = parseCliArgs(argv);
|
||||||
|
|||||||
+19
-16
@@ -1,5 +1,4 @@
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { app, dialog, shell } from 'electron';
|
import { app, dialog, shell } from 'electron';
|
||||||
import { printHelp } from './cli/help';
|
import { printHelp } from './cli/help';
|
||||||
import {
|
import {
|
||||||
@@ -20,9 +19,9 @@ import {
|
|||||||
shouldHandleHelpOnlyAtEntry,
|
shouldHandleHelpOnlyAtEntry,
|
||||||
shouldHandleLaunchMpvAtEntry,
|
shouldHandleLaunchMpvAtEntry,
|
||||||
shouldHandleStatsDaemonCommandAtEntry,
|
shouldHandleStatsDaemonCommandAtEntry,
|
||||||
|
spawnDetachedApp,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||||
import { resolveAppImageMountKeepaliveInvocation } from './main/appimage-mount-keepalive';
|
|
||||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||||
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
|
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +43,10 @@ import {
|
|||||||
resolveDefaultLogFilePath,
|
resolveDefaultLogFilePath,
|
||||||
type LogRotation,
|
type LogRotation,
|
||||||
} from './shared/log-files';
|
} from './shared/log-files';
|
||||||
|
import {
|
||||||
|
resolveX11ElectronRelaunchArgs,
|
||||||
|
X11_ELECTRON_BOOTSTRAP_ENV,
|
||||||
|
} from './core/utils/electron-backend';
|
||||||
|
|
||||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||||
|
|
||||||
@@ -296,26 +299,26 @@ async function runEntryProcess(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
|
||||||
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
|
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
|
||||||
const keepalive = resolveAppImageMountKeepaliveInvocation(process.env);
|
const x11ChildArgs = resolveX11ElectronRelaunchArgs(childArgs, process.env);
|
||||||
const child = keepalive
|
|
||||||
? spawn(keepalive.command, [...keepalive.args, ...childArgs], {
|
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||||
detached: true,
|
const childEnv = sanitizeBackgroundEnv(process.env);
|
||||||
stdio: 'ignore',
|
if (x11ChildArgs) childEnv[X11_ELECTRON_BOOTSTRAP_ENV] = '1';
|
||||||
env: sanitizeBackgroundEnv(process.env),
|
spawnDetachedApp(x11ChildArgs ?? childArgs, childEnv);
|
||||||
})
|
|
||||||
: spawn(process.execPath, childArgs, {
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore',
|
|
||||||
env: sanitizeBackgroundEnv(process.env),
|
|
||||||
});
|
|
||||||
child.unref();
|
|
||||||
// Let Electron stop bootstrap Chromium children before its AppImage mount is released.
|
// Let Electron stop bootstrap Chromium children before its AppImage mount is released.
|
||||||
exitBackgroundBootstrap(app);
|
exitBackgroundBootstrap(app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (x11ChildArgs) {
|
||||||
|
const childEnv = sanitizeStartupEnv(process.env);
|
||||||
|
childEnv[X11_ELECTRON_BOOTSTRAP_ENV] = '1';
|
||||||
|
spawnDetachedApp(x11ChildArgs, childEnv);
|
||||||
|
exitBackgroundBootstrap(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
startMainProcess();
|
startMainProcess();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-10
@@ -256,7 +256,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
enforceUnsupportedWaylandMode,
|
enforceUnsupportedWaylandMode,
|
||||||
forceX11Backend,
|
forceX11Backend,
|
||||||
shouldForceX11ElectronBackend,
|
|
||||||
generateDefaultConfigFile,
|
generateDefaultConfigFile,
|
||||||
resolveConfiguredShortcuts,
|
resolveConfiguredShortcuts,
|
||||||
resolveKeybindings,
|
resolveKeybindings,
|
||||||
@@ -597,15 +596,6 @@ if (process.platform === 'linux') {
|
|||||||
);
|
);
|
||||||
app.commandLine.appendSwitch('password-store', passwordStore);
|
app.commandLine.appendSwitch('password-store', passwordStore);
|
||||||
createLogger('main').debug(`Applied --password-store ${passwordStore}`);
|
createLogger('main').debug(`Applied --password-store ${passwordStore}`);
|
||||||
// Pin the overlay to XWayland on unsupported Wayland sessions (everything except
|
|
||||||
// Hyprland/Sway). `setAlwaysOnTop`/`moveTop` are no-ops under a native Wayland surface,
|
|
||||||
// so the overlay can only stay above mpv under X11/XWayland. The command-line switch is
|
|
||||||
// applied at module load (before app init) so it reliably wins over the late env-var
|
|
||||||
// fallback in forceX11Backend().
|
|
||||||
if (shouldForceX11ElectronBackend(process.env)) {
|
|
||||||
app.commandLine.appendSwitch('ozone-platform-hint', 'x11');
|
|
||||||
createLogger('main').debug('Forced ozone-platform-hint=x11 for XWayland overlay stacking');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.setName('SubMiner');
|
app.setName('SubMiner');
|
||||||
|
|||||||
@@ -47,6 +47,29 @@ WINDOW=44040194
|
|||||||
assert.deepEqual(reader.getCursorScreenPoint({ x: 877, y: 718 }), { x: 1700, y: 1050 });
|
assert.deepEqual(reader.getCursorScreenPoint({ x: 877, y: 718 }), { x: 1700, y: 1050 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createLinuxX11CursorPointReader converts physical X11 coordinates to Electron DIP', async () => {
|
||||||
|
const convertedPoints: Array<{ x: number; y: number }> = [];
|
||||||
|
const reader = createLinuxX11CursorPointReader({
|
||||||
|
env: { DISPLAY: ':1' },
|
||||||
|
platform: 'linux',
|
||||||
|
runCommand: async () => `X=1424
|
||||||
|
Y=697
|
||||||
|
SCREEN=0
|
||||||
|
WINDOW=44040194
|
||||||
|
`,
|
||||||
|
screenToDipPoint: (point) => {
|
||||||
|
convertedPoints.push(point);
|
||||||
|
return { x: 1139, y: 557 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
reader.refresh();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.deepEqual(reader.getCursorScreenPoint({ x: 0, y: 0 }), { x: 1139, y: 557 });
|
||||||
|
assert.deepEqual(convertedPoints, [{ x: 1424, y: 697 }]);
|
||||||
|
});
|
||||||
|
|
||||||
test('createLinuxX11CursorPointReader does not spawn off X11 Linux', () => {
|
test('createLinuxX11CursorPointReader does not spawn off X11 Linux', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const reader = createLinuxX11CursorPointReader({
|
const reader = createLinuxX11CursorPointReader({
|
||||||
|
|||||||
@@ -36,11 +36,13 @@ export function createLinuxX11CursorPointReader(options?: {
|
|||||||
now?: () => number;
|
now?: () => number;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
runCommand?: CommandRunner;
|
runCommand?: CommandRunner;
|
||||||
|
screenToDipPoint?: (point: PointerPoint) => PointerPoint;
|
||||||
}) {
|
}) {
|
||||||
const env = options?.env ?? process.env;
|
const env = options?.env ?? process.env;
|
||||||
const now = options?.now ?? (() => Date.now());
|
const now = options?.now ?? (() => Date.now());
|
||||||
const platform = options?.platform ?? process.platform;
|
const platform = options?.platform ?? process.platform;
|
||||||
const runCommand = options?.runCommand ?? execFileUtf8;
|
const runCommand = options?.runCommand ?? execFileUtf8;
|
||||||
|
const screenToDipPoint = options?.screenToDipPoint ?? ((point: PointerPoint) => point);
|
||||||
let latest: { point: PointerPoint; updatedAtMs: number } | null = null;
|
let latest: { point: PointerPoint; updatedAtMs: number } | null = null;
|
||||||
let inFlight = false;
|
let inFlight = false;
|
||||||
let retryAfterMs = 0;
|
let retryAfterMs = 0;
|
||||||
@@ -63,7 +65,7 @@ export function createLinuxX11CursorPointReader(options?: {
|
|||||||
retryAfterMs = now() + COMMAND_FAILURE_RETRY_DELAY_MS;
|
retryAfterMs = now() + COMMAND_FAILURE_RETRY_DELAY_MS;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
latest = { point, updatedAtMs: now() };
|
latest = { point: screenToDipPoint(point), updatedAtMs: now() };
|
||||||
retryAfterMs = 0;
|
retryAfterMs = 0;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -289,7 +289,9 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
if (initialArgs && isHeadlessInitialCommand(initialArgs)) {
|
if (initialArgs && isHeadlessInitialCommand(initialArgs)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return createWindowTrackerCore(override, targetMpvSocketPath);
|
return createWindowTrackerCore(override, targetMpvSocketPath, (point) =>
|
||||||
|
screen.screenToDipPoint(point),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindVisibleOverlayOwner(): void {
|
function bindVisibleOverlayOwner(): void {
|
||||||
@@ -627,7 +629,9 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
|
|
||||||
ensureWindowsVisibleOverlayForegroundPollLoop();
|
ensureWindowsVisibleOverlayForegroundPollLoop();
|
||||||
|
|
||||||
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
|
const linuxX11CursorPointReader = createLinuxX11CursorPointReader({
|
||||||
|
screenToDipPoint: (point) => screen.screenToDipPoint(point),
|
||||||
|
});
|
||||||
|
|
||||||
function getLinuxOverlayPointerMeasurement() {
|
function getLinuxOverlayPointerMeasurement() {
|
||||||
const measurement = overlayContentMeasurementStore.getLatestByLayer('visible');
|
const measurement = overlayContentMeasurementStore.getLatestByLayer('visible');
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { BaseWindowTracker } from './base-tracker';
|
|||||||
import { HyprlandWindowTracker } from './hyprland-tracker';
|
import { HyprlandWindowTracker } from './hyprland-tracker';
|
||||||
import { SwayWindowTracker } from './sway-tracker';
|
import { SwayWindowTracker } from './sway-tracker';
|
||||||
import { X11WindowTracker } from './x11-tracker';
|
import { X11WindowTracker } from './x11-tracker';
|
||||||
|
import type { ScreenToDipPoint } from './x11-tracker';
|
||||||
import { MacOSWindowTracker } from './macos-tracker';
|
import { MacOSWindowTracker } from './macos-tracker';
|
||||||
import { WindowsWindowTracker } from './windows-tracker';
|
import { WindowsWindowTracker } from './windows-tracker';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
@@ -51,6 +52,7 @@ function normalizeCompositor(value: string): Compositor | null {
|
|||||||
export function createWindowTracker(
|
export function createWindowTracker(
|
||||||
override?: string | null,
|
override?: string | null,
|
||||||
targetMpvSocketPath?: string | null,
|
targetMpvSocketPath?: string | null,
|
||||||
|
screenToDipPoint?: ScreenToDipPoint,
|
||||||
): BaseWindowTracker | null {
|
): BaseWindowTracker | null {
|
||||||
let compositor = detectCompositor();
|
let compositor = detectCompositor();
|
||||||
|
|
||||||
@@ -70,7 +72,11 @@ export function createWindowTracker(
|
|||||||
case 'sway':
|
case 'sway':
|
||||||
return new SwayWindowTracker(targetMpvSocketPath?.trim() || undefined);
|
return new SwayWindowTracker(targetMpvSocketPath?.trim() || undefined);
|
||||||
case 'x11':
|
case 'x11':
|
||||||
return new X11WindowTracker(targetMpvSocketPath?.trim() || undefined);
|
return new X11WindowTracker(
|
||||||
|
targetMpvSocketPath?.trim() || undefined,
|
||||||
|
undefined,
|
||||||
|
screenToDipPoint,
|
||||||
|
);
|
||||||
case 'macos':
|
case 'macos':
|
||||||
return new MacOSWindowTracker(targetMpvSocketPath?.trim() || undefined);
|
return new MacOSWindowTracker(targetMpvSocketPath?.trim() || undefined);
|
||||||
case 'windows':
|
case 'windows':
|
||||||
|
|||||||
@@ -82,6 +82,47 @@ Height: 360`;
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('X11WindowTracker converts both physical rectangle corners to Electron DIP', async () => {
|
||||||
|
const convertedPoints: Array<{ x: number; y: number }> = [];
|
||||||
|
const tracker = new X11WindowTracker(
|
||||||
|
undefined,
|
||||||
|
async (command, args) => {
|
||||||
|
if (command === 'xdotool' && args[0] === 'search') {
|
||||||
|
return '123';
|
||||||
|
}
|
||||||
|
if (command === 'xdotool' && args[0] === 'getactivewindow') {
|
||||||
|
return '123';
|
||||||
|
}
|
||||||
|
if (command === 'xwininfo') {
|
||||||
|
return `Absolute upper-left X: 2000
|
||||||
|
Absolute upper-left Y: 125
|
||||||
|
Width: 1000
|
||||||
|
Height: 750`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
(point) => {
|
||||||
|
convertedPoints.push(point);
|
||||||
|
if (point.x === 2000) return { x: 1600, y: 100 };
|
||||||
|
return { x: 2400, y: 700 };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.deepEqual(convertedPoints, [
|
||||||
|
{ x: 2000, y: 125 },
|
||||||
|
{ x: 3000, y: 875 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(tracker.getGeometry(), {
|
||||||
|
x: 1600,
|
||||||
|
y: 100,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('X11WindowTracker updates target focus from active X11 window', async () => {
|
test('X11WindowTracker updates target focus from active X11 window', async () => {
|
||||||
let activeWindowId = '999';
|
let activeWindowId = '999';
|
||||||
const tracker = new X11WindowTracker(undefined, async (command, args) => {
|
const tracker = new X11WindowTracker(undefined, async (command, args) => {
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import { execFile } from 'child_process';
|
|||||||
import { BaseWindowTracker } from './base-tracker';
|
import { BaseWindowTracker } from './base-tracker';
|
||||||
|
|
||||||
type CommandRunner = (command: string, args: string[]) => Promise<string>;
|
type CommandRunner = (command: string, args: string[]) => Promise<string>;
|
||||||
|
export type ScreenToDipPoint = (point: { x: number; y: number }) => { x: number; y: number };
|
||||||
|
|
||||||
|
const preservePoint: ScreenToDipPoint = (point) => point;
|
||||||
|
|
||||||
function execFileUtf8(command: string, args: string[]): Promise<string> {
|
function execFileUtf8(command: string, args: string[]): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -87,16 +90,22 @@ export class X11WindowTracker extends BaseWindowTracker {
|
|||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private readonly targetMpvSocketPath: string | null;
|
private readonly targetMpvSocketPath: string | null;
|
||||||
private readonly runCommand: CommandRunner;
|
private readonly runCommand: CommandRunner;
|
||||||
|
private readonly screenToDipPoint: ScreenToDipPoint;
|
||||||
private targetWindowId: string | null = null;
|
private targetWindowId: string | null = null;
|
||||||
private targetWindowPid: number | null = null;
|
private targetWindowPid: number | null = null;
|
||||||
private pollInFlight = false;
|
private pollInFlight = false;
|
||||||
private currentPollIntervalMs = 750;
|
private currentPollIntervalMs = 750;
|
||||||
private readonly stablePollIntervalMs = 250;
|
private readonly stablePollIntervalMs = 250;
|
||||||
|
|
||||||
constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileUtf8) {
|
constructor(
|
||||||
|
targetMpvSocketPath?: string,
|
||||||
|
runCommand: CommandRunner = execFileUtf8,
|
||||||
|
screenToDipPoint: ScreenToDipPoint = preservePoint,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
||||||
this.runCommand = runCommand;
|
this.runCommand = runCommand;
|
||||||
|
this.screenToDipPoint = screenToDipPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -196,11 +205,25 @@ export class X11WindowTracker extends BaseWindowTracker {
|
|||||||
this.targetWindowPid = targetPid;
|
this.targetWindowPid = targetPid;
|
||||||
|
|
||||||
const winInfo = await this.runCommand('xwininfo', ['-id', windowId]);
|
const winInfo = await this.runCommand('xwininfo', ['-id', windowId]);
|
||||||
const geometry = parseX11WindowGeometry(winInfo);
|
const physicalGeometry = parseX11WindowGeometry(winInfo);
|
||||||
if (!geometry) {
|
if (!physicalGeometry) {
|
||||||
this.updateGeometry(null);
|
this.updateGeometry(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const topLeft = this.screenToDipPoint({
|
||||||
|
x: physicalGeometry.x,
|
||||||
|
y: physicalGeometry.y,
|
||||||
|
});
|
||||||
|
const bottomRight = this.screenToDipPoint({
|
||||||
|
x: physicalGeometry.x + physicalGeometry.width,
|
||||||
|
y: physicalGeometry.y + physicalGeometry.height,
|
||||||
|
});
|
||||||
|
const geometry = {
|
||||||
|
x: topLeft.x,
|
||||||
|
y: topLeft.y,
|
||||||
|
width: bottomRight.x - topLeft.x,
|
||||||
|
height: bottomRight.y - topLeft.y,
|
||||||
|
};
|
||||||
|
|
||||||
const focused = await this.isWindowActive(windowId, targetPid);
|
const focused = await this.isWindowActive(windowId, targetPid);
|
||||||
this.updateGeometry(geometry, focused);
|
this.updateGeometry(geometry, focused);
|
||||||
|
|||||||
Reference in New Issue
Block a user