diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e746a..142ff85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,13 @@ Anything that changes what the dashboard, the admin or a widget can do gets a li sessions after 30 minutes (on by default), and group the board by application. ### Fixed +- Shortcut buttons draw the real icon of an application that is not in the Dock again. The widget + read the list of installed applications itself, which the `connect-src 'none'` its frame is + served with has always refused, so anything the user had never docked fell back to the kind's + glyph. The host page reads that list for it now, as it already does for a link's favicon + (@mcouzinet, #6). +- For widget authors: `Fremkit.installedApps()` is how a widget turns an application name into the + bundle id its icon is filed under. - Spotify's progress bar moves: on a Mac whose number format uses a decimal comma the position read as nothing, and the bar sat at zero. It now glides across each second instead of stepping, lands at once on a seek, a new track or a pause, and empties when Spotify closes (@mcouzinet, #4). diff --git a/docs/widgets.md b/docs/widgets.md index e37ed3f..a0f1a4a 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -287,7 +287,9 @@ host, depending on the kind. shortcut. A tap lights the button and answers with a brief ✓ or ✗. Application buttons draw the app's real icon: the one the native helper uploaded when it is in the Dock, and otherwise one the server extracts from the installed bundle itself (`sips`, cached under `data/icons/apps/`), so an -application that has never been docked still shows its icon. The kind's glyph is the last resort. +application that has never been docked still shows its icon. The name on the button is matched +against the installed applications, asked for through the host page (`Fremkit.installedApps`), to +find the bundle identifier that icon is filed under. The kind's glyph is the last resort. In the admin, the *Target* box of an application row offers the applications installed on this machine as you type — `/Applications` and its subfolders, `~/Applications`, and the system ones. diff --git a/docs/writing-widgets.md b/docs/writing-widgets.md index e7e7fb8..1eea487 100644 --- a/docs/writing-widgets.md +++ b/docs/writing-widgets.md @@ -225,6 +225,7 @@ Fremkit.whenReady(() => { | `sendCommand(channel, name, payload)` | Sends a command. Returns a promise | | `fetch(url, init)` | GET through the server's proxy. Only the host in `permissions.network` is reachable; `body` and `credentials` are ignored | | `favicon(url)` | The icon of the site `url` is on, as a `data:` URL for an ``; rejects when there is none. Ask this rather than loading `/api/favicon` in an ``: a widget's frame has an opaque origin, so the server sees that request as cross-site and refuses it | +| `installedApps()` | The applications installed on this Mac, as `{ name, bundleId, file }` — enough to match a name the user typed and ask for its icon at `/api/apps/icon/`, and no filesystem path. Ask this rather than `fetch('/api/apps/installed')`: a widget's frame is served with `connect-src 'none'`, so its own request never leaves the page | | `t(dict, params)` | Resolves a `{ fr, en }` table entry and interpolates `{name}` placeholders | | `onSettings(cb)` | Fires when the admin edits the settings, without a reload. Returns an off function | | `onLocale(cb)` | Fires when the language changes. Returns an off function | diff --git a/server/src/bridge/fremkit.js b/server/src/bridge/fremkit.js index c8b0d3f..afbfd64 100644 --- a/server/src/bridge/fremkit.js +++ b/server/src/bridge/fremkit.js @@ -212,6 +212,17 @@ favicon: function (url) { return request({ type: 'fremkit:favicon', url: url }) }, + /** + * The applications installed on this Mac, as `{ name, bundleId, file }` — enough to name one + * and to ask for its icon at `/api/apps/icon/`, and nothing about where it lives. + * + * Asked of the host for the same reason as a favicon, one step earlier: a widget's frame is + * served with `connect-src 'none'`, so a `fetch` from here never leaves the page at all. The + * host page makes the request from the server's own origin and hands the list over. + */ + installedApps: function () { + return request({ type: 'fremkit:apps' }) + }, /** * Escapes a string for HTML. * diff --git a/server/test/shortcuts-widget.test.ts b/server/test/shortcuts-widget.test.ts new file mode 100644 index 0000000..dfc91de --- /dev/null +++ b/server/test/shortcuts-widget.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +/** + * The shortcuts widget's application icons, drawn for real. + * + * The widget is a plain browser script in an HTML file, so it is evaluated here against stubs. + * What this watches is where the bundle id comes from: the widget asks the *host* for the + * installed applications, because its own frame is served with `connect-src 'none'` and a + * `fetch` from there never leaves the page — which is why every button for an application the + * user never docked drew the kind's glyph instead of its icon. + */ +interface StubNode { + tag: string; className: string; textContent: string; src: string + children: StubNode[] + retryTimer?: unknown +} + +interface Loaded { + /** The buttons currently in the grid, in order. */ + buttons(): StubNode[] + /** What the first button draws as its artwork: an `img` node, or the kind's glyph. */ + artwork(): StubNode | undefined + /** Hands the widget one answer from the dock channel, as the host delivers it. */ + publishDock(data: unknown): void +} + +async function loadWidget(installed: unknown, settings: Record): Promise { + const html = await readFile(fileURLToPath(new URL('../../widgets/shortcuts/index.html', import.meta.url)), 'utf8') + const script = [...html.matchAll(/]*>([\s\S]*?)<\/script>/g)].map((m) => m[1]).join('\n') + + const node = (tag = 'div'): StubNode => { + const self = { + tag, className: '', textContent: '', src: '', alt: '', hidden: false, clientHeight: 300, + children: [] as StubNode[], + style: { setProperty: () => {} }, + classList: { add: () => {}, remove: () => {}, toggle: () => {} }, + appendChild: (child: StubNode) => { self.children.push(child) }, + replaceChild: (next: StubNode, old: StubNode) => { self.children.splice(self.children.indexOf(old), 1, next) }, + addEventListener: () => {}, + onclick: null, + // The widget empties the grid this way before rebuilding it. + set innerHTML(_v: string) { self.children.length = 0 }, + get innerHTML() { return '' }, + } as unknown as StubNode + return self + } + const nodes = new Map() + const byId = (id: string): StubNode => { + if (!nodes.has(id)) nodes.set(id, node()) + return nodes.get(id)! + } + const dockSubscribers: ((data: unknown) => void)[] = [] + const document = { + documentElement: node('html'), + getElementById: byId, + createElement: (tag: string) => node(tag), + } + const Fremkit = { + settings, locale: 'fr', compact: false, instanceId: 'shortcuts-1', + size: { h: 8, px: { width: 320, height: 320 } }, + whenReady: (cb: () => void) => cb(), + subscribe: (channel: string, cb: (data: unknown) => void) => { + if (channel === 'dock') dockSubscribers.push(cb) + return () => {} + }, + onLocale: () => {}, onResize: () => {}, onSettings: () => {}, + sendCommand: () => Promise.resolve({ ok: true }), + el: (tag: string, className: string, text?: unknown) => { + const n = node(tag) + n.className = className ?? '' + n.textContent = String(text ?? '') + return n + }, + t: (dict: Record) => dict.fr, + esc: (value: unknown) => String(value ?? ''), + color: (_value: unknown, fallback: string) => fallback, + installedApps: () => (installed === REFUSED ? Promise.reject(new Error('unavailable')) : Promise.resolve(installed)), + } + // The widget refreshes the list on a timer; a test wants the one call it makes on startup. + new Function('document', 'Fremkit', 'setInterval', script)(document, Fremkit, () => 0) + const grid = () => byId('grid') + return { + buttons: () => grid().children, + artwork: () => grid().children[0]?.children[0], + publishDock: (data) => dockSubscribers.forEach((cb) => cb(data)), + } +} + +const terminal = { label: 'Terminal', kind: 'app', target: 'Terminal' } +/** Passed as the installed list to make the host's answer fail instead of arriving. */ +const REFUSED = Symbol('refused') + +describe('the shortcuts widget application icons', () => { + it('draws the icon of an application that is not in the Dock', async () => { + const loaded = await loadWidget([{ name: 'Terminal', bundleId: 'com.apple.Terminal' }], { buttons: [terminal] }) + await Promise.resolve() + await Promise.resolve() + expect(loaded.artwork()?.tag).toBe('img') + expect(loaded.artwork()?.src).toBe('/api/apps/icon/com.apple.Terminal') + }) + + it('matches the bundle a name is filed under, not only its own name', async () => { + const loaded = await loadWidget([{ name: 'Visual Studio Code', bundleId: 'com.microsoft.VSCode', file: 'Code' }], { + buttons: [{ label: 'Code', kind: 'app', target: 'Code.app' }], + }) + await Promise.resolve() + await Promise.resolve() + expect(loaded.artwork()?.src).toBe('/api/apps/icon/com.microsoft.VSCode') + }) + + it('keeps the glyph while the host has not answered, and when it never does', async () => { + const loaded = await loadWidget(REFUSED, { buttons: [terminal] }) + expect(loaded.artwork()?.className).toContain('glyph') + await Promise.resolve() + expect(loaded.buttons()).toHaveLength(1) + }) + + it('still falls back to the Dock, which names a bundle id too', async () => { + const loaded = await loadWidget([], { buttons: [terminal] }) + await Promise.resolve() + await Promise.resolve() + loaded.publishDock({ available: true, apps: [{ name: 'Terminal', bundleId: 'com.apple.Terminal', badge: null, running: true }] }) + expect(loaded.artwork()?.src).toBe('/api/apps/icon/com.apple.Terminal') + }) +}) diff --git a/ui/src/shared/locales/en.ts b/ui/src/shared/locales/en.ts index bb420a7..e385747 100644 --- a/ui/src/shared/locales/en.ts +++ b/ui/src/shared/locales/en.ts @@ -268,6 +268,7 @@ export const en: Record = { 'bridge.badRequest': 'invalid widget request', 'bridge.noIcon': 'no icon for this site', + 'bridge.noApps': 'installed applications unavailable', 'bridge.channelNotAllowed': 'channel not allowed: {channel}', 'socket.unknownError': 'unknown error', diff --git a/ui/src/shared/locales/fr.ts b/ui/src/shared/locales/fr.ts index 15353cf..5f2f9e7 100644 --- a/ui/src/shared/locales/fr.ts +++ b/ui/src/shared/locales/fr.ts @@ -268,6 +268,7 @@ export const fr: Record = { 'bridge.badRequest': 'requête du widget invalide', 'bridge.noIcon': 'pas d’icône pour ce site', + 'bridge.noApps': 'liste des applications indisponible', 'bridge.channelNotAllowed': 'canal non autorisé : {channel}', 'socket.unknownError': 'erreur inconnue', diff --git a/ui/src/shared/useWidgetBridge.ts b/ui/src/shared/useWidgetBridge.ts index a449494..8acef1a 100644 --- a/ui/src/shared/useWidgetBridge.ts +++ b/ui/src/shared/useWidgetBridge.ts @@ -194,6 +194,21 @@ export function useWidgetBridge( .catch((e: Error) => reply(id, undefined, e.message)) break } + case 'fremkit:apps': { + // Same reason as the favicon above, one step earlier: the widget's frame is served with + // `connect-src 'none'`, so its own fetch never leaves the page. The route answers the + // name, the bundle id and the file name of each application — what a widget needs to + // turn a name the user typed into the icon it asks for — and no filesystem path. + const id = str(m.id) + if (!id) break + fetch('/api/apps/installed') + .then(async (r) => { + if (!r.ok) { reply(id, undefined, t('bridge.noApps')); return } + reply(id, await r.json()) + }) + .catch((e: Error) => reply(id, undefined, e.message)) + break + } } } diff --git a/widgets/shortcuts/index.html b/widgets/shortcuts/index.html index 15c89e7..7222e6b 100644 --- a/widgets/shortcuts/index.html +++ b/widgets/shortcuts/index.html @@ -309,10 +309,10 @@ rerenderIfResolutionChanged() }) // Every installed application, not only the ones in the Dock: a button pointing at something - // the user never docked still gets its real icon. + // the user never docked still gets its real icon. Asked of the host, which can reach the + // route: this frame is served with `connect-src 'none'`, so its own fetch goes nowhere. function loadInstalled() { - fetch('/api/apps/installed') - .then(function (r) { return r.ok ? r.json() : [] }) + Fremkit.installedApps() .then(function (list) { installed = Array.isArray(list) ? list : [] rerenderIfResolutionChanged() diff --git a/widgets/shortcuts/manifest.json b/widgets/shortcuts/manifest.json index 254c29f..5de1598 100644 --- a/widgets/shortcuts/manifest.json +++ b/widgets/shortcuts/manifest.json @@ -1,6 +1,6 @@ { "id": "shortcuts", "name": { "fr": "Raccourcis", "en": "Shortcuts" }, - "version": "1.3.0", + "version": "1.3.1", "description": { "fr": "Gros boutons tactiles qui ouvrent une application, un lien ou un raccourci", "en": "Big touch buttons that open an application, a link or a shortcut" }, "category": "productivity",