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

This commit is contained in:
2026-06-14 17:25:28 -07:00
committed by GitHub
parent ae7e6f82a8
commit a117c5759c
53 changed files with 3050 additions and 152 deletions
+4
View File
@@ -243,6 +243,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
runUpdateCommand: async (args) => {
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
calls.push('runEnsureLinuxRuntimePluginAssetsCommand');
},
printHelp: () => {
calls.push('printHelp');
},
@@ -624,6 +627,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
stop: () => {},
hasMainWindow: () => true,
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
},
dispatchSessionAction: async () => {},
+19
View File
@@ -97,6 +97,10 @@ export interface CliCommandServiceDeps {
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runEnsureLinuxRuntimePluginAssetsCommand: (
args: CliArgs,
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
@@ -182,6 +186,7 @@ interface AppCliRuntime {
stop: () => void;
hasMainWindow: () => boolean;
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
}
@@ -292,6 +297,7 @@ export function createCliCommandDepsRuntime(
runStatsCommand: options.jellyfin.runStatsCommand,
runJellyfinCommand: options.jellyfin.runCommand,
runUpdateCommand: options.app.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow,
@@ -454,6 +460,19 @@ export function handleCliCommand(
deps.stopApp();
}
});
} else if (args.ensureLinuxRuntimePluginAssets) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps
.runEnsureLinuxRuntimePluginAssetsCommand(args, source)
.catch((err) => {
deps.error('runEnsureLinuxRuntimePluginAssetsCommand failed:', err);
deps.showMpvOsd(`Linux runtime plugin install failed: ${(err as Error).message}`);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.toggleSecondarySub) {
deps.cycleSecondarySubMode();
} else if (args.triggerFieldGrouping) {
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveDefaultNotificationIconPath } from './notification';
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
const path = resolveDefaultNotificationIconPath({
platform: 'linux',
resourcesPath: '/opt/SubMiner/resources',
appPath: '/opt/SubMiner/resources/app.asar',
dirname: '/opt/SubMiner/resources/app.asar/dist/core/utils',
cwd: '/opt/SubMiner',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => candidate === '/opt/SubMiner/resources/assets/SubMiner.png',
});
assert.equal(path, '/opt/SubMiner/resources/assets/SubMiner.png');
});
test('default notification icon prefers the square app icon when bundled images are available', () => {
const path = resolveDefaultNotificationIconPath({
platform: 'linux',
resourcesPath: '/opt/SubMiner/resources',
appPath: '/opt/SubMiner/resources/app.asar',
dirname: '/opt/SubMiner/resources/app.asar/dist/core/utils',
cwd: '/opt/SubMiner',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) =>
candidate === '/opt/SubMiner/resources/assets/SubMiner.png' ||
candidate === '/opt/SubMiner/resources/assets/SubMiner-square.png',
});
assert.equal(path, '/opt/SubMiner/resources/assets/SubMiner-square.png');
});
test('default notification icon avoids macOS tray template assets', () => {
const seen: string[] = [];
const path = resolveDefaultNotificationIconPath({
platform: 'darwin',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
dirname: '/Applications/SubMiner.app/Contents/Resources/app.asar/dist/core/utils',
cwd: '/Applications/SubMiner.app/Contents/Resources',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => {
seen.push(candidate);
return candidate.endsWith('/assets/SubMiner-square.png');
},
});
assert.equal(path, '/Applications/SubMiner.app/Contents/Resources/assets/SubMiner-square.png');
assert.equal(
seen.some((candidate) => candidate.includes('SubMinerTemplate')),
false,
);
});
test('default notification icon resolves cwd fallback through injected deps', () => {
const resolvedPath = resolveDefaultNotificationIconPath({
platform: 'linux',
resourcesPath: '/missing/resources',
appPath: '/missing/app',
dirname: '/missing/dist/core/utils',
cwd: '/portable/SubMiner',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => candidate === '/portable/SubMiner/assets/SubMiner-square.png',
});
assert.equal(resolvedPath, '/portable/SubMiner/assets/SubMiner-square.png');
});
+57 -9
View File
@@ -1,10 +1,57 @@
import electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger';
const { Notification, nativeImage } = electron;
const logger = createLogger('core:notification');
export function resolveDefaultNotificationIconPath(deps: {
platform: string;
resourcesPath: string;
appPath: string;
dirname: string;
cwd: string;
joinPath: (...parts: string[]) => string;
fileExists: (path: string) => boolean;
}): string | null {
const iconNames =
deps.platform === 'win32'
? ['SubMiner.ico', 'SubMiner-square.png', 'SubMiner.png']
: ['SubMiner-square.png', 'SubMiner.png'];
const baseDirs = [
deps.joinPath(deps.resourcesPath, 'assets'),
deps.joinPath(deps.appPath, 'assets'),
deps.joinPath(deps.dirname, '..', 'assets'),
deps.joinPath(deps.dirname, '..', '..', 'assets'),
deps.joinPath(deps.cwd, 'assets'),
];
for (const baseDir of baseDirs) {
for (const iconName of iconNames) {
const candidate = deps.joinPath(baseDir, iconName);
if (deps.fileExists(candidate)) {
return candidate;
}
}
}
return null;
}
function resolveRuntimeDefaultNotificationIconPath(): string | null {
return resolveDefaultNotificationIconPath({
platform: process.platform,
resourcesPath: process.resourcesPath,
appPath: electron.app?.getAppPath?.() ?? process.cwd(),
dirname: __dirname,
cwd: process.cwd(),
joinPath: (...parts) => path.join(...parts),
fileExists: (candidate) => fs.existsSync(candidate),
});
}
export function showDesktopNotification(
title: string,
options: { body?: string; icon?: string },
@@ -19,19 +66,20 @@ export function showDesktopNotification(
notificationOptions.body = options.body;
}
if (options.icon) {
const icon = options.icon ?? resolveRuntimeDefaultNotificationIconPath() ?? undefined;
if (icon) {
const isFilePath =
typeof options.icon === 'string' &&
(options.icon.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(options.icon));
typeof icon === 'string' && (icon.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(icon));
if (isFilePath) {
if (fs.existsSync(options.icon)) {
notificationOptions.icon = options.icon;
if (fs.existsSync(icon)) {
notificationOptions.icon = icon;
} else {
logger.warn('Notification icon file not found', options.icon);
logger.warn('Notification icon file not found', icon);
}
} else if (typeof options.icon === 'string' && options.icon.startsWith('data:image/')) {
const base64Data = options.icon.replace(/^data:image\/\w+;base64,/, '');
} else if (typeof icon === 'string' && icon.startsWith('data:image/')) {
const base64Data = icon.replace(/^data:image\/\w+;base64,/, '');
try {
const image = nativeImage.createFromBuffer(Buffer.from(base64Data, 'base64'));
if (image.isEmpty()) {
@@ -45,7 +93,7 @@ export function showDesktopNotification(
logger.error('Failed to create notification icon from base64', err);
}
} else {
notificationOptions.icon = options.icon;
notificationOptions.icon = icon;
}
}