308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import type { FullscreenOutcome } from './fullscreen.ts';
|
|
import { resolveXauthority, sessionEnv } from './x11.ts';
|
|
|
|
const run = promisify(execFile);
|
|
|
|
export interface HotkeyRequest {
|
|
/** Touche à envoyer : « f », « F11 », « space »… */
|
|
key: string;
|
|
/** Fragment du titre de la fenêtre cible (insensible à la casse). */
|
|
windowMatch: string;
|
|
}
|
|
|
|
/**
|
|
* Jeu de touches autorisé. Ces valeurs viennent de la configuration serveur et
|
|
* finissent dans une ligne de commande : on refuse tout ce qui sort du lot
|
|
* plutôt que d'échapper au cas par cas.
|
|
*/
|
|
const KEY_PATTERN = /^(?:[A-Za-z0-9]|F[1-9]|F1[0-2]|space|Return|Escape|Tab)$/;
|
|
|
|
function assertKey(key: string): string {
|
|
if (!KEY_PATTERN.test(key)) {
|
|
throw new Error(
|
|
`Touche « ${key} » non autorisée (lettres, chiffres, F1-F12, space, Return, Escape, Tab)`,
|
|
);
|
|
}
|
|
return key;
|
|
}
|
|
|
|
/**
|
|
* Envoie une touche au lecteur vidéo, par exemple pour rebasculer en plein
|
|
* écran après un show privé.
|
|
*
|
|
* Limite assumée : sous X11 comme sous Windows, la fenêtre cible doit passer au
|
|
* premier plan. Les navigateurs ignorent les évènements clavier synthétiques
|
|
* envoyés par XSendEvent (`xdotool key --window`), donc on active la fenêtre et
|
|
* on injecte la touche via XTEST. Sur une VM d'enregistrement dédiée c'est sans
|
|
* conséquence, mais ça vole le focus si quelqu'un est en train de s'en servir.
|
|
*/
|
|
export async function sendHotkey(request: HotkeyRequest): Promise<FullscreenOutcome> {
|
|
const key = assertKey(request.key);
|
|
const match = request.windowMatch.trim();
|
|
if (!match) throw new Error('Aucun titre de fenêtre à cibler (windowMatch vide)');
|
|
|
|
switch (process.platform) {
|
|
case 'linux':
|
|
return sendLinux(key, match);
|
|
case 'win32':
|
|
return sendWindows(key, match);
|
|
case 'darwin':
|
|
return sendDarwin(key, match);
|
|
default:
|
|
throw new Error(`Envoi de touche non supporté sur ${process.platform}`);
|
|
}
|
|
}
|
|
|
|
// --- X11 --------------------------------------------------------------------
|
|
|
|
interface VisibleWindow {
|
|
id: string;
|
|
title: string;
|
|
}
|
|
|
|
/**
|
|
* Fenêtres techniques des compositeurs. Sous Wayland, le serveur XWayland n'en
|
|
* expose qu'elles : les applications, elles, sont des clients Wayland natifs,
|
|
* invisibles à xdotool. N'avoir qu'elles à l'écran est donc une signature, pas
|
|
* un hasard — et le diagnostic doit le dire plutôt que de laisser croire à un
|
|
* titre de fenêtre mal renseigné.
|
|
*/
|
|
const COMPOSITOR_WINDOWS = [/^mutter guard window$/i, /^gnome-shell$/i, /^kwin/i, /^plasmashell$/i];
|
|
|
|
export function looksLikeWayland(windows: VisibleWindow[]): boolean {
|
|
return (
|
|
windows.length > 0 &&
|
|
windows.every((window) => COMPOSITOR_WINDOWS.some((pattern) => pattern.test(window.title)))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Traduit un échec de xdotool en cause identifiable.
|
|
*
|
|
* xdotool sort en code 1 aussi bien quand rien ne correspond que quand il ne
|
|
* peut pas ouvrir l'affichage. Confondre les deux envoyait l'opérateur corriger
|
|
* un titre de fenêtre parfaitement valide.
|
|
*/
|
|
function displayFault(err: unknown): string | null {
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return 'xdotool est absent — installe-le : apt install xdotool';
|
|
}
|
|
const stderr = String((err as { stderr?: string }).stderr ?? '').trim();
|
|
const auth = resolveXauthority();
|
|
// « Invalid MIT-MAGIC-COOKIE-1 key » est le symptôme d'un XAUTHORITY qui pointe
|
|
// sur le mauvais fichier — le cas le plus courant sous GDM.
|
|
if (
|
|
/can't open display|no protocol specified|authorization required|bad display|magic-cookie/i.test(
|
|
stderr,
|
|
)
|
|
) {
|
|
return (
|
|
`xdotool ne peut pas ouvrir l'affichage : ${stderr}. Cookie d'autorisation ` +
|
|
(auth
|
|
? `utilisé : ${auth} — il existe mais ne vaut pas pour cette session. ` +
|
|
'Ouvre puis referme une session graphique, ou redémarre le service.'
|
|
: "introuvable : aucun des emplacements connus n'existe " +
|
|
'(XAUTHORITY, /run/user/<uid>/gdm/Xauthority, ~/.Xauthority). ' +
|
|
"Une session graphique est-elle ouverte sur cette machine ?")
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Fenêtres visibles portant un titre, avec leur identifiant. */
|
|
async function listVisibleWindows(env: NodeJS.ProcessEnv): Promise<VisibleWindow[]> {
|
|
let ids: string[];
|
|
try {
|
|
// « . » : toute fenêtre dont le titre contient au moins un caractère.
|
|
const { stdout } = await run('xdotool', ['search', '--onlyvisible', '--name', '.'], {
|
|
env,
|
|
timeout: 5000,
|
|
});
|
|
ids = stdout.split('\n').map((line) => line.trim()).filter(Boolean).slice(0, 60);
|
|
} catch (err) {
|
|
const fault = displayFault(err);
|
|
if (fault) throw new Error(fault);
|
|
return [];
|
|
}
|
|
|
|
const windows = await Promise.all(
|
|
ids.map(async (id) => ({ id, title: await windowName(env, id) })),
|
|
);
|
|
return windows.filter((window) => window.title !== '');
|
|
}
|
|
|
|
async function windowName(env: NodeJS.ProcessEnv, id: string): Promise<string> {
|
|
try {
|
|
const { stdout } = await run('xdotool', ['getwindowname', id], { env, timeout: 5000 });
|
|
return stdout.trim();
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Localise la fenêtre cible.
|
|
*
|
|
* Deux passes : on laisse d'abord xdotool filtrer lui-même, puis, s'il ne trouve
|
|
* rien, on énumère et on compare en JavaScript. La seconde passe ne dépend ni de
|
|
* la syntaxe d'expression régulière de xdotool ni de sa sensibilité à la casse,
|
|
* et elle fournit la liste des fenêtres réellement présentes — de loin
|
|
* l'information la plus utile quand ça échoue.
|
|
*/
|
|
async function findWindow(env: NodeJS.ProcessEnv, match: string): Promise<VisibleWindow> {
|
|
try {
|
|
const { stdout } = await run('xdotool', ['search', '--onlyvisible', '--name', match], {
|
|
env,
|
|
timeout: 5000,
|
|
});
|
|
const ids = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
if (ids.length > 0) {
|
|
// La dernière listée est en général la plus récemment mappée.
|
|
const id = ids[ids.length - 1]!;
|
|
return { id, title: (await windowName(env, id)) || id };
|
|
}
|
|
} catch (err) {
|
|
const fault = displayFault(err);
|
|
if (fault) throw new Error(fault);
|
|
// Sinon : simple absence de correspondance, la seconde passe prend le relais.
|
|
}
|
|
|
|
const windows = await listVisibleWindows(env);
|
|
const needle = match.toLowerCase();
|
|
const hit = [...windows].reverse().find((window) => window.title.toLowerCase().includes(needle));
|
|
if (hit) return hit;
|
|
|
|
throw new Error(describeNoMatch(match, windows));
|
|
}
|
|
|
|
function describeNoMatch(match: string, windows: VisibleWindow[]): string {
|
|
const display = sessionEnv().DISPLAY;
|
|
|
|
if (windows.length === 0) {
|
|
return (
|
|
`Aucune fenêtre visible sur DISPLAY=${display}. L'agent ne voit pas ta session ` +
|
|
"graphique : le navigateur tourne-t-il bien sur cette machine, dans la session de " +
|
|
"l'utilisateur qui exécute l'agent ?"
|
|
);
|
|
}
|
|
|
|
if (looksLikeWayland(windows)) {
|
|
const only = windows.map((window) => `« ${window.title} »`).join(', ');
|
|
return (
|
|
`Session Wayland : côté X11, seule la fenêtre technique du compositeur est visible ` +
|
|
`(${only}). Firefox y tourne en client Wayland natif — xdotool ne peut ni le voir ni ` +
|
|
'lui envoyer de touche, et aucun réglage ne changera cela. Passe le pilotage du ' +
|
|
'navigateur en mode « WebDriver BiDi » dans la configuration de cet agent : l\'agent ' +
|
|
'parle alors à Firefox directement, sans passer par le serveur d\'affichage.'
|
|
);
|
|
}
|
|
|
|
const shown = windows.slice(-12).map((window) => `« ${window.title} »`).join(', ');
|
|
const more = windows.length > 12 ? ` (+${windows.length - 12} autres)` : '';
|
|
return (
|
|
`Aucune fenêtre visible ne correspond à « ${match} ». Fenêtres ouvertes : ${shown}${more}. ` +
|
|
"Reporte un fragment de l'une d'elles dans « Titre de la fenêtre du lecteur »."
|
|
);
|
|
}
|
|
|
|
async function sendLinux(key: string, match: string): Promise<FullscreenOutcome> {
|
|
const env = sessionEnv();
|
|
const target = await findWindow(env, match);
|
|
|
|
await run('xdotool', ['windowactivate', '--sync', target.id], { env, timeout: 5000 });
|
|
await run('xdotool', ['key', '--clearmodifiers', key], { env, timeout: 5000 });
|
|
|
|
return { method: 'xdotool', target: target.title };
|
|
}
|
|
|
|
// --- Windows ----------------------------------------------------------------
|
|
|
|
/** Échappe une valeur pour une chaîne littérale PowerShell entre apostrophes. */
|
|
function psLiteral(value: string): string {
|
|
return `'${value.replace(/'/g, "''")}'`;
|
|
}
|
|
|
|
async function sendWindows(key: string, match: string): Promise<FullscreenOutcome> {
|
|
// SendKeys interprète certains caractères ; les touches nommées se notent {F11}.
|
|
const sendKeysArg = key.length === 1 ? key.toLowerCase() : `{${key.toUpperCase()}}`;
|
|
|
|
const script = `
|
|
$ErrorActionPreference = 'Stop'
|
|
Add-Type @"
|
|
using System;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
public class Win32 {
|
|
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
|
[DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd);
|
|
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
|
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
|
|
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
|
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
|
}
|
|
"@
|
|
$needle = ${psLiteral(match)}
|
|
$found = [IntPtr]::Zero
|
|
$foundTitle = ''
|
|
$callback = [Win32+EnumWindowsProc]{
|
|
param($hWnd, $lParam)
|
|
if (-not [Win32]::IsWindowVisible($hWnd)) { return $true }
|
|
$len = [Win32]::GetWindowTextLength($hWnd)
|
|
if ($len -eq 0) { return $true }
|
|
$sb = New-Object System.Text.StringBuilder($len + 1)
|
|
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity)
|
|
$title = $sb.ToString()
|
|
if ($title -like ('*' + $needle + '*')) { $script:found = $hWnd; $script:foundTitle = $title; return $false }
|
|
return $true
|
|
}
|
|
[void][Win32]::EnumWindows($callback, [IntPtr]::Zero)
|
|
if ($script:found -eq [IntPtr]::Zero) { Write-Error ('Aucune fenetre ne correspond a ' + $needle); exit 1 }
|
|
[void][Win32]::ShowWindow($script:found, 9)
|
|
[void][Win32]::SetForegroundWindow($script:found)
|
|
Start-Sleep -Milliseconds 300
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
[System.Windows.Forms.SendKeys]::SendWait(${psLiteral(sendKeysArg)})
|
|
Write-Output $script:foundTitle
|
|
`;
|
|
|
|
// -EncodedCommand évite tout problème de guillemets entre cmd.exe et PowerShell.
|
|
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
|
|
try {
|
|
const { stdout } = await run(
|
|
'powershell.exe',
|
|
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
|
{ timeout: 20_000, windowsHide: true },
|
|
);
|
|
return { method: 'SendKeys', target: stdout.trim() || undefined };
|
|
} catch (err) {
|
|
const stderr = (err as { stderr?: string }).stderr?.trim();
|
|
throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`);
|
|
}
|
|
}
|
|
|
|
// --- macOS (confort de développement) ---------------------------------------
|
|
|
|
async function sendDarwin(key: string, match: string): Promise<FullscreenOutcome> {
|
|
const script = `
|
|
tell application "System Events"
|
|
set matches to (every process whose name contains "${match.replace(/["\\]/g, '')}")
|
|
if (count of matches) = 0 then error "Aucun processus ne correspond"
|
|
set target to item 1 of matches
|
|
set frontmost of target to true
|
|
delay 0.3
|
|
keystroke "${key.toLowerCase()}"
|
|
end tell`;
|
|
|
|
try {
|
|
await run('osascript', ['-e', script], { timeout: 15_000 });
|
|
return { method: 'osascript', target: match };
|
|
} catch (err) {
|
|
const stderr = (err as { stderr?: string }).stderr?.trim();
|
|
throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`);
|
|
}
|
|
}
|