Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 3 additions & 1 deletion docs/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/writing-widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>`; rejects when there is none. Ask this rather than loading `/api/favicon` in an `<img>`: 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/<bundleId>`, 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 |
Expand Down
11 changes: 11 additions & 0 deletions server/src/bridge/fremkit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<bundleId>`, 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.
*
Expand Down
127 changes: 127 additions & 0 deletions server/test/shortcuts-widget.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<Loaded> {
const html = await readFile(fileURLToPath(new URL('../../widgets/shortcuts/index.html', import.meta.url)), 'utf8')
const script = [...html.matchAll(/<script[^>]*>([\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<string, StubNode>()
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<string, string>) => 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')
})
})
1 change: 1 addition & 0 deletions ui/src/shared/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export const en: Record<string, string> = {

'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',
Expand Down
1 change: 1 addition & 0 deletions ui/src/shared/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export const fr: Record<string, string> = {

'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',
Expand Down
15 changes: 15 additions & 0 deletions ui/src/shared/useWidgetBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

Expand Down
6 changes: 3 additions & 3 deletions widgets/shortcuts/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion widgets/shortcuts/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading