Skip to content

feat: v0 Etherpad Desktop — Linux + Windows MVP#1

Merged
JohnMcLear merged 197 commits into
mainfrom
feat/linux-mvp
May 5, 2026
Merged

feat: v0 Etherpad Desktop — Linux + Windows MVP#1
JohnMcLear merged 197 commits into
mainfrom
feat/linux-mvp

Conversation

@JohnMcLear

Copy link
Copy Markdown
Member

Summary

This is the v0 beta merge — 197 commits implementing the Etherpad Desktop MVP for Linux and Windows. Squashed highlights:

Functionality

  • Multi-instance thin client. Each Etherpad server is its own session partition (cookies, localStorage, IndexedDB) with full isolation. 5 commits.
  • Hybrid pad navigation: workspace rail + per-instance pad sidebar (recent + pinned) + tab strip.
  • Quick switcher (Ctrl+K / Ctrl+F) with cross-instance fuzzy content search (monki matches monkey).
  • "Open Pad by URL" (Ctrl+L) — paste any Etherpad pad URL, instance is added if needed and pad opens.
  • Hard reload (Ctrl+Shift+R) bypasses HTTP cache. UA-tokens stripped from pad WebContentsView so plugins like ep_webrtc don't UA-sniff us out.
  • Per-instance editing in Settings: name, colour (click swatch), URL.
  • Theme: light/dark/auto with prefers-color-scheme support; HTML lang follows the active language.
  • System tray (white silhouette) with Show/Quit; close-to-tray default on.
  • Auto-update via electron-updater for AppImage + .deb + Windows; snap-publish workflow with edge channel + manual gate to stable.
  • a11y: focus trap + restore on every dialog, focus-visible outlines, all aria-labels via i18n.
  • Window state restoration (bounds, active instance, open pads).

Build + Release

  • Linux: AppImage, .deb, Snap (electron-builder + snapcraft).
  • Windows: NSIS installer + portable .exe (parallel job in release.yml on windows-latest). Currently unsigned — SmartScreen will warn first run.
  • release-please for auto version bumps + CHANGELOG on every push to main.

Tests

  • 485 unit + component tests (vitest).
  • ~60 E2E tests (Playwright Electron) against an in-process mock Etherpad fixture.
  • Full CI green on the head commit.

Test plan

  • Unit suite green locally and in CI (485/485)
  • E2E suite green in CI (only restore-on-relaunch occasionally retries under load; passes on retry)
  • Manual click-through of every dialog, rail, sidebar, quick-switcher, Open-by-URL flow
  • Tray + minimize-to-tray verified on Linux
  • First Windows build runs on tag push — to be validated by release-windows job on the v0.1.0 tag
  • First snap publish runs on tag push — to be validated by snap-publish.yml on the v0.1.0 tag

🤖 Generated with Claude Code

JohnMcLear and others added 30 commits May 4, 2026 07:00
Code quality review of Task 1.1 flagged Electron 32 EOL, missing private
flag, missing prefer-frozen-lockfile, engine-strict, packageManager.
Plan updated so the implementer fix-up commit has matching reference
text.
- bump electron to ^35.0.0 (32.x is past Electron's support window;
  upgrade brings active CVE patches)
