mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-04 21:15:50 +00:00
feat(gui): notifications settings for push devices and apps
Settings -> Notifications turns pushes on and off for this browser through a push client an extension registers, lists the user's devices and the apps allowed to push, and switches Puter's own pushes. The tab shows only when the pushEnabled gui param is true. The sessions window shares the user agent formatting with it.
This commit is contained in:
1 parent
7d8711bf5b
commit
af13c33678
11 files changed
+1164
-34
No files matched your search
@@ -0,0 +1,37 @@
|
||||
# Browser push client
|
||||
|
||||
Puter's desktop shows pushes through `/sw.js`, which core registers when the server has push enabled (`push.enabled`). Core does not subscribe the browser to a push service. A GUI extension does that by registering a push client, and Settings → Notifications drives it.
|
||||
|
||||
## Registering
|
||||
|
||||
The registry is in [src/gui/src/helpers/pushClient.js](../src/gui/src/helpers/pushClient.js). A service script registers its client like this:
|
||||
|
||||
```js
|
||||
service_script(async (api) => {
|
||||
api.use()['push.registerPushClient']?.(client);
|
||||
});
|
||||
```
|
||||
|
||||
`use(id)` throws when nothing is registered under `id`, so the form above also runs on a Puter version without the registry and does nothing there. Any script that runs after the GUI bundle can call `use()['push.registerPushClient']?.(client)` the same way. Registering again replaces the client. Registering dispatches `push-client-registered` on `window`, and an open Notifications tab redraws.
|
||||
|
||||
With no client registered, the tab says push notifications aren't available in this browser.
|
||||
|
||||
## Interface
|
||||
|
||||
```ts
|
||||
interface PushClient {
|
||||
enable(): Promise<{ deviceUid: string }>;
|
||||
disable(): Promise<void>;
|
||||
status(): Promise<{
|
||||
permission: 'default' | 'granted' | 'denied';
|
||||
registered: boolean;
|
||||
deviceUid?: string;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
- `enable()` is called from a click, so it may ask for notification permission. It subscribes with the `/sw.js` registration, registers the device with `POST /push/devices` (`platform: 'web'`), and resolves the device `uid` the server returned. It rejects when permission is refused or the device couldn't be registered.
|
||||
- `disable()` unsubscribes and removes this browser's device with `DELETE /push/devices/:uid`.
|
||||
- `status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user, and `deviceUid` is that device's `uid`.
|
||||
|
||||
After `enable()` resolves, the tab calls `POST /push/devices/:uid/test` and asks the user whether a notification showed up.
|
||||
@@ -0,0 +1,377 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
getPuterPushPreference,
|
||||
listPushApps,
|
||||
listPushDevices,
|
||||
removePushDevice,
|
||||
revokePushApp,
|
||||
sendTestPush,
|
||||
setPuterPushPreference,
|
||||
} from '../../helpers/notificationApi.js';
|
||||
import { appIconAttrs } from '../../helpers/appIcon.js';
|
||||
import { getPushClient, PUSH_CLIENT_REGISTERED_EVENT } from '../../helpers/pushClient.js';
|
||||
import { formatBrowserOs, parseUserAgent } from '../../helpers/userAgent.js';
|
||||
import { formatAbsoluteTime, formatRelativeTime } from '../../i18n/formatTime.js';
|
||||
import UIDashboardDialog from './UIDashboardDialog.js';
|
||||
|
||||
const SECTION = '.dashboard-section-notifications';
|
||||
|
||||
const svg = (body) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
|
||||
|
||||
const ICONS = {
|
||||
bell: svg('<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>'),
|
||||
laptop: svg('<rect x="3" y="4" width="18" height="12" rx="2"/><line x1="2" y1="20" x2="22" y2="20"/>'),
|
||||
phone: svg('<rect x="6" y="2" width="12" height="20" rx="2.5"/><line x1="10" y1="18.5" x2="14" y2="18.5"/>'),
|
||||
puter: svg('<rect x="3" y="3" width="18" height="18" rx="4"/><path d="M8 12h8M12 8v8"/>'),
|
||||
};
|
||||
|
||||
const PLATFORM_KEYS = { web: 'push_platform_web', ios: 'push_platform_ios', android: 'push_platform_android' };
|
||||
|
||||
/** @param {{ pushEnabled?: boolean }|undefined} params - `window.gui_params` */
|
||||
export const notificationsTabEnabled = (params) => params?.pushEnabled === true;
|
||||
|
||||
/** @typedef {'checking'|'unavailable'|'blocked'|'on'|'off'} BrowserPushState */
|
||||
|
||||
/** @param {import('../../helpers/pushClient.js').PushClient|null} client @returns {Promise<BrowserPushState>} */
|
||||
export const browserPushState = async (client) => {
|
||||
if ( ! client ) return 'unavailable';
|
||||
try {
|
||||
const status = await client.status();
|
||||
if ( status?.permission === 'denied' ) return 'blocked';
|
||||
return status?.registered ? 'on' : 'off';
|
||||
} catch ( e ) {
|
||||
console.warn('[push] client status failed', e);
|
||||
return 'unavailable';
|
||||
}
|
||||
};
|
||||
|
||||
const card = ({ icon, title, badge, detail, detailTitle, action, extraClass = '' }) => {
|
||||
let h = `<div class="dashboard-card dashboard-settings-card ${extraClass}">`;
|
||||
h += '<div class="dashboard-settings-card-content">';
|
||||
h += `<div class="dashboard-settings-card-icon">${icon}</div>`;
|
||||
h += '<div class="dashboard-settings-card-info">';
|
||||
h += `<strong>${title}${badge ? ` <span class="dashboard-settings-badge">${badge}</span>` : ''}</strong>`;
|
||||
if ( detail ) h += `<span${detailTitle ? ` title="${detailTitle}"` : ''}>${detail}</span>`;
|
||||
h += '</div></div>';
|
||||
h += action ?? '';
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const BROWSER_DETAIL_KEYS = {
|
||||
checking: 'push_browser_checking',
|
||||
unavailable: 'push_browser_unavailable',
|
||||
blocked: 'push_browser_blocked',
|
||||
on: 'push_browser_on',
|
||||
off: 'push_browser_off',
|
||||
};
|
||||
|
||||
/** @param {BrowserPushState} state */
|
||||
export const browserCardHtml = (state) => {
|
||||
let action = '';
|
||||
if ( state === 'on' ) action = `<button type="button" class="button push-turn-off">${i18n('push_turn_off')}</button>`;
|
||||
if ( state === 'off' ) action = `<button type="button" class="button push-turn-on">${i18n('push_turn_on')}</button>`;
|
||||
return card({
|
||||
icon: ICONS.bell,
|
||||
title: i18n('push_this_browser'),
|
||||
detail: i18n(BROWSER_DETAIL_KEYS[state] ?? BROWSER_DETAIL_KEYS.unavailable),
|
||||
action,
|
||||
extraClass: `push-browser-card push-browser-${state}`,
|
||||
});
|
||||
};
|
||||
|
||||
/** @param {import('../../helpers/notificationApi.js').PushDevice} device */
|
||||
const deviceName = (device) => device.label
|
||||
|| formatBrowserOs(parseUserAgent(device.user_agent))
|
||||
|| i18n('push_device_unknown', [], false);
|
||||
|
||||
/** @param {import('../../helpers/notificationApi.js').PushDevice} device @param {number} now - Epoch ms */
|
||||
export const deviceCardHtml = (device, now) => {
|
||||
const seenAt = Number(device.last_seen_at) * 1000;
|
||||
const locale = window.locale ?? 'en';
|
||||
const platform = PLATFORM_KEYS[device.platform] ? i18n(PLATFORM_KEYS[device.platform]) : '';
|
||||
const seen = Number.isFinite(seenAt) && seenAt > 0
|
||||
? i18n('push_device_last_seen', { time: formatRelativeTime(seenAt, now, locale) })
|
||||
: '';
|
||||
return card({
|
||||
icon: device.platform === 'web' ? ICONS.laptop : ICONS.phone,
|
||||
title: html_encode(deviceName(device)),
|
||||
badge: device.current ? i18n('push_this_browser') : '',
|
||||
detail: [platform, seen].filter(Boolean).join(' · '),
|
||||
detailTitle: seen ? html_encode(formatAbsoluteTime(seenAt, locale)) : '',
|
||||
action: `<button type="button" class="button push-remove-device" data-uid="${html_encode(device.uid)}">${i18n('push_device_remove')}</button>`,
|
||||
extraClass: 'push-device-card',
|
||||
});
|
||||
};
|
||||
|
||||
/** @param {unknown} url @returns {boolean} An https or same-origin URL */
|
||||
export const isLoadableIcon = (url) => {
|
||||
if ( typeof url !== 'string' || ! url ) return false;
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
return parsed.protocol === 'https:' || parsed.origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** @param {import('../../helpers/notificationApi.js').PushApp} app */
|
||||
export const appCardHtml = (app) => {
|
||||
const icon = isLoadableIcon(app.icon) ? app.icon : '';
|
||||
return card({
|
||||
icon: `<img ${appIconAttrs({ icon }, window.icons?.['app.svg'] ?? '')} alt="">`,
|
||||
title: html_encode(app.title || app.name || ''),
|
||||
action: `<button type="button" class="button push-revoke-app" data-app-uid="${html_encode(app.uid)}">${i18n('push_turn_off')}</button>`,
|
||||
extraClass: 'push-app-card',
|
||||
});
|
||||
};
|
||||
|
||||
const emptyHtml = (key) => `<p class="dashboard-settings-empty">${i18n(key)}</p>`;
|
||||
|
||||
const showError = (key, $el_window) => UIDashboardDialog({
|
||||
message: i18n(key),
|
||||
tone: 'error',
|
||||
$container: $el_window,
|
||||
});
|
||||
|
||||
const TabNotifications = {
|
||||
id: 'notifications',
|
||||
get label () {
|
||||
return i18n('notifications');
|
||||
},
|
||||
icon: ICONS.bell.replace('stroke-width="2"', 'stroke-width="1.5"'),
|
||||
|
||||
html () {
|
||||
let h = '<div class="dashboard-tab-content">';
|
||||
h += '<div class="dashboard-section-header">';
|
||||
h += `<h2>${i18n('notifications')}</h2>`;
|
||||
h += `<p>${i18n('push_settings_subtitle')}</p>`;
|
||||
h += '</div>';
|
||||
|
||||
h += '<div class="dashboard-settings-grid push-browser-slot">';
|
||||
h += browserCardHtml('checking');
|
||||
h += card({
|
||||
extraClass: 'push-puter-card',
|
||||
icon: ICONS.puter,
|
||||
title: i18n('push_puter_notifications'),
|
||||
detail: i18n('push_puter_notifications_hint'),
|
||||
action: '<label class="dashboard-switch">'
|
||||
+ `<input type="checkbox" class="push-puter-toggle" disabled aria-label="${html_encode(i18n('push_puter_notifications', [], false))}">`
|
||||
+ '<span class="dashboard-switch-slider"></span></label>',
|
||||
});
|
||||
h += '</div>';
|
||||
|
||||
h += `<h3 class="dashboard-settings-group-title">${i18n('push_devices')}</h3>`;
|
||||
h += '<div class="dashboard-settings-grid push-devices-list"></div>';
|
||||
|
||||
h += `<h3 class="dashboard-settings-group-title">${i18n('push_apps')}</h3>`;
|
||||
h += '<div class="dashboard-settings-grid push-apps-list"></div>';
|
||||
|
||||
h += '</div>';
|
||||
return h;
|
||||
},
|
||||
|
||||
init ($el_window) {
|
||||
const $section = $el_window.find(SECTION);
|
||||
let devices = [];
|
||||
let apps = [];
|
||||
|
||||
// Only the latest status read is drawn, so a slow one can't undo a newer state.
|
||||
let browserSeq = 0;
|
||||
const refreshBrowser = async () => {
|
||||
const seq = ++browserSeq;
|
||||
const state = await browserPushState(getPushClient());
|
||||
if ( seq === browserSeq ) $section.find('.push-browser-card').replaceWith(browserCardHtml(state));
|
||||
return state;
|
||||
};
|
||||
|
||||
const renderDevices = () => {
|
||||
const now = Date.now();
|
||||
$section.find('.push-devices-list').html(devices.length
|
||||
? devices.map(device => deviceCardHtml(device, now)).join('')
|
||||
: emptyHtml('push_devices_empty'));
|
||||
};
|
||||
const refreshDevices = async () => {
|
||||
try {
|
||||
devices = await listPushDevices();
|
||||
renderDevices();
|
||||
} catch ( e ) {
|
||||
console.warn('[push] device list failed', e);
|
||||
$section.find('.push-devices-list').html(emptyHtml('push_load_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const renderApps = () => {
|
||||
$section.find('.push-apps-list').html(apps.length
|
||||
? apps.map(appCardHtml).join('')
|
||||
: emptyHtml('push_apps_empty'));
|
||||
};
|
||||
const refreshApps = async () => {
|
||||
try {
|
||||
apps = await listPushApps();
|
||||
renderApps();
|
||||
} catch ( e ) {
|
||||
console.warn('[push] app list failed', e);
|
||||
$section.find('.push-apps-list').html(emptyHtml('push_load_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const refreshPreference = async () => {
|
||||
const $toggle = $section.find('.push-puter-toggle');
|
||||
try {
|
||||
$toggle.prop('checked', await getPuterPushPreference()).prop('disabled', false);
|
||||
} catch ( e ) {
|
||||
console.warn('[push] preference read failed', e);
|
||||
$toggle.prop('checked', false).prop('disabled', true);
|
||||
$section.find('.push-puter-card .dashboard-settings-card-info > span').html(i18n('push_load_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
this.refresh = () => Promise.all([refreshBrowser(), refreshDevices(), refreshApps(), refreshPreference()]);
|
||||
|
||||
window.addEventListener(PUSH_CLIENT_REGISTERED_EVENT, () => {
|
||||
if ( $section[0]?.isConnected && $section.hasClass('active') ) refreshBrowser();
|
||||
});
|
||||
|
||||
const askIfSeen = async () => {
|
||||
const seen = await UIDashboardDialog({
|
||||
title: i18n('push_test_confirm_title', [], false),
|
||||
message: i18n('push_test_confirm_message'),
|
||||
buttons: [
|
||||
{ label: i18n('no', [], false), value: 'no', type: 'quiet' },
|
||||
{ label: i18n('yes', [], false), value: 'yes', type: 'primary' },
|
||||
],
|
||||
$container: $el_window,
|
||||
});
|
||||
if ( seen !== 'no' ) return;
|
||||
await UIDashboardDialog({
|
||||
title: i18n('push_test_help_title', [], false),
|
||||
message: i18n('push_test_help'),
|
||||
$container: $el_window,
|
||||
});
|
||||
};
|
||||
|
||||
$section.on('click', '.push-turn-on', async function () {
|
||||
const client = getPushClient();
|
||||
if ( ! client ) return;
|
||||
$(this).prop('disabled', true);
|
||||
let deviceUid;
|
||||
try {
|
||||
({ deviceUid } = await client.enable());
|
||||
} catch ( e ) {
|
||||
console.warn('[push] enable failed', e);
|
||||
if ( await refreshBrowser() !== 'blocked' ) showError('push_enable_failed', $el_window);
|
||||
return;
|
||||
}
|
||||
const refreshed = Promise.all([refreshBrowser(), refreshDevices()]);
|
||||
try {
|
||||
await sendTestPush(deviceUid);
|
||||
} catch ( e ) {
|
||||
console.warn('[push] test push failed', e);
|
||||
showError('push_test_failed', $el_window);
|
||||
await refreshed;
|
||||
await Promise.all([refreshBrowser(), refreshDevices()]);
|
||||
return;
|
||||
}
|
||||
await refreshed;
|
||||
await askIfSeen();
|
||||
});
|
||||
|
||||
$section.on('click', '.push-turn-off', async function () {
|
||||
const client = getPushClient();
|
||||
if ( ! client ) return;
|
||||
$(this).prop('disabled', true);
|
||||
try {
|
||||
await client.disable();
|
||||
} catch ( e ) {
|
||||
console.warn('[push] disable failed', e);
|
||||
showError('push_disable_failed', $el_window);
|
||||
}
|
||||
await Promise.all([refreshBrowser(), refreshDevices()]);
|
||||
});
|
||||
|
||||
$section.on('change', '.push-puter-toggle', async function () {
|
||||
const $toggle = $(this);
|
||||
const enabled = $toggle.is(':checked');
|
||||
$toggle.prop('disabled', true);
|
||||
try {
|
||||
await setPuterPushPreference(enabled);
|
||||
} catch ( e ) {
|
||||
console.warn('[push] preference save failed', e);
|
||||
$toggle.prop('checked', ! enabled);
|
||||
showError('push_save_failed', $el_window);
|
||||
} finally {
|
||||
$toggle.prop('disabled', false);
|
||||
}
|
||||
});
|
||||
|
||||
$section.on('click', '.push-remove-device', async function () {
|
||||
const uid = $(this).attr('data-uid');
|
||||
const device = devices.find(d => d.uid === uid);
|
||||
if ( ! device ) return;
|
||||
const confirmed = await UIDashboardDialog({
|
||||
title: i18n('push_device_remove_title', [], false),
|
||||
message: i18n('push_device_remove_message', { name: deviceName(device) }),
|
||||
tone: 'danger',
|
||||
buttons: [
|
||||
{ label: i18n('cancel', [], false), value: false, type: 'quiet' },
|
||||
{ label: i18n('push_device_remove', [], false), value: true, type: 'danger' },
|
||||
],
|
||||
$container: $el_window,
|
||||
returnFocusTo: this,
|
||||
});
|
||||
if ( confirmed !== true ) return;
|
||||
try {
|
||||
await removePushDevice(uid);
|
||||
} catch ( e ) {
|
||||
if ( e?.status !== 404 ) {
|
||||
console.warn('[push] device removal failed', e);
|
||||
showError('push_device_remove_failed', $el_window);
|
||||
return;
|
||||
}
|
||||
}
|
||||
devices = devices.filter(d => d.uid !== uid);
|
||||
renderDevices();
|
||||
if ( device.current ) refreshBrowser();
|
||||
});
|
||||
|
||||
$section.on('click', '.push-revoke-app', async function () {
|
||||
const appUid = $(this).attr('data-app-uid');
|
||||
$(this).prop('disabled', true);
|
||||
try {
|
||||
await revokePushApp(appUid);
|
||||
} catch ( e ) {
|
||||
console.warn('[push] app revoke failed', e);
|
||||
$(this).prop('disabled', false);
|
||||
showError('push_app_turn_off_failed', $el_window);
|
||||
return;
|
||||
}
|
||||
apps = apps.filter(app => app.uid !== appUid);
|
||||
renderApps();
|
||||
});
|
||||
},
|
||||
|
||||
onActivate () {
|
||||
return this.refresh?.();
|
||||
},
|
||||
};
|
||||
|
||||
export default TabNotifications;
|
||||
@@ -0,0 +1,447 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.html_encode = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
window.html_encode = globalThis.html_encode;
|
||||
// Echoes the key and any named values, encoded the way the real one encodes.
|
||||
globalThis.i18n = (key, vars, encode = true) => {
|
||||
const named = vars && typeof vars === 'object' && !Array.isArray(vars) ? Object.values(vars) : [];
|
||||
const text = [key, ...named].join(' ');
|
||||
return encode ? html_encode(text) : text;
|
||||
};
|
||||
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.auth_token = 'tok-1';
|
||||
|
||||
const settle = async () => {
|
||||
for ( let i = 0; i < 10; i++ ) await new Promise(resolve => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
const reply = (status, data = {}) => new Response(JSON.stringify(data), { status });
|
||||
|
||||
/** Answers each `METHOD path` from `routes`; anything else is a 404. */
|
||||
const serve = (routes) => {
|
||||
globalThis.fetch = vi.fn(async (url, init = {}) => {
|
||||
const path = new URL(url).pathname;
|
||||
const handler = routes[`${init.method ?? 'GET'} ${path}`];
|
||||
if ( ! handler ) return reply(404, { code: 'not_found' });
|
||||
return typeof handler === 'function' ? handler(init) : reply(200, handler);
|
||||
});
|
||||
};
|
||||
|
||||
const calls = () => fetch.mock.calls.map(([url, init]) => `${init?.method ?? 'GET'} ${new URL(url).pathname}`);
|
||||
|
||||
const device = (fields = {}) => ({
|
||||
uid: 'dev-1',
|
||||
platform: 'web',
|
||||
label: 'Work laptop',
|
||||
user_agent: null,
|
||||
created_at: 1_700_000_000,
|
||||
last_seen_at: Math.floor(Date.now() / 1000) - 120,
|
||||
current: false,
|
||||
...fields,
|
||||
});
|
||||
|
||||
const fakeClient = (status = { permission: 'default', registered: false }) => ({
|
||||
enable: vi.fn(async () => ({ deviceUid: 'dev-new' })),
|
||||
disable: vi.fn(async () => {}),
|
||||
status: vi.fn(async () => status),
|
||||
});
|
||||
|
||||
let tab;
|
||||
let pushClient;
|
||||
|
||||
/** Renders the tab the way the dashboard does and activates it. */
|
||||
const open = async () => {
|
||||
document.body.innerHTML = '<div class="win"><div class="dashboard-section dashboard-section-notifications active"></div></div>';
|
||||
$('.dashboard-section-notifications').html(tab.html());
|
||||
tab.init($('.win'));
|
||||
await tab.onActivate($('.win'));
|
||||
await settle();
|
||||
};
|
||||
|
||||
const browserCard = () => $('.push-browser-card');
|
||||
// A closed dialog stays in the DOM while it fades out.
|
||||
const dialog = (selector) => $('.dashboard-dialog-overlay.dashboard-dialog-show').find(selector);
|
||||
const dialogButton = (label) => dialog('.dashboard-dialog-btn').filter((_, el) => $(el).text() === label);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
pushClient = await import('../../helpers/pushClient.js');
|
||||
tab = (await import('./TabNotifications.js')).default;
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifications tab visibility', () => {
|
||||
it('is shown only when the server has push enabled', async () => {
|
||||
const { notificationsTabEnabled } = await import('./TabNotifications.js');
|
||||
expect(notificationsTabEnabled({ pushEnabled: true })).toBe(true);
|
||||
expect(notificationsTabEnabled({ pushEnabled: false })).toBe(false);
|
||||
expect(notificationsTabEnabled(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('this browser', () => {
|
||||
it('says push is not available when no client is registered', async () => {
|
||||
await open();
|
||||
expect(browserCard().hasClass('push-browser-unavailable')).toBe(true);
|
||||
expect(browserCard().text()).toContain('push_browser_unavailable');
|
||||
expect(browserCard().find('button')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('offers Turn on when this browser is not registered', async () => {
|
||||
pushClient.registerPushClient(fakeClient());
|
||||
await open();
|
||||
expect(browserCard().hasClass('push-browser-off')).toBe(true);
|
||||
expect(browserCard().find('.push-turn-on')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('offers Turn off when this browser is registered', async () => {
|
||||
pushClient.registerPushClient(fakeClient({ permission: 'granted', registered: true, deviceUid: 'dev-1' }));
|
||||
await open();
|
||||
expect(browserCard().hasClass('push-browser-on')).toBe(true);
|
||||
expect(browserCard().find('.push-turn-off')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('says notifications are blocked when the browser denied them', async () => {
|
||||
pushClient.registerPushClient(fakeClient({ permission: 'denied', registered: false }));
|
||||
await open();
|
||||
expect(browserCard().hasClass('push-browser-blocked')).toBe(true);
|
||||
expect(browserCard().find('button')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('redraws when a client registers after the tab opened', async () => {
|
||||
await open();
|
||||
pushClient.registerPushClient(fakeClient());
|
||||
await settle();
|
||||
expect(browserCard().hasClass('push-browser-off')).toBe(true);
|
||||
});
|
||||
|
||||
it('turns on, sends a test push to that device, then asks whether it showed up', async () => {
|
||||
const client = fakeClient();
|
||||
pushClient.registerPushClient(client);
|
||||
const test = vi.fn(() => reply(200, { sent: true }));
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
'POST /push/devices/dev-new/test': test,
|
||||
});
|
||||
await open();
|
||||
client.status.mockResolvedValue({ permission: 'granted', registered: true, deviceUid: 'dev-new' });
|
||||
|
||||
$('.push-turn-on').trigger('click');
|
||||
await settle();
|
||||
|
||||
expect(client.enable).toHaveBeenCalledTimes(1);
|
||||
expect(test).toHaveBeenCalledTimes(1);
|
||||
expect(client.enable.mock.invocationCallOrder[0]).toBeLessThan(fetch.mock.invocationCallOrder[calls().indexOf('POST /push/devices/dev-new/test')]);
|
||||
expect(dialog('.dashboard-dialog-title').text()).toBe('push_test_confirm_title');
|
||||
expect(browserCard().hasClass('push-browser-on')).toBe(true);
|
||||
|
||||
dialogButton('no').trigger('click');
|
||||
await settle();
|
||||
expect(dialog('.dashboard-dialog-title').text()).toBe('push_test_help_title');
|
||||
expect(dialog('.dashboard-dialog-message').text()).toContain('push_test_help');
|
||||
});
|
||||
|
||||
it('shows no help when the user saw the test notification', async () => {
|
||||
pushClient.registerPushClient(fakeClient());
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
'POST /push/devices/dev-new/test': { sent: true },
|
||||
});
|
||||
await open();
|
||||
|
||||
$('.push-turn-on').trigger('click');
|
||||
await settle();
|
||||
dialogButton('yes').trigger('click');
|
||||
await settle();
|
||||
|
||||
expect(dialog('.dashboard-dialog-title')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reports a test push that could not be sent', async () => {
|
||||
pushClient.registerPushClient(fakeClient());
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
'POST /push/devices/dev-new/test': () => reply(502, { code: 'push_test_failed' }),
|
||||
});
|
||||
await open();
|
||||
|
||||
$('.push-turn-on').trigger('click');
|
||||
await settle();
|
||||
|
||||
expect(dialog('.dashboard-dialog-message').text()).toBe('push_test_failed');
|
||||
});
|
||||
|
||||
it('sends no test push when the browser refuses permission', async () => {
|
||||
const client = fakeClient();
|
||||
client.enable.mockRejectedValue(new Error('denied'));
|
||||
pushClient.registerPushClient(client);
|
||||
await open();
|
||||
client.status.mockResolvedValue({ permission: 'denied', registered: false });
|
||||
|
||||
$('.push-turn-on').trigger('click');
|
||||
await settle();
|
||||
|
||||
expect(calls().some(c => c.endsWith('/test'))).toBe(false);
|
||||
expect(browserCard().hasClass('push-browser-blocked')).toBe(true);
|
||||
expect($('.dashboard-dialog-overlay')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the newest state when an older status read finishes last', async () => {
|
||||
const client = fakeClient();
|
||||
pushClient.registerPushClient(client);
|
||||
await open();
|
||||
let finishSlow;
|
||||
client.status
|
||||
.mockImplementationOnce(() => new Promise(resolve => {
|
||||
finishSlow = resolve;
|
||||
}))
|
||||
.mockResolvedValueOnce({ permission: 'granted', registered: true, deviceUid: 'dev-1' });
|
||||
|
||||
tab.onActivate($('.win'));
|
||||
tab.onActivate($('.win'));
|
||||
await settle();
|
||||
expect(browserCard().hasClass('push-browser-on')).toBe(true);
|
||||
finishSlow({ permission: 'default', registered: false });
|
||||
await settle();
|
||||
|
||||
expect(browserCard().hasClass('push-browser-on')).toBe(true);
|
||||
});
|
||||
|
||||
it('turns off through the client', async () => {
|
||||
const client = fakeClient({ permission: 'granted', registered: true, deviceUid: 'dev-1' });
|
||||
pushClient.registerPushClient(client);
|
||||
await open();
|
||||
client.status.mockResolvedValue({ permission: 'granted', registered: false });
|
||||
|
||||
$('.push-turn-off').trigger('click');
|
||||
await settle();
|
||||
|
||||
expect(client.disable).toHaveBeenCalledTimes(1);
|
||||
expect(browserCard().hasClass('push-browser-off')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Puter notifications', () => {
|
||||
it('shows the saved preference and saves a change', async () => {
|
||||
const save = vi.fn(() => reply(200, { puter: true }));
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: false },
|
||||
'POST /push/preferences': save,
|
||||
});
|
||||
await open();
|
||||
const $toggle = $('.push-puter-toggle');
|
||||
expect($toggle.prop('checked')).toBe(false);
|
||||
expect($toggle.prop('disabled')).toBe(false);
|
||||
|
||||
$toggle.prop('checked', true).trigger('change');
|
||||
await settle();
|
||||
|
||||
expect(JSON.parse(save.mock.calls[0][0].body)).toEqual({ puter: true });
|
||||
});
|
||||
|
||||
it('shows the switch off and disabled with a message when the preference can’t be read', async () => {
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': () => reply(500, {}),
|
||||
});
|
||||
await open();
|
||||
|
||||
const $toggle = $('.push-puter-toggle');
|
||||
expect($toggle.prop('checked')).toBe(false);
|
||||
expect($toggle.prop('disabled')).toBe(true);
|
||||
expect($('.push-puter-card').text()).toContain('push_load_failed');
|
||||
});
|
||||
|
||||
it('puts the switch back when the save fails', async () => {
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
'POST /push/preferences': () => reply(500, {}),
|
||||
});
|
||||
await open();
|
||||
|
||||
$('.push-puter-toggle').prop('checked', false).trigger('change');
|
||||
await settle();
|
||||
|
||||
expect($('.push-puter-toggle').prop('checked')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('your devices', () => {
|
||||
it('says so when there are none', async () => {
|
||||
await open();
|
||||
expect($('.push-devices-list').text()).toBe('push_devices_empty');
|
||||
});
|
||||
|
||||
it('lists each device with its name, platform, last seen time and this-browser badge', async () => {
|
||||
serve({
|
||||
'GET /push/devices': {
|
||||
devices: [
|
||||
device({ uid: 'a', current: true }),
|
||||
device({ uid: 'b', label: null, platform: 'android', user_agent: 'Mozilla/5.0 (Linux; Android 14) Chrome/120.0 Mobile' }),
|
||||
device({ uid: 'c', label: null, platform: 'ios' }),
|
||||
],
|
||||
},
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
});
|
||||
await open();
|
||||
|
||||
const $cards = $('.push-device-card');
|
||||
expect($cards).toHaveLength(3);
|
||||
expect($cards.eq(0).find('strong').text()).toContain('Work laptop');
|
||||
expect($cards.eq(0).find('.dashboard-settings-badge').text()).toBe('push_this_browser');
|
||||
expect($cards.eq(0).find('.dashboard-settings-card-info > span').text()).toContain('push_platform_web');
|
||||
expect($cards.eq(0).find('.dashboard-settings-card-info > span').text()).toContain('push_device_last_seen');
|
||||
expect($cards.eq(1).find('strong').text()).toBe('Chrome on Android');
|
||||
expect($cards.eq(1).find('.dashboard-settings-badge')).toHaveLength(0);
|
||||
expect($cards.eq(2).find('strong').text()).toBe('push_device_unknown');
|
||||
});
|
||||
|
||||
it('removes a device once the user confirms', async () => {
|
||||
const remove = vi.fn(() => reply(200, { removed: true }));
|
||||
serve({
|
||||
'GET /push/devices': { devices: [device({ uid: 'a' }), device({ uid: 'b', label: 'Phone' })] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
'DELETE /push/devices/b': remove,
|
||||
});
|
||||
await open();
|
||||
|
||||
$('.push-remove-device[data-uid="b"]').trigger('click');
|
||||
await settle();
|
||||
expect(dialog('.dashboard-dialog-message').text()).toContain('Phone');
|
||||
dialogButton('push_device_remove').trigger('click');
|
||||
await settle();
|
||||
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
expect($('.push-device-card')).toHaveLength(1);
|
||||
expect($('.push-remove-device').attr('data-uid')).toBe('a');
|
||||
});
|
||||
|
||||
it('keeps the device when the user cancels', async () => {
|
||||
serve({
|
||||
'GET /push/devices': { devices: [device()] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
});
|
||||
await open();
|
||||
|
||||
$('.push-remove-device').trigger('click');
|
||||
await settle();
|
||||
dialogButton('cancel').trigger('click');
|
||||
await settle();
|
||||
|
||||
expect(calls().some(c => c.startsWith('DELETE'))).toBe(false);
|
||||
expect($('.push-device-card')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows a device label containing markup as text', async () => {
|
||||
serve({
|
||||
'GET /push/devices': { devices: [device({ label: '<img src=x onerror=alert(1)>' })] },
|
||||
'GET /push/apps': { apps: [] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
});
|
||||
await open();
|
||||
|
||||
expect($('.push-device-card img')).toHaveLength(0);
|
||||
expect($('.push-device-card strong').text()).toContain('<img src=x onerror=alert(1)>');
|
||||
|
||||
$('.push-remove-device').trigger('click');
|
||||
await settle();
|
||||
expect(dialog('.dashboard-dialog-message img')).toHaveLength(0);
|
||||
expect(dialog('.dashboard-dialog-message').text()).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('apps that can send pushes', () => {
|
||||
it('says so when there are none', async () => {
|
||||
await open();
|
||||
expect($('.push-apps-list').text()).toBe('push_apps_empty');
|
||||
});
|
||||
|
||||
it('turns an app off by revoking its push permission', async () => {
|
||||
const revoke = vi.fn(() => reply(200, {}));
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
'GET /push/apps': { apps: [{ uid: 'app-1', name: 'chat', title: 'Chat', icon: 'https://api.puter.test/app-icon/app-1/64' }] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
'POST /auth/revoke-user-app': revoke,
|
||||
});
|
||||
await open();
|
||||
expect($('.push-app-card strong').text()).toBe('Chat');
|
||||
expect($('.push-app-card img').attr('src')).toBe('https://api.puter.test/app-icon/app-1/64');
|
||||
|
||||
$('.push-revoke-app').trigger('click');
|
||||
await settle();
|
||||
|
||||
expect(JSON.parse(revoke.mock.calls[0][0].body)).toEqual({ app_uid: 'app-1', permission: 'notifications:push' });
|
||||
expect($('.push-app-card')).toHaveLength(0);
|
||||
expect($('.push-apps-list').text()).toBe('push_apps_empty');
|
||||
});
|
||||
|
||||
it('shows the default icon for an app icon that is not https', async () => {
|
||||
window.icons = { 'app.svg': '/images/app.svg' };
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
'GET /push/apps': { apps: [{ uid: 'app-1', name: 'chat', title: 'Chat', icon: 'http://elsewhere.test/icon.png' }] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
});
|
||||
await open();
|
||||
|
||||
expect($('.push-app-card img').attr('src')).toBe('/images/app.svg');
|
||||
delete window.icons;
|
||||
});
|
||||
|
||||
it('shows an app name and icon containing markup as text', async () => {
|
||||
serve({
|
||||
'GET /push/devices': { devices: [] },
|
||||
'GET /push/apps': { apps: [{ uid: 'app-1', name: 'x', title: '<b onclick=alert(1)>Chat</b>', icon: '" onerror="alert(1)' }] },
|
||||
'GET /push/preferences': { puter: true },
|
||||
});
|
||||
await open();
|
||||
|
||||
expect($('.push-app-card b')).toHaveLength(0);
|
||||
expect($('.push-app-card strong').text()).toBe('<b onclick=alert(1)>Chat</b>');
|
||||
expect($('.push-app-card img').attr('onerror')).toBeUndefined();
|
||||
expect($('.push-app-card img').attr('src')).toBe('" onerror="alert(1)');
|
||||
});
|
||||
});
|
||||
@@ -52,10 +52,11 @@ import TabApps from './TabApps.js';
|
||||
import TabUsage from './TabUsage.js';
|
||||
import TabAccount from './TabAccount.js';
|
||||
import TabSecurity from './TabSecurity.js';
|
||||
import TabNotifications, { notificationsTabEnabled } from './TabNotifications.js';
|
||||
import teamBadgeHtml from './teamBadge.js';
|
||||
|
||||
// Registry of built-in tabs
|
||||
const builtinTabs = [
|
||||
const builtinTabs = () => [
|
||||
TabHome,
|
||||
TabApps,
|
||||
TabFiles,
|
||||
@@ -63,6 +64,7 @@ const builtinTabs = [
|
||||
TabUsage,
|
||||
TabAccount,
|
||||
TabSecurity,
|
||||
...(notificationsTabEnabled(window.gui_params) ? [TabNotifications] : []),
|
||||
];
|
||||
|
||||
async function UIDashboard (options) {
|
||||
@@ -74,7 +76,7 @@ async function UIDashboard (options) {
|
||||
$('body').addClass('dashboard-mode');
|
||||
|
||||
// Create mutable tabs array from built-in tabs
|
||||
const tabs = [...builtinTabs];
|
||||
const tabs = builtinTabs();
|
||||
|
||||
// Dispatch 'dashboard-will-open' event to allow extensions to add tabs
|
||||
window.dispatchEvent(new CustomEvent('dashboard-will-open', { detail: { tabs } }));
|
||||
|
||||
@@ -24,38 +24,7 @@
|
||||
|
||||
import { fetchAllEventsWorkers, eventsWorkerLabel } from '../helpers/eventsWorkers.js';
|
||||
import { forgetGodmodeTokens } from '../helpers/godmodeTokens.js';
|
||||
|
||||
// Hand-rolled UA → {browser, os} extractor. Covers Chrome/Edge/Firefox/
|
||||
// Safari/Opera + Windows/macOS/iOS/Android/Linux. The backend already
|
||||
// has `ua-parser-js`; pulling it into the GUI bundle just for this
|
||||
// label wasn't worth the bytes.
|
||||
const parseUserAgent = (ua) => {
|
||||
if ( ! ua || typeof ua !== 'string' ) return { browser: null, os: null };
|
||||
let browser = null;
|
||||
if ( /Edg\//i.test(ua) ) browser = 'Edge';
|
||||
else if ( /OPR\/|Opera/i.test(ua) ) browser = 'Opera';
|
||||
else if ( /Chrome\//i.test(ua) && !/Chromium/i.test(ua) ) browser = 'Chrome';
|
||||
else if ( /Firefox\//i.test(ua) ) browser = 'Firefox';
|
||||
else if ( /Safari\//i.test(ua) && !/Chrome\//i.test(ua) ) browser = 'Safari';
|
||||
|
||||
let os = null;
|
||||
if ( /Windows NT/i.test(ua) ) os = 'Windows';
|
||||
// iOS Safari/Chrome UAs include "like Mac OS X", so the iOS device
|
||||
// check has to win against the macOS regex — otherwise iPhones get
|
||||
// mislabeled as macOS. Android similarly fakes a "Linux" token, so
|
||||
// it has to precede the Linux check below.
|
||||
else if ( /iPhone|iPad|iPod/i.test(ua) ) os = 'iOS';
|
||||
else if ( /Android/i.test(ua) ) os = 'Android';
|
||||
else if ( /Mac OS X|Macintosh/i.test(ua) ) os = 'macOS';
|
||||
else if ( /Linux/i.test(ua) ) os = 'Linux';
|
||||
|
||||
return { browser, os };
|
||||
};
|
||||
|
||||
const formatBrowserOs = ({ browser, os }) => {
|
||||
if ( browser && os ) return `${browser} on ${os}`;
|
||||
return browser || os || null;
|
||||
};
|
||||
import { formatBrowserOs, parseUserAgent } from '../helpers/userAgent.js';
|
||||
|
||||
// Inline line-icons (stroke, currentColor) so the list is scannable at a
|
||||
// glance without pulling an icon font into the bundle.
|
||||
|
||||
@@ -7291,6 +7291,43 @@ body.myapps-reordering .myapps-tile {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
||||
/* A titled group of settings cards */
|
||||
.dashboard-settings-group-title {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--dashboard-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.dashboard-settings-card-info .dashboard-settings-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
vertical-align: 1px;
|
||||
color: var(--dashboard-success-text);
|
||||
background: var(--dashboard-success-background);
|
||||
border: 1px solid var(--dashboard-success-border);
|
||||
}
|
||||
|
||||
.dashboard-settings-empty {
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
color: var(--dashboard-text-hint);
|
||||
}
|
||||
|
||||
.dashboard-settings-card-icon img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Danger zone */
|
||||
.dashboard-danger-zone {
|
||||
padding-top: 24px;
|
||||
|
||||
@@ -139,3 +139,62 @@ export async function getNotification (uid) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {{ uid: string, platform: 'web'|'ios'|'android', label: string|null, user_agent: string|null, created_at: number, last_seen_at: number, current: boolean }} PushDevice */
|
||||
|
||||
/** @typedef {{ uid: string, name: string, title: string, icon: string|null }} PushApp */
|
||||
|
||||
/** Rejects with the server's `code` and `status` on a failed call. */
|
||||
const pushCall = async (method, path, body) => {
|
||||
const res = await fetch(`${window.api_origin}${path}`, {
|
||||
method,
|
||||
headers: authHeaders(),
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if ( ! res.ok ) {
|
||||
const error = new Error(data?.message ?? `${path} failed (${res.status})`);
|
||||
error.status = res.status;
|
||||
error.code = data?.code;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
/** @returns {Promise<PushDevice[]>} */
|
||||
export async function listPushDevices () {
|
||||
const { devices } = await pushCall('GET', '/push/devices');
|
||||
return Array.isArray(devices) ? devices : [];
|
||||
}
|
||||
|
||||
/** @param {string} uid */
|
||||
export async function removePushDevice (uid) {
|
||||
await pushCall('DELETE', `/push/devices/${encodeURIComponent(uid)}`);
|
||||
}
|
||||
|
||||
/** @param {string} uid */
|
||||
export async function sendTestPush (uid) {
|
||||
await pushCall('POST', `/push/devices/${encodeURIComponent(uid)}/test`);
|
||||
}
|
||||
|
||||
/** @returns {Promise<PushApp[]>} */
|
||||
export async function listPushApps () {
|
||||
const { apps } = await pushCall('GET', '/push/apps');
|
||||
return Array.isArray(apps) ? apps : [];
|
||||
}
|
||||
|
||||
/** @param {string} appUid */
|
||||
export async function revokePushApp (appUid) {
|
||||
await pushCall('POST', '/auth/revoke-user-app', { app_uid: appUid, permission: 'notifications:push' });
|
||||
}
|
||||
|
||||
/** @returns {Promise<boolean>} Whether Puter's own notifications are pushed */
|
||||
export async function getPuterPushPreference () {
|
||||
const { puter } = await pushCall('GET', '/push/preferences');
|
||||
return puter !== false;
|
||||
}
|
||||
|
||||
/** @param {boolean} enabled */
|
||||
export async function setPuterPushPreference (enabled) {
|
||||
await pushCall('POST', '/push/preferences', { puter: enabled });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// A GUI extension owns the browser's push subscription; doc/push-client.md has the contract.
|
||||
|
||||
/** @typedef {{ permission: 'default'|'granted'|'denied', registered: boolean, deviceUid?: string }} PushClientStatus */
|
||||
|
||||
/** @typedef {{ enable: () => Promise<{ deviceUid: string }>, disable: () => Promise<void>, status: () => Promise<PushClientStatus> }} PushClient */
|
||||
|
||||
/** Dispatched on `window` when a client is registered. */
|
||||
export const PUSH_CLIENT_REGISTERED_EVENT = 'push-client-registered';
|
||||
|
||||
/** @type {PushClient|null} */
|
||||
let pushClient = null;
|
||||
|
||||
const isPushClient = (client) => (
|
||||
!! client
|
||||
&& typeof client.enable === 'function'
|
||||
&& typeof client.disable === 'function'
|
||||
&& typeof client.status === 'function'
|
||||
);
|
||||
|
||||
/** @param {PushClient} client @returns {boolean} Whether it was accepted */
|
||||
export const registerPushClient = (client) => {
|
||||
if ( ! isPushClient(client) ) {
|
||||
console.warn('[push] client needs enable, disable and status');
|
||||
return false;
|
||||
}
|
||||
pushClient = client;
|
||||
globalThis.dispatchEvent?.(new CustomEvent(PUSH_CLIENT_REGISTERED_EVENT));
|
||||
return true;
|
||||
};
|
||||
|
||||
/** @returns {PushClient|null} */
|
||||
export const getPushClient = () => pushClient;
|
||||
|
||||
globalThis.def?.(registerPushClient, 'push.registerPushClient');
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const client = () => ({ enable: vi.fn(), disable: vi.fn(), status: vi.fn() });
|
||||
|
||||
let mod;
|
||||
const registry = new Map();
|
||||
|
||||
beforeEach(async () => {
|
||||
registry.clear();
|
||||
globalThis.def = (value, id) => registry.set(id, value);
|
||||
vi.resetModules();
|
||||
mod = await import('./pushClient.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.def;
|
||||
});
|
||||
|
||||
describe('push client registry', () => {
|
||||
it('has no client until one registers', () => {
|
||||
expect(mod.getPushClient()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the registered client and announces it', () => {
|
||||
const announced = vi.fn();
|
||||
window.addEventListener(mod.PUSH_CLIENT_REGISTERED_EVENT, announced);
|
||||
const first = client();
|
||||
const second = client();
|
||||
|
||||
expect(mod.registerPushClient(first)).toBe(true);
|
||||
expect(mod.registerPushClient(second)).toBe(true);
|
||||
|
||||
expect(mod.getPushClient()).toBe(second);
|
||||
expect(announced).toHaveBeenCalledTimes(2);
|
||||
window.removeEventListener(mod.PUSH_CLIENT_REGISTERED_EVENT, announced);
|
||||
});
|
||||
|
||||
it('refuses a client missing a method', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(mod.registerPushClient({ enable () {}, status () {} })).toBe(false);
|
||||
expect(mod.registerPushClient(null)).toBe(false);
|
||||
expect(mod.getPushClient()).toBeNull();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('is reachable from service scripts as push.registerPushClient', () => {
|
||||
const registered = client();
|
||||
registry.get('push.registerPushClient')(registered);
|
||||
expect(mod.getPushClient()).toBe(registered);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** @param {unknown} ua @returns {{ browser: string|null, os: string|null }} The common browsers and systems only */
|
||||
export const parseUserAgent = (ua) => {
|
||||
if ( ! ua || typeof ua !== 'string' ) return { browser: null, os: null };
|
||||
let browser = null;
|
||||
if ( /Edg\//i.test(ua) ) browser = 'Edge';
|
||||
else if ( /OPR\/|Opera/i.test(ua) ) browser = 'Opera';
|
||||
else if ( /Chrome\//i.test(ua) && !/Chromium/i.test(ua) ) browser = 'Chrome';
|
||||
else if ( /Firefox\//i.test(ua) ) browser = 'Firefox';
|
||||
else if ( /Safari\//i.test(ua) && !/Chrome\//i.test(ua) ) browser = 'Safari';
|
||||
|
||||
let os = null;
|
||||
if ( /Windows NT/i.test(ua) ) os = 'Windows';
|
||||
// iOS says "like Mac OS X" and Android says "Linux", so both are checked first.
|
||||
else if ( /iPhone|iPad|iPod/i.test(ua) ) os = 'iOS';
|
||||
else if ( /Android/i.test(ua) ) os = 'Android';
|
||||
else if ( /Mac OS X|Macintosh/i.test(ua) ) os = 'macOS';
|
||||
else if ( /Linux/i.test(ua) ) os = 'Linux';
|
||||
|
||||
return { browser, os };
|
||||
};
|
||||
|
||||
/** @param {{ browser: string|null, os: string|null }} ua @returns {string|null} */
|
||||
export const formatBrowserOs = ({ browser, os }) => {
|
||||
if ( browser && os ) return `${browser} on ${os}`;
|
||||
return browser || os || null;
|
||||
};
|
||||
@@ -422,6 +422,40 @@ const en = {
|
||||
publish_name_required: 'Enter an address.',
|
||||
publish_website_intro: 'Serves %strong% at a public address anyone can visit.',
|
||||
publish_worker_intro: 'Runs %strong% at a public address anyone can call.',
|
||||
push_app_turn_off_failed: "Couldn't turn that off. Please try again.",
|
||||
push_apps: 'Apps that can send you notifications',
|
||||
push_apps_empty: 'No apps can send you push notifications.',
|
||||
push_browser_blocked: 'Notifications are blocked in your browser settings',
|
||||
push_browser_checking: 'Checking…',
|
||||
push_browser_off: 'Push notifications are off',
|
||||
push_browser_on: 'Push notifications are on',
|
||||
push_browser_unavailable: "Push notifications aren't available in this browser",
|
||||
push_device_last_seen: 'Last seen {{time}}',
|
||||
push_device_remove: 'Remove',
|
||||
push_device_remove_failed: "Couldn't remove that device. Please try again.",
|
||||
push_device_remove_message: "{{name}} won't get push notifications until you turn them on there again.",
|
||||
push_device_remove_title: 'Remove this device?',
|
||||
push_device_unknown: 'Unknown browser',
|
||||
push_devices: 'Your devices',
|
||||
push_devices_empty: 'No devices get push notifications yet.',
|
||||
push_disable_failed: "Couldn't turn off push notifications. Please try again.",
|
||||
push_enable_failed: "Couldn't turn on push notifications. Please try again.",
|
||||
push_load_failed: "Couldn't load this. Please try again.",
|
||||
push_platform_android: 'Android',
|
||||
push_platform_ios: 'iPhone or iPad',
|
||||
push_platform_web: 'Web',
|
||||
push_puter_notifications: 'Puter notifications',
|
||||
push_puter_notifications_hint: 'Shares, deploys and other updates from Puter',
|
||||
push_save_failed: "Couldn't save that. Please try again.",
|
||||
push_settings_subtitle: 'Choose where you get notifications when Puter is closed',
|
||||
push_test_confirm_message: 'We sent a test notification to this browser.',
|
||||
push_test_confirm_title: 'Did you see a notification?',
|
||||
push_test_failed: "Couldn't send a test notification. Please try again.",
|
||||
push_test_help: 'Check that your device allows notifications from this browser. If you just turned notifications back on, close your browser completely and open it again.',
|
||||
push_test_help_title: 'No notification?',
|
||||
push_this_browser: 'This browser',
|
||||
push_turn_off: 'Turn off',
|
||||
push_turn_on: 'Turn on',
|
||||
puter_description: 'Puter is a privacy-first personal cloud to keep all your files, apps, and games in one secure place, accessible from anywhere at any time.',
|
||||
ram: 'RAM',
|
||||
reading: 'Reading %strong%',
|
||||
|
||||
Reference in new issue
Block a user