fix(linux): auto-install managed plugin copy; include in asset updates (#127)

This commit is contained in:
2026-06-14 17:25:28 -07:00
committed by GitHub
parent ae7e6f82a8
commit a117c5759c
53 changed files with 3050 additions and 152 deletions
+19
View File
@@ -69,6 +69,25 @@ test('parseArgs captures update command and internal launcher paths', () => {
assert.equal(shouldRunYomitanOnlyStartup(args), false);
});
test('parseArgs captures hidden Linux runtime plugin asset ensure command', () => {
const args = parseArgs([
'--ensure-linux-runtime-plugin-assets',
'--ensure-linux-runtime-plugin-assets-response-path',
'/tmp/subminer-plugin-response.json',
]);
assert.equal(args.ensureLinuxRuntimePluginAssets, true);
assert.equal(
args.ensureLinuxRuntimePluginAssetsResponsePath,
'/tmp/subminer-plugin-response.json',
);
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
assert.equal(isHeadlessInitialCommand(args), true);
assert.equal(commandNeedsOverlayRuntime(args), false);
assert.equal(shouldRunYomitanOnlyStartup(args), false);
});
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']);
assert.equal(args.launchMpv, true);
+21 -3
View File
@@ -80,6 +80,8 @@ export interface CliArgs {
update?: boolean;
updateLauncherPath?: string;
updateResponsePath?: string;
ensureLinuxRuntimePluginAssets?: boolean;
ensureLinuxRuntimePluginAssetsResponsePath?: string;
autoStartOverlay: boolean;
generateConfig: boolean;
configPath?: string;
@@ -178,6 +180,8 @@ export function parseArgs(argv: string[]): CliArgs {
update: false,
updateLauncherPath: undefined,
updateResponsePath: undefined,
ensureLinuxRuntimePluginAssets: false,
ensureLinuxRuntimePluginAssetsResponsePath: undefined,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
@@ -379,7 +383,15 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
else if (arg === '--app-ping') args.appPing = true;
else if (arg === '--update') args.update = true;
else if (arg.startsWith('--update-launcher-path=')) {
else if (arg === '--ensure-linux-runtime-plugin-assets') {
args.ensureLinuxRuntimePluginAssets = true;
} else if (arg.startsWith('--ensure-linux-runtime-plugin-assets-response-path=')) {
const value = arg.split('=', 2)[1];
if (value) args.ensureLinuxRuntimePluginAssetsResponsePath = value;
} else if (arg === '--ensure-linux-runtime-plugin-assets-response-path') {
const value = readValue(argv[i + 1]);
if (value) args.ensureLinuxRuntimePluginAssetsResponsePath = value;
} else if (arg.startsWith('--update-launcher-path=')) {
const value = arg.split('=', 2)[1];
if (value) args.updateLauncherPath = value;
} else if (arg === '--update-launcher-path') {
@@ -581,13 +593,16 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.texthooker ||
args.appPing ||
args.update ||
args.ensureLinuxRuntimePluginAssets === true ||
args.generateConfig ||
args.help
);
}
export function isHeadlessInitialCommand(args: CliArgs): boolean {
return args.refreshKnownWords || args.update === true;
return (
args.refreshKnownWords || args.update === true || args.ensureLinuxRuntimePluginAssets === true
);
}
export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
@@ -654,6 +669,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.jellyfinPreviewAuth &&
!args.appPing &&
!args.update &&
!args.ensureLinuxRuntimePluginAssets &&
!args.help &&
!args.autoStartOverlay &&
!args.generateConfig
@@ -707,7 +723,8 @@ export function shouldStartApp(args: CliArgs): boolean {
args.jellyfin ||
args.jellyfinPlay ||
args.texthooker ||
args.update
args.update ||
args.ensureLinuxRuntimePluginAssets
) {
if (args.launchMpv) {
return false;
@@ -780,6 +797,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.texthooker &&
!args.appPing &&
!args.update &&
!args.ensureLinuxRuntimePluginAssets &&
!args.help &&
!args.autoStartOverlay &&
!args.generateConfig &&
+4
View File
@@ -243,6 +243,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
runUpdateCommand: async (args) => {
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
calls.push('runEnsureLinuxRuntimePluginAssetsCommand');
},
printHelp: () => {
calls.push('printHelp');
},
@@ -624,6 +627,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
stop: () => {},
hasMainWindow: () => true,
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
},
dispatchSessionAction: async () => {},
+19
View File
@@ -97,6 +97,10 @@ export interface CliCommandServiceDeps {
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runEnsureLinuxRuntimePluginAssetsCommand: (
args: CliArgs,
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
@@ -182,6 +186,7 @@ interface AppCliRuntime {
stop: () => void;
hasMainWindow: () => boolean;
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
}
@@ -292,6 +297,7 @@ export function createCliCommandDepsRuntime(
runStatsCommand: options.jellyfin.runStatsCommand,
runJellyfinCommand: options.jellyfin.runCommand,
runUpdateCommand: options.app.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow,
@@ -454,6 +460,19 @@ export function handleCliCommand(
deps.stopApp();
}
});
} else if (args.ensureLinuxRuntimePluginAssets) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps
.runEnsureLinuxRuntimePluginAssetsCommand(args, source)
.catch((err) => {
deps.error('runEnsureLinuxRuntimePluginAssetsCommand failed:', err);
deps.showMpvOsd(`Linux runtime plugin install failed: ${(err as Error).message}`);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.toggleSecondarySub) {
deps.cycleSecondarySubMode();
} else if (args.triggerFieldGrouping) {
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveDefaultNotificationIconPath } from './notification';
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
const path = resolveDefaultNotificationIconPath({
platform: 'linux',
resourcesPath: '/opt/SubMiner/resources',
appPath: '/opt/SubMiner/resources/app.asar',
dirname: '/opt/SubMiner/resources/app.asar/dist/core/utils',
cwd: '/opt/SubMiner',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => candidate === '/opt/SubMiner/resources/assets/SubMiner.png',
});
assert.equal(path, '/opt/SubMiner/resources/assets/SubMiner.png');
});
test('default notification icon prefers the square app icon when bundled images are available', () => {
const path = resolveDefaultNotificationIconPath({
platform: 'linux',
resourcesPath: '/opt/SubMiner/resources',
appPath: '/opt/SubMiner/resources/app.asar',
dirname: '/opt/SubMiner/resources/app.asar/dist/core/utils',
cwd: '/opt/SubMiner',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) =>
candidate === '/opt/SubMiner/resources/assets/SubMiner.png' ||
candidate === '/opt/SubMiner/resources/assets/SubMiner-square.png',
});
assert.equal(path, '/opt/SubMiner/resources/assets/SubMiner-square.png');
});
test('default notification icon avoids macOS tray template assets', () => {
const seen: string[] = [];
const path = resolveDefaultNotificationIconPath({
platform: 'darwin',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
dirname: '/Applications/SubMiner.app/Contents/Resources/app.asar/dist/core/utils',
cwd: '/Applications/SubMiner.app/Contents/Resources',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => {
seen.push(candidate);
return candidate.endsWith('/assets/SubMiner-square.png');
},
});
assert.equal(path, '/Applications/SubMiner.app/Contents/Resources/assets/SubMiner-square.png');
assert.equal(
seen.some((candidate) => candidate.includes('SubMinerTemplate')),
false,
);
});
test('default notification icon resolves cwd fallback through injected deps', () => {
const resolvedPath = resolveDefaultNotificationIconPath({
platform: 'linux',
resourcesPath: '/missing/resources',
appPath: '/missing/app',
dirname: '/missing/dist/core/utils',
cwd: '/portable/SubMiner',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => candidate === '/portable/SubMiner/assets/SubMiner-square.png',
});
assert.equal(resolvedPath, '/portable/SubMiner/assets/SubMiner-square.png');
});
+57 -9
View File
@@ -1,10 +1,57 @@
import electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger';
const { Notification, nativeImage } = electron;
const logger = createLogger('core:notification');
export function resolveDefaultNotificationIconPath(deps: {
platform: string;
resourcesPath: string;
appPath: string;
dirname: string;
cwd: string;
joinPath: (...parts: string[]) => string;
fileExists: (path: string) => boolean;
}): string | null {
const iconNames =
deps.platform === 'win32'
? ['SubMiner.ico', 'SubMiner-square.png', 'SubMiner.png']
: ['SubMiner-square.png', 'SubMiner.png'];
const baseDirs = [
deps.joinPath(deps.resourcesPath, 'assets'),
deps.joinPath(deps.appPath, 'assets'),
deps.joinPath(deps.dirname, '..', 'assets'),
deps.joinPath(deps.dirname, '..', '..', 'assets'),
deps.joinPath(deps.cwd, 'assets'),
];
for (const baseDir of baseDirs) {
for (const iconName of iconNames) {
const candidate = deps.joinPath(baseDir, iconName);
if (deps.fileExists(candidate)) {
return candidate;
}
}
}
return null;
}
function resolveRuntimeDefaultNotificationIconPath(): string | null {
return resolveDefaultNotificationIconPath({
platform: process.platform,
resourcesPath: process.resourcesPath,
appPath: electron.app?.getAppPath?.() ?? process.cwd(),
dirname: __dirname,
cwd: process.cwd(),
joinPath: (...parts) => path.join(...parts),
fileExists: (candidate) => fs.existsSync(candidate),
});
}
export function showDesktopNotification(
title: string,
options: { body?: string; icon?: string },
@@ -19,19 +66,20 @@ export function showDesktopNotification(
notificationOptions.body = options.body;
}
if (options.icon) {
const icon = options.icon ?? resolveRuntimeDefaultNotificationIconPath() ?? undefined;
if (icon) {
const isFilePath =
typeof options.icon === 'string' &&
(options.icon.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(options.icon));
typeof icon === 'string' && (icon.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(icon));
if (isFilePath) {
if (fs.existsSync(options.icon)) {
notificationOptions.icon = options.icon;
if (fs.existsSync(icon)) {
notificationOptions.icon = icon;
} else {
logger.warn('Notification icon file not found', options.icon);
logger.warn('Notification icon file not found', icon);
}
} else if (typeof options.icon === 'string' && options.icon.startsWith('data:image/')) {
const base64Data = options.icon.replace(/^data:image\/\w+;base64,/, '');
} else if (typeof icon === 'string' && icon.startsWith('data:image/')) {
const base64Data = icon.replace(/^data:image\/\w+;base64,/, '');
try {
const image = nativeImage.createFromBuffer(Buffer.from(base64Data, 'base64'));
if (image.isEmpty()) {
@@ -45,7 +93,7 @@ export function showDesktopNotification(
logger.error('Failed to create notification icon from base64', err);
}
} else {
notificationOptions.icon = options.icon;
notificationOptions.icon = icon;
}
}
+66 -1
View File
@@ -352,7 +352,10 @@ import {
clearYoutubePrimarySubtitleNotificationTimer,
createYoutubePrimarySubtitleNotificationRuntime,
} from './main/runtime/youtube-primary-subtitle-notification';
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate';
import {
createAutoplayReadyGate,
type AutoplayReadySignal,
} from './main/runtime/autoplay-ready-gate';
import { createAutoplaySubtitlePrimingRuntime } from './main/runtime/autoplay-subtitle-priming-runtime';
import { createAutoplayTokenizationWarmRelease } from './main/runtime/autoplay-tokenization-warm-release';
import { isVisibleOverlayAutoplayTargetReady } from './main/runtime/visible-overlay-autoplay-readiness';
@@ -508,6 +511,11 @@ import {
runUpdateCliCommand,
writeUpdateCliCommandResponse,
} from './main/runtime/update/update-cli-command';
import {
runEnsureLinuxRuntimePluginAssetsCliCommand,
writeEnsureLinuxRuntimePluginAssetsCliCommandResponse,
} from './main/runtime/linux-runtime-plugin-assets-cli-command';
import { ensureLinuxRuntimePluginAssets } from './main/runtime/linux-runtime-plugin-assets';
import { createUpdateServiceRuntime } from './main/runtime/update/update-service-runtime';
import {
createRefreshSubtitlePrefetchFromActiveTrackHandler,
@@ -1169,6 +1177,33 @@ const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
now: () => Date.now(),
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
});
const POST_WARM_AUTOPLAY_SUBTITLE_PRIME_DELAYS_MS = [0, 75, 200, 500, 1_000] as const;
function schedulePostWarmAutoplaySubtitlePrime(signal: AutoplayReadySignal): void {
if (signal.payload.text.trim() !== '__warm__') {
return;
}
const mediaPath = signal.mediaPath;
for (const delayMs of POST_WARM_AUTOPLAY_SUBTITLE_PRIME_DELAYS_MS) {
const timer = setTimeout(() => {
const currentMediaPath =
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
if (currentMediaPath !== mediaPath || !overlayManager.getVisibleOverlayVisible()) {
return;
}
void primeCurrentSubtitleForAutoplay(mediaPath).catch((error) => {
logger.debug(
`[autoplay-subtitle-prime] failed to prime current subtitle after warm readiness: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
}, delayMs);
timer.unref?.();
}
}
const autoplayReadyGate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => youtubePrimarySubtitleNotificationRuntime.isAppOwnedFlowInFlight(),
getCurrentMediaPath: () => appState.currentMediaPath,
@@ -1194,6 +1229,9 @@ const autoplayReadyGate = createAutoplayReadyGate({
});
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
},
onAutoplayReadyReleased: (signal) => {
schedulePostWarmAutoplaySubtitlePrime(signal);
},
isSignalTargetReady: (signal) =>
isVisibleOverlayAutoplayTargetReady(
{
@@ -1758,6 +1796,7 @@ const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
},
getCurrentSubText: () => appState.currentSubText,
getCurrentSubtitleData: () => appState.currentSubtitleData,
getActiveParsedSubtitleCues: () => appState.activeParsedSubtitleCues,
setActiveParsedSubtitleMediaPath: (mediaPath) => {
appState.activeParsedSubtitleMediaPath = mediaPath;
},
@@ -1781,6 +1820,10 @@ function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
return autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay();
}
function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
return autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForAutoplay(mediaPath);
}
function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
}
@@ -2526,6 +2569,10 @@ function resetLinuxVisibleOverlayStartupInputPrimer(): void {
visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer();
}
function startLinuxVisibleOverlayStartupInputGrace(): void {
visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace();
}
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
return visibleOverlayInteractionRuntime.applyLinuxOverlayInputShapeFromLatestMeasurement();
}
@@ -5765,6 +5812,21 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
logWarn: (message, error) => logger.warn(message, error),
});
},
runEnsureLinuxRuntimePluginAssetsCommand: async (
argsFromCommand: CliArgs,
source: CliCommandSource,
) => {
await runEnsureLinuxRuntimePluginAssetsCliCommand(
argsFromCommand,
{
ensureLinuxRuntimePluginAssets: () => ensureLinuxRuntimePluginAssets(),
writeResponse: (responsePath, payload) =>
writeEnsureLinuxRuntimePluginAssetsCliCommandResponse(responsePath, payload),
logWarn: (message, error) => logger.warn(message, error),
},
source,
);
},
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
@@ -6235,6 +6297,7 @@ function setVisibleOverlayVisible(visible: boolean): void {
if (visible) {
maybeStartOverlayLoadingOsd();
resetLinuxVisibleOverlayStartupInputPrimer();
startLinuxVisibleOverlayStartupInputGrace();
restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden();
void primeCurrentSubtitleForVisibleOverlay();
@@ -6256,6 +6319,7 @@ function toggleVisibleOverlay(): void {
} else {
maybeStartOverlayLoadingOsd();
resetLinuxVisibleOverlayStartupInputPrimer();
startLinuxVisibleOverlayStartupInputGrace();
restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden();
void primeCurrentSubtitleForVisibleOverlay();
@@ -6275,6 +6339,7 @@ function setOverlayVisible(visible: boolean): void {
if (visible) {
maybeStartOverlayLoadingOsd();
resetLinuxVisibleOverlayStartupInputPrimer();
startLinuxVisibleOverlayStartupInputGrace();
restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden();
void primeCurrentSubtitleForVisibleOverlay();
+2
View File
@@ -45,6 +45,7 @@ export interface CliCommandRuntimeServiceContext {
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceDepsParams['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
@@ -124,6 +125,7 @@ function createCliCommandDepsFromContext(
stop: context.stopApp,
hasMainWindow: context.hasMainWindow,
runUpdateCommand: context.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: context.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
},
dispatchSessionAction: context.dispatchSessionAction,
+2
View File
@@ -198,6 +198,7 @@ export interface CliCommandRuntimeServiceDepsParams {
stop: CliCommandDepsRuntimeOptions['app']['stop'];
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandDepsRuntimeOptions['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
};
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
@@ -392,6 +393,7 @@ export function createCliCommandRuntimeServiceDeps(
stop: params.app.stop,
hasMainWindow: params.app.hasMainWindow,
runUpdateCommand: params.app.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: params.app.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
},
dispatchSessionAction: params.dispatchSessionAction,
+86 -3
View File
@@ -232,6 +232,31 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
);
});
test('autoplay subtitle prime reuses active parsed cues before synthetic warm release', () => {
const source = readMainSource();
const runtimeDepsBlock = source.match(
/const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
const primeSource = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
const emptyTextBlock = primeSource.match(
/if \(!text\.trim\(\) && isCurrentAutoplayMediaPath\(mediaPath\)\) \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
assert.ok(runtimeDepsBlock);
assert.match(
runtimeDepsBlock,
/getActiveParsedSubtitleCues:\s*\(\) => appState\.activeParsedSubtitleCues/,
);
assert.ok(emptyTextBlock);
assert.ok(
emptyTextBlock.indexOf('await deps.refreshSubtitlePrefetchFromActiveTrack()') <
emptyTextBlock.indexOf(
'await primeAutoplaySubtitleFromParsedCues(mediaPath, deps.getActiveParsedSubtitleCues())',
),
);
});
test('startup autoplay release is tied to visible overlay measurement readiness', () => {
const source = readMainSource();
const gateBlock = source.match(
@@ -497,11 +522,11 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
assert.ok(toggleBlock);
assert.match(
setBlock,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
);
assert.match(
toggleBlock,
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
);
});
@@ -522,10 +547,68 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
assert.match(
setBlock,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
);
});
test('Linux visible overlay startup reapplies passive passthrough after input reset', () => {
const pointerSource = readSource('src/main/runtime/linux-overlay-pointer-interaction.ts');
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
const resetPrimerBlock = runtimeSource.match(
/function resetLinuxVisibleOverlayStartupInputPrimer\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
const startGraceBlock = runtimeSource.match(
/function startLinuxVisibleOverlayStartupInputGrace\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
const depsBlock = runtimeSource.match(
/const linuxOverlayPointerInteractionDeps = \{(?<body>[\s\S]*?)\n \};/,
)?.groups?.body;
assert.ok(resetPrimerBlock);
assert.ok(startGraceBlock);
assert.ok(depsBlock);
assert.match(resetPrimerBlock, /visibleOverlayInteractionActive = false;/);
assert.match(resetPrimerBlock, /linuxOverlayPointerInteractionStateApplied = false;/);
assert.match(
startGraceBlock,
/linuxVisibleOverlayStartupInputGraceUntilMs =\s+Date\.now\(\) \+ LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;/,
);
assert.match(startGraceBlock, /linuxOverlayPointerInteractionStateApplied = false;/);
assert.match(
depsBlock,
/isInteractionStateApplied:\s*\(\) => linuxOverlayPointerInteractionStateApplied/,
);
assert.match(
pointerSource,
/deps\.getInteractionActive\(\) === desired && deps\.isInteractionStateApplied\?\.\(\) !== false/,
);
});
test('Linux visible overlay show starts input grace before first measurement', () => {
const source = readMainSource();
const setVisibleBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const setOverlayBlock = source.match(
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
for (const block of [setVisibleBlock, toggleBlock, setOverlayBlock]) {
assert.ok(block);
assert.ok(
block.indexOf('resetLinuxVisibleOverlayStartupInputPrimer();') <
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();'),
);
assert.ok(
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();') <
block.indexOf('void primeCurrentSubtitleForVisibleOverlay();'),
);
}
});
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
const source = readSource('src/main/runtime/overlay-geometry-runtime.ts');
const afterBoundsBlock = source.match(
@@ -137,6 +137,46 @@ test('autoplay ready gate requests overlay pointer recovery when media readiness
assert.equal(pointerRecoveryRequests, 1);
});
test('autoplay ready gate reports the released autoplay signal once', async () => {
const releasedSignals: string[] = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: () => {},
}) as never,
signalPluginAutoplayReady: () => {},
onAutoplayReadyReleased: (signal) => {
releasedSignals.push(signal.payload.text);
},
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
gate.maybeSignalPluginAutoplayReady(
{ text: '次の字幕', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(releasedSignals, ['__warm__']);
});
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
const commands: Array<Array<string | boolean>> = [];
let playbackPaused = true;
+2
View File
@@ -27,6 +27,7 @@ export type AutoplayReadyGateDeps = {
getMpvClient: () => MpvClientLike | null;
signalPluginAutoplayReady: () => void;
requestOverlayPointerRecovery?: () => void;
onAutoplayReadyReleased?: (signal: AutoplayReadySignal) => void;
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
now?: () => number;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
@@ -182,6 +183,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady();
deps.requestOverlayPointerRecovery?.();
deps.onAutoplayReadyReleased?.(signal);
attemptRelease(playbackGeneration, 0);
};
@@ -38,6 +38,7 @@ test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback'
setCurrentSubText: () => {},
getCurrentSubText: () => '',
getCurrentSubtitleData: () => null,
getActiveParsedSubtitleCues: () => [],
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: {
consumeCachedSubtitle: () => null,
@@ -63,3 +64,118 @@ test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback'
'[autoplay-subtitle-prime] subtitle prefetch refresh failed: refresh failed',
]);
});
test('primeCurrentSubtitleForAutoplay refreshes active subtitle cues when mpv sub-text is empty', async () => {
const calls: string[] = [];
let currentSubText = '';
let activeParsedSubtitleCues: Array<{ startTime: number; endTime: number; text: string }> = [];
const mediaPath = '/media/video.mkv';
const runtime = createAutoplaySubtitlePrimingRuntime({
getCurrentMediaPath: () => mediaPath,
getMpvClient: () => ({
connected: true,
currentVideoPath: mediaPath,
requestProperty: async (name) => {
calls.push(`request:${name}`);
if (name === 'sub-text') return '';
if (name === 'time-pos') return 12;
return null;
},
}),
setCurrentSubText: (text) => {
currentSubText = text;
calls.push(`set:${text}`);
},
getCurrentSubText: () => currentSubText,
getCurrentSubtitleData: () => null,
getActiveParsedSubtitleCues: () => activeParsedSubtitleCues,
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: {
consumeCachedSubtitle: () => null,
onSubtitleChange: (text) => calls.push(`change:${text}`),
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
},
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
getSubtitlePrefetchService: () => ({
pause: () => calls.push('prefetch:pause'),
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
}),
getLastObservedTimePos: () => 12,
getVisibleOverlayVisible: () => true,
emitSecondarySubtitle: () => {},
initSubtitlePrefetch: async () => {},
refreshSubtitlePrefetchFromActiveTrack: async () => {
calls.push('refresh-active-track');
activeParsedSubtitleCues = [{ startTime: 10, endTime: 20, text: '起動字幕' }];
},
logDebug: (message) => calls.push(`debug:${message}`),
});
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
assert.deepEqual(calls, [
'request:sub-text',
'refresh-active-track',
'request:time-pos',
'set:起動字幕',
'prefetch:pause',
'emit:起動字幕',
'change:起動字幕',
]);
});
test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before tokenization', async () => {
const calls: string[] = [];
let currentSubText = '';
const mediaPath = '/media/video.mkv';
const runtime = createAutoplaySubtitlePrimingRuntime({
getCurrentMediaPath: () => mediaPath,
getMpvClient: () => ({
connected: true,
currentVideoPath: mediaPath,
requestProperty: async (name) => {
calls.push(`request:${name}`);
if (name === 'sub-text') return '起動字幕';
return null;
},
}),
setCurrentSubText: (text) => {
currentSubText = text;
calls.push(`set:${text}`);
},
getCurrentSubText: () => currentSubText,
getCurrentSubtitleData: () => null,
getActiveParsedSubtitleCues: () => [],
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: {
consumeCachedSubtitle: () => null,
onSubtitleChange: (text) => calls.push(`change:${text}`),
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
},
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
getSubtitlePrefetchService: () => ({
pause: () => calls.push('prefetch:pause'),
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
}),
getLastObservedTimePos: () => 12,
getVisibleOverlayVisible: () => true,
emitSecondarySubtitle: () => {},
initSubtitlePrefetch: async () => {},
refreshSubtitlePrefetchFromActiveTrack: async () => {
calls.push('refresh-active-track');
},
logDebug: (message) => calls.push(`debug:${message}`),
});
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
assert.deepEqual(calls, [
'request:sub-text',
'set:起動字幕',
'prefetch:pause',
'emit:起動字幕',
'change:起動字幕',
]);
});
@@ -26,6 +26,7 @@ export interface AutoplaySubtitlePrimingRuntimeDeps {
setCurrentSubText: (text: string) => void;
getCurrentSubText: () => string;
getCurrentSubtitleData: () => SubtitleData | null;
getActiveParsedSubtitleCues: () => SubtitleCue[];
setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void;
subtitleProcessingController: {
consumeCachedSubtitle: (text: string) => SubtitleData | null;
@@ -107,6 +108,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
return true;
}
emitSubtitlePayload({ text, tokens: null });
subtitleProcessingController.onSubtitleChange(text);
return true;
}
@@ -126,7 +128,20 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
return null;
});
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
emitAutoplayPrimedSubtitle(mediaPath, text);
if (emitAutoplayPrimedSubtitle(mediaPath, text)) {
return;
}
if (!text.trim() && isCurrentAutoplayMediaPath(mediaPath)) {
await deps.refreshSubtitlePrefetchFromActiveTrack().catch((error) => {
deps.logDebug(
`[autoplay-subtitle-prime] active subtitle refresh failed after empty sub-text: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
await primeAutoplaySubtitleFromParsedCues(mediaPath, deps.getActiveParsedSubtitleCues());
}
}
async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
@@ -66,6 +66,9 @@ test('build cli command context deps maps handlers and values', () => {
runUpdateCommand: async () => {
calls.push('run-update');
},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
calls.push('run-ensure-linux-runtime-plugin-assets');
},
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
@@ -43,6 +43,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandContextFactoryDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
@@ -100,6 +101,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand,
runUpdateCommand: deps.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
@@ -72,6 +72,7 @@ test('cli command context factory composes main deps and context handlers', () =
runStatsCommand: async () => {},
runJellyfinCommand: async () => {},
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
@@ -97,6 +97,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
runUpdateCommand: async () => {
calls.push('run-update');
},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
calls.push('run-ensure-linux-runtime-plugin-assets');
},
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
@@ -56,6 +56,10 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: (
args: CliArgs,
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
@@ -133,6 +137,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
runUpdateCommand: (args: CliArgs, source: CliCommandSource) =>
deps.runUpdateCommand(args, source),
runEnsureLinuxRuntimePluginAssetsCommand: (args: CliArgs, source: CliCommandSource) =>
deps.runEnsureLinuxRuntimePluginAssetsCommand(args, source),
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
openYomitanSettings: () => deps.openYomitanSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
@@ -54,6 +54,7 @@ function createDeps() {
runStatsCommand: async () => {},
runJellyfinCommand: async () => {},
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
+2
View File
@@ -48,6 +48,7 @@ export type CliCommandContextFactoryDeps = {
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceContext['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
@@ -127,6 +128,7 @@ export function createCliCommandContext(
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand,
runUpdateCommand: deps.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
@@ -48,6 +48,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
runJellyfinCommand: async () => {},
runStatsCommand: async () => {},
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
@@ -341,6 +341,21 @@ test('tick only writes interaction state on change', () => {
assert.deepEqual(calls, [true]);
});
test('tick reapplies an unchanged inactive state when the window passthrough state is dirty', () => {
const calls: boolean[] = [];
const { deps, state } = makeDeps({
getCursorScreenPoint: () => ({ x: 200, y: 200 }),
isInteractionStateApplied: () => false,
setInteractionActive: (active) => {
calls.push(active);
state.active = active;
},
});
tickLinuxOverlayPointerInteraction(deps);
assert.deepEqual(calls, [false]);
});
test('tick does not flip state when suspended (returns null)', () => {
const calls: boolean[] = [];
const { deps } = makeDeps({
@@ -53,6 +53,7 @@ export type LinuxOverlayPointerInteractionDeps = {
shouldSuppressInteraction?: () => boolean;
shouldUseInputShape?: () => boolean;
getInteractionActive: () => boolean;
isInteractionStateApplied?: () => boolean;
setInteractionActive: (active: boolean) => void;
};
@@ -273,7 +274,9 @@ export function tickLinuxOverlayPointerInteraction(deps: LinuxOverlayPointerInte
if (deps.shouldUseInputShape?.()) return;
const desired = resolveDesiredOverlayInteractive(deps);
if (desired === null) return;
if (deps.getInteractionActive() === desired) return;
if (deps.getInteractionActive() === desired && deps.isInteractionStateApplied?.() !== false) {
return;
}
deps.setInteractionActive(desired);
}
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { CliArgs } from '../../cli/args';
import { runEnsureLinuxRuntimePluginAssetsCliCommand } from './linux-runtime-plugin-assets-cli-command';
test('runEnsureLinuxRuntimePluginAssetsCliCommand writes success response for install', async () => {
const writes: Array<{ path: string; payload: unknown }> = [];
await runEnsureLinuxRuntimePluginAssetsCliCommand(
{
ensureLinuxRuntimePluginAssets: true,
ensureLinuxRuntimePluginAssetsResponsePath: '/tmp/subminer-plugin-response.json',
} as CliArgs,
{
ensureLinuxRuntimePluginAssets: async () => ({
ok: true,
status: 'installed',
path: '/home/tester/.local/share/SubMiner/plugin/subminer/main.lua',
}),
writeResponse: (responsePath, payload) => {
writes.push({ path: responsePath, payload });
},
logWarn: () => {},
},
);
assert.deepEqual(writes, [
{
path: '/tmp/subminer-plugin-response.json',
payload: {
ok: true,
status: 'installed',
path: '/home/tester/.local/share/SubMiner/plugin/subminer/main.lua',
},
},
]);
});
test('runEnsureLinuxRuntimePluginAssetsCliCommand writes failure response on error', async () => {
const writes: Array<{ path: string; payload: unknown }> = [];
await assert.rejects(
() =>
runEnsureLinuxRuntimePluginAssetsCliCommand(
{
ensureLinuxRuntimePluginAssets: true,
ensureLinuxRuntimePluginAssetsResponsePath: '/tmp/subminer-plugin-response.json',
} as CliArgs,
{
ensureLinuxRuntimePluginAssets: async () => {
throw new Error('copy failed');
},
writeResponse: (responsePath, payload) => {
writes.push({ path: responsePath, payload });
},
logWarn: () => {},
},
),
/copy failed/,
);
assert.deepEqual(writes, [
{
path: '/tmp/subminer-plugin-response.json',
payload: {
ok: false,
status: 'failed',
error: 'copy failed',
},
},
]);
});
@@ -0,0 +1,71 @@
import fs from 'node:fs';
import path from 'node:path';
import type { CliArgs, CliCommandSource } from '../../cli/args';
import {
ensureLinuxRuntimePluginAssets,
type EnsureLinuxRuntimePluginAssetsResult,
} from './linux-runtime-plugin-assets';
export interface EnsureLinuxRuntimePluginAssetsResponse {
ok: boolean;
status: 'installed' | 'already-present' | 'failed';
path?: string;
error?: string;
}
export interface EnsureLinuxRuntimePluginAssetsCliCommandDeps {
ensureLinuxRuntimePluginAssets: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
writeResponse: (responsePath: string, payload: EnsureLinuxRuntimePluginAssetsResponse) => void;
logWarn: (message: string, error?: unknown) => void;
}
export function writeEnsureLinuxRuntimePluginAssetsCliCommandResponse(
responsePath: string,
payload: EnsureLinuxRuntimePluginAssetsResponse,
): void {
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
fs.writeFileSync(responsePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
function writeResponseSafe(
responsePath: string | undefined,
payload: EnsureLinuxRuntimePluginAssetsResponse,
deps: Pick<EnsureLinuxRuntimePluginAssetsCliCommandDeps, 'writeResponse' | 'logWarn'>,
): void {
if (!responsePath) return;
try {
deps.writeResponse(responsePath, payload);
} catch (error) {
deps.logWarn(`Failed to write Linux runtime plugin asset response: ${responsePath}`, error);
}
}
const defaultDeps: EnsureLinuxRuntimePluginAssetsCliCommandDeps = {
ensureLinuxRuntimePluginAssets: () => ensureLinuxRuntimePluginAssets(),
writeResponse: (responsePath, payload) =>
writeEnsureLinuxRuntimePluginAssetsCliCommandResponse(responsePath, payload),
logWarn: () => {},
};
export async function runEnsureLinuxRuntimePluginAssetsCliCommand(
args: Pick<
CliArgs,
'ensureLinuxRuntimePluginAssets' | 'ensureLinuxRuntimePluginAssetsResponsePath'
>,
deps: EnsureLinuxRuntimePluginAssetsCliCommandDeps = defaultDeps,
_source: CliCommandSource = 'initial',
): Promise<EnsureLinuxRuntimePluginAssetsResult> {
try {
const result = await deps.ensureLinuxRuntimePluginAssets();
writeResponseSafe(args.ensureLinuxRuntimePluginAssetsResponsePath, result, deps);
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
writeResponseSafe(
args.ensureLinuxRuntimePluginAssetsResponsePath,
{ ok: false, status: 'failed', error: message },
deps,
);
throw error;
}
}
@@ -0,0 +1,399 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
ensureLinuxRuntimePluginAssets,
resolveManagedLinuxRuntimePluginPaths,
} from './linux-runtime-plugin-assets';
async function withTempDir<T>(fn: (dir: string) => Promise<T> | T): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-linux-plugin-assets-test-'));
try {
return await fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
async function withProcessResourcesPath<T>(
resourcesPath: string,
fn: () => Promise<T> | T,
): Promise<T> {
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
const previousResourcesPath = processWithResources.resourcesPath;
processWithResources.resourcesPath = resourcesPath;
try {
return await fn();
} finally {
if (previousResourcesPath === undefined) {
Reflect.deleteProperty(processWithResources, 'resourcesPath');
} else {
processWithResources.resourcesPath = previousResourcesPath;
}
}
}
test('resolveManagedLinuxRuntimePluginPaths resolves XDG data target paths', () => {
const resolved = resolveManagedLinuxRuntimePluginPaths({
homeDir: '/home/tester',
xdgDataHome: '/tmp/xdg-data',
});
assert.deepEqual(resolved, {
dataDir: '/tmp/xdg-data/SubMiner',
rootDir: '/tmp/xdg-data/SubMiner/plugin',
pluginDir: '/tmp/xdg-data/SubMiner/plugin/subminer',
pluginEntrypointPath: '/tmp/xdg-data/SubMiner/plugin/subminer/main.lua',
pluginConfigPath: '/tmp/xdg-data/SubMiner/plugin/subminer.conf',
themePath: '/tmp/xdg-data/SubMiner/themes/subminer.rasi',
});
});
test('resolveManagedLinuxRuntimePluginPaths treats blank XDG data homes as unset', () => {
const previousXdgDataHome = process.env.XDG_DATA_HOME;
try {
delete process.env.XDG_DATA_HOME;
const emptyOption = resolveManagedLinuxRuntimePluginPaths({
homeDir: '/home/tester',
xdgDataHome: '',
});
assert.equal(emptyOption.dataDir, path.join('/home/tester', '.local', 'share', 'SubMiner'));
process.env.XDG_DATA_HOME = ' ';
const blankEnv = resolveManagedLinuxRuntimePluginPaths({
homeDir: '/home/tester',
});
assert.equal(blankEnv.dataDir, path.join('/home/tester', '.local', 'share', 'SubMiner'));
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
});
test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and rofi theme when missing', async () => {
await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin');
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const targetRoot = path.join(tempDir, 'xdg-data', 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
fs.writeFileSync(path.join(sourceRoot, 'subminer', 'main.lua'), '-- plugin\n');
fs.writeFileSync(path.join(sourceRoot, 'subminer.conf'), 'configured=true\n');
fs.writeFileSync(themeSourcePath, '/* theme */\n');
const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux',
homeDir: path.join(tempDir, 'home'),
xdgDataHome: path.join(tempDir, 'xdg-data'),
resolveBundledAssets: () => ({
pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
themeSourcePath,
}),
});
assert.deepEqual(result, {
ok: true,
status: 'installed',
path: path.join(targetRoot, 'subminer', 'main.lua'),
});
assert.equal(
fs.readFileSync(path.join(targetRoot, 'subminer', 'main.lua'), 'utf8'),
'-- plugin\n',
);
assert.equal(
fs.readFileSync(path.join(targetRoot, 'subminer.conf'), 'utf8'),
'configured=true\n',
);
assert.equal(
fs.readFileSync(
path.join(tempDir, 'xdg-data', 'SubMiner', 'themes', 'subminer.rasi'),
'utf8',
),
'/* theme */\n',
);
});
});
test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets already exist', async () => {
await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin');
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
fs.mkdirSync(path.join(targetRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(sourceRoot, 'subminer', 'main.lua'), '-- new plugin\n');
fs.writeFileSync(path.join(sourceRoot, 'subminer.conf'), 'new=true\n');
fs.writeFileSync(themeSourcePath, '/* theme */\n');
fs.writeFileSync(path.join(targetRoot, 'subminer', 'main.lua'), '-- existing plugin\n');
fs.writeFileSync(path.join(targetRoot, 'subminer.conf'), 'configured=true\n');
const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux',
homeDir: path.join(tempDir, 'home'),
xdgDataHome,
resolveBundledAssets: () => ({
pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
themeSourcePath,
}),
});
assert.deepEqual(result, {
ok: true,
status: 'installed',
path: path.join(targetRoot, 'subminer', 'main.lua'),
});
assert.equal(
fs.readFileSync(path.join(targetRoot, 'subminer', 'main.lua'), 'utf8'),
'-- existing plugin\n',
);
assert.equal(
fs.readFileSync(path.join(targetRoot, 'subminer.conf'), 'utf8'),
'configured=true\n',
);
assert.equal(
fs.readFileSync(path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'), 'utf8'),
'/* theme */\n',
);
});
});
test('ensureLinuxRuntimePluginAssets installs managed theme without resolving plugin sources when plugin assets already exist', async () => {
await withTempDir(async (tempDir) => {
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
fs.mkdirSync(path.join(targetRoot, 'subminer'), { recursive: true });
fs.writeFileSync(themeSourcePath, '/* theme */\n');
fs.writeFileSync(path.join(targetRoot, 'subminer', 'main.lua'), '-- existing plugin\n');
fs.writeFileSync(path.join(targetRoot, 'subminer.conf'), 'configured=true\n');
const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux',
homeDir: path.join(tempDir, 'home'),
xdgDataHome,
resolveBundledAssets: () => ({
themeSourcePath,
}),
});
assert.deepEqual(result, {
ok: true,
status: 'installed',
path: path.join(targetRoot, 'subminer', 'main.lua'),
});
assert.equal(
fs.readFileSync(path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'), 'utf8'),
'/* theme */\n',
);
});
});
test('ensureLinuxRuntimePluginAssets default resolver can recover a missing theme when plugin sources are unavailable', async () => {
await withTempDir(async (tempDir) => {
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.join(targetRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(targetRoot, 'subminer', 'main.lua'), '-- existing plugin\n');
fs.writeFileSync(path.join(targetRoot, 'subminer.conf'), 'configured=true\n');
const result = await withProcessResourcesPath(process.cwd(), () =>
ensureLinuxRuntimePluginAssets({
platform: 'linux',
homeDir: path.join(tempDir, 'home'),
xdgDataHome,
existsSync: (candidate) => {
if (
!candidate.startsWith(tempDir) &&
(candidate.endsWith(path.join('plugin', 'subminer')) ||
candidate.endsWith(path.join('plugin', 'subminer.conf')) ||
candidate.endsWith(path.join('plugin', 'subminer', 'main.lua')))
) {
return false;
}
return fs.existsSync(candidate);
},
}),
);
assert.deepEqual(result, {
ok: true,
status: 'installed',
path: path.join(targetRoot, 'subminer', 'main.lua'),
});
assert.equal(
fs.readFileSync(path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'), 'utf8'),
fs.readFileSync(path.join(process.cwd(), 'assets', 'themes', 'subminer.rasi'), 'utf8'),
);
});
});
test('ensureLinuxRuntimePluginAssets installs managed plugin assets without resolving theme source when theme already exists', async () => {
await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin');
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
fs.mkdirSync(path.join(xdgDataHome, 'SubMiner', 'themes'), { recursive: true });
fs.writeFileSync(path.join(sourceRoot, 'subminer', 'main.lua'), '-- plugin\n');
fs.writeFileSync(path.join(sourceRoot, 'subminer.conf'), 'configured=true\n');
fs.writeFileSync(
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
'/* existing theme */\n',
);
const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux',
homeDir: path.join(tempDir, 'home'),
xdgDataHome,
resolveBundledAssets: () => ({
pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
}),
});
assert.deepEqual(result, {
ok: true,
status: 'installed',
path: path.join(targetRoot, 'subminer', 'main.lua'),
});
assert.equal(
fs.readFileSync(path.join(targetRoot, 'subminer', 'main.lua'), 'utf8'),
'-- plugin\n',
);
assert.equal(
fs.readFileSync(path.join(targetRoot, 'subminer.conf'), 'utf8'),
'configured=true\n',
);
});
});
test('ensureLinuxRuntimePluginAssets default resolver can recover missing plugin assets when theme source is unavailable', async () => {
await withTempDir(async (tempDir) => {
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.join(xdgDataHome, 'SubMiner', 'themes'), { recursive: true });
fs.writeFileSync(
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
'/* existing theme */\n',
);
const result = await withProcessResourcesPath(process.cwd(), () =>
ensureLinuxRuntimePluginAssets({
platform: 'linux',
homeDir: path.join(tempDir, 'home'),
xdgDataHome,
existsSync: (candidate) => {
if (
!candidate.startsWith(tempDir) &&
candidate.endsWith(path.join('assets', 'themes', 'subminer.rasi'))
) {
return false;
}
return fs.existsSync(candidate);
},
}),
);
assert.deepEqual(result, {
ok: true,
status: 'installed',
path: path.join(targetRoot, 'subminer', 'main.lua'),
});
assert.equal(
fs.readFileSync(path.join(targetRoot, 'subminer', 'main.lua'), 'utf8'),
fs.readFileSync(path.join(process.cwd(), 'plugin', 'subminer', 'main.lua'), 'utf8'),
);
assert.equal(
fs.readFileSync(path.join(targetRoot, 'subminer.conf'), 'utf8'),
fs.readFileSync(path.join(process.cwd(), 'plugin', 'subminer.conf'), 'utf8'),
);
});
});
test('ensureLinuxRuntimePluginAssets returns already-present when managed assets already exist', async () => {
await withTempDir(async (tempDir) => {
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.join(targetRoot, 'subminer'), { recursive: true });
fs.mkdirSync(path.join(xdgDataHome, 'SubMiner', 'themes'), { recursive: true });
fs.writeFileSync(path.join(targetRoot, 'subminer', 'main.lua'), '-- existing\n');
fs.writeFileSync(path.join(targetRoot, 'subminer.conf'), 'configured=true\n');
fs.writeFileSync(
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
'/* theme */\n',
);
const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux',
homeDir: path.join(tempDir, 'home'),
xdgDataHome,
resolveBundledAssets: () => {
throw new Error('should not resolve bundled assets when already installed');
},
});
assert.deepEqual(result, {
ok: true,
status: 'already-present',
path: path.join(targetRoot, 'subminer', 'main.lua'),
});
});
});
test('ensureLinuxRuntimePluginAssets fails when bundled assets cannot be resolved', async () => {
await withTempDir(async (tempDir) => {
const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux',
homeDir: path.join(tempDir, 'home'),
xdgDataHome: path.join(tempDir, 'xdg-data'),
resolveBundledAssets: () => ({}),
});
assert.equal(result.ok, false);
assert.equal(result.status, 'failed');
assert.match(result.error ?? '', /bundled.*plugin assets/i);
});
});
test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed install', async () => {
await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin');
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
fs.writeFileSync(path.join(sourceRoot, 'subminer', 'main.lua'), '-- plugin\n');
fs.writeFileSync(path.join(sourceRoot, 'subminer.conf'), 'configured=true\n');
fs.writeFileSync(themeSourcePath, '/* theme */\n');
const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux',
homeDir: path.join(tempDir, 'home'),
xdgDataHome,
resolveBundledAssets: () => ({
pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
themeSourcePath,
}),
copyFile: async () => {
throw new Error('copy failed');
},
});
assert.equal(result.ok, false);
assert.equal(result.status, 'failed');
assert.equal(fs.existsSync(path.join(targetRoot, 'subminer', 'main.lua')), false);
assert.equal(fs.existsSync(path.join(targetRoot, 'subminer.conf')), false);
});
});
@@ -0,0 +1,290 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolvePackagedFirstRunPluginAssets } from './first-run-setup-plugin';
export interface ManagedLinuxRuntimePluginPaths {
dataDir: string;
rootDir: string;
pluginDir: string;
pluginEntrypointPath: string;
pluginConfigPath: string;
themePath: string;
}
export interface EnsureLinuxRuntimePluginAssetsResult {
ok: boolean;
status: 'installed' | 'already-present' | 'failed';
path?: string;
error?: string;
}
interface RuntimePluginAssetSources {
pluginDirSource?: string;
pluginConfigSource?: string;
themeSourcePath?: string;
}
interface RuntimePluginDirentLike {
name: string;
isDirectory(): boolean;
}
interface EnsureLinuxRuntimePluginAssetsOptions {
platform?: NodeJS.Platform;
homeDir?: string;
xdgDataHome?: string;
pathModule?: typeof path;
existsSync?: (candidate: string) => boolean;
resolveBundledAssets?: () => RuntimePluginAssetSources | null;
mkdir?: (targetPath: string, options: { recursive: true }) => Promise<void>;
readdir?: (
targetPath: string,
options: { withFileTypes: true },
) => Promise<RuntimePluginDirentLike[]>;
copyFile?: (sourcePath: string, targetPath: string) => Promise<void>;
rename?: (fromPath: string, toPath: string) => Promise<void>;
rm?: (targetPath: string, options: { recursive?: boolean; force?: boolean }) => Promise<void>;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function resolveManagedLinuxRuntimePluginPaths(options: {
homeDir?: string;
xdgDataHome?: string;
pathModule?: typeof path;
}): ManagedLinuxRuntimePluginPaths {
const pathModule = options.pathModule ?? path;
const homeDir = options.homeDir ?? os.homedir();
const explicitXdgDataHome = options.xdgDataHome?.trim();
const envXdgDataHome = process.env.XDG_DATA_HOME?.trim();
const xdgDataHome =
explicitXdgDataHome || envXdgDataHome || pathModule.join(homeDir, '.local', 'share');
const dataDir = pathModule.join(xdgDataHome, 'SubMiner');
const rootDir = pathModule.join(dataDir, 'plugin');
const pluginDir = pathModule.join(rootDir, 'subminer');
return {
dataDir,
rootDir,
pluginDir,
pluginEntrypointPath: pathModule.join(pluginDir, 'main.lua'),
pluginConfigPath: pathModule.join(rootDir, 'subminer.conf'),
themePath: pathModule.join(dataDir, 'themes', 'subminer.rasi'),
};
}
async function copyDirectoryRecursive(
sourceDir: string,
targetDir: string,
options: Required<
Pick<EnsureLinuxRuntimePluginAssetsOptions, 'mkdir' | 'readdir' | 'copyFile' | 'pathModule'>
>,
): Promise<void> {
await options.mkdir(targetDir, { recursive: true });
const entries = await options.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = options.pathModule.join(sourceDir, entry.name);
const targetPath = options.pathModule.join(targetDir, entry.name);
if (entry.isDirectory()) {
await copyDirectoryRecursive(sourcePath, targetPath, options);
continue;
}
await options.copyFile(sourcePath, targetPath);
}
}
function resolveBundledThemePath(options: {
dirname: string;
appPath: string;
resourcesPath: string;
existsSync: (candidate: string) => boolean;
}): string | null {
const roots = [
path.join(options.resourcesPath, 'assets'),
path.join(options.resourcesPath, 'app.asar', 'assets'),
path.join(options.appPath, 'assets'),
path.join(options.dirname, '..', 'assets'),
path.join(options.dirname, '..', '..', 'assets'),
path.join(options.dirname, '..', '..', '..', 'assets'),
];
for (const root of roots) {
const candidate = path.join(root, 'themes', 'subminer.rasi');
if (options.existsSync(candidate)) return candidate;
}
return null;
}
function resolveBundledAssetsDefault(
existsSync: (candidate: string) => boolean,
): RuntimePluginAssetSources {
const resourcesPath = process.resourcesPath ?? path.dirname(process.execPath);
const pluginAssets = resolvePackagedFirstRunPluginAssets({
dirname: __dirname,
appPath: process.execPath,
resourcesPath,
existsSync,
});
const themeSourcePath = resolveBundledThemePath({
dirname: __dirname,
appPath: process.execPath,
resourcesPath,
existsSync,
});
return {
...(pluginAssets ?? {}),
...(themeSourcePath ? { themeSourcePath } : {}),
};
}
export async function ensureLinuxRuntimePluginAssets(
options: EnsureLinuxRuntimePluginAssetsOptions = {},
): Promise<EnsureLinuxRuntimePluginAssetsResult> {
const platform = options.platform ?? process.platform;
if (platform !== 'linux') {
return {
ok: false,
status: 'failed',
error: 'Linux runtime plugin asset install is only supported on Linux.',
};
}
const pathModule = options.pathModule ?? path;
const existsSync = options.existsSync ?? fs.existsSync;
const mkdir =
options.mkdir ??
(async (targetPath, mkdirOptions) => {
await fs.promises.mkdir(targetPath, mkdirOptions);
});
const readdir =
options.readdir ??
((targetPath, readdirOptions) =>
fs.promises.readdir(targetPath, readdirOptions) as Promise<RuntimePluginDirentLike[]>);
const copyFile =
options.copyFile ?? ((sourcePath, targetPath) => fs.promises.copyFile(sourcePath, targetPath));
const rename = options.rename ?? ((fromPath, toPath) => fs.promises.rename(fromPath, toPath));
const rm = options.rm ?? ((targetPath, rmOptions) => fs.promises.rm(targetPath, rmOptions));
const managedPaths = resolveManagedLinuxRuntimePluginPaths({
homeDir: options.homeDir,
xdgDataHome: options.xdgDataHome,
pathModule,
});
const pluginAssetsExist =
existsSync(managedPaths.pluginEntrypointPath) && existsSync(managedPaths.pluginConfigPath);
const themeExists = existsSync(managedPaths.themePath);
if (pluginAssetsExist && themeExists) {
return {
ok: true,
status: 'already-present',
path: managedPaths.pluginEntrypointPath,
};
}
const bundledAssets =
(options.resolveBundledAssets
? options.resolveBundledAssets()
: resolveBundledAssetsDefault(existsSync)) ?? {};
const shouldInstallPluginAssets = !pluginAssetsExist;
const shouldInstallTheme = !themeExists;
if (
shouldInstallPluginAssets &&
(!bundledAssets.pluginDirSource || !bundledAssets.pluginConfigSource)
) {
return {
ok: false,
status: 'failed',
error: 'Bundled Linux runtime plugin assets were not found.',
};
}
if (shouldInstallTheme && !bundledAssets.themeSourcePath) {
return {
ok: false,
status: 'failed',
error: 'Bundled Linux runtime theme asset was not found.',
};
}
const stagingSuffix = `${process.pid}-${Date.now()}`;
const stagedPluginDir = pathModule.join(managedPaths.rootDir, `.subminer-stage-${stagingSuffix}`);
const stagedPluginConfigPath = pathModule.join(
managedPaths.rootDir,
`.subminer.conf-stage-${stagingSuffix}`,
);
const stagedThemePath = pathModule.join(
pathModule.dirname(managedPaths.themePath),
`.subminer.rasi-stage-${stagingSuffix}`,
);
let pluginDirInstalled = false;
let pluginConfigInstalled = false;
let themeInstalled = false;
try {
if (shouldInstallPluginAssets) {
const pluginDirSource = bundledAssets.pluginDirSource;
const pluginConfigSource = bundledAssets.pluginConfigSource;
if (!pluginDirSource || !pluginConfigSource) {
throw new Error('Bundled Linux runtime plugin assets were not found.');
}
await mkdir(managedPaths.rootDir, { recursive: true });
await copyDirectoryRecursive(pluginDirSource, stagedPluginDir, {
mkdir,
readdir,
copyFile,
pathModule,
});
await copyFile(pluginConfigSource, stagedPluginConfigPath);
}
if (shouldInstallTheme) {
const themeSourcePath = bundledAssets.themeSourcePath;
if (!themeSourcePath) {
throw new Error('Bundled Linux runtime theme asset was not found.');
}
await mkdir(pathModule.dirname(managedPaths.themePath), { recursive: true });
await copyFile(themeSourcePath, stagedThemePath);
}
if (shouldInstallPluginAssets) {
await rm(managedPaths.pluginDir, { recursive: true, force: true });
await rm(managedPaths.pluginConfigPath, { force: true });
await rename(stagedPluginDir, managedPaths.pluginDir);
pluginDirInstalled = true;
await rename(stagedPluginConfigPath, managedPaths.pluginConfigPath);
pluginConfigInstalled = true;
}
if (shouldInstallTheme) {
await rm(managedPaths.themePath, { force: true });
await rename(stagedThemePath, managedPaths.themePath);
themeInstalled = true;
}
return {
ok: true,
status: 'installed',
path: managedPaths.pluginEntrypointPath,
};
} catch (error) {
if (pluginDirInstalled && !pluginConfigInstalled) {
await rm(managedPaths.pluginDir, { recursive: true, force: true }).catch(() => {});
}
if (pluginConfigInstalled && !pluginDirInstalled) {
await rm(managedPaths.pluginConfigPath, { force: true }).catch(() => {});
}
if (themeInstalled) {
await rm(managedPaths.themePath, { force: true }).catch(() => {});
}
await rm(stagedPluginDir, { recursive: true, force: true }).catch(() => {});
await rm(stagedPluginConfigPath, { force: true }).catch(() => {});
await rm(stagedThemePath, { force: true }).catch(() => {});
return {
ok: false,
status: 'failed',
error: errorMessage(error),
};
}
}
+2
View File
@@ -14,6 +14,7 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
initialArgs?.settings ||
initialArgs?.update ||
initialArgs?.ensureLinuxRuntimePluginAssets ||
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
),
@@ -24,6 +25,7 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.update ||
initialArgs.ensureLinuxRuntimePluginAssets ||
initialArgs.setup),
),
};
+447 -26
View File
@@ -9,18 +9,43 @@ import {
buildProtectedSupportAssetsCommand,
detectSupportAssetDataDirs,
updateSupportAssetsFromRelease,
type SupportAssetsUpdateResult,
} from './support-assets';
type SupportAssetsResultWithComponent = SupportAssetsUpdateResult & {
component?: 'theme' | 'plugin';
};
function sha256(data: Buffer): string {
return createHash('sha256').update(data).digest('hex');
}
function makeSupportAssetsArchive(): { archive: Buffer; tempDir: string } {
function makeSupportAssetsArchive(options?: {
themeContent?: string;
pluginVersion?: string | null;
pluginMainContent?: string;
extraPluginFiles?: Array<{ relativePath: string; content: string }>;
}): { archive: Buffer; tempDir: string } {
const themeContent = options?.themeContent ?? 'new theme\n';
const pluginVersion = options && 'pluginVersion' in options ? options.pluginVersion : '0.12.0';
const pluginMainContent = options?.pluginMainContent ?? 'new plugin\n';
const extraPluginFiles = options?.extraPluginFiles ?? [];
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-support-assets-test-'));
fs.mkdirSync(path.join(tempDir, 'assets/themes'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'plugin/subminer'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'assets/themes/subminer.rasi'), 'new theme\n');
fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), 'new plugin\n');
fs.writeFileSync(path.join(tempDir, 'assets/themes/subminer.rasi'), themeContent);
fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), pluginMainContent);
if (pluginVersion !== null) {
fs.writeFileSync(
path.join(tempDir, 'plugin/subminer/version.lua'),
`return {\n\tversion = "${pluginVersion}",\n}\n`,
);
}
for (const extraFile of extraPluginFiles) {
const targetPath = path.join(tempDir, 'plugin/subminer', extraFile.relativePath);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, extraFile.content);
}
execFileSync('tar', ['-czf', 'subminer-assets.tar.gz', 'assets', 'plugin'], { cwd: tempDir });
return {
archive: fs.readFileSync(path.join(tempDir, 'subminer-assets.tar.gz')),
@@ -28,7 +53,29 @@ function makeSupportAssetsArchive(): { archive: Buffer; tempDir: string } {
};
}
test('detectSupportAssetDataDirs only returns Linux rofi theme locations', () => {
async function runLinuxSupportAssetUpdate(options: {
archive: Buffer;
xdgDataHome?: string;
platform?: NodeJS.Platform;
}): Promise<SupportAssetsResultWithComponent[]> {
return (await updateSupportAssetsFromRelease({
release: {
tag_name: 'v0.15.0',
assets: [
{
name: 'subminer-assets.tar.gz',
browser_download_url: 'https://example.test/subminer-assets.tar.gz',
},
],
},
sha256Sums: new Map([['subminer-assets.tar.gz', sha256(options.archive)]]),
downloadAsset: async () => options.archive,
platform: options.platform ?? 'linux',
xdgDataHome: options.xdgDataHome,
})) as SupportAssetsResultWithComponent[];
}
test('detectSupportAssetDataDirs only returns Linux support-asset locations', () => {
assert.deepEqual(
detectSupportAssetDataDirs({
platform: 'darwin',
@@ -46,9 +93,10 @@ test('detectSupportAssetDataDirs only returns Linux rofi theme locations', () =>
);
});
test('buildProtectedSupportAssetsCommand cleans up temporary extraction directory', () => {
test('buildProtectedSupportAssetsCommand installs both theme and plugin assets', () => {
const command = buildProtectedSupportAssetsCommand(
"https://example.test/subminer assets.tar.gz?sig='abc'",
'ABCDEF1234',
"/usr/local/share/SubMiner's data",
);
@@ -58,46 +106,419 @@ test('buildProtectedSupportAssetsCommand cleans up temporary extraction director
command,
/curl -fSL 'https:\/\/example\.test\/subminer assets\.tar\.gz\?sig='\\''abc'\\''' -o "\$tmp\/subminer-assets\.tar\.gz"/,
);
assert.match(
command,
/printf '%s %s\\n' 'abcdef1234' "\$tmp\/subminer-assets\.tar\.gz" \| sha256sum -c -/,
);
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/themes/);
assert.match(
command,
/sudo cp "\$tmp\/assets\/themes\/subminer\.rasi" '\/usr\/local\/share\/SubMiner'\\''s data'\/themes\/subminer\.rasi/,
);
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/plugin/);
assert.match(command, /sudo rm -rf .*plugin\/subminer\.next/);
assert.match(command, /sudo cp -R "\$tmp\/plugin\/subminer" .*plugin\/subminer\.next/);
assert.match(command, /sudo mv .*plugin\/subminer\.next.*plugin\/subminer/);
});
test('updateSupportAssetsFromRelease updates only the Linux rofi theme', async () => {
test('updateSupportAssetsFromRelease skips on non-Linux platforms', async () => {
const { archive, tempDir } = makeSupportAssetsArchive();
try {
const results = await runLinuxSupportAssetUpdate({
archive,
platform: 'darwin',
});
assert.deepEqual(results, [
{
status: 'skipped',
message: 'Support assets are only installed on Linux.',
},
]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('updateSupportAssetsFromRelease skips when no managed support-asset roots exist', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n');
const { archive, tempDir } = makeSupportAssetsArchive();
try {
const results = await updateSupportAssetsFromRelease({
release: {
tag_name: 'v0.15.0',
assets: [
{
name: 'subminer-assets.tar.gz',
browser_download_url: 'https://example.test/subminer-assets.tar.gz',
},
],
},
sha256Sums: new Map([['subminer-assets.tar.gz', sha256(archive)]]),
downloadAsset: async () => archive,
platform: 'linux',
const results = await runLinuxSupportAssetUpdate({
archive,
xdgDataHome,
});
assert.deepEqual(results, [{ status: 'updated', path: dataDir }]);
assert.deepEqual(results, [
{
status: 'skipped',
message: 'No managed SubMiner support-asset install detected.',
},
]);
} finally {
fs.rmSync(xdgDataHome, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('updateSupportAssetsFromRelease skips existing data roots without managed asset markers', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(path.join(dataDir, 'preferences.json'), '{}\n');
const { archive, tempDir } = makeSupportAssetsArchive();
try {
const results = await runLinuxSupportAssetUpdate({
archive,
xdgDataHome,
});
assert.deepEqual(results, [
{
status: 'skipped',
message: 'No managed SubMiner support-asset install detected.',
},
]);
} finally {
fs.rmSync(xdgDataHome, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('updateSupportAssetsFromRelease installs missing plugin into a root with a managed theme marker', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
const { archive, tempDir } = makeSupportAssetsArchive({
extraPluginFiles: [{ relativePath: 'nested.lua', content: 'nested file\n' }],
});
try {
const results = await runLinuxSupportAssetUpdate({
archive,
xdgDataHome,
});
assert.deepEqual(results, [
{
status: 'updated',
component: 'theme',
path: dataDir,
message: 'Updated theme.',
},
{
status: 'updated',
component: 'plugin',
path: dataDir,
message: 'Installed plugin.',
},
]);
assert.equal(
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
'new theme\n',
);
assert.equal(
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
'old plugin\n',
'new plugin\n',
);
assert.equal(
fs.readFileSync(path.join(dataDir, 'plugin/subminer/version.lua'), 'utf8'),
'return {\n\tversion = "0.12.0",\n}\n',
);
assert.equal(
fs.readFileSync(path.join(dataDir, 'plugin/subminer/nested.lua'), 'utf8'),
'nested file\n',
);
} finally {
fs.rmSync(xdgDataHome, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('updateSupportAssetsFromRelease preserves existing plugin when staged replacement copy fails', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
const pluginDir = path.join(dataDir, 'plugin/subminer');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'same theme\n');
fs.writeFileSync(path.join(pluginDir, 'main.lua'), 'old plugin\n');
fs.writeFileSync(path.join(pluginDir, 'version.lua'), 'return {\n\tversion = "0.11.0",\n}\n');
const { archive, tempDir } = makeSupportAssetsArchive({
themeContent: 'same theme\n',
pluginVersion: '0.12.0',
extraPluginFiles: [{ relativePath: 'blocked.lua', content: 'blocked\n' }],
});
try {
const originalCp = fs.promises.cp;
fs.promises.cp = async (...args: Parameters<typeof fs.promises.cp>) => {
const targetPath = String(args[1]);
if (
targetPath.endsWith(`${path.sep}subminer`) ||
targetPath.endsWith(`${path.sep}subminer.next`)
) {
throw new Error('copy failed');
}
return originalCp(...args);
};
try {
await assert.rejects(
() =>
runLinuxSupportAssetUpdate({
archive,
xdgDataHome,
}),
/copy failed/,
);
} finally {
fs.promises.cp = originalCp;
}
assert.equal(fs.readFileSync(path.join(pluginDir, 'main.lua'), 'utf8'), 'old plugin\n');
assert.equal(
fs.readFileSync(path.join(pluginDir, 'version.lua'), 'utf8'),
'return {\n\tversion = "0.11.0",\n}\n',
);
} finally {
fs.rmSync(xdgDataHome, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('updateSupportAssetsFromRelease reports both activation and rollback failures', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
const pluginDir = path.join(dataDir, 'plugin/subminer');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'same theme\n');
fs.writeFileSync(path.join(pluginDir, 'main.lua'), 'old plugin\n');
fs.writeFileSync(path.join(pluginDir, 'version.lua'), 'return {\n\tversion = "0.11.0",\n}\n');
const { archive, tempDir } = makeSupportAssetsArchive({
themeContent: 'same theme\n',
pluginVersion: '0.12.0',
});
try {
const originalRename = fs.promises.rename;
fs.promises.rename = async (...args: Parameters<typeof fs.promises.rename>) => {
const sourcePath = String(args[0]);
const targetPath = String(args[1]);
if (sourcePath.endsWith(`${path.sep}subminer.next`)) {
throw new Error('activate failed');
}
if (
sourcePath.endsWith(`${path.sep}subminer.bak`) &&
targetPath.endsWith(`${path.sep}subminer`)
) {
throw new Error('rollback failed');
}
return originalRename(...args);
};
try {
await assert.rejects(
() =>
runLinuxSupportAssetUpdate({
archive,
xdgDataHome,
}),
(error) =>
error instanceof AggregateError &&
/failed to activate staged plugin/i.test(error.message) &&
error.errors.some((nested) => String(nested).includes('activate failed')) &&
error.errors.some((nested) => String(nested).includes('rollback failed')),
);
} finally {
fs.promises.rename = originalRename;
}
} finally {
fs.rmSync(xdgDataHome, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('updateSupportAssetsFromRelease skips identical theme and up-to-date plugin', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'same theme\n');
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'same plugin\n');
fs.writeFileSync(
path.join(dataDir, 'plugin/subminer/version.lua'),
'return {\n\tversion = "0.12.0",\n}\n',
);
const { archive, tempDir } = makeSupportAssetsArchive({
themeContent: 'same theme\n',
pluginVersion: '0.12.0',
pluginMainContent: 'release plugin differs but version matches\n',
});
try {
const results = await runLinuxSupportAssetUpdate({
archive,
xdgDataHome,
});
assert.deepEqual(results, [
{
status: 'skipped',
component: 'theme',
path: dataDir,
message: 'Theme already up to date.',
},
{
status: 'skipped',
component: 'plugin',
path: dataDir,
message: 'Plugin already up to date.',
},
]);
assert.equal(
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
'same theme\n',
);
assert.equal(
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
'same plugin\n',
);
} finally {
fs.rmSync(xdgDataHome, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('updateSupportAssetsFromRelease updates changed theme and outdated plugin while removing stale plugin files', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n');
fs.writeFileSync(
path.join(dataDir, 'plugin/subminer/version.lua'),
'return {\n\tversion = "0.11.0",\n}\n',
);
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/stale.lua'), 'stale\n');
const { archive, tempDir } = makeSupportAssetsArchive({
themeContent: 'new theme\n',
pluginVersion: '0.12.0',
pluginMainContent: 'new plugin main\n',
extraPluginFiles: [{ relativePath: 'fresh.lua', content: 'fresh\n' }],
});
try {
const results = await runLinuxSupportAssetUpdate({
archive,
xdgDataHome,
});
assert.deepEqual(results, [
{
status: 'updated',
component: 'theme',
path: dataDir,
message: 'Updated theme.',
},
{
status: 'updated',
component: 'plugin',
path: dataDir,
message: 'Updated plugin.',
},
]);
assert.equal(
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
'new theme\n',
);
assert.equal(
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
'new plugin main\n',
);
assert.equal(
fs.readFileSync(path.join(dataDir, 'plugin/subminer/fresh.lua'), 'utf8'),
'fresh\n',
);
assert.equal(fs.existsSync(path.join(dataDir, 'plugin/subminer/stale.lua')), false);
} finally {
fs.rmSync(xdgDataHome, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('updateSupportAssetsFromRelease returns protected commands for managed roots that are not writable', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'managed theme\n');
const originalMode = fs.statSync(dataDir).mode & 0o777;
fs.chmodSync(dataDir, 0o555);
const { archive, tempDir } = makeSupportAssetsArchive();
try {
const results = await runLinuxSupportAssetUpdate({
archive,
xdgDataHome,
});
assert.deepEqual(
results.map((result) => ({
status: result.status,
component: result.component,
path: result.path,
command: typeof result.command === 'string',
})),
[
{
status: 'protected',
component: 'theme',
path: dataDir,
command: true,
},
{
status: 'protected',
component: 'plugin',
path: dataDir,
command: true,
},
],
);
assert.match(results[0]?.command ?? '', /themes\/subminer\.rasi/);
assert.match(results[0]?.command ?? '', /plugin\/subminer/);
} finally {
fs.chmodSync(dataDir, originalMode);
fs.rmSync(xdgDataHome, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('updateSupportAssetsFromRelease returns missing-asset when release plugin version metadata is absent', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'managed theme\n');
const { archive, tempDir } = makeSupportAssetsArchive({
pluginVersion: null,
});
try {
const results = await runLinuxSupportAssetUpdate({
archive,
xdgDataHome,
});
assert.deepEqual(results, [
{
status: 'missing-asset',
message: 'Support asset archive has no readable plugin version metadata.',
},
]);
} finally {
fs.rmSync(xdgDataHome, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
+254 -48
View File
@@ -5,12 +5,17 @@ import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import type { GitHubRelease } from './release-assets';
import { findReleaseAsset } from './release-assets';
import { compareSemverLike, findReleaseAsset } from './release-assets';
const execFileAsync = promisify(execFile);
const THEME_RELATIVE_PATH = path.join('themes', 'subminer.rasi');
const PLUGIN_ENTRYPOINT_RELATIVE_PATH = path.join('plugin', 'subminer', 'main.lua');
const PLUGIN_VERSION_RELATIVE_PATH = path.join('plugin', 'subminer', 'version.lua');
const PLUGIN_DIR_RELATIVE_PATH = path.join('plugin', 'subminer');
export interface SupportAssetsUpdateResult {
status: 'updated' | 'skipped' | 'protected' | 'hash-mismatch' | 'missing-asset';
component?: 'theme' | 'plugin';
path?: string;
command?: string;
message?: string;
@@ -24,6 +29,108 @@ function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function parsePluginVersion(content: string): string | null {
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
return match?.[1] ?? null;
}
async function pathExists(targetPath: string): Promise<boolean> {
return await fs.promises
.access(targetPath)
.then(() => true)
.catch(() => false);
}
async function canWrite(targetPath: string): Promise<boolean> {
return await fs.promises
.access(targetPath, fs.constants.W_OK)
.then(() => true)
.catch(() => false);
}
async function readFileIfExists(targetPath: string): Promise<Buffer | null> {
try {
return await fs.promises.readFile(targetPath);
} catch {
return null;
}
}
async function readInstalledPluginVersion(pluginDir: string): Promise<string | null> {
try {
return parsePluginVersion(
await fs.promises.readFile(path.join(pluginDir, 'version.lua'), 'utf8'),
);
} catch {
return null;
}
}
async function detectManagedSupportAssetDataDirs(dataDirs: string[]): Promise<string[]> {
const managedDataDirs: string[] = [];
for (const dataDir of dataDirs) {
const [hasTheme, hasPlugin] = await Promise.all([
pathExists(path.join(dataDir, THEME_RELATIVE_PATH)),
pathExists(path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH)),
]);
if (hasTheme || hasPlugin) {
managedDataDirs.push(dataDir);
}
}
return managedDataDirs;
}
async function replacePluginDir(sourcePluginDir: string, targetPluginDir: string): Promise<void> {
const parentDir = path.dirname(targetPluginDir);
const stagedDir = `${targetPluginDir}.next`;
const backupDir = `${targetPluginDir}.bak`;
const targetExists = await pathExists(targetPluginDir);
await fs.promises.rm(stagedDir, { recursive: true, force: true });
await fs.promises.rm(backupDir, { recursive: true, force: true });
await fs.promises.mkdir(parentDir, { recursive: true });
await fs.promises.cp(sourcePluginDir, stagedDir, { recursive: true });
if (targetExists) {
await fs.promises.rename(targetPluginDir, backupDir);
}
try {
await fs.promises.rename(stagedDir, targetPluginDir);
} catch (err) {
if (targetExists) {
try {
await fs.promises.rename(backupDir, targetPluginDir);
} catch (rollbackErr) {
throw new AggregateError(
[err, rollbackErr],
'Failed to activate staged plugin and failed to restore previous plugin directory.',
);
}
}
throw err;
}
await fs.promises.rm(backupDir, { recursive: true, force: true });
}
function makeSupportAssetResult(
status: SupportAssetsUpdateResult['status'],
component: SupportAssetsUpdateResult['component'],
dataDir: string,
message: string,
command?: string,
): SupportAssetsUpdateResult {
const result: SupportAssetsUpdateResult = {
status,
component,
path: dataDir,
message,
};
if (command) {
result.command = command;
}
return result;
}
export function detectSupportAssetDataDirs(options: {
platform: NodeJS.Platform;
homeDir: string;
@@ -40,32 +147,33 @@ export function detectSupportAssetDataDirs(options: {
return [];
}
export function buildProtectedSupportAssetsCommand(assetUrl: string, dataDir: string): string {
export function buildProtectedSupportAssetsCommand(
assetUrl: string,
expectedSha256: string,
dataDir: string,
): string {
const quotedDir = shellQuote(dataDir);
const quotedPluginDir = shellQuote(path.posix.join(dataDir, 'plugin/subminer'));
const quotedStagedPluginDir = shellQuote(path.posix.join(dataDir, 'plugin/subminer.next'));
const quotedBackupPluginDir = shellQuote(path.posix.join(dataDir, 'plugin/subminer.bak'));
const quotedExpectedSha256 = shellQuote(expectedSha256.toLowerCase());
return [
'tmp=$(mktemp -d)',
'trap \'rm -rf "$tmp"\' EXIT',
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
`printf '%s %s\\n' ${quotedExpectedSha256} "$tmp/subminer-assets.tar.gz" | sha256sum -c -`,
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
`sudo mkdir -p ${quotedDir}/themes`,
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
`sudo mkdir -p ${quotedDir}/plugin`,
`sudo rm -rf ${quotedStagedPluginDir} ${quotedBackupPluginDir}`,
`sudo cp -R "$tmp/plugin/subminer" ${quotedStagedPluginDir}`,
`[ ! -e ${quotedPluginDir} ] || sudo mv ${quotedPluginDir} ${quotedBackupPluginDir}`,
`sudo mv ${quotedStagedPluginDir} ${quotedPluginDir}`,
`sudo rm -rf ${quotedBackupPluginDir}`,
].join(' && ');
}
async function pathExists(targetPath: string): Promise<boolean> {
return await fs.promises
.access(targetPath)
.then(() => true)
.catch(() => false);
}
async function canWrite(targetPath: string): Promise<boolean> {
return await fs.promises
.access(targetPath, fs.constants.W_OK)
.then(() => true)
.catch(() => false);
}
export async function updateSupportAssetsFromRelease(options: {
release: GitHubRelease | null;
sha256Sums: Map<string, string>;
@@ -79,10 +187,12 @@ export async function updateSupportAssetsFromRelease(options: {
}
if (!options.release) return [{ status: 'missing-asset', message: 'No release found.' }];
const asset = findReleaseAsset(options.release, 'subminer-assets.tar.gz');
if (!asset) return [{ status: 'missing-asset', message: 'Release has no rofi theme asset.' }];
if (!asset) {
return [{ status: 'missing-asset', message: 'Release has no support asset archive.' }];
}
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
if (!expectedSha256) {
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no rofi theme entry.' }];
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no support asset entry.' }];
}
const dataDirs = detectSupportAssetDataDirs({
@@ -90,41 +200,69 @@ export async function updateSupportAssetsFromRelease(options: {
homeDir: options.homeDir ?? os.homedir(),
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
});
const existingDataDirs: string[] = [];
for (const dataDir of dataDirs) {
const hasTheme = await pathExists(path.join(dataDir, 'themes/subminer.rasi'));
if (hasTheme) existingDataDirs.push(dataDir);
}
if (existingDataDirs.length === 0) {
return [{ status: 'skipped', message: 'No existing rofi theme install detected.' }];
const managedDataDirs = await detectManagedSupportAssetDataDirs(dataDirs);
if (managedDataDirs.length === 0) {
return [{ status: 'skipped', message: 'No managed SubMiner support-asset install detected.' }];
}
const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs
.filter((dataDir) => !fs.existsSync(dataDir) || !fs.statSync(dataDir).isDirectory())
.map((dataDir) => ({
status: 'skipped' as const,
path: dataDir,
message: 'Support asset path is not a directory.',
}));
const results: SupportAssetsUpdateResult[] = [];
const writableDataDirs: string[] = [];
for (const dataDir of existingDataDirs) {
for (const dataDir of managedDataDirs) {
if (!fs.existsSync(dataDir) || !fs.statSync(dataDir).isDirectory()) {
results.push(
makeSupportAssetResult(
'skipped',
'theme',
dataDir,
'Support asset path is not a directory.',
),
makeSupportAssetResult(
'skipped',
'plugin',
dataDir,
'Support asset path is not a directory.',
),
);
continue;
}
if (await canWrite(dataDir)) {
writableDataDirs.push(dataDir);
} else {
protectedResults.push({
status: 'protected',
path: dataDir,
command: buildProtectedSupportAssetsCommand(asset.browser_download_url, dataDir),
});
continue;
}
const command = buildProtectedSupportAssetsCommand(
asset.browser_download_url,
expectedSha256,
dataDir,
);
results.push(
makeSupportAssetResult(
'protected',
'theme',
dataDir,
'Theme install requires a manual command.',
command,
),
makeSupportAssetResult(
'protected',
'plugin',
dataDir,
'Plugin install requires a manual command.',
command,
),
);
}
if (writableDataDirs.length === 0) {
return results;
}
if (writableDataDirs.length === 0) return protectedResults;
const archive = await options.downloadAsset(asset.browser_download_url);
const actualSha256 = sha256(archive);
if (actualSha256 !== expectedSha256.toLowerCase()) {
return [
...protectedResults,
...results,
{
status: 'hash-mismatch',
message: `Expected ${expectedSha256}, got ${actualSha256}.`,
@@ -137,17 +275,85 @@ export async function updateSupportAssetsFromRelease(options: {
const archivePath = path.join(tempDir, 'subminer-assets.tar.gz');
await fs.promises.writeFile(archivePath, archive);
await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir]);
const results: SupportAssetsUpdateResult[] = [...protectedResults];
const themeSourcePath = path.join(tempDir, 'assets/themes/subminer.rasi');
if (!(await pathExists(themeSourcePath))) {
return [
{ status: 'missing-asset', message: 'Support asset archive is missing the rofi theme.' },
];
}
const themeBytes = await fs.promises.readFile(themeSourcePath);
const sourcePluginDir = path.join(tempDir, PLUGIN_DIR_RELATIVE_PATH);
const sourcePluginEntrypoint = path.join(tempDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH);
if (!(await pathExists(sourcePluginEntrypoint))) {
return [
{
status: 'missing-asset',
message: 'Support asset archive is missing the runtime plugin.',
},
];
}
const sourcePluginVersion = parsePluginVersion(
await fs.promises
.readFile(path.join(tempDir, PLUGIN_VERSION_RELATIVE_PATH), 'utf8')
.catch(() => ''),
);
if (!sourcePluginVersion) {
return [
{
status: 'missing-asset',
message: 'Support asset archive has no readable plugin version metadata.',
},
];
}
for (const dataDir of writableDataDirs) {
const targetThemePath = path.join(dataDir, 'themes/subminer.rasi');
if (await pathExists(targetThemePath)) {
await fs.promises.copyFile(
path.join(tempDir, 'assets/themes/subminer.rasi'),
targetThemePath,
const targetThemePath = path.join(dataDir, THEME_RELATIVE_PATH);
const existingThemeBytes = await readFileIfExists(targetThemePath);
if (existingThemeBytes && Buffer.compare(existingThemeBytes, themeBytes) === 0) {
results.push(
makeSupportAssetResult('skipped', 'theme', dataDir, 'Theme already up to date.'),
);
} else {
await fs.promises.mkdir(path.dirname(targetThemePath), { recursive: true });
await fs.promises.writeFile(targetThemePath, themeBytes);
results.push(
makeSupportAssetResult(
'updated',
'theme',
dataDir,
existingThemeBytes ? 'Updated theme.' : 'Installed theme.',
),
);
}
results.push({ status: 'updated', path: dataDir });
const targetPluginDir = path.join(dataDir, PLUGIN_DIR_RELATIVE_PATH);
const targetPluginEntrypoint = path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH);
const installedPluginVersion = await readInstalledPluginVersion(targetPluginDir);
const installedEntrypointExists = await pathExists(targetPluginEntrypoint);
const shouldInstallPlugin =
!installedEntrypointExists ||
!installedPluginVersion ||
compareSemverLike(sourcePluginVersion, installedPluginVersion) > 0;
if (!shouldInstallPlugin) {
results.push(
makeSupportAssetResult('skipped', 'plugin', dataDir, 'Plugin already up to date.'),
);
continue;
}
await replacePluginDir(sourcePluginDir, targetPluginDir);
results.push(
makeSupportAssetResult(
'updated',
'plugin',
dataDir,
installedEntrypointExists ? 'Updated plugin.' : 'Installed plugin.',
),
);
}
return results;
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true });
@@ -97,6 +97,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
};
let visibleOverlayInteractionActive = false;
let linuxOverlayInputShapeActive = false;
let linuxOverlayPointerInteractionStateApplied = process.platform !== 'linux';
let linuxVisibleOverlayStartupInputPrimed = false;
let linuxVisibleOverlayStartupInputGraceUntilMs = 0;
// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal
@@ -122,6 +123,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
function resetVisibleOverlayInputState(): void {
visibleOverlayInteractionActive = false;
linuxOverlayInputShapeActive = false;
linuxOverlayPointerInteractionStateApplied = false;
resetLinuxVisibleOverlayStartupInputPrimer();
linuxOverlayInteractiveHint = false;
overlayContentMeasurementStore.clear('visible');
@@ -616,9 +618,23 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
linuxVisibleOverlayStartupInputGraceUntilMs = 0;
}
function startLinuxVisibleOverlayStartupInputGrace(): void {
if (process.platform !== 'linux') {
return;
}
linuxVisibleOverlayStartupInputGraceUntilMs =
Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;
linuxOverlayPointerInteractionStateApplied = false;
}
function resetLinuxVisibleOverlayStartupInputPrimer(): void {
linuxVisibleOverlayStartupInputPrimed = false;
clearLinuxVisibleOverlayStartupInputGrace();
if (process.platform === 'linux') {
visibleOverlayInteractionActive = false;
linuxOverlayInteractiveHint = false;
linuxOverlayPointerInteractionStateApplied = false;
}
}
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
@@ -636,6 +652,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
});
linuxOverlayInputShapeActive = result.active;
linuxOverlayPointerInteractionStateApplied = result.handled;
return result.handled;
}
@@ -652,9 +669,11 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
})
) {
linuxOverlayPointerInteractionStateApplied = true;
return;
}
linuxOverlayPointerInteractionStateApplied = true;
deps.updateVisibleOverlayVisibility();
}
@@ -749,6 +768,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
shouldUseInputShape: shouldUseLinuxOverlayInputShape,
getInteractionActive: () => visibleOverlayInteractionActive,
isInteractionStateApplied: () => linuxOverlayPointerInteractionStateApplied,
setInteractionActive: updateLinuxOverlayPointerInteractionActive,
};
@@ -785,6 +805,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
getLinuxOverlayPointerMeasurement,
hasLinuxVisibleOverlayStartupInputGrace,
clearLinuxVisibleOverlayStartupInputGrace,
startLinuxVisibleOverlayStartupInputGrace,
resetLinuxVisibleOverlayStartupInputPrimer,
applyLinuxOverlayInputShapeFromLatestMeasurement,
updateLinuxOverlayPointerInteractionActive,