Skip to content
Merged
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
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ Adding a service to the catalog is a one-line change in [`catalog.js`](catalog.j
- `category` must be one of the values in `CATEGORIES`
- `color` is only used for the generated letter avatar shown before the service reports its own favicon

`npm test` enforces all four of those. No icon file is needed — Panebox picks up the real favicon from the service itself.
`npm test` enforces all four of those. No icon file is needed: run `npm run brand-icons` and, if simple-icons carries that brand, its official mark is inlined into `icons.js` automatically. Otherwise the service gets a letter avatar until the site reports its own favicon.

`icons.js` is generated — edit `tools/make-brand-icons.js`, never `icons.js` itself.

## Project layout

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ 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, or run `xattr -dr com.apple.quarantine /Applications/Panebox.app`.
- **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.
- **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.
Expand Down Expand Up @@ -169,6 +170,10 @@ Panebox exists because [Rambox](https://rambox.app) went closed-source and [Fran
- **[ElectronIM](https://github.com/manusa/electronim)** (Apache-2.0) — minimal and privacy-focused
- **[Hamsket](https://github.com/TheGoddessInari/hamsket)** (GPL-3.0) — a fork of the last open-source Rambox

## Credits

Brand marks come from [simple-icons](https://github.com/simple-icons/simple-icons) (CC0-1.0), inlined into `icons.js` at build time so the app ships no icon dependency and never fetches an icon over the network. Services without an official mark use a generated letter avatar until the site reports its own favicon.

## License

[MIT](LICENSE)
51 changes: 50 additions & 1 deletion catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,54 @@
return SERVICES.find((s) => s.key === key) || null;
}

return { SERVICES, CATEGORIES, DEFAULT_KEYS, byKey };
const bareHost = (value) => {
try {
return new URL(value).hostname.replace(/^www\./i, '').toLowerCase();
} catch {
return '';
}
};

const barePath = (value) => {
try {
return new URL(value).pathname.replace(/\/+$/, '') || '/';
} catch {
return '/';
}
};

/**
* Finds a catalog entry for a URL, so one typed by hand still gets the
* official brand mark instead of a letter avatar.
*
* The path matters, not just the host: Gmail and Google Chat both live on
* mail.google.com, separated only by /chat. Host-only matching handed every
* Gmail URL the Google Chat icon.
*/
function byHost(url) {
const host = bareHost(url);
if (!host) return null;
const path = barePath(url);

const sameHost = SERVICES.filter((s) => bareHost(s.url) === host);
if (sameHost.length) {
// Most specific path that the URL actually sits under wins; '/' matches
// anything and acts as the fallback for that host.
const scored = sameHost
.map((s) => ({ service: s, prefix: barePath(s.url) }))
.filter((c) => c.prefix === '/' || path === c.prefix || path.startsWith(`${c.prefix}/`))
.sort((a, b) => b.prefix.length - a.prefix.length);
if (scored.length) return scored[0].service;
}

// Fall back to a subdomain relationship: app.slack.com -> Slack.
return (
SERVICES.find((s) => {
const known = bareHost(s.url);
return known && (host.endsWith(`.${known}`) || known.endsWith(`.${host}`));
}) || null
);
}

return { SERVICES, CATEGORIES, DEFAULT_KEYS, byKey, byHost };
});
143 changes: 107 additions & 36 deletions icons.js

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@
</div>

<div class="action-buttons">
<button id="btn-split" class="nav-btn" title="Split view — every service in this group at once (Cmd/Ctrl+Shift+S)" aria-label="Split view">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"></rect><rect x="14" y="3" width="7" height="7" rx="1"></rect><rect x="3" y="14" width="7" height="7" rx="1"></rect><rect x="14" y="14" width="7" height="7" rx="1"></rect></svg>
</button>
<button id="btn-find" class="nav-btn" title="Find in page (Cmd/Ctrl+F)" aria-label="Find in page">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</button>
Expand Down Expand Up @@ -149,7 +152,7 @@ <h3 id="settings-title">Settings</h3>
</div>

<section id="tab-services" class="tab-panel active">
<p class="hint">Drag services in the sidebar to reorder them.</p>
<p class="hint">Drag the &#10303; handle to reorder, or drag icons in the sidebar.</p>
<div id="manage-app-list" class="manage-app-list"></div>
</section>

Expand Down Expand Up @@ -313,9 +316,11 @@ <h3 id="screen-title">Choose what to share</h3>
</div>

<div id="workspace-menu" class="popover" hidden></div>
<div id="split-menu" class="popover" hidden></div>

<script src="catalog.js"></script>
<script src="icons.js"></script>
<script src="lib/urls.js"></script>
<script src="lib/badge.js"></script>
<script src="lib/activity.js"></script>
<script src="renderer.js"></script>
Expand Down
31 changes: 30 additions & 1 deletion lib/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function defaultConfig() {
workspaces: [{ id: 'ws-all', name: 'All', appIds: null }],
activeWorkspace: 'ws-all',
todos: [],
splitIds: [],
window: null,
settings: {
theme: 'system',
Expand All @@ -59,6 +60,7 @@ function defaultConfig() {
spellcheckLanguages: ['en-US'],
// The only network request Panebox makes on its own behalf.
checkForUpdates: true,
sidebarHidden: false,
},
};
}
Expand All @@ -84,9 +86,36 @@ function createStore(filePath) {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
// Merge over defaults so upgrades pick up newly added settings keys.
data = deepMerge(defaultConfig(), parsed);

// Keep the last known-good copy. Losing a service list is annoying;
// losing it silently, with no way back, is worse.
try {
fs.copyFileSync(filePath, `${filePath}.backup`);
} catch {
/* a missing backup must never stop the app starting */
}
}
} catch (err) {
console.error('Could not read config, starting fresh:', err.message);
// Do NOT fall through to defaults and then overwrite the file — that turns
// one bad read into permanent data loss. Preserve the original first.
const quarantined = `${filePath}.corrupt-${Date.now()}`;
try {
fs.renameSync(filePath, quarantined);
console.error(`Config unreadable (${err.message}); kept a copy at ${quarantined}`);
} catch {
console.error('Config unreadable and could not be preserved:', err.message);
}

// Prefer the last good backup over starting from scratch.
try {
const backup = `${filePath}.backup`;
if (fs.existsSync(backup)) {
data = deepMerge(defaultConfig(), JSON.parse(fs.readFileSync(backup, 'utf8')));
console.error('Recovered configuration from backup.');
}
} catch {
console.error('Backup was unreadable too; starting fresh.');
}
}

let writeTimer = null;
Expand Down
56 changes: 55 additions & 1 deletion lib/urls.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,58 @@ function isAuthUrl(rawUrl) {
return pathMatches || hasOauthParams;
}

module.exports = { isAuthUrl, AUTH_HOSTS };
/**
* Turns whatever a user typed into a URL we are willing to load, or null.
*
* Prefixing "https://" onto anything is too permissive on its own: "not a url"
* becomes "https://not a url", which parses, gets percent-encoded, and lands a
* permanently broken service in the sidebar named "Not%20a%20url". Likewise
* "file:///etc/passwd" becomes "https://file:///etc/passwd" — harmless, but
* still a dead entry the user has to work out how to remove.
*/
function normalizeServiceUrl(raw) {
const input = String(raw == null ? '' : raw).trim();
if (!input) return null;

// An explicit scheme that isn't http(s) is a refusal, not something to prefix.
// "localhost:3000" is host:port, not a scheme, so a colon followed by digits
// does not count — self-hosting a service is a legitimate thing to do here.
const scheme = input.match(/^([a-z][a-z0-9+.-]*):(?!\d)/i);
if (scheme && !/^https?$/i.test(scheme[1])) return null;

let url;
try {
url = new URL(/^https?:\/\//i.test(input) ? input : `https://${input}`);
} catch {
return null;
}

if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
if (!url.hostname) return null;

const isIpv6 = /^\[[0-9a-f:]+\]$/i.test(url.host);
if (!isIpv6 && !/^[a-z0-9.-]+$/i.test(url.hostname)) return null; // spaces, %20, junk
if (url.hostname.startsWith('.') || url.hostname.endsWith('.')) return null;

// Bare words aren't hosts. localhost and IP literals are.
const isIpv4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(url.hostname);
if (!url.hostname.includes('.') && url.hostname !== 'localhost' && !isIpv6) return null;
if (!isIpv4 && !isIpv6 && url.hostname !== 'localhost' && !/\.[a-z]{2,}$/i.test(url.hostname)) {
return null;
}

return url;
}

/** A readable default name from a URL: "https://www.notion.so/x" -> "Notion". */
function nameFromUrl(url) {
const host = url.hostname.replace(/^www\./i, '');
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || url.host.startsWith('[')) return url.host;
const label = host.split('.')[0];
return label.charAt(0).toUpperCase() + label.slice(1);
}

const API = { isAuthUrl, AUTH_HOSTS, normalizeServiceUrl, nameFromUrl };

if (typeof module === 'object' && module.exports) module.exports = API;
else if (typeof self !== 'undefined') self.URLS = API;
2 changes: 2 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,8 @@ function buildAppMenu() {
{ label: 'Next Service', accelerator: 'CmdOrCtrl+Tab', click: () => send('pb:cycle', 1) },
{ label: 'Previous Service', accelerator: 'CmdOrCtrl+Shift+Tab', click: () => send('pb:cycle', -1) },
{ type: 'separator' },
{ label: 'Toggle Sidebar', accelerator: 'CmdOrCtrl+B', click: () => send('pb:toggle-sidebar') },
{ label: 'Split View', accelerator: 'CmdOrCtrl+Shift+S', click: () => send('pb:toggle-split') },
{ label: 'Toggle Todo Panel', accelerator: 'CmdOrCtrl+T', click: () => send('pb:toggle-todo') },
{ label: 'Task Manager', accelerator: 'CmdOrCtrl+Shift+M', click: () => send('pb:open-taskmanager') },
{ type: 'separator' },
Expand Down
27 changes: 24 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 7 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "panebox",
"productName": "Panebox",
"version": "2.1.0",
"version": "2.1.1",
"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",
Expand Down Expand Up @@ -36,11 +36,13 @@
"dist:linux": "electron-builder --linux",
"pack": "electron-builder --dir",
"test": "node --test \"test/**/*.test.js\"",
"icons:check": "node tools/make-icons.js --check"
"icons:check": "node tools/make-icons.js --check",
"brand-icons": "node tools/make-brand-icons.js"
},
"devDependencies": {
"electron": "^43.2.0",
"electron-builder": "^25.1.8"
"electron-builder": "^25.1.8",
"simple-icons": "^16.27.1"
},
"build": {
"appId": "io.github.panebox",
Expand Down Expand Up @@ -130,7 +132,8 @@
"repo": "panebox",
"releaseType": "draft"
}
]
],
"afterPack": "./tools/after-pack.js"
},
"dependencies": {
"electron-updater": "^6.8.9"
Expand Down
2 changes: 2 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ contextBridge.exposeInMainWorld('panebox', {
onOpenAdd: on('pb:open-add'),
onOpenFind: on('pb:open-find'),
onOpenTaskManager: on('pb:open-taskmanager'),
onToggleSidebar: on('pb:toggle-sidebar'),
onToggleSplit: on('pb:toggle-split'),
onToggleTodo: on('pb:toggle-todo'),
onReloadActive: on('pb:reload-active'),
onCycle: on('pb:cycle'),
Expand Down
Loading