From 353f6d0a79283fdaa0a14aaa7d511279de06ead4 Mon Sep 17 00:00:00 2001 From: Mickael Couzinet Date: Tue, 22 Sep 2026 19:07:07 +0200 Subject: [PATCH] shortcuts: the host reads the installed applications for the widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A button pointing at an application that is not in the Dock has drawn the kind's glyph rather than its icon. The widget knows the name the user typed and needs the bundle id the icon is filed under, so it read `/api/apps/installed` itself — but a widget's frame is served with `connect-src 'none'`, so that request never left the page: the console says the connection violates the policy, the list stayed empty, and only the applications the Dock had already named resolved to an icon. `Fremkit.installedApps()` asks the host page, which is the server's own origin, exactly as `Fremkit.favicon()` does one step further along. The route already answers names and bundle ids without a filesystem path, so the widget learns nothing it could not have asked for before. When the answer does not come the Dock match and the glyph still stand. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 ++ docs/widgets.md | 4 +- docs/writing-widgets.md | 1 + server/src/bridge/fremkit.js | 11 +++ server/test/shortcuts-widget.test.ts | 127 +++++++++++++++++++++++++++ ui/src/shared/locales/en.ts | 1 + ui/src/shared/locales/fr.ts | 1 + ui/src/shared/useWidgetBridge.ts | 15 ++++ widgets/shortcuts/index.html | 6 +- widgets/shortcuts/manifest.json | 2 +- 10 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 server/test/shortcuts-widget.test.ts 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",