diff --git a/doc/push-client.md b/doc/push-client.md new file mode 100644 index 000000000..3d8160e1b --- /dev/null +++ b/doc/push-client.md @@ -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; + 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. diff --git a/src/gui/src/UI/Dashboard/TabNotifications.js b/src/gui/src/UI/Dashboard/TabNotifications.js new file mode 100644 index 000000000..68c68bbc2 --- /dev/null +++ b/src/gui/src/UI/Dashboard/TabNotifications.js @@ -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 . + */ + +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) => `${body}`; + +const ICONS = { + bell: svg(''), + laptop: svg(''), + phone: svg(''), + puter: svg(''), +}; + +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} */ +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 = `
`; + h += '
'; + h += `
${icon}
`; + h += '
'; + h += `${title}${badge ? ` ${badge}` : ''}`; + if ( detail ) h += `${detail}`; + h += '
'; + h += action ?? ''; + h += '
'; + 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 = ``; + if ( state === 'off' ) action = ``; + 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: ``, + 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: ``, + title: html_encode(app.title || app.name || ''), + action: ``, + extraClass: 'push-app-card', + }); +}; + +const emptyHtml = (key) => `

${i18n(key)}

`; + +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 = '
'; + h += '
'; + h += `

${i18n('notifications')}

`; + h += `

${i18n('push_settings_subtitle')}

`; + h += '
'; + + h += '
'; + h += browserCardHtml('checking'); + h += card({ + extraClass: 'push-puter-card', + icon: ICONS.puter, + title: i18n('push_puter_notifications'), + detail: i18n('push_puter_notifications_hint'), + action: '', + }); + h += '
'; + + h += `

${i18n('push_devices')}

`; + h += '
'; + + h += `

${i18n('push_apps')}

`; + h += '
'; + + h += '
'; + 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; diff --git a/src/gui/src/UI/Dashboard/TabNotifications.test.js b/src/gui/src/UI/Dashboard/TabNotifications.test.js new file mode 100644 index 000000000..da103ff95 --- /dev/null +++ b/src/gui/src/UI/Dashboard/TabNotifications.test.js @@ -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 . + */ +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, '"'); +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 = '
'; + $('.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: '' })] }, + '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(''); + + $('.push-remove-device').trigger('click'); + await settle(); + expect(dialog('.dashboard-dialog-message img')).toHaveLength(0); + expect(dialog('.dashboard-dialog-message').text()).toContain(''); + }); +}); + +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: 'Chat', 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('Chat'); + expect($('.push-app-card img').attr('onerror')).toBeUndefined(); + expect($('.push-app-card img').attr('src')).toBe('" onerror="alert(1)'); + }); +}); diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index c715980a0..b2bbb85c1 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -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 } })); diff --git a/src/gui/src/UI/UIWindowManageSessions.js b/src/gui/src/UI/UIWindowManageSessions.js index 7d9bfeee0..ca3b4d526 100644 --- a/src/gui/src/UI/UIWindowManageSessions.js +++ b/src/gui/src/UI/UIWindowManageSessions.js @@ -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. diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 2059ed193..3a7bc10e7 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -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; diff --git a/src/gui/src/helpers/notificationApi.js b/src/gui/src/helpers/notificationApi.js index e6f8906cc..f411fbe2f 100644 --- a/src/gui/src/helpers/notificationApi.js +++ b/src/gui/src/helpers/notificationApi.js @@ -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} */ +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} */ +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} 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 }); +} diff --git a/src/gui/src/helpers/pushClient.js b/src/gui/src/helpers/pushClient.js new file mode 100644 index 000000000..a2283bd84 --- /dev/null +++ b/src/gui/src/helpers/pushClient.js @@ -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 . + */ + +// 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, status: () => Promise }} 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'); diff --git a/src/gui/src/helpers/pushClient.test.js b/src/gui/src/helpers/pushClient.test.js new file mode 100644 index 000000000..e2f614d87 --- /dev/null +++ b/src/gui/src/helpers/pushClient.test.js @@ -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 . + */ +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); + }); +}); diff --git a/src/gui/src/helpers/userAgent.js b/src/gui/src/helpers/userAgent.js new file mode 100644 index 000000000..a94f81d24 --- /dev/null +++ b/src/gui/src/helpers/userAgent.js @@ -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 . + */ + +/** @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; +}; diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 2f0c6a000..239dc2238 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -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%',