mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-17 03:13:30 -07:00
fix(linux): auto-install managed plugin copy; include in asset updates (#127)
This commit is contained in:
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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 () => {},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user