feat(notifications): add overlay notifications with position config (#110)

This commit is contained in:
2026-06-10 22:46:52 -07:00
committed by GitHub
parent c09d009a3e
commit 7be1843c41
177 changed files with 7524 additions and 440 deletions
@@ -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> = [];
+18 -7
View File
@@ -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 {