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(/