- add "private": true (prevents accidental npm publish)
- add "packageManager": "pnpm@10.33.0" (Corepack pin)
- .npmrc: prefer-frozen-lockfile, engine-strict (CI hygiene)
Picking up the deferred .gitignore item from Task 1.1's code review.
- Remove dual-inclusion of shared sources from leaf configs (sources are
  now consumed via project references' .d.ts outputs only).
- Add 'paths' mapping for @shared/* on each leaf so type resolution
  matches the vite alias.
- tsBuildInfoFile under out/shared so all build artifacts live in out/.
- allowUnreachableCode/allowUnusedLabels=false for stricter dead-code
  detection.

Defers Issue 2 (verbatimModuleSyntax) and Issue 4 (preload types)
follow-ups to a later cleanup.
- Remove src/shared from leaf include arrays (composite projects
  consume shared via .d.ts; re-including the source duplicated work
  and would have caused issues once real shared types landed).
- Add 'paths' mapping for @shared/* on each leaf config so tsc resolves
  the alias the same way vite does.
- tsBuildInfoFile under out/shared/ for consistent build-artifact
  placement.
- allowUnreachableCode/allowUnusedLabels=false for stricter dead-code
  detection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code review flagged that script-src 'self' blocks Vite HMR's eval(),
breaking 'pnpm dev' (blank screen). Relaxed shell renderer CSP to allow
'unsafe-eval' and HMR WebSocket. Pad content runs in separate
WebContentsView sessions and is unaffected.
Vite HMR uses eval() in its dev runtime; without 'unsafe-eval' the
shell renders blank in dev mode. WebSocket connect-src is needed for
the HMR connection. Pad content runs in separate WebContentsView
sessions, unaffected by this relaxation.
- lint script: --no-error-on-unmatched-pattern (avoids failure when
  src/tests dirs are empty during early M1 setup).
- Vitest 2.x: split into vitest.config.ts (base) + vitest.workspace.ts
  (defineWorkspace), since 'workspace' on defineConfig is now a path,
  not an inline array.
…paths)

TypeScript 5.6 errors with TS5090 if 'paths' contains non-relative
patterns like 'src/shared/*' without a 'baseUrl'. The implementer
adapted Task 1.8 with this fix; updating the plan to match.
electron-log/main imports electron eagerly which fails in Vitest's node
env. Dynamic import inside async configureLogging/getLogger keeps the
test-importable surface clean. Lifecycle now awaits both calls.
JohnMcLear and others added 25 commits May 5, 2026 09:01
Disabled UI in production is a code smell. The "Use a local Etherpad
server" checkbox was previously gated behind an "in development" hint
because the underlying flow (\`npx etherpad-lite@latest\`) was always
broken — that npm package doesn't exist; Etherpad core is distributed
via GitHub Releases only.

Until the real implementation lands (clone + pnpm install + start
node, with a \`/admin\` UI for plugins like ep_webrtc), no UI surface.
The main-process EmbeddedServerController and the kind: 'embedded'
schema field stay in place so future work can re-light it without
plumbing changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ad rename

The E2E fixture was spawning \`npx --yes etherpad-lite@latest\`, but that
package isn't published on npm — it 404s. Etherpad core lives at
ether/etherpad and is distributed via GitHub Releases only. Result: 20/20
recent CI runs failed at globalSetup before any test body ran, blocking
release-please from ever firing.

- Replace the spawn with a tiny in-process http.createServer that returns
  the JSON shape the desktop probes for (\`/api/\` → currentVersion) and
  serves a minimal HTML page for \`/p/<name>\` so tab-load + cookie-based
  partition isolation tests still work. Sets per-pad cookies so the
  partition-isolation suite can verify distinct cookie jars.
- Fall back to using whatever's already on 9003 if listen() fails with
  EADDRINUSE and the existing service speaks Etherpad — supports local
  dev where the user runs the etherpad snap on the same port.
- Drop the npx pre-warm step + 30-min timeout in ci.yml since we no
  longer wait on a multi-hundred-MB tarball download.

Plus three i18n misses surfaced by the now-running tests:
- "Close tab" → "Close pad" in keyboard-shortcuts, stress-flows, tab-error
  (matches the abc40e5 i18n rename).
- updater-state.spec.ts now waits for the App module's e2e seam to
  attach before evaluating; firstWindow() resolves before the renderer
  has finished loading scripts.

Outcome locally: 20→57 e2e passing. \`restore-on-relaunch\` flakes only
under full-suite contention (passes in isolation) — pre-existing, not
introduced here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e stays clickable

User flagged: clicking the minimize-left-bar button made it disappear,
leaving no way to expand again.

Root cause: the WebContentsView is painted by the main process *above*
the shell renderer in the z-order. Setting x=0 on the pad area in
collapsed mode covered the DOM-rendered handle at left:4, so focus mode
became a one-way trip.

Reserve COLLAPSED_LEFT_GUTTER (28px) on the left when railCollapsed is
true. The handle sits in that strip; the WebContentsView starts at x=28
and fills the rest. The two values must agree — pinned by a regression
test in app-window-layout.spec.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reference:
https://cdn-1.webcatalog.io/catalog/etherpad/etherpad-icon-filled-256.png

The previous tray icon was a B&W silhouette derived from icon-32.png.
This regenerates from the filled Etherpad logo: resize 256→32 with
LANCZOS, then threshold luminance >180 → opaque black (preserving
anti-aliased edge alpha), everything else → transparent. Result is the
Etherpad logo's horizontal text lines as a clean monochrome silhouette
that reads correctly under both light and dark tray themes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The repo already has a comprehensive AGENTS.md as its canonical AI
guide. Adding a small CLAUDE.md so Claude Code (and any tool that looks
specifically for CLAUDE.md) lands on the same source of truth without
us duplicating the full guide.

What's in CLAUDE.md beyond the pointer:
- Project gotchas that bite without context (no etherpad-lite npm
  package, port 9003/9001 conventions, dirty.db is the pad store not a
  lock file, WebContentsView z-order vs DOM handle, all strings go
  through i18n).
- Working-style notes for this repo (inline execution, push-on-every-
  fix, run backend tests, check CI promptly, never push to main/dev,
  skip ep_kaput).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/reopen

User reported ep_webrtc's "Enable Audio/Video Chat" toggle didn't
persist across a reload, even though it persisted in the same
Etherpad opened in a regular browser. That points the finger at our
\`persist:ws-\${id}\` partition setup — if localStorage doesn't survive
a navigation inside a pad WebContentsView, plugins that store UI
state in localStorage will appear to forget their settings.

Both new tests pass against current code:
- localStorage written in a pad survives a same-origin reload.
- localStorage survives closing + reopening the same tab in the same
  workspace (same partition).

So the desktop side is doing the right thing. If a setting fails to
persist after this contract is green, the bug is elsewhere
(server-side per-pad/per-user state, sessionStorage misuse in the
plugin, or a cookie-SameSite issue worth its own investigation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's e2e job has been failing with 'Electron failed to install
correctly, please delete node_modules/electron and try installing
again'. Root cause: pnpm 9+ blocks postinstall scripts by default
unless the package is on the onlyBuiltDependencies allowlist. The
.npmrc had \`approve-builds[]=electron\` which is not a valid pnpm
config key — pnpm silently ignored it.

Add the canonical \`pnpm.onlyBuiltDependencies\` array in package.json
so CI runs the electron download postinstall and the binary is
extracted. Verified locally with \`pnpm rebuild electron\` — produces
the 206MB electron binary at the expected path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: previous tray icon was too small and dark — disappeared
into dark tray backgrounds. Switch to white-on-transparent at 48x48,
and tight-crop to the logo's bounding box so the silhouette actually
fills the icon canvas instead of floating in the middle 40%.

Method: threshold the source at full resolution (lum > 240 isolates
the logo's white text lines from the green background), find the
bounding box of the white pixels, crop tight + 8% padding, square the
crop so circular tray cells don't squish the logo, then downsample the
binary mask to 48x48 with LANCZOS for clean anti-aliased edges. Apply
white as the colour and the downsampled mask as the alpha.

Open question (filed mentally): cross-DE adaptation. White silhouette
reads correctly on the dark tray themes most Linux DEs default to
(GNOME, KDE Breeze Dark, etc.), but a light tray theme would benefit
from a black variant. Electron Tray on Linux doesn't have a built-in
template-icon mode (only macOS does). If this becomes a problem we'd
need to detect the system theme and swap PNGs at runtime — out of
scope for v0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: "If an Etherpad Instance URL changes we should be able
to change it in Settings."

The workspace.update IPC already accepted serverUrl; just no UI for it.
Adds a URL field as a second row beneath each workspace's name/colour
edit row. Behaviour:

- Local draft state (useState) so transient mid-typing values don't
  thrash the persistent store.
- Commits on blur OR on Enter, after a quick http(s) validity check
  with the URL constructor.
- Invalid URLs surface an inline error and aren't persisted; the
  draft stays in the input so the user can fix it.
- aria-invalid + role=alert error messaging.

Open tabs in the workspace keep pointing at the old src until manually
reloaded — that's the simpler v0 contract; auto-reloading every open
tab on an URL edit can land later if it becomes annoying.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…orks

User reported: ep_webrtc's "Enable Audio/Video Chat" toggle in pad
settings did nothing on click in the desktop app, while the same
Etherpad worked fine in a browser.

Root cause: we installed setPermissionRequestHandler only. Without
setPermissionCheckHandler, navigator.permissions.query({name:'camera'})
returns 'denied' (Electron defaults a missing check handler to false),
so plugins that gate UI on the synchronous check never even render
their toggle's active path. The button visually existed but was
no-oping at click time. Without setDevicePermissionHandler, even a
granted media permission could fail at the device-selection step.

Three handlers now installed:

- setPermissionRequestHandler   — async, fired by getUserMedia() etc.
- setPermissionCheckHandler     — sync, fired by permissions.query()
- setDevicePermissionHandler    — fired when picking a specific
                                  camera/mic; allows audioinput,
                                  videoinput, audiooutput; denies USB,
                                  serial, hid, bluetooth.

Also added 'display-capture' to the allowlist so ep_webrtc's screen-
share path works, and wired the logger so main.log records each
permission request/check (debug for checks, info for requests). That
gives us a paper trail next time something like this is "silently
broken".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Help > Open Log Folder timed out at 120s in CI (e2e job: "Worker
teardown timeout of 120000ms exceeded"). \`shell.openPath\` invokes
\`xdg-open\` to open the system file manager — on a headless ubuntu
runner with no DE, that call hangs the Electron app and the worker
can never finish teardown.

Under E2E_TEST=1, no-op the callback so the menu item still fires
(its presence and click contract are what the test verifies) without
spawning a process that won't return. Production behaviour
unchanged.

Local result with the fix: full menu-click suite goes from 1 timeout
to 9/9 passing in 13s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…st launch

User report: "On first load stored pads don't load into UI, they show
up when you click 'Open Pad' or 'New Pad'."

Root cause: state.getInitial returned workspaces+settings only. The
renderer's padHistory map started empty and was only populated when
EV_PAD_HISTORY_CHANGED fired — which only happens on touch/pin/unpin,
i.e. the first user-driven mutation. Until then the sidebar's "Recent"
and "Pinned" sections rendered empty.

Now getInitial pre-bundles padHistory for every known workspace and
hydrate() applies it. Pinned by 4 contract tests (eager bundle, empty-
workspace map, fresh-workspace empty array, and the original
getInitial shape).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User asked: "Would there be an expectation a user can use keyboard to
fast switch between pads like they can a browser tab? Alt 1,2 etc."

Yes. Wires Ctrl/Cmd+1..8 to focus the Nth open pad of the active
workspace, with Ctrl/Cmd+9 jumping to the LAST pad — matching the
browser convention from Chrome and Firefox. The shortcut is skipped
when an input/textarea/contentEditable has focus so users can still
type "1" in fields.

Three E2E regression tests:
- Ctrl+1 focuses the first pad.
- Ctrl+9 focuses the last pad regardless of count.
- Ctrl+1 in an input is a no-op (dialog stays open).

Picked Ctrl/Cmd over Alt to match browsers; Linux DEs frequently
already bind Alt+N for window-manager actions, so going with browser
conventions reduces conflicts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
collapse Settings color column into the swatch

Three UI carryovers from this round of click-through feedback:

1. "the slit being visible" in collapsed mode — paint
   .shell-root-wrapper.rail-collapsed with var(--rail-bg) so the 28px
   gutter on the left of the WebContentsView reads as an intentional
   thin "rail strip with a handle", not as random body colour bleeding
   through.

2. "if the window is smaller than the settings the user cannot access
   the bottom of the settings dialog" — cap .dialog-panel at
   max-height: calc(100vh - 32px) and overflow-y: auto so any tall
   dialog scrolls inside the modal instead of running off the viewport.

3. "the remove button overlaps the color" — drop the dedicated
   <input type=color> column. The leftmost swatch is now a clickable
   <label> wrapping a visually-hidden colour input, so clicking the
   swatch opens the native picker. Saves a column, fixes the overlap,
   and adds a hover scale + accent-ring for affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User asked for a verbose README with:
- screenshot/gif placeholders
- all features currently baked in
- roadmap of upcoming work

Sections covered: why-use, full feature inventory (workspaces, pads,
tabs, quick switcher, settings, window/tray, theming, i18n, a11y,
auto-update, packaging), keyboard shortcut cheat-sheet, Linux install
paths (AppImage, deb, snap), develop quick-start, where-to-change-what
guide, roadmap split into "Soon" and "Later", and a brief test-layer
overview.

Screenshot placeholders point at docs/images/ — replace with real
captures when convenient. README states explicitly that those paths
are placeholders so reviewers don't think they're broken assets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two user-flagged regressions:

1. "+ NEW PAD button still visible when minimized" — the existing CSS
   targeted .workspace-rail and aside[aria-label], but in some
   flex/overflow combinations sidebar children could still paint into
   the 0-width grid column. Hide the wrapping cells too: tag the rail
   and sidebar wrapper divs with .rail-cell / .sidebar-cell and add
   them to the collapsed-state display:none rule.

2. "Alt 1,2,3 etc does nothing" — the previous keybinding accepted
   Ctrl/Cmd only and explicitly excluded altKey. User asked for Alt
   too. Accept any of Ctrl, Cmd, Alt as the modifier. Browser
   conventions (Ctrl/Cmd) and Linux DE conventions (Alt) both work.

E2E test added for Alt+1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported two regressions when focus is inside a pad's
WebContentsView (a separate Chromium instance from our shell):

1. "Alt 1,2,3 etc only works when focus is not in a pad. Control K
   works if in pad or not."
2. "Right clicking is suppressed, can't do it."

Native menu accelerators (like Ctrl+K) capture from any focused
WebContents, but our renderer-level keydown listener only sees keys
when the shell view has focus. And sandboxed Electron WebContentsViews
show no context menu by default — the host has to wire one.

Fixes:

- TabManager.wireViewEvents now hooks before-input-event on each pad
  view: when Alt/Ctrl/Cmd+1..9 is pressed inside a pad, the keystroke
  is preventDefault'd and forwarded to the shell webContents via a
  new \`shell.padFastSwitch\` IPC event, which the renderer handles
  with the same applyFastSwitch logic as its own keydown listener.
- TabManager.wireViewEvents also hooks context-menu and delegates to
  a host-supplied callback. AppWindow renders a basic menu (undo/redo
  when editable, cut/copy/paste, select all, reload pad, plus Inspect
  Element under NODE_ENV !== production) via Menu.buildFromTemplate
  + popup.
- Refactored applyFastSwitch out of the keydown effect so the IPC
  path and the keydown path share one source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report + DevTools probe showed the smoking gun:

  ua: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)
      etherpad-desktop/0.1.0 Chrome/146.0.7680.216 Electron/41.5.0
      Safari/537.36

Permissions and devices were both fine ("camera: granted",
"mic: granted", 7 devices enumerated). ep_webrtc's "Enable A/V Chat"
button rendered but the click silently no-oped because the plugin
user-agent-sniffs and conservatively whitelists known browsers. The
"Electron/" token was tripping its rejection path — same pattern Slack,
Notion, Loom, and many other Electron apps work around.

PadViewFactory.create now sets the WebContentsView's user agent BEFORE
the first loadURL, with stripElectronTokens removing two specific
substrings:

  - " Electron/<version>"
  - " etherpad-desktop/<version>"

Chrome and platform tokens are preserved so server-side feature
detection still works correctly. Pinned by 7 unit tests (including
the order-of-operations contract: setUserAgent fires before loadURL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: "pressing alt 1 focus to different pad worked but then no
focus landed in pad after changing pad so you cant do alt 1, alt 2 as
it will not switch."

Root cause: applyVisibility called setVisible(true) on the new active
view but never called webContents.focus(). The previously-focused
(now-hidden) view kept keyboard focus, so the NEXT Alt+1..9 keystroke
was delivered to the wrong WebContents — the one that's hidden — and
its before-input-event handler ran instead. The forwarded fast-switch
key would still work (it routes through main), but if the user typed
ANY other key meant for the new pad it landed in the old hidden one.

After the visibility flip, call webContents.focus() on the now-visible
view inside a try/catch (defensive — focus is non-critical for the
visibility transition).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ng strings

User asked: "Should we replace the terminology 'Workspaces' with
'Etherpad Instances'."

Decision: rename only at the user-facing string layer (i18n + JSX
copy + e2e/unit test selectors that match those strings). Internal
identifiers stay as workspace — IPC channels (workspace.list, .add,
.update, .remove, .reorder), the WorkspaceStore class, the
removeWorkspace dialog kind, the persist:ws-<uuid> partition format,
the workspaces[] state key, schema names — all unchanged. Renaming
those would have churned ~50 files for zero user-visible benefit.

Strings updated:
- Rail: "Add Etherpad instance", "Open instance {{name}}", "Hide
  instances (focus mode)", "Search instances and pads (Ctrl+K)".
- Add dialog title: "Add an Etherpad instance".
- Remove dialog: "Remove this Etherpad instance?" + body retitled.
- Workspace edit row: "Instance name", "Instance colour",
  + new "Etherpad instances" section heading.
- Quick switcher: "Search instances and pads…", workspaceLabel
  "Instance", empty-state "Type to search across all instances and
  recent pads."
- Settings → userName hint: "Pre-filled when you add an Etherpad
  instance…"

Test selectors updated to match the new strings (rail spec, e2e specs
that assert button labels). No source-code identifiers renamed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y pass

Stale HTTP cache caused a confusing ep_webrtc bug today: the plugin's
JS/translation bundles were cached from an older Etherpad and the
toggle silently no-oped until the user ticked "Disable cache" + force
reload in DevTools. Adding two affordances to make the workaround
discoverable without DevTools.

Hard reload pad
- New View → "Hard Reload Pad" menu item, accelerator Ctrl+Shift+R.
- New tab.hardReload IPC channel + main-side handler that calls
  webContents.reloadIgnoringCache() (falls back to plain reload on
  older Electron). Renderer wires the menu broadcast through.

Tray icon top-edge crop fix
- The first rail icon's 2px active-state box-shadow ring was being
  clipped by .workspace-rail-scroll's overflow-y: auto. Add 4px
  top/bottom padding inside the scroll viewport so the ring breathes.

README terminology
- Sweep README mentions of "workspace" → "instance" / "Etherpad
  instance" to match the renderer i18n rename. Internal identifiers
  (test file names like scroll-with-30-workspaces) left alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User asked: "include an 'Open by URL' type thing where a user just has
to paste in the URL of the pad and it automatically adds the instance
and the pad."

Flow: File → Open Pad by URL… (Ctrl+L, browser address-bar convention).
Paste e.g. https://pad.example.com/p/some-pad and:
  1. parsePadUrl extracts (serverUrl, padName).
  2. If an instance already exists for that serverUrl, the pad opens
     in it.
  3. Otherwise the instance is added (host as default name, palette
     colour rotated through), the rail icon appears, the pad opens.

One step from the user's perspective: paste, hit Enter.

Wiring:
- src/shared/url.ts: parsePadUrl utility — extracts (serverUrl, padName)
  with full handling of path-prefixed Etherpad installs, query/hash
  stripping, percent-encoded names, trailing slashes. 11 unit tests.
- src/renderer/dialogs/OpenByUrlDialog.tsx: new dialog using the
  shared DialogShell. Submit-on-Enter, inline error/status messaging,
  AppError mapping for unreachable / not-Etherpad responses.
- src/renderer/state/store.ts: 'openByUrl' added to DialogKind.
- src/renderer/i18n/en.ts: openByUrl namespace (title, label,
  placeholder, errors, status). i18n shape contract updated.
- src/main/app/menu.ts + lifecycle.ts + preload + App.tsx: File menu
  item, Ctrl+L accelerator, IPC broadcast through to the dialog.
- 3 E2E tests: new instance branch, existing instance branch,
  malformed URL branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rename

The earlier i18n rename pass missed several test selectors that match
on heading/title text. Local tests pass because the runner shells out
to a pre-built bundle that may be cached, but CI builds fresh and
loads the new strings — so dialog-dismissal, first-launch, menu-click,
url-validation, settings-flow, renderer-mount and rail-collapse all
got element-not-found timeouts in CI even though the user-visible
behaviour is correct.

- /add a workspace/i → /add an etherpad instance/i (heading text)
- /remove workspace/i → /remove this etherpad instance/i
- /workspace rail/i → /etherpad instance rail/i (nav aria-label)
- /hide workspaces/i → /hide instances/i (collapse-handle title)
- /show workspaces/i → /show instances/i

All 25 affected tests pass locally; pushing so CI lands green and we
can cut the v0.1.0 beta.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…licate

User report: "I was able to get a pad to open twice if I click it
quickly multiple times. It shows up multiple times in the Tab view."

Root cause: TabManager.open() looks for an existing tab via
this.tabs.find(...) and bails early if found, but factory.create() is
async (it awaits webContents.loadURL). Two clicks <100ms apart both
pass the existence check while neither has pushed its tab yet, then
both push, then both register events — duplicate tabs.

Add an inflight Map<key, Promise<OpenTab>> keyed by
"\${workspaceId}|\${padName}". Concurrent calls that find an inflight
promise return that same promise, so all callers resolve to the same
OpenTab and only one factory.create + viewHost.add fires. Also re-
check for an existing tab AFTER the await, in case some other path
created one while we were waiting.

Three regression tests pin the contract:
- two concurrent opens for the same key resolve to one tab
- three concurrent opens still resolve to one tab
- after coalescing, a later open with the same key still dedups

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User wants Windows in the same beta release: "a lot of our testers
will want to test with windows."

Build pipeline:
- build/icons/icon.ico — multi-resolution Windows icon (16/24/32/48/
  64/128/256 px) generated from the existing icon-256.png.
- build/electron-builder.yml — new \`win:\` block targeting:
    1. NSIS — proper installer with desktop + Start Menu shortcuts
       and an uninstaller. oneClick: false so the user can pick the
       install dir; perMachine: false so installs go to the user
       profile (no UAC prompt). Artefact: Etherpad-Desktop-Setup-X.Y.Z.exe
    2. portable — single-file .exe that runs without installing.
       Artefact: Etherpad-Desktop-X.Y.Z-portable.exe.
  Both x64. Both UNSIGNED — Windows SmartScreen will warn on first
  run (signing needs an EV cert; punted to a follow-up).
- .github/workflows/release.yml — new release-windows job runs in
  parallel with release-linux on every \`v*\` tag. Uses
  windows-latest runner so we get genuine Windows artefacts (not
  Wine cross-compiled). Uploads .exe + latest.yml for
  electron-updater. publish: always sends to GitHub Releases via the
  existing GH_TOKEN.
- package.json — added package:linux and package:win scripts so the
  default \`pnpm package\` (still Linux-only) doesn't change behaviour
  but Windows can be invoked locally on a Windows machine.

Docs:
- README — status note bumped to "v0 beta" with Linux + Windows
  listed; new "Windows" install section under Install describes both
  artefacts and the SmartScreen warning. Roadmap "Windows and macOS
  builds" entry split: macOS still pending, Windows-signing called
  out as the remaining piece.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@JohnMcLear JohnMcLear merged commit 2f211ab into main May 5, 2026
3 checks passed
JohnMcLear added a commit that referenced this pull request May 12, 2026
… + X-Frame fallback + https:// auto-prefix

Six device-test issues from real-hardware run:

1. Dialogs (AddWorkspace / OpenPad / etc.) overflowed phone viewports —
   DialogShell width now `min(${width}px, calc(100vw - 16px))` so a 420
   panel caps at viewport-16 on phones. Tighter padding under 480px.
2. Bare hostname URLs were rejected — AddWorkspaceDialog auto-prefixes
   `https://` when the input has no `://` scheme.
3. (Same as #1 — OpenPadDialog uses DialogShell.)
4. "Black screen" on pad open — likely X-Frame-Options DENY/SAMEORIGIN
   from the server. PadIframeStack now starts a 6s timeout per iframe;
   if no onLoad fires, an opaque overlay surfaces with an "Open in
   browser" button that hands the URL off to @capacitor/browser.
5. Status bar overlapped the rail — shell-root-wrapper now applies
   `env(safe-area-inset-*)` padding. Zero visual change on desktop;
   stops mobile WebView from drawing under the status bar / notch.
6. Newly added workspaces didn't appear in the rail — mobile platform's
   `events.onWorkspacesChanged` was a noop. workspace-store now owns
   a tiny listener Set; CapacitorPlatform wires it through so the
   shell's `onWorkspacesChanged` handler in App.tsx receives the new
   `{workspaces, order}` after every add / update / remove / reorder.

DialogShell width-test dropped (jsdom can't parse CSS `min()`; behaviour
is exercised in Playwright on real Chromium).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JohnMcLear added a commit that referenced this pull request May 12, 2026
…ame fallback + https auto-prefix (Phase 7 fixes) (#37)

* fix(mobile): responsive dialogs + safe-area insets + workspace events + X-Frame fallback + https:// auto-prefix

Six device-test issues from real-hardware run:

1. Dialogs (AddWorkspace / OpenPad / etc.) overflowed phone viewports —
   DialogShell width now `min(${width}px, calc(100vw - 16px))` so a 420
   panel caps at viewport-16 on phones. Tighter padding under 480px.
2. Bare hostname URLs were rejected — AddWorkspaceDialog auto-prefixes
   `https://` when the input has no `://` scheme.
3. (Same as #1 — OpenPadDialog uses DialogShell.)
4. "Black screen" on pad open — likely X-Frame-Options DENY/SAMEORIGIN
   from the server. PadIframeStack now starts a 6s timeout per iframe;
   if no onLoad fires, an opaque overlay surfaces with an "Open in
   browser" button that hands the URL off to @capacitor/browser.
5. Status bar overlapped the rail — shell-root-wrapper now applies
   `env(safe-area-inset-*)` padding. Zero visual change on desktop;
   stops mobile WebView from drawing under the status bar / notch.
6. Newly added workspaces didn't appear in the rail — mobile platform's
   `events.onWorkspacesChanged` was a noop. workspace-store now owns
   a tiny listener Set; CapacitorPlatform wires it through so the
   shell's `onWorkspacesChanged` handler in App.tsx receives the new
   `{workspaces, order}` after every add / update / remove / reorder.

DialogShell width-test dropped (jsdom can't parse CSS `min()`; behaviour
is exercised in Playwright on real Chromium).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mobile): icons + collapse handle + auto-collapse + userName + pad-history + Ctrl-K hint + tests

Round 2 of device feedback:

- App icons: regenerated all five mipmap densities + adaptive foregrounds
  from packages/desktop/build/icons/icon-512.png. The Capacitor default
  icon is gone; Etherpad branding everywhere.
- Removed the always-visible PadActionsOverlay (share is redundant with
  Etherpad's own UI; the "open in browser" button forced over content).
- Collapse handle now flush to left edge, 14px wide (was 22 + 3px gap),
  right-flat border so it reads as a tab sticking out.
- Tab.open auto-collapses the workspace rail (mobile UX) — fires only on
  the open event, doesn't fight subsequent manual expands.
- Tab.open upserts pad-history so QuickSwitcher's name search finds the
  pad. Wired events.onPadHistoryChanged through.
- Settings.userName threads into the iframe src as `&userName=` so the
  user's name applies to existing + new pads (Etherpad reads the query
  param at join time).
- "Tip: Ctrl+K opens this from anywhere" hidden via `@media (pointer:
  coarse)` — touch devices can't issue keyboard shortcuts anyway.
- Tests: 8 mobile Playwright cases now (added 3 — auto-collapse,
  pad-history populate, userName in src). X-Frame detection removed
  (Chromium fires onLoad even for blocked iframes; needs the native
  WebChromeClient hook in Phase 6b).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mobile): swipe gestures + Android back-button + upsert error logging

- Edge-swipe right from left edge expands the rail; swipe left from
  inside collapses it. Touch-only — doesn't fire when a dialog is open
  (dialogs handle their own gestures).
- Android hardware/gesture back: dismiss open dialog first, else collapse
  rail, else minimise the app. Mirrors stock Android navigation expectations.
- padHistory.upsert errors now log to console.warn instead of being
  swallowed by `void`. Earlier logcat confirmed upsert is firing and
  writing 'Jehejej' to SharedPreferences as expected.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JohnMcLear added a commit that referenced this pull request May 12, 2026
…y relaunch test (#42)

* fix(mobile): responsive dialogs + safe-area insets + workspace events + X-Frame fallback + https:// auto-prefix

Six device-test issues from real-hardware run:

1. Dialogs (AddWorkspace / OpenPad / etc.) overflowed phone viewports —
   DialogShell width now `min(${width}px, calc(100vw - 16px))` so a 420
   panel caps at viewport-16 on phones. Tighter padding under 480px.
2. Bare hostname URLs were rejected — AddWorkspaceDialog auto-prefixes
   `https://` when the input has no `://` scheme.
3. (Same as #1 — OpenPadDialog uses DialogShell.)
4. "Black screen" on pad open — likely X-Frame-Options DENY/SAMEORIGIN
   from the server. PadIframeStack now starts a 6s timeout per iframe;
   if no onLoad fires, an opaque overlay surfaces with an "Open in
   browser" button that hands the URL off to @capacitor/browser.
5. Status bar overlapped the rail — shell-root-wrapper now applies
   `env(safe-area-inset-*)` padding. Zero visual change on desktop;
   stops mobile WebView from drawing under the status bar / notch.
6. Newly added workspaces didn't appear in the rail — mobile platform's
   `events.onWorkspacesChanged` was a noop. workspace-store now owns
   a tiny listener Set; CapacitorPlatform wires it through so the
   shell's `onWorkspacesChanged` handler in App.tsx receives the new
   `{workspaces, order}` after every add / update / remove / reorder.

DialogShell width-test dropped (jsdom can't parse CSS `min()`; behaviour
is exercised in Playwright on real Chromium).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mobile): icons + collapse handle + auto-collapse + userName + pad-history + Ctrl-K hint + tests

Round 2 of device feedback:

- App icons: regenerated all five mipmap densities + adaptive foregrounds
  from packages/desktop/build/icons/icon-512.png. The Capacitor default
  icon is gone; Etherpad branding everywhere.
- Removed the always-visible PadActionsOverlay (share is redundant with
  Etherpad's own UI; the "open in browser" button forced over content).
- Collapse handle now flush to left edge, 14px wide (was 22 + 3px gap),
  right-flat border so it reads as a tab sticking out.
- Tab.open auto-collapses the workspace rail (mobile UX) — fires only on
  the open event, doesn't fight subsequent manual expands.
- Tab.open upserts pad-history so QuickSwitcher's name search finds the
  pad. Wired events.onPadHistoryChanged through.
- Settings.userName threads into the iframe src as `&userName=` so the
  user's name applies to existing + new pads (Etherpad reads the query
  param at join time).
- "Tip: Ctrl+K opens this from anywhere" hidden via `@media (pointer:
  coarse)` — touch devices can't issue keyboard shortcuts anyway.
- Tests: 8 mobile Playwright cases now (added 3 — auto-collapse,
  pad-history populate, userName in src). X-Frame detection removed
  (Chromium fires onLoad even for blocked iframes; needs the native
  WebChromeClient hook in Phase 6b).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mobile): swipe gestures + Android back-button + upsert error logging

- Edge-swipe right from left edge expands the rail; swipe left from
  inside collapses it. Touch-only — doesn't fire when a dialog is open
  (dialogs handle their own gestures).
- Android hardware/gesture back: dismiss open dialog first, else collapse
  rail, else minimise the app. Mirrors stock Android navigation expectations.
- padHistory.upsert errors now log to console.warn instead of being
  swallowed by `void`. Earlier logcat confirmed upsert is firing and
  writing 'Jehejej' to SharedPreferences as expected.

* feat(mobile): persist + restore open tabs across app restarts

Closing the app then reopening it now puts the user back on the pad
they were on. Implementation:

- New `tab-persistence.ts` writes `{tabs, activeTabId, activeWorkspaceId}`
  to Capacitor Preferences under `etherpad:windowState` whenever the
  tab-store mutates (debounced 150ms).
- `loadFromStorage()` is awaited inside `state.getInitial()` so the
  shell's first `onTabsChanged` subscription sees the restored set.
- `window.setActiveWorkspace` now writes through to the persistence
  layer instead of being a no-op.

Shell change: InitialState gains optional `activeWorkspaceId`. App.tsx
prefers it over `workspaceOrder[0]` when present and valid. Desktop
main process doesn't populate it (yet) so desktop behaviour is
unchanged — the field is informational only when omitted.

Also fix(mobile): wire `events.onSettingsChanged` so changing language
or any other setting actually updates the iframe + UI in-place. Was a
noop subscriber; settings-store now has an onChanged emitter and
CapacitorPlatform routes the shell's subscription through it. Without
this, `applySettings()` + `setLanguage()` never fired and the iframe's
?lang= param never refreshed.

Also fix(desktop e2e): bump restore-on-relaunch.spec.ts timeout from
30s → 60s. The test has flaked on ~50% of recent PR runs (cold-start
under xvfb contention); reruns succeed at the same 30s. Happy path
still completes in 8-12s.

* fix(mobile): write tab state immediately + B&W app icon + test round-trip

- The 150ms debounce in tab-store.scheduleSave was eating writes on
  app-kill: open pad → \`am force-stop\` ≤150ms later → save never
  fires → on relaunch tab list is empty (the device-side bug the user
  hit). Write-through immediately on every mutation; tab.open is a
  per-user-gesture event so the write rate is fine.

- New Playwright smoke "opening a pad then reloading restores the same
  pad (full write+read cycle)" — characterizes the bug above. Failed
  before this commit, passes after. Reload is the closest in-browser
  analogue to app-kill+relaunch (same JS context boundary, same
  Preferences read on init).

- B&W app icon: regenerated all five mipmap densities + adaptive
  foregrounds from build/icons/tray-icon.png (the official Etherpad
  pencil-pad silhouette). Adaptive background flipped #FFFFFF → #000000.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant