add app control server for launcher-to-app attachment

- Launcher detects a running app via control socket and attaches without spawning a new process
- Own-lifecycle app launches now pass --background --managed-playback; borrowed apps skip --background
- Separate plain subtitle websocket (tokens: []) from annotation websocket
- Default pauseVideoOnHover to true; update docs and config.example.jsonc
- Setup: remove plugin readiness card, add Open SubMiner Settings button
This commit is contained in:
2026-05-21 01:32:58 -07:00
parent 47f92129af
commit 355d7d95b2
58 changed files with 1618 additions and 205 deletions
+103
View File
@@ -0,0 +1,103 @@
import net from 'node:net';
import {
encodeAppControlRequest,
getAppControlSocketPath,
parseAppControlResponseLine,
type AppControlSocketPathOptions,
} from './app-control';
export interface AppControlClientOptions extends AppControlSocketPathOptions {
socketPath?: string;
timeoutMs?: number;
}
export interface AppControlCommandResult {
ok: boolean;
unavailable?: boolean;
error?: string;
}
function resolveSocketPath(options: AppControlClientOptions): string {
return options.socketPath ?? getAppControlSocketPath(options);
}
export function isAppControlServerAvailable(
options: AppControlClientOptions = {},
): Promise<boolean> {
const socketPath = resolveSocketPath(options);
const timeoutMs = options.timeoutMs ?? 350;
return new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
const finish = (available: boolean): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(available);
};
socket.once('connect', () => finish(typeof socket.write === 'function'));
socket.once('error', () => finish(false));
socket.setTimeout(timeoutMs, () => finish(false));
});
}
export function sendAppControlCommand(
argv: string[],
options: AppControlClientOptions = {},
): Promise<AppControlCommandResult> {
const socketPath = resolveSocketPath(options);
const timeoutMs = options.timeoutMs ?? 1000;
return new Promise<AppControlCommandResult>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
let connected = false;
let responseBuffer = '';
const finish = (result: AppControlCommandResult): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(result);
};
socket.once('connect', () => {
connected = true;
if (typeof socket.write !== 'function') {
finish({ ok: false, unavailable: true, error: 'App control socket is not writable' });
return;
}
socket.write(encodeAppControlRequest(argv));
});
socket.on('data', (chunk) => {
responseBuffer += chunk.toString('utf8');
const newlineIndex = responseBuffer.indexOf('\n');
if (newlineIndex < 0) return;
try {
finish(parseAppControlResponseLine(responseBuffer.slice(0, newlineIndex)));
} catch (error) {
finish({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
});
socket.once('error', (error) => {
finish({ ok: false, unavailable: !connected, error: error.message });
});
socket.once('close', () => {
finish({ ok: false, unavailable: !connected, error: 'App control socket closed' });
});
socket.setTimeout(timeoutMs, () => {
finish({ ok: false, unavailable: !connected, error: 'App control socket timed out' });
});
});
}
+98
View File
@@ -0,0 +1,98 @@
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
export const SUBMINER_APP_CONTROL_SOCKET_ENV = 'SUBMINER_APP_CONTROL_SOCKET';
export interface AppControlSocketPathOptions {
configDir?: string;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
tmpDir?: string;
}
export interface AppControlRequest {
argv: string[];
}
export interface AppControlResponse {
ok: boolean;
error?: string;
}
function getUserKey(): string {
if (typeof process.getuid === 'function') {
return String(process.getuid());
}
try {
const user = os.userInfo();
if (typeof user.uid === 'number') {
return String(user.uid);
}
if (user.username) {
return user.username.replace(/[^\w.-]/g, '_');
}
} catch {
// Fall back below.
}
return 'user';
}
export function getAppControlSocketPath(options: AppControlSocketPathOptions = {}): string {
const env = options.env ?? process.env;
const override = env[SUBMINER_APP_CONTROL_SOCKET_ENV]?.trim();
if (override) return override;
const platform = options.platform ?? process.platform;
const identity = options.configDir?.trim() || 'default';
const digest = crypto.createHash('sha256').update(identity).digest('hex').slice(0, 16);
if (platform === 'win32') {
return `\\\\.\\pipe\\subminer-control-${digest}`;
}
return path.join(
options.tmpDir ?? os.tmpdir(),
`subminer-control-${getUserKey()}-${digest}.sock`,
);
}
export function encodeAppControlRequest(argv: string[]): string {
return `${JSON.stringify({ argv })}\n`;
}
export function encodeAppControlResponse(response: AppControlResponse): string {
return `${JSON.stringify(response)}\n`;
}
function normalizeArgv(value: unknown): string[] | null {
if (!Array.isArray(value) || value.length > 128) return null;
const argv: string[] = [];
for (const entry of value) {
if (typeof entry !== 'string' || entry.length > 8192) {
return null;
}
argv.push(entry);
}
return argv;
}
export function parseAppControlRequestLine(line: string): AppControlRequest {
const payload = JSON.parse(line) as { argv?: unknown };
const argv = normalizeArgv(payload.argv);
if (!argv) {
throw new Error('Invalid app-control argv payload');
}
return { argv };
}
export function parseAppControlResponseLine(line: string): AppControlResponse {
const payload = JSON.parse(line) as { ok?: unknown; error?: unknown };
if (payload.ok === true) {
return { ok: true };
}
return {
ok: false,
error: typeof payload.error === 'string' ? payload.error : 'App control command failed',
};
}