fix(overlay): handle X11 display scaling across monitors

- Convert X11 coordinates to Electron DIP per screen
- Relaunch unsupported Wayland sessions with the X11 Ozone backend
This commit is contained in:
2026-08-11 18:32:30 -07:00
parent 7b0fbdf254
commit 91c6ea492f
12 changed files with 250 additions and 35 deletions
+4
View File
@@ -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.
+2 -1
View File
@@ -405,8 +405,9 @@ On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and othe
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.
- 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 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.
+68 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldForceX11ElectronBackend } from './electron-backend';
import { resolveX11ElectronRelaunchArgs, shouldForceX11ElectronBackend } from './electron-backend';
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
const original = Object.getOwnPropertyDescriptor(process, 'platform');
@@ -32,3 +32,70 @@ test('shouldForceX11ElectronBackend is false off Linux', () => {
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'],
);
});
+36 -2
View File
@@ -4,6 +4,9 @@ import { isSupportedWaylandCompositor } from '../../shared/mpv-x11-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 {
const hint = env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
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
* {@link enforceUnsupportedWaylandMode} can report it.
*/
export function shouldForceX11ElectronBackend(env: NodeJS.ProcessEnv = process.env): boolean {
if (process.platform !== 'linux') return false;
export function shouldForceX11ElectronBackend(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): boolean {
if (platform !== 'linux') return false;
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 {
if (!shouldStartApp(args)) return;
if (!shouldForceX11ElectronBackend()) return;
+34 -14
View File
@@ -44,6 +44,10 @@ import {
resolveDefaultLogFilePath,
type LogRotation,
} from './shared/log-files';
import {
resolveX11ElectronRelaunchArgs,
X11_ELECTRON_BOOTSTRAP_ENV,
} from './core/utils/electron-backend';
const DEFAULT_TEXTHOOKER_PORT = 5174;
@@ -71,6 +75,22 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
}
}
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.unref();
}
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
return (
resolvePackagedRuntimePluginPath({
@@ -296,26 +316,26 @@ async function runEntryProcess(): Promise<void> {
return;
}
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
const x11ChildArgs = resolveX11ElectronRelaunchArgs(childArgs, process.env);
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
const keepalive = resolveAppImageMountKeepaliveInvocation(process.env);
const child = keepalive
? spawn(keepalive.command, [...keepalive.args, ...childArgs], {
detached: true,
stdio: 'ignore',
env: sanitizeBackgroundEnv(process.env),
})
: spawn(process.execPath, childArgs, {
detached: true,
stdio: 'ignore',
env: sanitizeBackgroundEnv(process.env),
});
child.unref();
const childEnv = sanitizeBackgroundEnv(process.env);
if (x11ChildArgs) childEnv[X11_ELECTRON_BOOTSTRAP_ENV] = '1';
spawnDetachedApp(x11ChildArgs ?? childArgs, childEnv);
// Let Electron stop bootstrap Chromium children before its AppImage mount is released.
exitBackgroundBootstrap(app);
return;
}
if (x11ChildArgs) {
const childEnv = sanitizeStartupEnv(process.env);
childEnv[X11_ELECTRON_BOOTSTRAP_ENV] = '1';
spawnDetachedApp(x11ChildArgs, childEnv);
exitBackgroundBootstrap(app);
return;
}
startMainProcess();
}
-10
View File
@@ -256,7 +256,6 @@ import {
import {
enforceUnsupportedWaylandMode,
forceX11Backend,
shouldForceX11ElectronBackend,
generateDefaultConfigFile,
resolveConfiguredShortcuts,
resolveKeybindings,
@@ -597,15 +596,6 @@ if (process.platform === 'linux') {
);
app.commandLine.appendSwitch('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');
@@ -47,6 +47,29 @@ WINDOW=44040194
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', () => {
const calls: string[] = [];
const reader = createLinuxX11CursorPointReader({
+3 -1
View File
@@ -36,11 +36,13 @@ export function createLinuxX11CursorPointReader(options?: {
now?: () => number;
platform?: NodeJS.Platform;
runCommand?: CommandRunner;
screenToDipPoint?: (point: PointerPoint) => PointerPoint;
}) {
const env = options?.env ?? process.env;
const now = options?.now ?? (() => Date.now());
const platform = options?.platform ?? process.platform;
const runCommand = options?.runCommand ?? execFileUtf8;
const screenToDipPoint = options?.screenToDipPoint ?? ((point: PointerPoint) => point);
let latest: { point: PointerPoint; updatedAtMs: number } | null = null;
let inFlight = false;
let retryAfterMs = 0;
@@ -63,7 +65,7 @@ export function createLinuxX11CursorPointReader(options?: {
retryAfterMs = now() + COMMAND_FAILURE_RETRY_DELAY_MS;
return;
}
latest = { point, updatedAtMs: now() };
latest = { point: screenToDipPoint(point), updatedAtMs: now() };
retryAfterMs = 0;
})
.catch(() => {
@@ -289,7 +289,9 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
if (initialArgs && isHeadlessInitialCommand(initialArgs)) {
return null;
}
return createWindowTrackerCore(override, targetMpvSocketPath);
return createWindowTrackerCore(override, targetMpvSocketPath, (point) =>
screen.screenToDipPoint(point),
);
}
function bindVisibleOverlayOwner(): void {
@@ -627,7 +629,9 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
ensureWindowsVisibleOverlayForegroundPollLoop();
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
const linuxX11CursorPointReader = createLinuxX11CursorPointReader({
screenToDipPoint: (point) => screen.screenToDipPoint(point),
});
function getLinuxOverlayPointerMeasurement() {
const measurement = overlayContentMeasurementStore.getLatestByLayer('visible');
+7 -1
View File
@@ -20,6 +20,7 @@ import { BaseWindowTracker } from './base-tracker';
import { HyprlandWindowTracker } from './hyprland-tracker';
import { SwayWindowTracker } from './sway-tracker';
import { X11WindowTracker } from './x11-tracker';
import type { ScreenToDipPoint } from './x11-tracker';
import { MacOSWindowTracker } from './macos-tracker';
import { WindowsWindowTracker } from './windows-tracker';
import { createLogger } from '../logger';
@@ -51,6 +52,7 @@ function normalizeCompositor(value: string): Compositor | null {
export function createWindowTracker(
override?: string | null,
targetMpvSocketPath?: string | null,
screenToDipPoint?: ScreenToDipPoint,
): BaseWindowTracker | null {
let compositor = detectCompositor();
@@ -70,7 +72,11 @@ export function createWindowTracker(
case 'sway':
return new SwayWindowTracker(targetMpvSocketPath?.trim() || undefined);
case 'x11':
return new X11WindowTracker(targetMpvSocketPath?.trim() || undefined);
return new X11WindowTracker(
targetMpvSocketPath?.trim() || undefined,
undefined,
screenToDipPoint,
);
case 'macos':
return new MacOSWindowTracker(targetMpvSocketPath?.trim() || undefined);
case 'windows':
+41
View File
@@ -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 () => {
let activeWindowId = '999';
const tracker = new X11WindowTracker(undefined, async (command, args) => {
+26 -3
View File
@@ -20,6 +20,9 @@ import { execFile } from 'child_process';
import { BaseWindowTracker } from './base-tracker';
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> {
return new Promise((resolve, reject) => {
@@ -87,16 +90,22 @@ export class X11WindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private readonly targetMpvSocketPath: string | null;
private readonly runCommand: CommandRunner;
private readonly screenToDipPoint: ScreenToDipPoint;
private targetWindowId: string | null = null;
private targetWindowPid: number | null = null;
private pollInFlight = false;
private currentPollIntervalMs = 750;
private readonly stablePollIntervalMs = 250;
constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileUtf8) {
constructor(
targetMpvSocketPath?: string,
runCommand: CommandRunner = execFileUtf8,
screenToDipPoint: ScreenToDipPoint = preservePoint,
) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.runCommand = runCommand;
this.screenToDipPoint = screenToDipPoint;
}
start(): void {
@@ -196,11 +205,25 @@ export class X11WindowTracker extends BaseWindowTracker {
this.targetWindowPid = targetPid;
const winInfo = await this.runCommand('xwininfo', ['-id', windowId]);
const geometry = parseX11WindowGeometry(winInfo);
if (!geometry) {
const physicalGeometry = parseX11WindowGeometry(winInfo);
if (!physicalGeometry) {
this.updateGeometry(null);
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);
this.updateGeometry(geometry, focused);