diff --git a/README.md b/README.md
index 4f883d9..7f2e359 100644
--- a/README.md
+++ b/README.md
@@ -73,8 +73,15 @@ Download the build for your platform from [Releases](https://github.com/viny4/pa
Builds are **not code-signed**, because signing certificates cost money this project doesn't have. That means:
-- **macOS** will say the app "cannot be opened because the developer cannot be verified". Right-click the app → Open → Open.
- - If it instead says **"Panebox is damaged and can't be opened"**, that is Gatekeeper rejecting a quarantined app, not a bad download. Run `xattr -dr com.apple.quarantine /Applications/Panebox.app` and open it again.
+- **macOS** will say *"Apple could not verify Panebox.app is free of malware"*. The app is fine — macOS simply cannot check a developer it does not know.
+
+ Click **Done** (never "Move to Bin"), then open **System Settings → Privacy & Security**, scroll to the bottom, and click **Open Anyway** next to "Panebox.app was blocked".
+
+ On macOS 15 and later, right-clicking the app and choosing Open no longer works; Apple removed that shortcut. If you would rather use the terminal:
+
+ ```bash
+ xattr -dr com.apple.quarantine /Applications/Panebox.app
+ ```
- **Windows** will show a SmartScreen warning. Click "More info" → "Run anyway".
If that trade-off isn't acceptable to you, build from source — it takes two commands.
diff --git a/SECURITY.md b/SECURITY.md
index c083062..fa1eb5d 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -29,7 +29,9 @@ Panebox loads arbitrary third-party websites, so the boundaries matter. What's i
**Custom JavaScript per service.** Panebox lets you paste JavaScript that runs inside a service, with full access to that service and your logged-in session. This is intentional, opt-in, off by default, and warned about in the UI. Only paste code you understand.
-**Builds are unsigned.** Releases are not code-signed on macOS or Windows, because certificates cost money this project doesn't have. Verify what you download, or build from source.
+**Builds are unsigned and un-notarized.** Releases carry only an ad-hoc signature — enough for macOS to run them at all, but not a Developer ID. macOS will say it "could not verify Panebox is free of malware", and Windows will show SmartScreen. Both are accurate: nobody has vouched for these binaries. Verify what you download, or build from source.
+
+Practically, this also means auto-update cannot install itself on macOS: Squirrel refuses to update an app without a valid signature, so macOS gets a notification and a download link instead.
**No sandboxing of service content beyond Chromium's own.** Panebox is a container around real websites. If a site is malicious, Chromium's sandbox and the session isolation above are what stand between it and you — the same as any browser.
diff --git a/index.html b/index.html
index 7d4539c..b5e206b 100644
--- a/index.html
+++ b/index.html
@@ -149,6 +149,7 @@
Settings
+
@@ -231,6 +232,10 @@
Settings
+
+
+
+
@@ -321,6 +326,7 @@
Choose what to share
+
diff --git a/lib/shortcuts.js b/lib/shortcuts.js
new file mode 100644
index 0000000..b08fd32
--- /dev/null
+++ b/lib/shortcuts.js
@@ -0,0 +1,102 @@
+/**
+ * Every shortcut and gesture in the app, in one place.
+ *
+ * This is what the Settings → Shortcuts tab renders. A test checks that every
+ * accelerator declared in the application menu appears here, so the list
+ * cannot quietly fall out of date with the real bindings.
+ *
+ * Keys are tokens rather than rendered strings: "mod" becomes ⌘ on macOS and
+ * Ctrl everywhere else.
+ */
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory();
+ else root.SHORTCUTS = factory();
+})(typeof self !== 'undefined' ? self : this, function () {
+ const SECTIONS = [
+ {
+ title: 'Services',
+ items: [
+ { keys: ['mod', '1'], label: 'Jump to the first service (1–9)' },
+ { keys: ['mod', 'Tab'], label: 'Next service' },
+ { keys: ['mod', 'shift', 'Tab'], label: 'Previous service' },
+ { keys: ['mod', 'N'], label: 'Add a service' },
+ { keys: ['mod', 'R'], label: 'Reload the current service' },
+ ],
+ },
+ {
+ title: 'Layout',
+ items: [
+ { keys: ['mod', 'shift', 'S'], label: 'Split view — several services at once' },
+ { keys: ['mod', 'B'], label: 'Show or hide the sidebar' },
+ { keys: ['mod', 'T'], label: 'Todo panel' },
+ { keys: ['Esc'], label: 'Step back: close a menu, then the spotlight, then the grid' },
+ ],
+ },
+ {
+ title: 'Window',
+ items: [
+ { keys: ['mod', 'F'], label: 'Find in page' },
+ { keys: ['mod', ','], label: 'Settings' },
+ { keys: ['mod', 'shift', 'M'], label: 'Task manager' },
+ { keys: ['mod', 'shift', 'R'], label: 'Restart Panebox' },
+ // These differ per platform rather than being a plain "mod" swap.
+ { mac: ['alt', 'cmd', 'I'], win: ['ctrl', 'shift', 'I'], label: 'Developer tools' },
+ { mac: ['ctrl', 'cmd', 'F'], win: ['F11'], label: 'Full screen' },
+ // Electron's quit role registers no accelerator on Windows or Linux;
+ // Alt+F4 closes the window there. Saying "Ctrl+Q" would be a guess.
+ { mac: ['cmd', 'Q'], win: null, label: 'Quit' },
+ ],
+ },
+ {
+ title: 'Mouse',
+ mouse: true,
+ items: [
+ { gesture: 'Right-click a sidebar icon', label: 'Configure that service' },
+ { gesture: 'Drag a sidebar icon', label: 'Reorder your services' },
+ { gesture: 'Drag a sidebar icon onto the grid', label: 'Add it as a pane' },
+ { gesture: 'Click a sidebar icon while in split view', label: 'Add or remove that pane' },
+ { gesture: 'Double-click a pane header', label: 'Expand that pane' },
+ { gesture: 'Triple-click a pane header', label: 'Open that service on its own' },
+ { gesture: 'Drag the ⠿ handle in Settings → Services', label: 'Reorder your services' },
+ ],
+ },
+ ];
+
+ const MAC_SYMBOLS = { mod: '⌘', cmd: '⌘', shift: '⇧', alt: '⌥', ctrl: '⌃', Esc: 'esc', Tab: '⇥' };
+ const PC_SYMBOLS = { mod: 'Ctrl', cmd: 'Ctrl', shift: 'Shift', alt: 'Alt', ctrl: 'Ctrl', Esc: 'Esc', Tab: 'Tab' };
+
+ /** Renders one key token for the given platform. */
+ function keyLabel(token, isMac) {
+ const table = isMac ? MAC_SYMBOLS : PC_SYMBOLS;
+ return table[token] || token;
+ }
+
+ /**
+ * Both platforms for every row, so the list works as documentation and not
+ * only as a reminder for whatever machine you happen to be on.
+ *
+ * `keys` means the same combination on both, with "mod" resolving to ⌘ or
+ * Ctrl. `mac` and `win` are for the cases where they genuinely differ, and a
+ * null side means there is no binding on that platform.
+ */
+ function rows() {
+ return SECTIONS.map((section) => ({
+ title: section.title,
+ mouse: !!section.mouse,
+ items: section.items.map((item) => ({
+ label: item.label,
+ gesture: item.gesture || null,
+ mac: item.gesture ? null : (item.mac || item.keys || null),
+ win: item.gesture ? null : (item.win !== undefined ? item.win : item.keys || null),
+ })),
+ }));
+ }
+
+ /** Renders one side of a row: ["⌘","⇧","S"] or null when unbound. */
+ function render(keys, isMac) {
+ if (!keys) return null;
+ return keys.map((k) => keyLabel(k, isMac));
+ }
+
+ return { SECTIONS, rows, render, keyLabel };
+});
diff --git a/package-lock.json b/package-lock.json
index 679d77d..bc1505f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "panebox",
- "version": "2.1.1",
+ "version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "panebox",
- "version": "2.1.1",
+ "version": "2.2.0",
"license": "MIT",
"dependencies": {
"electron-updater": "^6.8.9"
diff --git a/package.json b/package.json
index 297d275..ad467af 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "panebox",
"productName": "Panebox",
- "version": "2.1.1",
+ "version": "2.2.0",
"description": "A small, private, open-source desktop deck for your web apps — AI tools, chat, mail and more in one window.",
"main": "main.js",
"license": "MIT",
diff --git a/renderer.js b/renderer.js
index 9672e7c..c6d0309 100644
--- a/renderer.js
+++ b/renderer.js
@@ -1147,6 +1147,72 @@ function commitManageOrder() {
}
}
+/** Settings → Shortcuts. Both platforms, so the list doubles as documentation. */
+function renderShortcuts() {
+ const root = $('shortcut-list');
+ root.textContent = '';
+ const isMac = window.panebox.platform === 'darwin';
+
+ const legend = document.createElement('div');
+ legend.className = 'shortcut-legend';
+ const spacer = document.createElement('span');
+ const macCol = document.createElement('span');
+ macCol.textContent = 'macOS';
+ macCol.className = isMac ? 'current' : '';
+ const winCol = document.createElement('span');
+ winCol.textContent = 'Windows / Linux';
+ winCol.className = isMac ? '' : 'current';
+ legend.append(spacer, macCol, winCol);
+ root.appendChild(legend);
+
+ const keyGroup = (keys, current) => {
+ const cell = document.createElement('span');
+ cell.className = 'shortcut-keys' + (current ? ' current' : '');
+ if (!keys) {
+ const dash = document.createElement('span');
+ dash.className = 'shortcut-none';
+ dash.textContent = '—';
+ cell.appendChild(dash);
+ return cell;
+ }
+ for (const k of keys) {
+ const kbd = document.createElement('kbd');
+ kbd.textContent = k;
+ cell.appendChild(kbd);
+ }
+ return cell;
+ };
+
+ for (const section of window.SHORTCUTS.rows()) {
+ const heading = document.createElement('h4');
+ heading.className = 'shortcut-heading';
+ heading.textContent = section.title;
+ root.appendChild(heading);
+
+ for (const item of section.items) {
+ const row = document.createElement('div');
+ row.className = 'shortcut-row' + (section.mouse ? ' gesture-row' : '');
+
+ const label = document.createElement('span');
+ label.className = 'shortcut-label';
+ label.textContent = item.label;
+ row.appendChild(label);
+
+ if (item.gesture) {
+ const g = document.createElement('span');
+ g.className = 'shortcut-gesture';
+ g.textContent = item.gesture;
+ row.appendChild(g);
+ } else {
+ row.appendChild(keyGroup(window.SHORTCUTS.render(item.mac, true), isMac));
+ row.appendChild(keyGroup(window.SHORTCUTS.render(item.win, false), !isMac));
+ }
+
+ root.appendChild(row);
+ }
+ }
+}
+
function renderWorkspaceList() {
const list = $('workspace-list');
list.textContent = '';
@@ -1904,6 +1970,7 @@ document.addEventListener('DOMContentLoaded', () => {
fillSettingsForm();
renderManageList();
renderWorkspaceList();
+ renderShortcuts();
openModal('settings-modal');
});
$('btn-workspace').addEventListener('click', (e) => {
@@ -2070,6 +2137,7 @@ document.addEventListener('DOMContentLoaded', () => {
fillSettingsForm();
renderManageList();
renderWorkspaceList();
+ renderShortcuts();
openModal('settings-modal');
});
window.panebox.menu.onOpenAdd(() => {
diff --git a/styles.css b/styles.css
index ca3c96a..cc82128 100644
--- a/styles.css
+++ b/styles.css
@@ -732,3 +732,45 @@ body.reordering webview { pointer-events: none; }
width: 18px; height: 18px; flex-shrink: 0; border-radius: 4px; font-size: 8px;
}
#split-menu { max-height: 320px; overflow-y: auto; }
+
+/* ---------------------------------------------------------- shortcuts tab */
+
+.shortcut-heading {
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
+ letter-spacing: .05em; color: var(--text-dim);
+ margin: 18px 0 6px; padding-bottom: 5px; border-bottom: 1px solid var(--border);
+}
+.shortcut-heading:first-child { margin-top: 0; }
+
+/* Three columns: what it does, the macOS keys, the Windows/Linux keys. */
+.shortcut-legend,
+.shortcut-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 118px 148px;
+ align-items: center; gap: 12px; padding: 8px 0;
+}
+.shortcut-legend {
+ padding: 0 0 4px; font-size: 10px; text-transform: uppercase;
+ letter-spacing: .05em; color: var(--text-dim);
+}
+.shortcut-legend .current { color: var(--accent); font-weight: 600; }
+
+/* A gesture spans both key columns. */
+.shortcut-row.gesture-row { grid-template-columns: minmax(0, 1fr) 278px; }
+
+.shortcut-label { font-size: 13px; color: var(--text); }
+.shortcut-keys { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
+.shortcut-keys:not(.current) { opacity: .5; }
+.shortcut-none { font-size: 12px; color: var(--text-dim); }
+
+.shortcut-keys kbd {
+ display: inline-flex; align-items: center; justify-content: center;
+ min-width: 24px; height: 24px; padding: 0 7px;
+ background: var(--bg-sunken); border: 1px solid var(--border);
+ border-bottom-width: 2px; border-radius: 6px;
+ font-family: inherit; font-size: 12px; color: var(--text);
+}
+.shortcut-gesture {
+ font-size: 11.5px; color: var(--text-muted); text-align: right;
+ max-width: 260px; line-height: 1.35;
+}
diff --git a/test/shortcuts.test.js b/test/shortcuts.test.js
new file mode 100644
index 0000000..4fa7346
--- /dev/null
+++ b/test/shortcuts.test.js
@@ -0,0 +1,122 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const SHORTCUTS = require('../lib/shortcuts');
+
+const root = path.join(__dirname, '..');
+const mainJs = fs.readFileSync(path.join(root, 'main.js'), 'utf8');
+const rendererJs = fs.readFileSync(path.join(root, 'renderer.js'), 'utf8');
+
+/** "CmdOrCtrl+Shift+S" -> "mod+shift+s", so both sides compare the same way. */
+function normalize(accelerator) {
+ return accelerator
+ .split('+')
+ .map((part) => {
+ const p = part.trim().toLowerCase();
+ if (p === 'cmdorctrl' || p === 'cmd' || p === 'command' || p === 'mod') return 'mod';
+ if (p === 'ctrl' || p === 'control') return 'ctrl';
+ if (p === 'alt' || p === 'option') return 'alt';
+ return p;
+ })
+ .sort()
+ .join('+');
+}
+
+function documented() {
+ const out = new Set();
+ for (const section of SHORTCUTS.SECTIONS) {
+ for (const item of section.items) {
+ for (const combo of [item.keys, item.mac, item.win]) {
+ if (Array.isArray(combo)) out.add(normalize(combo.join('+')));
+ }
+ }
+ }
+ return out;
+}
+
+test('every accelerator in the application menu is documented', () => {
+ // Without this the Shortcuts tab drifts: a binding changes in main.js and the
+ // list keeps advertising the old key, which is worse than no list at all.
+ const listed = documented();
+ const missing = [];
+
+ // Only the value that follows `accelerator:` — a line-wide scan also picked
+ // up labels and IPC channel names. Both branches of a ternary count, since
+ // `accelerator: isMac ? 'Alt+Cmd+I' : 'Ctrl+Shift+I'` went undocumented
+ // behind a simpler pattern.
+ const accelerators = [];
+ for (const m of mainJs.matchAll(/accelerator:\s*'([^']+)'/g)) accelerators.push(m[1]);
+ for (const m of mainJs.matchAll(/accelerator:[^'\n]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g)) {
+ accelerators.push(m[1], m[2]);
+ }
+
+ for (const accel of accelerators) {
+ // Roles GitHub/Electron provide for free and that users already know.
+ if (/^(CmdOrCtrl\+(C|V|X|Z|A))$/i.test(accel)) continue;
+ if (!listed.has(normalize(accel))) missing.push(accel);
+ }
+
+ assert.deepStrictEqual(
+ missing,
+ [],
+ `menu accelerators missing from lib/shortcuts.js:\n ${missing.join('\n ')}`,
+ );
+});
+
+test('the number-key shortcut is documented', () => {
+ // Handled in the renderer rather than the menu, so the scan above misses it.
+ assert.ok(rendererJs.includes("e.key >= '1' && e.key <= '9'"), 'renderer still binds 1-9');
+ assert.ok(documented().has(normalize('mod+1')), 'Shortcuts tab must mention it');
+});
+
+test('both platforms are listed for every keyboard row', () => {
+ for (const section of SHORTCUTS.rows()) {
+ if (section.mouse) continue;
+ for (const item of section.items) {
+ assert.ok(
+ item.mac !== undefined && item.win !== undefined,
+ `${item.label} must state both platforms, even if one is null`,
+ );
+ }
+ }
+});
+
+test('a binding that does not exist on a platform renders as unbound', () => {
+ // Electron's quit role registers no accelerator on Windows or Linux, so
+ // claiming Ctrl+Q there would be inventing one.
+ const quit = SHORTCUTS.rows()
+ .flatMap((s) => s.items)
+ .find((i) => i.label === 'Quit');
+ assert.deepStrictEqual(SHORTCUTS.render(quit.mac, true), ['⌘', 'Q']);
+ assert.strictEqual(SHORTCUTS.render(quit.win, false), null);
+});
+
+test('platform-specific keys render differently where they differ', () => {
+ const devtools = SHORTCUTS.rows()
+ .flatMap((s) => s.items)
+ .find((i) => i.label === 'Developer tools');
+ assert.deepStrictEqual(SHORTCUTS.render(devtools.mac, true), ['⌥', '⌘', 'I']);
+ assert.deepStrictEqual(SHORTCUTS.render(devtools.win, false), ['Ctrl', 'Shift', 'I']);
+});
+
+test('keys render with the right symbols per platform', () => {
+ assert.strictEqual(SHORTCUTS.keyLabel('mod', true), '⌘');
+ assert.strictEqual(SHORTCUTS.keyLabel('mod', false), 'Ctrl');
+ assert.strictEqual(SHORTCUTS.keyLabel('shift', true), '⇧');
+ assert.strictEqual(SHORTCUTS.keyLabel('shift', false), 'Shift');
+ assert.strictEqual(SHORTCUTS.keyLabel('S', true), 'S');
+});
+
+test('every documented shortcut has a description', () => {
+ for (const section of SHORTCUTS.SECTIONS) {
+ for (const item of section.items) {
+ assert.ok(item.label && item.label.length > 3, `missing label in ${section.title}`);
+ const hasKeys = item.keys || item.mac || item.win;
+ assert.ok(hasKeys || item.gesture, `${item.label} has neither keys nor a gesture`);
+ }
+ }
+});