fix: address CodeRabbit review findings across runtime modules

- Extract filterLegacyMpvPluginFileCandidates, buildYomitanAnkiSettingsKey, setMpvCurrentSecondarySubText, runSupportAssetUpdatesForLauncherResult helpers
- Include forceOverride in yomitan anki settings cache key (was missing, causing incorrect cache hits)
- Detect same-PID stale stats daemon state to avoid self-connect
- Validate non-empty extension in buildFfmpegSubtitleExtractionArgs
- Drop unused message param from showOverlayLoadingStatusNotification
- Log and rethrow on session bindings artifact write failure
- Add unit tests for all extracted helpers
This commit is contained in:
2026-06-12 00:36:33 -07:00
parent b9fe555b94
commit 572bdd1cf7
20 changed files with 261 additions and 36 deletions
+1
View File
@@ -45,6 +45,7 @@ export interface MpvRuntimeClientLike {
playNextSubtitle?: () => void; playNextSubtitle?: () => void;
setSubVisibility?: (visible: boolean) => void; setSubVisibility?: (visible: boolean) => void;
setSecondarySubVisibility?: (visible: boolean) => void; setSecondarySubVisibility?: (visible: boolean) => void;
setCurrentSecondarySubText?: (text: string) => void;
} }
export function showMpvOsdRuntime( export function showMpvOsdRuntime(
+2 -2
View File
@@ -2616,8 +2616,8 @@ function showYoutubeFlowStatusNotification(message: string): void {
overlayNotificationsRuntime.showYoutubeFlowStatusNotification(message); overlayNotificationsRuntime.showYoutubeFlowStatusNotification(message);
} }
function showOverlayLoadingStatusNotification(message: string): void { function showOverlayLoadingStatusNotification(_message: string): void {
overlayNotificationsRuntime.showOverlayLoadingStatusNotification(message); overlayNotificationsRuntime.showOverlayLoadingStatusNotification();
} }
function dismissOverlayLoadingStatusNotification(): void { function dismissOverlayLoadingStatusNotification(): void {
+8 -5
View File
@@ -1,12 +1,15 @@
const PASSWORD_STORE_ARG = '--password-store';
const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret';
export function getPasswordStoreArg(argv: string[]): string | null { export function getPasswordStoreArg(argv: string[]): string | null {
let resolved: string | null = null; let resolved: string | null = null;
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]; const arg = argv[i];
if (!arg?.startsWith('--password-store')) { if (!arg?.startsWith(PASSWORD_STORE_ARG)) {
continue; continue;
} }
if (arg === '--password-store') { if (arg === PASSWORD_STORE_ARG) {
const value = argv[i + 1]; const value = argv[i + 1];
if (value && !value.startsWith('--')) { if (value && !value.startsWith('--')) {
resolved = value.trim(); resolved = value.trim();
@@ -16,7 +19,7 @@ export function getPasswordStoreArg(argv: string[]): string | null {
} }
const [prefix, value] = arg.split('=', 2); const [prefix, value] = arg.split('=', 2);
if (prefix === '--password-store' && value && value.trim().length > 0) { if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) {
resolved = value.trim(); resolved = value.trim();
} }
} }
@@ -26,11 +29,11 @@ export function getPasswordStoreArg(argv: string[]): string | null {
export function normalizePasswordStoreArg(value: string): string { export function normalizePasswordStoreArg(value: string): string {
const normalized = value.trim(); const normalized = value.trim();
if (normalized.toLowerCase() === 'gnome') { if (normalized.toLowerCase() === 'gnome') {
return 'gnome-libsecret'; return DEFAULT_LINUX_PASSWORD_STORE;
} }
return normalized; return normalized;
} }
export function getDefaultPasswordStore(): string { export function getDefaultPasswordStore(): string {
return 'gnome-libsecret'; return DEFAULT_LINUX_PASSWORD_STORE;
} }
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { setMpvCurrentSecondarySubText } from './autoplay-subtitle-priming-runtime';
test('setMpvCurrentSecondarySubText uses client setter when available', () => {
const calls: string[] = [];
const client = {
currentSecondarySubText: '',
setCurrentSecondarySubText: (text: string) => {
calls.push(text);
},
};
setMpvCurrentSecondarySubText(client, 'secondary');
assert.deepEqual(calls, ['secondary']);
assert.equal(client.currentSecondarySubText, '');
});
@@ -12,6 +12,7 @@ type AutoplaySubtitlePrimingMpvClient = {
currentVideoPath?: string; currentVideoPath?: string;
currentTimePos?: number; currentTimePos?: number;
currentSecondarySubText?: string; currentSecondarySubText?: string;
setCurrentSecondarySubText?: (text: string) => void;
}; };
type AutoplaySubtitlePrimingPrefetchService = { type AutoplaySubtitlePrimingPrefetchService = {
@@ -45,6 +46,20 @@ export interface AutoplaySubtitlePrimingRuntimeDeps {
logDebug: (message: string) => void; logDebug: (message: string) => void;
} }
export function setMpvCurrentSecondarySubText(
client: Pick<
AutoplaySubtitlePrimingMpvClient,
'currentSecondarySubText' | 'setCurrentSecondarySubText'
>,
text: string,
): void {
if (typeof client.setCurrentSecondarySubText === 'function') {
client.setCurrentSecondarySubText(text);
return;
}
client.currentSecondarySubText = text;
}
export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) { export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) {
const { subtitleProcessingController, emitSubtitlePayload } = deps; const { subtitleProcessingController, emitSubtitlePayload } = deps;
@@ -137,7 +152,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
setCurrentSecondarySubText: (text) => { setCurrentSecondarySubText: (text) => {
const client = deps.getMpvClient(); const client = deps.getMpvClient();
if (client) { if (client) {
client.currentSecondarySubText = text; setMpvCurrentSecondarySubText(client, text);
} }
}, },
emitSecondarySubtitle: (text) => { emitSecondarySubtitle: (text) => {
@@ -7,6 +7,7 @@ import {
detectInstalledFirstRunPlugin, detectInstalledFirstRunPlugin,
detectInstalledFirstRunPluginCandidates, detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin, detectInstalledMpvPlugin,
filterLegacyMpvPluginFileCandidates,
removeLegacyMpvPluginCandidates, removeLegacyMpvPluginCandidates,
resolvePackagedFirstRunPluginAssets, resolvePackagedFirstRunPluginAssets,
resolvePackagedRuntimePluginPath, resolvePackagedRuntimePluginPath,
@@ -220,6 +221,20 @@ test('detectInstalledMpvPlugin detects Linux legacy single-file plugin without v
}); });
}); });
test('filterLegacyMpvPluginFileCandidates keeps only legacy file candidates', () => {
assert.deepEqual(
filterLegacyMpvPluginFileCandidates([
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' },
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
{ path: '/tmp/mpv/scripts/subminer-loader.lua', kind: 'file' },
]),
[
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
{ path: '/tmp/mpv/scripts/subminer-loader.lua', kind: 'file' },
],
);
});
test('removeLegacyMpvPluginCandidates trashes candidates and reports partial failures', async () => { test('removeLegacyMpvPluginCandidates trashes candidates and reports partial failures', async () => {
const calls: string[] = []; const calls: string[] = [];
const result = await removeLegacyMpvPluginCandidates({ const result = await removeLegacyMpvPluginCandidates({
@@ -180,6 +180,12 @@ export function detectInstalledFirstRunPluginCandidates(options: {
return candidates; return candidates;
} }
export function filterLegacyMpvPluginFileCandidates(
candidates: InstalledFirstRunPluginCandidate[],
): InstalledFirstRunPluginCandidate[] {
return candidates.filter((candidate) => candidate.kind === 'file');
}
function parseInstalledPluginVersion(content: string): string | null { function parseInstalledPluginVersion(content: string): string | null {
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/); const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
return match?.[1] ?? null; return match?.[1] ?? null;
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildFfmpegSubtitleExtractionArgs } from './internal-subtitle-extraction';
test('buildFfmpegSubtitleExtractionArgs rejects output paths without an extension', () => {
assert.throws(
() => buildFfmpegSubtitleExtractionArgs('/tmp/video.mkv', 2, '/tmp/subtitle-output'),
/outputPath.*file extension/,
);
});
@@ -51,6 +51,10 @@ export function buildFfmpegSubtitleExtractionArgs(
ffIndex: number, ffIndex: number,
outputPath: string, outputPath: string,
): string[] { ): string[] {
const outputFormat = path.extname(outputPath).slice(1);
if (!outputFormat) {
throw new Error(`outputPath must include a file extension for ffmpeg format: ${outputPath}`);
}
return [ return [
'-hide_banner', '-hide_banner',
'-nostdin', '-nostdin',
@@ -64,7 +68,7 @@ export function buildFfmpegSubtitleExtractionArgs(
'-map', '-map',
`0:${ffIndex}`, `0:${ffIndex}`,
'-f', '-f',
path.extname(outputPath).slice(1), outputFormat,
outputPath, outputPath,
]; ];
} }
@@ -56,7 +56,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
) => void; ) => void;
showSubsyncStatusNotification: (message: string) => void; showSubsyncStatusNotification: (message: string) => void;
showYoutubeFlowStatusNotification: (message: string) => void; showYoutubeFlowStatusNotification: (message: string) => void;
showOverlayLoadingStatusNotification: (message: string) => void; showOverlayLoadingStatusNotification: () => void;
dismissOverlayLoadingStatusNotification: () => void; dismissOverlayLoadingStatusNotification: () => void;
maybeStartOverlayLoadingOsd: (mediaPath?: string | null) => void; maybeStartOverlayLoadingOsd: (mediaPath?: string | null) => void;
} { } {
@@ -213,8 +213,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
return overlayLoadingOsdController; return overlayLoadingOsdController;
} }
function showOverlayLoadingStatusNotification(message: string): void { function showOverlayLoadingStatusNotification(): void {
void message;
getOverlayLoadingOsdController().start(); getOverlayLoadingOsdController().start();
} }
@@ -231,7 +230,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
getVisibleOverlayRequested: () => deps.getVisibleOverlayVisible(), getVisibleOverlayRequested: () => deps.getVisibleOverlayVisible(),
isOverlayContentReady: () => isVisibleOverlayContentReady(), isOverlayContentReady: () => isVisibleOverlayContentReady(),
startOverlayLoadingOsd: () => { startOverlayLoadingOsd: () => {
showOverlayLoadingStatusNotification('Overlay loading...'); showOverlayLoadingStatusNotification();
}, },
}); });
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import type { CompiledSessionBinding, ResolvedConfig } from '../../types';
import { createSessionBindingsRuntime } from './session-bindings-runtime';
test('persistSessionBindings logs and does not publish bindings when artifact write fails', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-bindings-runtime-'));
const configDir = path.join(root, 'config-file');
fs.writeFileSync(configDir, 'not a directory');
const calls: string[] = [];
const runtime = createSessionBindingsRuntime({
configDir,
getKeybindings: () => [],
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
getResolvedConfig: () =>
({
stats: { toggleKey: 's', markWatchedKey: 'w' },
}) as ResolvedConfig,
getMpvClient: () => null,
setSessionBindings: () => calls.push('setSessionBindings'),
setSessionBindingsInitialized: () => calls.push('setSessionBindingsInitialized'),
logWarn: (message) => calls.push(`warn:${message}`),
});
try {
assert.throws(
() => runtime.persistSessionBindings([] as CompiledSessionBinding[]),
/ENOTDIR|EEXIST/,
);
assert.deepEqual(calls, ['warn:[session-bindings] Failed to write session bindings artifact']);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+6 -1
View File
@@ -54,7 +54,12 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
warnings, warnings,
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs, numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
}); });
writeSessionBindingsArtifact(deps.configDir, artifact); try {
writeSessionBindingsArtifact(deps.configDir, artifact);
} catch (error) {
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
throw error;
}
deps.setSessionBindings(bindings); deps.setSessionBindings(bindings);
deps.setSessionBindingsInitialized(true); deps.setSessionBindingsInitialized(true);
const mpvClient = deps.getMpvClient(); const mpvClient = deps.getMpvClient();
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isSelfOwnedBackgroundStatsDaemonState,
shouldClearAppStateStatsServerOnStop,
} from './stats-server-runtime';
test('background stats daemon state owned by the current process is stale for stop flow', () => {
assert.equal(
isSelfOwnedBackgroundStatsDaemonState({ pid: process.pid, port: 6969, startedAtMs: 1 }),
true,
);
});
test('stats server app-state reference should be cleared after private server stop', () => {
assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true);
});
+21
View File
@@ -18,6 +18,20 @@ import {
import { createEnsureStatsServerUrlHandler } from './stats-server-routing'; import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server'; import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
export function isSelfOwnedBackgroundStatsDaemonState(state: {
pid: number;
port?: number;
startedAtMs?: number;
}): boolean {
return state.pid === process.pid;
}
export function shouldClearAppStateStatsServerOnStop(options: {
hadStatsServer: boolean;
}): boolean {
return options.hadStatsServer;
}
export interface StatsServerRuntimeDeps { export interface StatsServerRuntimeDeps {
userDataPath: string; userDataPath: string;
statsDistPath: string; statsDistPath: string;
@@ -90,6 +104,9 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
} }
statsServer.close(); statsServer.close();
statsServer = null; statsServer = null;
if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) {
deps.setAppStateStatsServer(null);
}
clearOwnedBackgroundStatsDaemonState(); clearOwnedBackgroundStatsDaemonState();
} }
@@ -198,6 +215,10 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
removeBackgroundStatsServerState(statsDaemonStatePath); removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true }; return { ok: true, stale: true };
} }
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if (!isBackgroundStatsServerProcessAlive(state.pid)) { if (!isBackgroundStatsServerProcessAlive(state.pid)) {
removeBackgroundStatsServerState(statsDaemonStatePath); removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true }; return { ok: true, stale: true };
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
test('runSupportAssetUpdatesForLauncherResult logs support-asset errors and preserves launcher result', async () => {
const warnings: string[] = [];
const launcherResult = { status: 'updated' } as const;
const result = await runSupportAssetUpdatesForLauncherResult({
launcherResult,
updateSupportAssets: async () => {
throw new Error('archive failed');
},
logWarn: (message, details) => {
warnings.push(`${message}:${details instanceof Error ? details.message : String(details)}`);
},
});
assert.equal(result, launcherResult);
assert.deepEqual(warnings, ['Support asset update failed after launcher update:archive failed']);
});
@@ -21,6 +21,7 @@ import { notifyUpdateAvailable } from './update-notifications';
import { createUpdateDialogPresenter } from './update-dialogs'; import { createUpdateDialogPresenter } from './update-dialogs';
import { createFileUpdateStateStore, createUpdateService } from './update-service'; import { createFileUpdateStateStore, createUpdateService } from './update-service';
import { updateSupportAssetsFromRelease } from './support-assets'; import { updateSupportAssetsFromRelease } from './support-assets';
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner'; const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
@@ -79,19 +80,16 @@ export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
launcherPath, launcherPath,
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url), downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
}); });
const supportResults = await updateSupportAssetsFromRelease({ return runSupportAssetUpdatesForLauncherResult({
release, launcherResult,
sha256Sums: sums, updateSupportAssets: () =>
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url), updateSupportAssetsFromRelease({
release,
sha256Sums: sums,
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
}),
logWarn: (message, details) => deps.logWarn(message, details),
}); });
for (const result of supportResults) {
if (result.status === 'protected' && result.command) {
deps.logWarn(`Rofi theme update requires manual command: ${result.command}`);
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
deps.logWarn(`Rofi theme update skipped: ${result.message ?? result.status}`);
}
}
return launcherResult;
} }
function getUpdateService() { function getUpdateService() {
@@ -0,0 +1,22 @@
export async function runSupportAssetUpdatesForLauncherResult<
TLauncherResult,
TSupportResult extends { status: string; command?: string; message?: string },
>(options: {
launcherResult: TLauncherResult;
updateSupportAssets: () => Promise<TSupportResult[]>;
logWarn: (message: string, details?: unknown) => void;
}): Promise<TLauncherResult> {
try {
const supportResults = await options.updateSupportAssets();
for (const result of supportResults) {
if (result.status === 'protected' && result.command) {
options.logWarn(`Rofi theme update requires manual command: ${result.command}`);
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
options.logWarn(`Rofi theme update skipped: ${result.message ?? result.status}`);
}
}
} catch (error) {
options.logWarn('Support asset update failed after launcher update', error);
}
return options.launcherResult;
}
@@ -3,6 +3,7 @@ import * as os from 'os';
import { import {
detectInstalledFirstRunPluginCandidates, detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin, detectInstalledMpvPlugin,
filterLegacyMpvPluginFileCandidates,
removeLegacyMpvPluginCandidates, removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath, resolvePackagedRuntimePluginPath,
} from './first-run-setup-plugin'; } from './first-run-setup-plugin';
@@ -86,12 +87,14 @@ export function createWindowsMpvPluginDetectionRuntime(
} }
const result = await removeLegacyMpvPluginCandidates({ const result = await removeLegacyMpvPluginCandidates({
candidates: detectInstalledFirstRunPluginCandidates({ candidates: filterLegacyMpvPluginFileCandidates(
platform: 'win32', detectInstalledFirstRunPluginCandidates({
homeDir: os.homedir(), platform: 'win32',
appDataDir: app.getPath('appData'), homeDir: os.homedir(),
mpvExecutablePath: mpvPath, appDataDir: app.getPath('appData'),
}), mpvExecutablePath: mpvPath,
}),
),
trashItem: (candidatePath) => shell.trashItem(candidatePath), trashItem: (candidatePath) => shell.trashItem(candidatePath),
}); });
if (result.ok) { if (result.ok) {
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildYomitanAnkiSettingsKey } from './yomitan-anki-server-sync';
test('buildYomitanAnkiSettingsKey includes force override policy', () => {
assert.notEqual(
buildYomitanAnkiSettingsKey({
targetUrl: 'http://127.0.0.1:8766',
targetDeck: 'Mining',
forceOverride: false,
}),
buildYomitanAnkiSettingsKey({
targetUrl: 'http://127.0.0.1:8766',
targetDeck: 'Mining',
forceOverride: true,
}),
);
});
+17 -4
View File
@@ -13,6 +13,14 @@ export interface YomitanAnkiServerSyncRuntimeDeps {
logInfo: (message: string, ...args: unknown[]) => void; logInfo: (message: string, ...args: unknown[]) => void;
} }
export function buildYomitanAnkiSettingsKey(options: {
targetUrl: string;
targetDeck: string;
forceOverride: boolean;
}): string {
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}`;
}
export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): { export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): {
syncYomitanDefaultProfileAnkiServer: () => Promise<void>; syncYomitanDefaultProfileAnkiServer: () => Promise<void>;
} { } {
@@ -30,7 +38,14 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
const targetUrl = getPreferredYomitanAnkiServerUrl().trim(); const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect; const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
const targetDeck = ankiConnectConfig?.deck?.trim() ?? ''; const targetDeck = ankiConnectConfig?.deck?.trim() ?? '';
const targetSettingsKey = `${targetUrl}\n${targetDeck}`; const forceOverride = ankiConnectConfig
? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig)
: false;
const targetSettingsKey = buildYomitanAnkiSettingsKey({
targetUrl,
targetDeck,
forceOverride,
});
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) { if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
return; return;
} }
@@ -47,9 +62,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
}, },
}, },
{ {
forceOverride: ankiConnectConfig forceOverride,
? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig)
: false,
deck: targetDeck, deck: targetDeck,
}, },
); );