mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-30 07:21:32 -07:00
feat(notifications): add overlay notifications with position config (#110)
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { notifyUpdateAvailable } from './update-notifications';
|
||||
import type { OverlayNotificationPayload } from '../../../types/notification';
|
||||
|
||||
test('notifyUpdateAvailable routes system and osd notifications from config', async () => {
|
||||
test('notifyUpdateAvailable routes notification surfaces from config', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = {
|
||||
showSystemNotification: (title: string, body: string) => {
|
||||
@@ -11,22 +12,52 @@ test('notifyUpdateAvailable routes system and osd notifications from config', as
|
||||
showOsdNotification: async (message: string) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload: OverlayNotificationPayload) => {
|
||||
calls.push(`overlay:${payload.title}:${payload.body ?? ''}`);
|
||||
},
|
||||
log: (message: string) => {
|
||||
calls.push(`log:${message}`);
|
||||
},
|
||||
};
|
||||
|
||||
await notifyUpdateAvailable({ notificationType: 'overlay', version: '0.15.0' }, deps);
|
||||
await notifyUpdateAvailable({ notificationType: 'system', version: '0.15.0' }, deps);
|
||||
await notifyUpdateAvailable({ notificationType: 'both', version: '0.15.0' }, deps);
|
||||
await notifyUpdateAvailable({ notificationType: 'osd-system', version: '0.15.0' }, deps);
|
||||
await notifyUpdateAvailable({ notificationType: 'none', version: '0.15.0' }, deps);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'overlay:SubMiner update available:SubMiner v0.15.0 is available',
|
||||
'system:SubMiner update available:SubMiner v0.15.0 is available',
|
||||
'overlay:SubMiner update available:SubMiner v0.15.0 is available',
|
||||
'system:SubMiner update available:SubMiner v0.15.0 is available',
|
||||
'osd:SubMiner v0.15.0 is available',
|
||||
'system:SubMiner update available:SubMiner v0.15.0 is available',
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifyUpdateAvailable adds an install action to overlay update notifications', async () => {
|
||||
const payloads: OverlayNotificationPayload[] = [];
|
||||
|
||||
await notifyUpdateAvailable(
|
||||
{ notificationType: 'overlay', version: '0.15.0' },
|
||||
{
|
||||
showSystemNotification: () => {},
|
||||
showOsdNotification: async () => {},
|
||||
showOverlayNotification: (nextPayload) => {
|
||||
payloads.push(nextPayload);
|
||||
},
|
||||
log: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const payload = payloads[0];
|
||||
assert.ok(payload);
|
||||
assert.deepEqual(payload.actions, [{ id: 'install-update', label: 'Update' }]);
|
||||
assert.equal(payload.id, 'subminer-update-available');
|
||||
assert.equal(payload.persistent, true);
|
||||
});
|
||||
|
||||
test('notifyUpdateAvailable logs osd fallback when overlay notification fails', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -39,6 +70,9 @@ test('notifyUpdateAvailable logs osd fallback when overlay notification fails',
|
||||
showOsdNotification: async () => {
|
||||
throw new Error('mpv disconnected');
|
||||
},
|
||||
showOverlayNotification: () => {
|
||||
calls.push('overlay');
|
||||
},
|
||||
log: (message) => {
|
||||
calls.push(message);
|
||||
},
|
||||
@@ -60,6 +94,9 @@ test('notifyUpdateAvailable logs non-error osd failures with thrown value', asyn
|
||||
showOsdNotification: async () => {
|
||||
throw 'mpv disconnected';
|
||||
},
|
||||
showOverlayNotification: () => {
|
||||
calls.push('overlay');
|
||||
},
|
||||
log: (message) => {
|
||||
calls.push(message);
|
||||
},
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { UpdateNotificationType } from '../../../types/config';
|
||||
import type { OverlayNotificationPayload } from '../../../types/notification';
|
||||
|
||||
export const UPDATE_AVAILABLE_NOTIFICATION_ID = 'subminer-update-available';
|
||||
export const INSTALL_UPDATE_ACTION_ID = 'install-update';
|
||||
|
||||
export interface UpdateNotificationDeps {
|
||||
showSystemNotification: (title: string, body: string) => void;
|
||||
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
|
||||
showOsdNotification: (message: string) => void | Promise<void>;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
@@ -13,10 +18,17 @@ export async function notifyUpdateAvailable(
|
||||
if (options.notificationType === 'none') return;
|
||||
|
||||
const message = `SubMiner v${options.version} is available`;
|
||||
if (options.notificationType === 'system' || options.notificationType === 'both') {
|
||||
deps.showSystemNotification('SubMiner update available', message);
|
||||
if (options.notificationType === 'overlay' || options.notificationType === 'both') {
|
||||
deps.showOverlayNotification({
|
||||
id: UPDATE_AVAILABLE_NOTIFICATION_ID,
|
||||
title: 'SubMiner update available',
|
||||
body: message,
|
||||
variant: 'info',
|
||||
persistent: true,
|
||||
actions: [{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' }],
|
||||
});
|
||||
}
|
||||
if (options.notificationType === 'osd' || options.notificationType === 'both') {
|
||||
if (options.notificationType === 'osd' || options.notificationType === 'osd-system') {
|
||||
try {
|
||||
await deps.showOsdNotification(message);
|
||||
} catch (error) {
|
||||
@@ -24,4 +36,11 @@ export async function notifyUpdateAvailable(
|
||||
deps.log(`Update OSD notification failed: ${reason}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
options.notificationType === 'system' ||
|
||||
options.notificationType === 'both' ||
|
||||
options.notificationType === 'osd-system'
|
||||
) {
|
||||
deps.showSystemNotification('SubMiner update available', message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,28 @@ test('manual update check falls back to GitHub release when app metadata is unav
|
||||
assert.deepEqual(calls, ['available-dialog:0.15.0']);
|
||||
});
|
||||
|
||||
test('manual update install request skips available dialog and updates app', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
|
||||
showUpdateAvailableDialog: async () => {
|
||||
throw new Error('unexpected update confirmation');
|
||||
},
|
||||
updateLauncher: async (_launcherPath, channel) => {
|
||||
calls.push(`launcher:${channel}`);
|
||||
return { status: 'skipped' };
|
||||
},
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({
|
||||
source: 'manual',
|
||||
installWhenAvailable: true,
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.deepEqual(calls, ['download', 'launcher:stable', 'restart-dialog']);
|
||||
});
|
||||
|
||||
test('manual update check reports available when no update asset was applied', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
|
||||
@@ -271,6 +293,28 @@ test('concurrent update checks share one in-flight check', async () => {
|
||||
assert.equal(checkCount, 1);
|
||||
});
|
||||
|
||||
test('manual install request does not reuse in-flight manual check', async () => {
|
||||
let checkCount = 0;
|
||||
const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = [];
|
||||
const { deps } = createDeps({
|
||||
checkAppUpdate: () =>
|
||||
new Promise((resolve) => {
|
||||
checkCount += 1;
|
||||
resolveChecks.push(resolve);
|
||||
}),
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
const manualCheck = service.checkForUpdates({ source: 'manual' });
|
||||
const manualInstall = service.checkForUpdates({ source: 'manual', installWhenAvailable: true });
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(checkCount, 2);
|
||||
for (const resolve of resolveChecks) {
|
||||
resolve({ available: false, version: '0.14.0' });
|
||||
}
|
||||
await Promise.all([manualCheck, manualInstall]);
|
||||
});
|
||||
|
||||
test('manual update check does not reuse in-flight automatic check', async () => {
|
||||
let checkCount = 0;
|
||||
const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = [];
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface UpdateCheckRequest {
|
||||
source: UpdateCheckSource;
|
||||
force?: boolean;
|
||||
launcherPath?: string;
|
||||
installWhenAvailable?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateCheckStatus =
|
||||
@@ -107,7 +108,14 @@ function summarizeError(error: unknown): string {
|
||||
}
|
||||
|
||||
export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
const inFlightBySource = new Map<UpdateCheckSource, Promise<UpdateCheckResult>>();
|
||||
const inFlightBySource = new Map<string, Promise<UpdateCheckResult>>();
|
||||
|
||||
function getInFlightKey(request: UpdateCheckRequest): string {
|
||||
if (request.source === 'manual') {
|
||||
return request.installWhenAvailable ? 'manual:install' : 'manual:check';
|
||||
}
|
||||
return request.source;
|
||||
}
|
||||
|
||||
async function runCheck(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||
const now = deps.now();
|
||||
@@ -164,9 +172,11 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
}
|
||||
|
||||
const choice = await deps.showUpdateAvailableDialog(latest.version);
|
||||
if (choice === 'close') {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
if (!request.installWhenAvailable) {
|
||||
const choice = await deps.showUpdateAvailableDialog(latest.version);
|
||||
if (choice === 'close') {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
}
|
||||
}
|
||||
|
||||
const canInstallAppUpdate = appUpdate.available && appUpdate.canUpdate !== false;
|
||||
@@ -203,12 +213,13 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
|
||||
return {
|
||||
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||
const inFlight = inFlightBySource.get(request.source);
|
||||
const key = getInFlightKey(request);
|
||||
const inFlight = inFlightBySource.get(key);
|
||||
if (inFlight) return inFlight;
|
||||
const nextInFlight = runCheck(request).finally(() => {
|
||||
inFlightBySource.delete(request.source);
|
||||
inFlightBySource.delete(key);
|
||||
});
|
||||
inFlightBySource.set(request.source, nextInFlight);
|
||||
inFlightBySource.set(key, nextInFlight);
|
||||
return nextInFlight;
|
||||
},
|
||||
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
|
||||
|
||||
Reference in New Issue
Block a user