mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-28 12:55:17 -07:00
feat(config): add configuration window (#70)
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -102,6 +102,11 @@ export const IPC_CHANNELS = {
|
||||
saveConfigSettingsPatch: 'config:save-settings-patch',
|
||||
openConfigSettingsFile: 'config:open-settings-file',
|
||||
openConfigSettingsWindow: 'config:open-settings-window',
|
||||
getConfigSettingsAnkiDeckNames: 'config-settings:anki-deck-names',
|
||||
getConfigSettingsAnkiDeckFieldNames: 'config-settings:anki-deck-field-names',
|
||||
getConfigSettingsAnkiDeckModelNames: 'config-settings:anki-deck-model-names',
|
||||
getConfigSettingsAnkiModelNames: 'config-settings:anki-model-names',
|
||||
getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names',
|
||||
},
|
||||
event: {
|
||||
subtitleSet: 'subtitle:set',
|
||||
|
||||
@@ -48,6 +48,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
|
||||
const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
|
||||
'anki.autoUpdateNewCards',
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
'subtitle.annotation.nPlusOne',
|
||||
'subtitle.annotation.jlpt',
|
||||
'subtitle.annotation.frequency',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getDefaultMpvSocketPath(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === 'win32' ? '\\\\.\\pipe\\subminer-socket' : '/tmp/subminer-socket';
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { MpvBackend } from '../types/config';
|
||||
|
||||
export interface SubminerPluginRuntimeScriptOptConfig {
|
||||
socketPath: string;
|
||||
binaryPath?: string;
|
||||
backend: MpvBackend;
|
||||
autoStart: boolean;
|
||||
autoStartVisibleOverlay: boolean;
|
||||
autoStartPauseUntilReady: boolean;
|
||||
texthookerEnabled: boolean;
|
||||
aniskipEnabled: boolean;
|
||||
aniskipButtonKey: string;
|
||||
}
|
||||
|
||||
function boolScriptOpt(value: boolean): 'yes' | 'no' {
|
||||
return value ? 'yes' : 'no';
|
||||
}
|
||||
|
||||
function sanitizeScriptOptValue(value: string): string {
|
||||
return value
|
||||
.replace(/,/g, ' ')
|
||||
.replace(/[\r\n]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function buildSubminerPluginRuntimeScriptOptParts(
|
||||
runtimeConfig: SubminerPluginRuntimeScriptOptConfig,
|
||||
fallbackAppPath: string,
|
||||
): string[] {
|
||||
const binaryPath = sanitizeScriptOptValue(runtimeConfig.binaryPath?.trim() || fallbackAppPath);
|
||||
const socketPath = sanitizeScriptOptValue(runtimeConfig.socketPath);
|
||||
const backend = sanitizeScriptOptValue(runtimeConfig.backend);
|
||||
const aniskipButtonKey = sanitizeScriptOptValue(runtimeConfig.aniskipButtonKey);
|
||||
return [
|
||||
`subminer-binary_path=${binaryPath}`,
|
||||
`subminer-socket_path=${socketPath}`,
|
||||
`subminer-backend=${backend}`,
|
||||
`subminer-auto_start=${boolScriptOpt(runtimeConfig.autoStart)}`,
|
||||
`subminer-auto_start_visible_overlay=${boolScriptOpt(runtimeConfig.autoStartVisibleOverlay)}`,
|
||||
`subminer-auto_start_pause_until_ready=${boolScriptOpt(
|
||||
runtimeConfig.autoStartPauseUntilReady,
|
||||
)}`,
|
||||
`subminer-texthooker_enabled=${boolScriptOpt(runtimeConfig.texthookerEnabled)}`,
|
||||
`subminer-aniskip_enabled=${boolScriptOpt(runtimeConfig.aniskipEnabled)}`,
|
||||
`subminer-aniskip_button_key=${aniskipButtonKey}`,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user