feat: Web app with Tailscale network access#58
Conversation
Create packages/web/ that runs the existing Electron renderer components in a standard browser via a WebSocket JSON-RPC shim. The shim implements the full window.api surface, translating IPC calls to RPC over the existing Fastify WebSocket server. - New packages/web/ with Vite + React 19 + Tailwind - api-shim.ts: 66-method WebSocket RPC bridge matching preload API - Server: @fastify/static serves web dist at /app/ with SPA fallback - Server: --host CLI arg for network binding - Config: add webAccessEnabled/mobileAccessEnabled to defaults - Fix: database.ts loadDefaults now reads web/mobile access flags
Add platform detection (isWeb/isElectron) and conditionally remove Electron-specific UI: traffic light padding (pl-[78px]), WindowControls. Grid cards use min(320px, 100%) for mobile viewport support.
…on settings (Phase 3) - Replace dialog no-ops with HTML5 file inputs and prompt() for paths - Add /api/task-images/:taskId/:filename HTTP route for image serving - Add task:imageUpload RPC for base64 image upload from browser - Fix file:// image URLs to use HTTP paths in web mode - Hide Floating Widget and Update Channel settings on web (isElectron) - Add PWA manifest, icons (192/512), theme-color, apple-mobile-web-app - Add /api proxy to Vite dev server config
Add Network Access settings panel that detects Tailscale, shows QR code for mobile connection, lists tailnet devices with online/offline status, and binds server to 0.0.0.0 when enabled. Fix loadDefaults whitelist to persist networkAccessEnabled config. Add responsive mobile layout with hamburger menu, single-column grid, and collapsible sidebar.
There was a problem hiding this comment.
Pull request overview
This PR adds an initial “web + mobile-friendly” mode for the renderer, backed by a WebSocket JSON-RPC shim and server-side static hosting under /app/, plus a new Network settings section for Tailscale-based remote access.
Changes:
- Introduces a new
packages/webVite app that mounts awindow.apishim and renders the existing renderer UI in the browser. - Extends the server to serve the web build at
/app/, serve task images over HTTP, and expose Tailscale status for “Network Access”. - Improves mobile responsiveness (sidebar drawer, single-column grid, condensed top bar) and adds a new “Network” settings panel with QR code + device list.
Reviewed changes
Copilot reviewed 32 out of 36 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Adds dependencies for web build, QR code, and Fastify static serving. |
| src/renderer/stores/types.ts | Adds network settings category. |
| src/renderer/lib/platform.ts | Adds isWeb / isElectron helpers for conditional rendering. |
| src/renderer/hooks/useIsMobile.ts | Adds a viewport-based mobile detection hook. |
| src/renderer/components/TaskDetailPanel.tsx | Uses file:// only in Electron for task images. |
| src/renderer/components/SettingsPage.tsx | Adds “Network” section and adjusts titlebar padding for web vs Electron. |
| src/renderer/components/settings/NetworkSettings.tsx | New Network Access UI (Tailscale status, toggle, URL copy, QR code, device list). |
| src/renderer/components/settings/GeneralSettings.tsx | Hides Electron-only settings in web mode. |
| src/renderer/components/PromptLauncher.tsx | Improves layout on smaller screens. |
| src/renderer/components/ProjectSidebar.tsx | Adds mobile drawer behavior and hides resize handle on mobile. |
| src/renderer/components/GridView.tsx | Switches to single-column layout on mobile. |
| src/renderer/components/FocusedTerminal.tsx | Adjusts titlebar padding for web vs Electron. |
| src/renderer/components/AddTaskDialog.tsx | Uses file:// only in Electron for images. |
| src/renderer/App.tsx | Adds mobile UI tweaks (hamburger/menu behavior, condensed buttons) and web window-control handling. |
| packages/web/vite.config.ts | New Vite config for web build (aliases, /app/ base, dev proxies). |
| packages/web/tsconfig.json | New TS config for the web package. |
| packages/web/tailwind.config.ts | Tailwind scan paths include shared renderer components. |
| packages/web/src/main.tsx | Mounts the API shim, waits for WS readiness, then loads the renderer App. |
| packages/web/src/global.css | Web-specific global styles (scrollbar, focus rings, titlebar no-ops). |
| packages/web/src/env.ts | Determines WS URL based on current host/protocol. |
| packages/web/src/api-shim.ts | Implements window.api surface over WebSocket JSON-RPC + web file/image handling. |
| packages/web/public/manifest.webmanifest | Adds PWA manifest for the web app. |
| packages/web/public/icon-192.png | Adds PWA icon asset. |
| packages/web/postcss.config.cjs | Adds Tailwind + Autoprefixer config for web. |
| packages/web/package.json | Adds new @vibegrid/web workspace package with Vite/Tailwind toolchain. |
| packages/web/index.html | New web entry HTML for Vite app and initial loading message. |
| packages/shared/src/types.ts | Adds Tailscale types and new default flags + IPC constants. |
| packages/server/src/task-images.ts | Adds base64 image upload support for web mode. |
| packages/server/src/tailscale.ts | Adds Tailscale binary discovery and status retrieval. |
| packages/server/src/register-methods.ts | Registers tailscale:status and task:imageUpload JSON-RPC methods. |
| packages/server/src/index.ts | Serves /app/ static build, adds HTTP task-image serving, and updates bind behavior for network access. |
| packages/server/src/database.ts | Persists newly added default flags from DB. |
| packages/server/package.json | Adds @fastify/static dependency. |
| package.json | Adds build:web script and adds qrcode + @types/qrcode. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
packages/server/src/index.ts
Outdated
| host = '0.0.0.0' // Bind to all interfaces so both localhost and Tailscale IP work | ||
| log.info( | ||
| `[server] network access enabled, binding to 0.0.0.0 (tailscale IP: ${tsStatus.selfIP})` |
There was a problem hiding this comment.
Kept 0.0.0.0 binding — it's needed so both localhost (Electron desktop app) and the Tailscale IP work simultaneously. The Tailscale network itself provides authentication (only devices signed into the same tailnet can connect). Binding to just the Tailscale IP would break the Electron app which connects via localhost. Added a clearer comment explaining this trade-off.
packages/server/src/index.ts
Outdated
| } | ||
| reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream') | ||
| reply.header('Cache-Control', 'public, max-age=86400') | ||
| return reply.send(fs.readFileSync(filePath)) |
There was a problem hiding this comment.
Fixed in e40bae1: switched to fs.createReadStream() for streaming instead of buffering, and added X-Content-Type-Options: nosniff header.
| function exec(cmd: string, args: string[]): Promise<string> { | ||
| return new Promise((resolve, reject) => { | ||
| execFile(cmd, args, { timeout: 10_000 }, (err, stdout) => { | ||
| if (err) reject(err) | ||
| else resolve(stdout.trim()) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Fixed in e40bae1: added env: getSafeEnv() to the execFile call, consistent with the rest of the server's subprocess hardening pattern.
packages/web/src/api-shim.ts
Outdated
| // Queue until connected | ||
| this._ready.then(() => { | ||
| if (this.ws.readyState === WebSocket.OPEN) { | ||
| this.ws.send(msg) | ||
| } else { | ||
| this.pending.delete(id) | ||
| reject(new Error('WebSocket not connected')) | ||
| } | ||
| }) |
There was a problem hiding this comment.
Fixed in e40bae1: invoke() now captures readyAtCall at call time and checks if the pending request still exists before rejecting. If the socket closes and _ready is replaced, queued calls will see the socket isn't open and reject properly instead of hanging.
| input.addEventListener('change', () => { | ||
| const files = input.files ? Array.from(input.files) : null | ||
| document.body.removeChild(input) | ||
| resolve(files && files.length > 0 ? files : null) | ||
| }) | ||
| // Handle cancel (user closes the dialog without selecting) | ||
| input.addEventListener('cancel', () => { | ||
| document.body.removeChild(input) | ||
| resolve(null) | ||
| }) | ||
| input.click() |
There was a problem hiding this comment.
Fixed in e40bae1: added a window.focus fallback (with 300ms debounce) for browsers that don't fire the cancel event on file inputs. Also ensures cleanup always runs via a resolved flag to prevent double-resolve.
| const copy = useCallback(() => { | ||
| navigator.clipboard.writeText(text).then(() => { | ||
| setCopied(true) | ||
| setTimeout(() => setCopied(false), 2000) | ||
| }) | ||
| }, [text]) |
There was a problem hiding this comment.
Fixed in e40bae1: added .catch() rejection handler with window.prompt() fallback so users can still copy the URL in non-HTTPS contexts.
| const allDevices = [ | ||
| { | ||
| ip: status.selfIP, | ||
| hostname: status.selfDNSName.split('.')[0] || 'This device', | ||
| os: 'macOS', | ||
| online: true, | ||
| isSelf: true | ||
| }, |
There was a problem hiding this comment.
Fixed in e40bae1: added selfOS field to TailscaleStatus interface, populated from status.Self.OS in the Tailscale JSON response. The DeviceList now uses status.selfOS || 'unknown' instead of the hard-coded 'macOS'.
| useEffect(() => { | ||
| QRCode.toDataURL(url, { | ||
| width: 200, | ||
| margin: 2, | ||
| color: { | ||
| dark: '#ffffffFF', | ||
| light: '#00000000' | ||
| } | ||
| }).then(setDataUrl) | ||
| }, [url]) |
There was a problem hiding this comment.
Fixed in e40bae1: added .catch() for error handling and a mounted flag in the effect cleanup to prevent state updates after unmount.
| export function saveTaskImageFromBase64( | ||
| taskId: string, | ||
| base64Data: string, | ||
| originalFilename: string | ||
| ): string { | ||
| if (!isSafeId(taskId)) throw new Error(`Invalid taskId: ${taskId}`) | ||
|
|
||
| const taskDir = resolveSafePath(taskId) | ||
| ensureDir(taskDir) | ||
|
|
||
| const ext = path.extname(originalFilename) || '.png' | ||
| const filename = `${randomUUID()}${ext}` | ||
| const destPath = resolveSafePath(taskId, filename) | ||
|
|
||
| const buffer = Buffer.from(base64Data, 'base64') | ||
| fs.writeFileSync(destPath, buffer) | ||
| return filename |
There was a problem hiding this comment.
Fixed in e40bae1: added a 10MB decoded size limit (MAX_IMAGE_SIZE), an allowlist of image extensions (.png, .jpg, .jpeg, .gif, .webp, .bmp, .svg), and validates both before allocating the buffer.
packages/shared/src/types.ts
Outdated
| TAILSCALE_STATUS: 'tailscale:status', | ||
| TAILSCALE_ENABLE: 'tailscale:enable' |
There was a problem hiding this comment.
Fixed in e40bae1: removed the unused TAILSCALE_ENABLE constant. The enable/disable toggle works through config:save (updating the networkAccessEnabled default), so no dedicated IPC method is needed.
# Conflicts: # .yarn/install-state.gz # packages/server/src/register-methods.ts # packages/shared/src/types.ts # src/renderer/components/GridView.tsx # yarn.lock
Security: - Stream task images instead of buffering (X-Content-Type-Options: nosniff) - Use getSafeEnv() for Tailscale subprocess execution - Add 10MB size limit and extension validation for base64 image uploads - Remove unused TAILSCALE_ENABLE IPC constant Reliability: - Fix WebSocket invoke() hang when connection drops mid-queue - Add focus-based fallback for file picker cancel detection (Safari) - Clean up pendingImageFiles after each upload to prevent stale references - Add Safari <14 addListener fallback for matchMedia in useIsMobile - Handle clipboard API errors with prompt() fallback - Add unmount guard and error handling for QR code generation Correctness: - Fix product name typo: VibGrid → VibeGrid in PWA manifest, HTML, env.ts - Use dynamic selfOS from Tailscale status instead of hard-coded 'macOS'
- Add **/*.config.cjs to eslint ignores (CJS files lack node globals) - TriggerNode: use static icon map instead of render-time getIcon() call - SlashCommandMenu: suppress pre-existing any/refresh warnings, fix deps - NewAgentDialog: suppress set-state-in-effect for intentional bulk resets
d971fcb to
5ee0246
Compare
Summary
Test plan
npm run devand openhttp://localhost:<port>/app/in browser