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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ All changes organized by pull request, newest first. Format is documented under

---

## [PR #118] feat: Discord widget — embedded Discord web with voice + screen share

**Branch:** `feat/discord-widget` → `master`
**Date:** 2026-07-13

### Context
Nish wants Discord on the dashboard — servers, DMs/group messages, replying, voice channels, screen share. A hand-built client is impossible via official APIs (no user-account messaging/DMs/voice; the local RPC API is private-beta and has no send-message command; a user-token self-bot violates Discord ToS → ban risk). Decision: embed the **real discord.com web app** in an Electron `<webview>` — the WebCord-proven approach. Full feature set, **zero setup** (no Discord application, no keys): log in once inside the widget, the session persists on a dedicated `persist:discord` partition. Cost ≈ one Chrome tab of RAM.

### Added
- **Discord widget** (`apps/renderer/src/widgets/discord/`): `<webview src="https://discord.com/app" partition="persist:discord" allowpopups>` with loading shimmer, error card (main-frame failures only — `ERR_ABORTED`/subframe noise ignored), mention-count badge parsed from the guest title `(N)` (`page-title-updated`), and header actions Home / Reload / Sign out (in-body confirm → clears the partition). Ephemeral `discordStore` bridges the header actions to the webview ref.
- **Screen-share picker**: Discord's `getDisplayMedia` is intercepted main-side (`setDisplayMediaRequestHandler`) → `desktopCapturer` sources cross as data-URI thumbnails (`discord:screenshare-request` push) → in-widget screens/windows grid → `discord:screenshare-select` resolves the stream. Subscribe-while-mounted lifecycle (clipboard-poller pattern), 60 s unanswered → deny. **Windows-only** system audio via `audio: 'loopback'`; macOS shares video-only.
- **Main module `apps/main/src/discord.ts`**: `persist:discord` session init — full clean-Chrome UA (Electron-token stripping is NOT enough; the `Nishboard/x` app token trips Discord's unsupported-browser wall), permission request+**check** handlers (Discord gates notifications on the sync `Notification.permission` read) allowing media/display-capture/notifications/clipboard-write/fullscreen for `*.discord.com` only.
- **Webview hardening**: `webviewTag: true` on the main + popout windows, gated by an app-wide `will-attach-webview` guard — https `*.discord.com` src only (URL-parsed, lookalike hosts rejected), preload/nodeIntegration stripped; guest `window.open`/off-site navigations go to the system browser, never an Electron window.
- **`WidgetShell` `keepMounted` prop** (opt-in, registry-driven): collapsed children render in an `h-0 overflow-hidden` wrapper instead of unmounting — collapsing Discord keeps voice running (`display:none` would blank/kill a webview; `backgroundThrottling=false` keeps its timers live).
- Tests: `discord.test.ts` (UA shape, URL allowlist incl. `discord.com.evil.com` rejection, permission matrix, source serialization) + `lib.test.ts` (title-badge parsing); electron-stub grew `session`/`desktopCapturer`/`app.on`.
- macOS packaging: `mac.extendInfo` mic/camera usage strings (TCC would otherwise silently deny voice in packaged builds).

### Notes
- Webview pages are **top-level documents** — Discord's `frame-ancestors` CSP never applies, so no CSP stripping was needed (unlike the Twitch/RainViewer iframes).
- Known caveats: unpinning, pop-out toggling, or quitting remounts the webview → an active voice call drops (login persists). The picker overlay is invisible while collapsed → the 60 s timeout denies harmlessly. Mention badge counts mentions/DMs only (Discord's `(N)` title prefix); plain unread channels ("•") don't badge.
- Discord notifications render as native OS toasts once granted (dev builds attribute them to "Electron"; packaged builds are correct).

---

## [PR #117] feat: Claude widget interactive prompts — Allow/Deny cards, questions, plan approval, loose sandbox

**Branch:** `feat/claude-interactive-prompts` → `master`
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Personal ambient desktop dashboard for Nish, running all day on a secondary moni
| Clipboard | none (main-process 1s `clipboard.readText` poller, ONLY while widget mounted+unpaused; text-only, in-memory, cap 50, never persisted) | none | 1s while active |
| Net Monitor | system `ping` via server-side background sampler (2s tick, 30-sample ring/host, lazy start + 60s idle stop) + `si.networkStats()` throughput — Win `ping -n 1 -w 1000` (localized output — parse the `=Nms` token, never the word "time") / mac `ping -c 1 -W 1000` | none | 1s poll (pings decoupled at 2s) |
| Claude | spawns the user-installed **Claude Code CLI** (`claude -p --output-format stream-json --include-partial-messages --verbose`, prompt via stdin, `--resume` for context, cwd `~/.dash`) — bills the claude.ai **Max subscription**, never API keys; `ANTHROPIC_API_KEY` is stripped from the child env. Assistant **text streams via `delta` frames only** (no authoritative-final "message" frame — a tool-using turn has multiple assistant turns that would clobber each other); `parseStreamJsonLine` returns an **array** of events per line. The composer's **Ask / Auto / Plan** mode switch (persisted in `claudeStore`, shift+tab cycles; legacy 'chat' migrates to 'ask'): Auto = `bypassPermissions`; Ask/Plan spawn with `--permission-prompt-tool stdio` + `--input-format stream-json` (stdin held OPEN all turn as the control channel; closed after the result frame, which is what lets the CLI exit) — permission checks/AskUserQuestion/ExitPlanMode arrive as `control_request` lines, surface as SSE `permission-request` frames + in-widget prompt cards, and are answered via `POST /api/claude/control` → a `control_response` stdin line (wire shapes pinned live against CLI 2.1.207; AskUserQuestion answers ride `updatedInput.answers` keyed by question text). Ask mode pre-allows reads via `--allowedTools`; unanswered prompts deny after 5min; `--resume` of an unknown session under stream-json input surfaces as stderr + is_error result — converted to an error event so the retry-without-resume still works. Workspace cwd + `--add-dir` dirs come from Settings → Claude (default cwd ~/.dash, add-dir ~). Model + effort go through `--model` / `--effort`; slash-command autocomplete comes from the init frame's `slash_commands`+`skills` (captured server-side to `~/.dash/claude-meta.json`, dir-scan fallback); usage popover hits the OAuth usage endpoint (token: `CLAUDE_CODE_OAUTH_TOKEN` env → macOS keychain → `~/.claude/.credentials.json` — never logged). Tool calls render as inline **chips** (`tool-use`/`tool-result` events → ordered `parts` in `claudeStore`) | none (CLI's own OAuth login; optional CLAUDE_CODE_OAUTH_TOKEN fallback) | SSE stream on demand |
| Discord | none — the REAL discord.com web app in an Electron `<webview>` (`partition="persist:discord"`; log in once in-widget, session survives restarts; no bot/OAuth app — official APIs can't do user-account DMs/messaging/voice, and a user-token self-bot is a ToS ban risk). Main-side plumbing in `apps/main/src/discord.ts`: clean-Chrome UA on the partition, permission request+CHECK handlers (media/display-capture/notifications/clipboard-write/fullscreen for `*.discord.com` only), `setDisplayMediaRequestHandler` → in-widget screen-share picker (`desktopCapturer` sources cross as data-URI thumbnails over `discord:screenshare-*` IPC, clipboard-poller-style subscribe-while-mounted, 60s deny timeout, Windows-only `audio:'loopback'`), app-wide `will-attach-webview` guard (https `*.discord.com` src only, preload/nodeIntegration stripped) + guest window-open/navigate → external browser. Registry `keepMounted`: collapse hides the body at h-0 (voice keeps running); unpin/pop-out-toggle/quit remount the webview → call drops (login persists). Mention badge parsed from the guest title `(N)` via `page-title-updated` | none | live embed |

## Secrets & Credentials
Settings → Developer is **write-only**: the renderer only ever learns *which* keys are set (`credentials:get-status` booleans); stored values can be replaced or cleared but never viewed. Decrypted keys exist only in the main process and the spawned server's env.
Expand All @@ -61,6 +62,10 @@ Three places a key can live — checked in this order at runtime:
- **Electron UA** — YouTube returns Error 153 for the Electron UA; the main process strips `Electron/x.x.x` from the session user-agent.
- **SSE routes bypass @fastify/cors** — `/api/claude/chat` streams via `reply.hijack()` + `reply.raw`, so the CORS plugin never runs; the route mirrors the allowed-origin list from `app.ts` manually. Keep the two lists in sync when adding origins.
- **On a hijacked SSE route, reap the child on `reply.raw` ('close'), NEVER `req.raw` ('close')** — an `http.IncomingMessage` (`req.raw`) fires `'close'` the instant its POST body is fully read, which for a body-bearing POST is *immediately*, while the response is still streaming (`aborted=false`). Keying a "client disconnected, kill the subprocess" handler off `req.raw` therefore kills it before it produces anything. Use the **response** stream (`reply.raw`/the hijacked `raw`), guarded by an `ended` flag so our own `raw.end()` doesn't self-trigger it. (This exact bug silently broke the Claude widget — 200 + zero frames — and never reproduced from a bare `spawn`, only through the full Fastify request lifecycle; see PR #110 + `claude.test.ts`, which needs a real `listen()` since `app.inject()` doesn't reproduce the premature close.)
- **Electron `<webview>` is a TOP-LEVEL document, not an iframe** — `frame-ancestors`/X-Frame-Options never apply to it, so the Discord embed needs NO CSP stripping (unlike the Twitch/RainViewer iframes). `webviewTag: true` is enabled on the main + popout windows for the Discord widget; the real security boundary is the app-wide `will-attach-webview` guard in `apps/main/src/discord.ts` (https discord.com src only, preload/nodeIntegration stripped) plus the guest's own window-open/will-navigate handlers — the embedder window's handlers do NOT govern the guest.
- **Never `display:none` a webview** (or any ancestor) — the guest blanks/dies. Hidden-but-alive = a zero-height `overflow-hidden` WRAPPER: `WidgetShell`'s `keepMounted` prop renders collapsed children in `h-0 overflow-hidden` (same pattern as the h-0 playing-iframe in `EmbedSearchWidget`), plus `webpreferences="backgroundThrottling=false"` on the tag so timers don't throttle while hidden.
- **The discord partition gets a FULL clean-Chrome UA** (`discordUserAgent()`), not the default session's Electron-token strip — the app-name token (`Nishboard/x.y.z`) alone trips Discord's unsupported-browser wall. And set BOTH `setPermissionRequestHandler` and `setPermissionCheckHandler`: Discord gates notifications on the synchronous `Notification.permission` read; setting only the request handler is the classic footgun.
- **Screen-share system audio is Windows-only** (`audio: 'loopback'` in the display-media callback; macOS shares video-only). macOS packaged builds need the `mac.extendInfo` mic/camera usage strings in `electron-builder.yml` or TCC silently denies voice — and first `desktopCapturer` use prompts for Screen Recording permission.
- **Spotify Dev Mode** — caps at 25 allowlisted users; a non-allowlisted account gets 403 on the API.
- **`apps/main` is `tsc`-compiled, NOT bundled** — so `import type … from '@dash/shared'` is safe (erased), but a **value** import (e.g. `import { CREDENTIAL_KEYS }`) survives as a runtime `require('@dash/shared')`. The packaged app has no `node_modules`, so that require crashes launch unless the module is shipped. `electron-builder.yml` ships `@dash/shared` into `node_modules/@dash/shared`; keep it there, and prefer `import type` from shared in main whenever possible. (The server is esbuild-bundled, so it's immune.) Bugs like this never show in `pnpm dev` — only in the packaged/built app.
- **Renderer `manualChunks` — don't hand-split React out of its consumers.** recharts reads React internals (`__SECRET_INTERNALS…`) at module-init; putting `recharts` and `react` in separate chunks creates a circular chunk and a bad init order → white screen under `file://`. Keep all `node_modules` in one `vendor` chunk (see `vite.config.ts`). If Rollup logs "Circular chunk", the build is broken even though dev works.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
| 📋 **Clipboard** | Rolling text-clipboard history (in-memory, never persisted) | Electron clipboard poller (only while visible) |
| 📶 **Net Monitor** | Per-host latency / jitter / packet-loss + up/down throughput sparklines | System `ping` via a server-side sampler + `systeminformation` |
| 🤖 **Claude** | Chat with Claude — streaming replies, GFM markdown, multi-turn context, thinking blocks, animated status shimmer. **Interactive prompts**: in **Ask** mode writes/commands pop Allow/Deny cards (reads run freely), the model can ask you **multiple-choice questions**, and **Plan** mode ends with an Approve-plan card — with a chime + native notification when Claude blocks on you. Composer: **Ask / Auto / Plan** switch, **model picker + effort slider**, `/` **slash & skill autocomplete**, **usage popover** (5h session + weekly + context meter). Workspace + extra folders configurable in Settings → Claude | Your installed **Claude Code CLI** (bills your claude.ai plan, not API keys) |
| 💬 **Discord** | The **real Discord**, embedded whole — servers, DMs & group chats, replying, **voice channels**, and **screen sharing** with an in-widget screen/window picker (system audio shared on Windows). Log in once inside the widget and the session persists; mention-count badge from the tab title; header actions for Home/Reload/Sign out. Collapsing the widget keeps voice running | Discord web app in an Electron `<webview>` (own `persist:discord` session) — **no keys, no bot, no API setup** |

Everything lives on a **draggable, resizable grid** (react-grid-layout) with built-in presets, saveable custom layouts, and 15 themes plus a custom theme editor. Any widget **collapses accordion-style to just its title bar** (chevron in the header) and the state persists across restarts. The **Ctrl/Cmd+K command palette** understands typed commands with arguments — `timer 1h5m3s tea`, `alarm 7:30am`, `task buy milk`, `volume 40`, `ticker AAPL`, `zone tokyo` — alongside fuzzy search over every action.

Expand Down Expand Up @@ -297,7 +298,7 @@ nishboard/
│ └── renderer/ # React UI
│ └── src/
│ ├── components/ # Titlebar, DashboardGrid, WidgetShell, SettingsModal
│ ├── widgets/ # weather, spotify, stocks, hardware, sound, calendar, youtube, twitch, news, notes, tasks, worldclock, timer, countdown, crypto, launcher, clipboard, embed (shared search+player frame)
│ ├── widgets/ # weather, spotify, stocks, hardware, sound, calendar, youtube, twitch, news, notes, tasks, worldclock, timer, countdown, crypto, launcher, clipboard, claude, discord, embed (shared search+player frame)
│ ├── store/ # Zustand stores (layout, theme, …)
│ ├── lib/ # layouts.ts (grid engine), apiClient, utils
│ └── index.css # theme tokens + global styles
Expand Down
79 changes: 79 additions & 0 deletions apps/main/src/discord.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
discordUserAgent,
isAllowedDiscordUrl,
decideDiscordPermission,
serializeShareSources,
} from './discord';
import type { CapturerSourceLike } from './discord';

describe('discordUserAgent', () => {
it('builds a clean Chrome UA with no Electron or app-name tokens', () => {
const ua = discordUserAgent('win32', '130.0.6723.137');
expect(ua).toContain('Chrome/130.0.6723.137');
expect(ua).toContain('Windows NT 10.0; Win64; x64');
expect(ua).not.toMatch(/Electron/i);
expect(ua).not.toMatch(/Nishboard/i);
});

it('uses the macOS token on darwin', () => {
expect(discordUserAgent('darwin', '130.0.0.0')).toContain('Macintosh; Intel Mac OS X 10_15_7');
});
});

describe('isAllowedDiscordUrl', () => {
it('allows discord.com and subdomains over https', () => {
expect(isAllowedDiscordUrl('https://discord.com/app')).toBe(true);
expect(isAllowedDiscordUrl('https://discord.com/channels/@me')).toBe(true);
expect(isAllowedDiscordUrl('https://ptb.discord.com/app')).toBe(true);
});

it('rejects http, lookalike hosts, and garbage', () => {
expect(isAllowedDiscordUrl('http://discord.com/app')).toBe(false);
// A prefix check would pass this — the URL parse must not.
expect(isAllowedDiscordUrl('https://discord.com.evil.com/app')).toBe(false);
expect(isAllowedDiscordUrl('https://evildiscord.com/app')).toBe(false);
expect(isAllowedDiscordUrl('file:///etc/passwd')).toBe(false);
expect(isAllowedDiscordUrl('not a url')).toBe(false);
expect(isAllowedDiscordUrl('')).toBe(false);
});
});

describe('decideDiscordPermission', () => {
it('allows the media/notification set for discord origins', () => {
expect(decideDiscordPermission('media', 'https://discord.com/channels/@me')).toBe(true);
expect(decideDiscordPermission('display-capture', 'https://discord.com/app')).toBe(true);
expect(decideDiscordPermission('notifications', 'https://ptb.discord.com')).toBe(true);
expect(decideDiscordPermission('clipboard-sanitized-write', 'https://discord.com')).toBe(true);
expect(decideDiscordPermission('fullscreen', 'https://discord.com')).toBe(true);
});

it('denies unlisted permissions even for discord, and everything for other origins', () => {
expect(decideDiscordPermission('geolocation', 'https://discord.com/app')).toBe(false);
expect(decideDiscordPermission('openExternal', 'https://discord.com/app')).toBe(false);
expect(decideDiscordPermission('media', 'https://evil.com')).toBe(false);
expect(decideDiscordPermission('media', 'https://discord.com.evil.com')).toBe(false);
});
});

describe('serializeShareSources', () => {
const img = (uri: string, empty = false) => ({ toDataURL: () => uri, isEmpty: () => empty });

it('maps sources to data-URI shapes and derives kind from the id prefix', () => {
const sources: CapturerSourceLike[] = [
{ id: 'screen:0:0', name: 'Screen 1', thumbnail: img('data:thumb1'), appIcon: null },
{ id: 'window:123:0', name: 'Notepad', thumbnail: img('data:thumb2'), appIcon: img('data:icon') },
];
expect(serializeShareSources(sources)).toEqual([
{ id: 'screen:0:0', name: 'Screen 1', kind: 'screen', thumbnail: 'data:thumb1' },
{ id: 'window:123:0', name: 'Notepad', kind: 'window', thumbnail: 'data:thumb2', appIcon: 'data:icon' },
]);
});

it('drops empty appIcons', () => {
const [out] = serializeShareSources([
{ id: 'window:9:0', name: 'W', thumbnail: img('data:t'), appIcon: img('data:x', true) },
]);
expect(out.appIcon).toBeUndefined();
});
});
Loading
Loading