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`); + } + } +});