mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 03:13:32 -07:00
Fix Windows mpv logging and add log export (#88)
This commit is contained in:
@@ -133,7 +133,6 @@ export const IPC_CHANNELS = {
|
||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||
sessionHelpOpen: 'session-help:open',
|
||||
characterDictionaryOpen: 'character-dictionary:open',
|
||||
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
|
||||
controllerSelectOpen: 'controller-select:open',
|
||||
controllerDebugOpen: 'controller-debug:open',
|
||||
|
||||
@@ -33,7 +33,6 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'toggleSubtitleSidebar',
|
||||
'openRuntimeOptions',
|
||||
'openSessionHelp',
|
||||
'openCharacterDictionary',
|
||||
'openCharacterDictionaryManager',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
|
||||
@@ -3,7 +3,14 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { appendLogLine, pruneLogFiles, resolveDefaultLogFilePath } from './log-files';
|
||||
import {
|
||||
applyLogFileTogglesToEnv,
|
||||
appendLogLine,
|
||||
isLogFileEnabled,
|
||||
pruneLogDirectoryForPath,
|
||||
pruneLogFiles,
|
||||
resolveDefaultLogFilePath,
|
||||
} from './log-files';
|
||||
|
||||
test('resolveDefaultLogFilePath uses app prefix by default', () => {
|
||||
const now = new Date('2026-03-22T12:00:00.000Z');
|
||||
@@ -15,16 +22,47 @@ test('resolveDefaultLogFilePath uses app prefix by default', () => {
|
||||
|
||||
assert.equal(
|
||||
resolved,
|
||||
path.join(
|
||||
'/home/tester',
|
||||
'.config',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`app-${now.toISOString().slice(0, 10)}.log`,
|
||||
),
|
||||
path.join('/home/tester', '.config', 'SubMiner', 'logs', 'app-2026-03-22.log'),
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDefaultLogFilePath uses daily filenames for mpv logs', () => {
|
||||
const now = new Date('2026-03-22T12:00:00.000Z');
|
||||
const resolved = resolveDefaultLogFilePath('mpv', {
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
now,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
resolved,
|
||||
path.join('/home/tester', '.config', 'SubMiner', 'logs', 'mpv-2026-03-22.log'),
|
||||
);
|
||||
});
|
||||
|
||||
test('log file toggles keep app and launcher enabled while mpv defaults off', () => {
|
||||
assert.equal(isLogFileEnabled('app', {}), true);
|
||||
assert.equal(isLogFileEnabled('launcher', {}), true);
|
||||
assert.equal(isLogFileEnabled('mpv', {}), false);
|
||||
assert.equal(isLogFileEnabled('mpv', { SUBMINER_MPV_LOG: '/tmp/mpv.log' }), true);
|
||||
assert.equal(
|
||||
isLogFileEnabled('mpv', {
|
||||
SUBMINER_MPV_LOG: '/tmp/mpv.log',
|
||||
SUBMINER_MPV_LOG_ENABLED: 'false',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('applyLogFileTogglesToEnv writes log enable env flags', () => {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
applyLogFileTogglesToEnv({ app: false, launcher: true, mpv: true }, env);
|
||||
|
||||
assert.equal(env.SUBMINER_APP_LOG_ENABLED, 'false');
|
||||
assert.equal(env.SUBMINER_LAUNCHER_LOG_ENABLED, 'true');
|
||||
assert.equal(env.SUBMINER_MPV_LOG_ENABLED, 'true');
|
||||
});
|
||||
|
||||
test('pruneLogFiles removes logs older than retention window', () => {
|
||||
const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-log-prune-'));
|
||||
const stalePath = path.join(logsDir, 'app-old.log');
|
||||
@@ -69,3 +107,22 @@ test('appendLogLine trims oversized logs to newest bytes', () => {
|
||||
fs.rmSync(logsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('empty log path operations are no-ops', () => {
|
||||
const cwd = process.cwd();
|
||||
const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-empty-log-path-'));
|
||||
const candidate = path.join(logsDir, 'cwd.log');
|
||||
|
||||
try {
|
||||
process.chdir(logsDir);
|
||||
fs.writeFileSync(candidate, 'keep\n', 'utf8');
|
||||
|
||||
pruneLogDirectoryForPath('', 1);
|
||||
appendLogLine('', 'ignored', { retentionDays: 1 });
|
||||
|
||||
assert.equal(fs.readFileSync(candidate, 'utf8'), 'keep\n');
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
fs.rmSync(logsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
+76
-3
@@ -3,15 +3,35 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export type LogKind = 'app' | 'launcher' | 'mpv';
|
||||
export type LogRotation = number;
|
||||
export type LogFileToggles = Record<LogKind, boolean>;
|
||||
|
||||
export const DEFAULT_LOG_RETENTION_DAYS = 7;
|
||||
export const DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
||||
export const DEFAULT_LOG_ROTATION: LogRotation = DEFAULT_LOG_RETENTION_DAYS;
|
||||
export const DEFAULT_LOG_FILE_TOGGLES: LogFileToggles = {
|
||||
app: true,
|
||||
launcher: true,
|
||||
mpv: false,
|
||||
};
|
||||
|
||||
const TRUNCATED_MARKER = '[truncated older log content]\n';
|
||||
const prunedDirectories = new Set<string>();
|
||||
const NS_PER_MS = 1_000_000n;
|
||||
const MS_PER_DAY = 86_400_000n;
|
||||
|
||||
const LOG_ENABLED_ENV_BY_KIND: Record<LogKind, string> = {
|
||||
app: 'SUBMINER_APP_LOG_ENABLED',
|
||||
launcher: 'SUBMINER_LAUNCHER_LOG_ENABLED',
|
||||
mpv: 'SUBMINER_MPV_LOG_ENABLED',
|
||||
};
|
||||
|
||||
const LOG_PATH_ENV_BY_KIND: Record<LogKind, string> = {
|
||||
app: 'SUBMINER_APP_LOG',
|
||||
launcher: 'SUBMINER_LAUNCHER_LOG',
|
||||
mpv: 'SUBMINER_MPV_LOG',
|
||||
};
|
||||
|
||||
function floorDiv(left: number, right: number): number {
|
||||
return Math.floor(left / right);
|
||||
}
|
||||
@@ -54,10 +74,56 @@ export function resolveDefaultLogFilePath(
|
||||
homeDir?: string;
|
||||
appDataDir?: string;
|
||||
now?: Date;
|
||||
rotation?: unknown;
|
||||
},
|
||||
): string {
|
||||
const date = (options?.now ?? new Date()).toISOString().slice(0, 10);
|
||||
return path.join(resolveLogBaseDir(options), 'logs', `${kind}-${date}.log`);
|
||||
const now = options?.now ?? new Date();
|
||||
const suffix = now.toISOString().slice(0, 10);
|
||||
return path.join(resolveLogBaseDir(options), 'logs', `${kind}-${suffix}.log`);
|
||||
}
|
||||
|
||||
export function normalizeLogRotation(rotation: unknown): LogRotation | undefined {
|
||||
const parsed =
|
||||
typeof rotation === 'number'
|
||||
? rotation
|
||||
: typeof rotation === 'string' && /^\d+$/.test(rotation.trim())
|
||||
? Number(rotation.trim())
|
||||
: Number.NaN;
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function normalizeLogFileEnabled(value: unknown): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isLogFileEnabled(kind: LogKind, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const configured = normalizeLogFileEnabled(env[LOG_ENABLED_ENV_BY_KIND[kind]]);
|
||||
if (configured !== undefined) return configured;
|
||||
const explicitPath = env[LOG_PATH_ENV_BY_KIND[kind]]?.trim();
|
||||
if (explicitPath) return true;
|
||||
return DEFAULT_LOG_FILE_TOGGLES[kind];
|
||||
}
|
||||
|
||||
export function applyLogFileTogglesToEnv(
|
||||
files: Partial<LogFileToggles> | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
for (const kind of Object.keys(LOG_ENABLED_ENV_BY_KIND) as LogKind[]) {
|
||||
const explicitPath = env[LOG_PATH_ENV_BY_KIND[kind]]?.trim();
|
||||
const enabled = files?.[kind] ?? (explicitPath ? true : DEFAULT_LOG_FILE_TOGGLES[kind]);
|
||||
env[LOG_ENABLED_ENV_BY_KIND[kind]] = String(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
export function getLogRetentionDays(rotation: unknown): number {
|
||||
const normalized = normalizeLogRotation(rotation) ?? DEFAULT_LOG_ROTATION;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function pruneLogFiles(
|
||||
@@ -107,6 +173,11 @@ function maybePruneLogDirectory(logPath: string, retentionDays: number): void {
|
||||
prunedDirectories.add(key);
|
||||
}
|
||||
|
||||
export function pruneLogDirectoryForPath(logPath: string, rotation?: unknown): void {
|
||||
if (!logPath.trim()) return;
|
||||
maybePruneLogDirectory(logPath, getLogRetentionDays(rotation));
|
||||
}
|
||||
|
||||
function trimLogFileToMaxBytes(logPath: string, maxBytes: number): void {
|
||||
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return;
|
||||
|
||||
@@ -135,10 +206,12 @@ export function appendLogLine(
|
||||
line: string,
|
||||
options?: {
|
||||
retentionDays?: number;
|
||||
rotation?: unknown;
|
||||
maxBytes?: number;
|
||||
},
|
||||
): void {
|
||||
const retentionDays = options?.retentionDays ?? DEFAULT_LOG_RETENTION_DAYS;
|
||||
if (!logPath.trim()) return;
|
||||
const retentionDays = options?.retentionDays ?? getLogRetentionDays(options?.rotation);
|
||||
const maxBytes = options?.maxBytes ?? DEFAULT_LOG_MAX_BYTES;
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export type SharedLogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
function hasOption(args: readonly string[], option: string): boolean {
|
||||
return args.some((arg) => arg === option || arg.startsWith(`${option}=`));
|
||||
}
|
||||
|
||||
export function buildMpvMsgLevel(logLevel: SharedLogLevel): string {
|
||||
return `all=warn,subminer=${logLevel}`;
|
||||
}
|
||||
|
||||
export function buildMpvLoggingArgs(
|
||||
logLevel: SharedLogLevel,
|
||||
logPath: string,
|
||||
existingArgs: readonly string[] = [],
|
||||
): string[] {
|
||||
if (!logPath.trim()) {
|
||||
return [];
|
||||
}
|
||||
const args: string[] = [];
|
||||
if (!hasOption(existingArgs, '--log-file')) {
|
||||
args.push(`--log-file=${logPath}`);
|
||||
}
|
||||
if (!hasOption(existingArgs, '--msg-level')) {
|
||||
args.push(`--msg-level=${buildMpvMsgLevel(logLevel)}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
type ZipEntry = {
|
||||
name: string;
|
||||
crc32: number;
|
||||
size: number;
|
||||
localHeaderOffset: number;
|
||||
};
|
||||
|
||||
const ZIP32_MAX_UINT16 = 0xffff;
|
||||
const ZIP32_MAX_UINT32 = 0xffffffff;
|
||||
|
||||
export type StoredZipFile = {
|
||||
name: string;
|
||||
data: Buffer;
|
||||
};
|
||||
|
||||
function writeUint32LE(buffer: Buffer, value: number, offset: number): number {
|
||||
const normalized = value >>> 0;
|
||||
buffer[offset] = normalized & 0xff;
|
||||
buffer[offset + 1] = (normalized >>> 8) & 0xff;
|
||||
buffer[offset + 2] = (normalized >>> 16) & 0xff;
|
||||
buffer[offset + 3] = (normalized >>> 24) & 0xff;
|
||||
return offset + 4;
|
||||
}
|
||||
|
||||
const CRC32_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
let crc = i;
|
||||
for (let j = 0; j < 8; j += 1) {
|
||||
crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
|
||||
}
|
||||
table[i] = crc >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(data: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of data) {
|
||||
crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function createLocalFileHeader(fileName: Buffer, fileCrc32: number, fileSize: number): Buffer {
|
||||
const local = Buffer.alloc(30 + fileName.length);
|
||||
let cursor = 0;
|
||||
writeUint32LE(local, 0x04034b50, cursor);
|
||||
cursor += 4;
|
||||
local.writeUInt16LE(20, cursor);
|
||||
cursor += 2;
|
||||
local.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
local.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
local.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
local.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
writeUint32LE(local, fileCrc32, cursor);
|
||||
cursor += 4;
|
||||
writeUint32LE(local, fileSize, cursor);
|
||||
cursor += 4;
|
||||
writeUint32LE(local, fileSize, cursor);
|
||||
cursor += 4;
|
||||
local.writeUInt16LE(fileName.length, cursor);
|
||||
cursor += 2;
|
||||
local.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
fileName.copy(local, cursor);
|
||||
return local;
|
||||
}
|
||||
|
||||
function createCentralDirectoryHeader(entry: ZipEntry): Buffer {
|
||||
const fileName = Buffer.from(entry.name, 'utf8');
|
||||
const central = Buffer.alloc(46 + fileName.length);
|
||||
let cursor = 0;
|
||||
writeUint32LE(central, 0x02014b50, cursor);
|
||||
cursor += 4;
|
||||
central.writeUInt16LE(20, cursor);
|
||||
cursor += 2;
|
||||
central.writeUInt16LE(20, cursor);
|
||||
cursor += 2;
|
||||
central.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
central.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
central.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
central.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
writeUint32LE(central, entry.crc32, cursor);
|
||||
cursor += 4;
|
||||
writeUint32LE(central, entry.size, cursor);
|
||||
cursor += 4;
|
||||
writeUint32LE(central, entry.size, cursor);
|
||||
cursor += 4;
|
||||
central.writeUInt16LE(fileName.length, cursor);
|
||||
cursor += 2;
|
||||
central.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
central.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
central.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
central.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
writeUint32LE(central, 0, cursor);
|
||||
cursor += 4;
|
||||
writeUint32LE(central, entry.localHeaderOffset, cursor);
|
||||
cursor += 4;
|
||||
fileName.copy(central, cursor);
|
||||
return central;
|
||||
}
|
||||
|
||||
function createEndOfCentralDirectory(
|
||||
entriesLength: number,
|
||||
centralSize: number,
|
||||
centralStart: number,
|
||||
): Buffer {
|
||||
if (
|
||||
entriesLength > ZIP32_MAX_UINT16 ||
|
||||
centralSize > ZIP32_MAX_UINT32 ||
|
||||
centralStart > ZIP32_MAX_UINT32
|
||||
) {
|
||||
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
|
||||
}
|
||||
|
||||
const end = Buffer.alloc(22);
|
||||
let cursor = 0;
|
||||
writeUint32LE(end, 0x06054b50, cursor);
|
||||
cursor += 4;
|
||||
end.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
end.writeUInt16LE(0, cursor);
|
||||
cursor += 2;
|
||||
end.writeUInt16LE(entriesLength, cursor);
|
||||
cursor += 2;
|
||||
end.writeUInt16LE(entriesLength, cursor);
|
||||
cursor += 2;
|
||||
writeUint32LE(end, centralSize, cursor);
|
||||
cursor += 4;
|
||||
writeUint32LE(end, centralStart, cursor);
|
||||
cursor += 4;
|
||||
end.writeUInt16LE(0, cursor);
|
||||
return end;
|
||||
}
|
||||
|
||||
function writeBuffer(fd: number, buffer: Buffer): void {
|
||||
let written = 0;
|
||||
while (written < buffer.length) {
|
||||
written += fs.writeSync(fd, buffer, written, buffer.length - written);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredZip(
|
||||
outputPath: string,
|
||||
files: Iterable<StoredZipFile>,
|
||||
): { entryCount: number } {
|
||||
const entries: ZipEntry[] = [];
|
||||
let offset = 0;
|
||||
const fd = fs.openSync(outputPath, 'w');
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const fileName = Buffer.from(file.name, 'utf8');
|
||||
const fileSize = file.data.length;
|
||||
if (fileName.length > ZIP32_MAX_UINT16) {
|
||||
throw new RangeError(`ZIP entry name too long: ${file.name}`);
|
||||
}
|
||||
if (fileSize > ZIP32_MAX_UINT32) {
|
||||
throw new RangeError(`ZIP entry too large for ZIP32: ${file.name}`);
|
||||
}
|
||||
if (offset > ZIP32_MAX_UINT32) {
|
||||
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
|
||||
}
|
||||
const fileCrc32 = crc32(file.data);
|
||||
const localHeader = createLocalFileHeader(fileName, fileCrc32, fileSize);
|
||||
const nextOffset = offset + localHeader.length + fileSize;
|
||||
if (nextOffset > ZIP32_MAX_UINT32) {
|
||||
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
|
||||
}
|
||||
writeBuffer(fd, localHeader);
|
||||
writeBuffer(fd, file.data);
|
||||
entries.push({
|
||||
name: file.name,
|
||||
crc32: fileCrc32,
|
||||
size: fileSize,
|
||||
localHeaderOffset: offset,
|
||||
});
|
||||
if (nextOffset > ZIP32_MAX_UINT32) {
|
||||
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
|
||||
}
|
||||
offset = nextOffset;
|
||||
}
|
||||
|
||||
const centralStart = offset;
|
||||
if (centralStart > ZIP32_MAX_UINT32) {
|
||||
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const centralHeader = createCentralDirectoryHeader(entry);
|
||||
writeBuffer(fd, centralHeader);
|
||||
offset += centralHeader.length;
|
||||
}
|
||||
|
||||
const centralSize = offset - centralStart;
|
||||
writeBuffer(fd, createEndOfCentralDirectory(entries.length, centralSize, centralStart));
|
||||
} catch (error) {
|
||||
fs.closeSync(fd);
|
||||
fs.rmSync(outputPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
fs.closeSync(fd);
|
||||
return { entryCount: entries.length };
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { MpvBackend } from '../types/config';
|
||||
import type { LogRotation } from './log-files';
|
||||
|
||||
export interface SubminerPluginRuntimeScriptOptConfig {
|
||||
socketPath: string;
|
||||
binaryPath?: string;
|
||||
backend: MpvBackend;
|
||||
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||
logRotation?: LogRotation;
|
||||
autoStart: boolean;
|
||||
autoStartVisibleOverlay: boolean;
|
||||
autoStartPauseUntilReady: boolean;
|
||||
|
||||
Reference in New Issue
Block a user