Skip to content

fix: open a window on Windows β€” WebView2 had nowhere to write its data - #3606

Closed
Dale-Black wants to merge 173 commits into
JuliaPluto:mainfrom
GroupTherapyOrg:fix/windows-webview2-data-dir
Closed

Dale-Black wants to merge 173 commits into
JuliaPluto:mainfrom
GroupTherapyOrg:fix/windows-webview2-data-dir

Conversation

@Dale-Black

@Dale-Black Dale-Black commented Aug 27, 2026 •

Copy link
Copy Markdown

Fixes #55.

What users saw

The Windows app started and did nothing: no window, no taskbar entry, and a process that never exited β€” so every click left another one behind. The reporter ended up with five SpaceStation.exe processes and, on the first launch after a reboot, one native dialog:

Microsoft Edge can't read and write to its data directory:
C:\Program Files\SpaceStation\SpaceStation.exe.WebView2\EBWebView

Why

WebView2 defaults its user-data folder to <exe>.WebView2 β€” beside the binary. Our MSI installs into %ProgramFiles%\SpaceStation, and deno desktop hard-codes ProgramFiles64Folder with no per-user install option, so on a standard account that path is read-only. Creating the WebView2 environment failed, the window was never created, and nothing in the shell treated that as fatal. Microsoft documents exactly this case: an unpackaged app in a protected install directory must name its own user-data folder.

The fix

desktop/webview2.ts points WEBVIEW2_USER_DATA_FOLDER at %LOCALAPPDATA%\SpaceStation\WebView2 before the first window exists. That reaches the loader because our code runs inside the webview host process β€” the app executable IS the laufey_webview host, which loads the Deno runtime and only then runs main.ts, while the WebView2 environment is created later on the first BrowserWindow. Verified by finding the WEBVIEW2_USER_DATA_FOLDER literal and GetEnvironmentVariableW in the shipped laufey_webview.exe, and by tracing the startup order on a real bundle.

Why CI never caught it

desktop/smoke.ts is headless by design β€” "everything except the window". The window layer had never once been executed in CI, on any OS. The .msi was built, uploaded and attached to releases without anything ever installing or running it (msiexec appeared nowhere in the repo).

So this PR also adds:

  • desktop/window_smoke.ts β€” a headed companion that opens a real window and makes the webview prove it rendered by fetching a beacon URL. Runs on macOS and Windows in the test job. (Linux is deliberately excluded: the runner has no display or WebKitGTK stack, and a check that is allowed to fail is how this stayed invisible.)
  • An install-and-launch job β€” installs the .msi it just built to the real Program Files location and launches it. GitHub's Windows runners are administrators with UAC disabled, so Program Files is writable there and Reload browser after pluto restart is confusingΒ #55 would not reproduce on its own; a naive test would go green against the very bug it exists to catch. The step therefore revokes its own write access to the install directory first, then asserts the profile landed under LOCALAPPDATA and that nothing was written beside the binary.
  • A liveness watchdog in main.ts β€” the runtime navigates the startup window to the shell's own server, so a healthy webview fetches a page within seconds. If nothing ever arrives, the shell now says why and exits non-zero instead of idling forever behind Deno.serve. That is what produced the pile of invisible processes.

Two smaller Windows bugs found along the way

  • home_dir() preferred HOME over USERPROFILE. Git Bash/MSYS set HOME to somewhere Julia's homedir() never looks, and the two must agree β€” the server writes its connection file under homedir() and boot.ts reads the access secret back out of it.
  • juliaup_info() split JULIA_DEPOT_PATH on ":", which severs the drive letter on Windows and turned C:\Users\me\.julia into C.

build:win now passes --icon: deno desktop decodes desktop.app.icons.macos (.icns) even when targeting Windows and fails the build, so that task was broken. A .ico is added and declared, though deno 2.9.5 embeds no Windows icon yet.

Known, not fixed here

Every build declares the same installer identity (ProductVersion 1.0.0, fixed ProductCode, no Upgrade table), so Windows can skip the upgrade and leave the old app in place. The release notes now tell Windows users to uninstall first; stamping real per-release identity is follow-up work.

Try this Pull Request!

Open Julia and type:

julia> import Pkg
julia> Pkg.activate(temp=true)
julia> Pkg.add(url="https://github.com/GroupTherapyOrg/SpaceStation.jl", rev="fix/windows-webview2-data-dir")
julia> using Pluto

Dale-Black and others added 30 commits June 11, 2026 06:38
…f running

- EvaluationOptions.on_code_change: "autorun" (default, vanilla) | "lazy"
- Cell.stale flag, sent to clients in cell_results
- External file edits (update_from_file) mark the changed cells + their
  downstream closure stale via mark_stale!, with no execution. Cell
  removals fall back to a reactive run so workspace variables are cleaned.
- Pull-based runs: run_multiple_cells expands requested cells with their
  stale ancestors (expand_stale_ancestors), so nothing computes against
  outdated inputs. Downstream of an explicit run still runs (Pluto's
  reactive guarantee is unchanged).
- Lazy mode implies file watching, so external tools (e.g. coding agents)
  can edit the notebook file and the UI shows staleness live.
- Watcher self-save detection by content hash (Notebook.last_saved_file_hash)
  instead of a time cooldown, which could swallow external edits landing
  just after a save. Handles atomic temp-file+rename writes.
- Frontend: stale trafficlight color (--stale-cell-color in both themes),
  floating 'N cells are stale (RUN)' notice reusing the undo_delete pattern,
  hidden in print/export. i18n keys t_stale_cells / t_run_stale_cells_link.
- test/LazyMode.jl: 50 tests incl. watcher integration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each cell records result_hash = H(output) and execution_key_produced =
H(own code, sorted immediate-upstream result hashes) when it produces
output. verify_stale! clears stale marks that are provably unnecessary
(key matches + no stale upstream), in topological order:
- reverting an edit un-stales the whole closure without running
- early cutoff: an upstream re-run with identical result un-stales
  downstream (backdating, as in salsa/Shake)
- 'always_stale' cell metadata opts impure cells (rand, time, I/O) out
  of verification; synced to frontend DEFAULT_CELL_METADATA
Runs after every reactive run and after every lazy stale-marking.
These keys also become the cache-trust mechanism for the output sidecar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
<notebook>.jl.pluto-cache.toml is written (atomically) after every reactive
run in lazy mode. Per cell: execution_key + result_hash (for verification),
errored, runtime, mime, a truncated plain-text representation (so external
tools can read outputs by reading a file), and a MsgPack+Base64 packed copy
of the full output + published_objects for exact restore.

Opening a notebook in lazy mode no longer runs it. Cached outputs are
restored, all cells are marked stale, and execution-key verification
immediately clears every cell whose code and upstream results are unchanged
β€” those display their old output and are flagged workspace_cold (new Cell
field, in the client protocol too): display is current, but their variables
don't exist in the fresh process. Cold cells are pulled like stale ancestors
(expand_stale_ancestors, and bond runs under lazy now expand too), so the
first interaction with a restored notebook re-runs exactly the chain it
needs. The worker process is warmed in the background on open.

The sidecar is a pure cache: deleting it costs only cached outputs. The
notebook .jl file stays byte-compatible with vanilla Pluto.

test/OutputCache.jl: 24 tests (restart survival, cold pull, offline-edit
staleness on open).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…AB.md

- Every server writes a connection file ($XDG_STATE_HOME/pluto/servers/
  <port>.json: pid, port, secret) on start, removed on shutdown β€” the
  Jupyter kernel-connection-file idiom. chmod 600.
- /api/v1 HTTP endpoints (behind Pluto's existing secret auth):
  GET  /api/v1/notebooks               open notebooks
  GET  /api/v1/notebook?path|id        per-cell state: code, stale, cold,
                                       errored, runtime, output text, key
  POST /api/v1/notebook/run?stale=true | cells=<ids>   BLOCKING run via
       the normal execution path/token; stale+cold ancestors pulled in;
       X-Pluto-Cells-Errored header for exit codes
  POST /api/v1/notebook/interrupt
  All take format=json|text β€” text means clients need no JSON parser.
  Input via query params; minimal hand-rolled JSON writer (no new deps).
- bin/pluto-collab: pure bash+curl+sed CLI (servers / notebooks / status /
  run / interrupt). Server-side realpath matching (handles /tmp symlinks).
  Exit codes: 0 ok, 1 cells errored, 2 no live server.
- test/collab_acceptance.sh: 20-step end-to-end test with a real server:
  lazy open β†’ CLI run β†’ atomic-rename agent edit β†’ stale closure β†’ run
  closure only β†’ error exit codes β†’ single-cell run pulls ancestors β†’
  restart restores outputs from sidecar (verified, cold) β†’ JSON output.
  All 20 pass.
- COLLAB.md: feature documentation + AGENTS.md stanza template for
  notebook repos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… stale color

- pluto-cell.stale trafficlight color no longer applies while the cell is
  queued/running, so the normal activity animation is visible during runs
- the 'N cells are stale (RUN)' notice switches to 'Running… N stale cells
  left' while cells execute (counts down live as cells finish), and hides
  the RUN link during the run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A stale cell and a locally-modified cell mean the same thing to a human β€”
'the displayed output does not match the code; run to update' β€” so they now
look identical: .stale is added to every code_differs style rule (input
tint, trafficlight, run-button prominence, gutters) instead of having its
own color. Custom --stale-cell-color theme vars removed. Cascade position
matches code_differs, so running/queued/errored visuals override stale the
same way they override modified cells.

(The server-side stale field itself remains necessary: code_differs is a
browser-local draft marker β€” non-transitive, invisible to the server and
other clients, gone on reload. stale is its server-side, dependency-
propagating, restart-surviving counterpart.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…+S flow

The custom 'N cells are stale (RUN)' floating notice is GONE β€” Pluto already
has the right affordances, so we light them up instead:

- In lazy mode, typing in a cell debounce-syncs the code to the server after
  1.5s of inactivity, through the exact same update_save_run!(external_trigger)
  path as an external file edit: code lands in the .jl file, the cell is
  marked stale (same yellow), nothing runs. Browser edits and agent file
  edits are now indistinguishable to every participant.
- Pluto's existing ⌘S/Ctrl+S badge (Preamble) now appears when any cell is
  stale, and set_and_run_all_changed_remote_cells runs local drafts AND
  stale cells. One affordance for 'changed but not run', whoever changed it.
- update_notebook handler: lazy + CodeChanged routes the changed cells
  through the lazy stale-marking path (saves the file too). Cell deletions
  keep the vanilla path (run_multiple_cells([]) handles workspace cleanup).
- notebook_to_js gains on_code_change so the frontend knows the mode.
- RunStaleCellsButton.js, its CSS, hide-ui rule, and i18n keys removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… runs

Running a cell no longer pulls in stale (edited-but-not-run) ancestors β€”
exactly like vanilla Pluto, where running a cell computes against the
workspace values from each ancestor's last run, and other cells' pending
edits apply only when those cells run (individually, or all at once with
Ctrl+S / run --stale).

expand_stale_ancestors now pulls ONLY workspace-cold ancestors (outputs
restored from cache after a restart): those are a physical necessity β€”
their variables don't exist in the kernel until they run once.

Tests updated: LazyMode asserts a pending upstream edit does not affect a
downstream run (53 tests); acceptance asserts running a cell does not pull
a stale ancestor's fix (22 steps, all green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e requested cells

On a freshly restored (cold) notebook, running a top cell reactively re-runs
its dependents β€” which may reference cold cells from OTHER branches (not
ancestors of the requested cell). Those weren't being pulled, causing
UndefVarError in the closure (observed live: running x re-ran summary, which
referenced the still-cold verdict).

expand_stale_ancestors now BFSes upstream from the entire downstream closure
of the requested cells. New regression test in OutputCache.jl (31 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hows during runs

Being queued for execution consumes the pending mark (run_reactive_core!
sets stale=false alongside queued=true) β€” matching vanilla Pluto, where
submitting a draft clears its modified state the moment the run starts.
Previously stale stayed set until the cell finished, so queued/running
cells were dominated by the gold stale styling instead of the normal
activity visuals. CSS stale rules additionally guarded with
:not(.queued):not(.running) to cover the client-side waiting window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pluto.run(workspace="/path") turns the server into a hub (the future
PlutoLand.jl): the root page becomes frontend/land.html β€” a VS-Code-like
shell with a workspace file tree in the sidebar, a running-notebooks list,
and notebooks open as TABS. Every tab is the stock, unmodified Pluto editor
in an iframe (own websocket, state preserved across tab switches).
Maximal reuse of existing machinery:
- multi-notebook + one Malt worker each: native session.notebooks
- open/new/move/shutdown: the existing endpoints, called from land.js
- styling: Pluto theme variables (themes/*.css) only β€” no new colors
- GET /api/v1/workspace: recursive tree (depth/entry budgeted, dotfiles +
  node_modules skipped, .jl files sniffed for the Pluto header to
  distinguish notebooks from scripts)
- connection file gains "workspace" so agents know the root
Closing a tab keeps the notebook running (JupyterHub semantics); shutdown
is explicit. New-notebook flow: /new then /move into the workspace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d by default

- Notebooks opened from the workspace tree now open WITHOUT
  execution_allowed, so Pluto's standard Safe-preview intro shows (the
  'run this notebook' flow, SafePreviewUI) β€” with cached outputs already
  restored by the sidecar, the preview isn't even blank.
- Bubble design language for the shell (the welcome page's card idiom:
  rounded cards with soft shadows, pill-shaped entries and tabs), still
  exclusively Pluto theme variables β€” follows light/dark automatically.
- Workspace folders are collapsed by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… sidebar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drag the gap between sidebar and editor to resize (180–560px); the ⟨ button
in the header hides the sidebar entirely, a ☰ pill brings it back. Width and
hidden state persist in localStorage. While dragging, iframe pointer events
are disabled so the drag isn't swallowed by the editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Code

The root page is now ALWAYS the PlutoLand hub (classic welcome remains at
/index.html). Without a workspace, it shows an Open Folder screen: browse
the server's filesystem (GET /api/v1/browse), recent workspaces
(localStorage), one click opens a folder as the workspace at runtime
(POST /api/v1/workspace/open β€” also rewrites the connection file so agents
see the new root; the server now remembers its bound port for this).
Already-running notebooks (e.g. Pluto.run(notebook=…)) auto-open as tabs
on first load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, paste-a-path

The opener rendered outside #land so the bubble/pill styles never applied
(raw browser buttons β€” the jank). Shared classes are now global.
Redesign in the welcome-page idiom: large centered bubble card, recent
workspaces as a card grid, clickable monospace breadcrumbs for navigation,
subfolders as a folder-pill grid, an accent-ringed 'Open X as workspace'
primary button, and a paste-a-path input. All Pluto theme variables.

(Considered Pluto's FilePicker.js for path entry β€” it's the right machinery
but needs a live PlutoConnection on the page for tab-completion; deferred.
PlutoUI.FilePicker is a browser upload widget β€” wrong tool for server paths.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cket

- src/webserver/PTY.jl: vendored from Sessions.jl services/pty.jl (same
  author/org, Tachikoma lineage) β€” openpty + posix_spawnp, nonblocking
  reader via poll_fd, TIOCSWINSZ resize. Unix only.
- src/webserver/CollabTerminal.jl: one PTY per /terminal websocket
  (authenticated with the normal Pluto secret). Trivial wire protocol:
  '0:<keys>' and '1:<rows>,<cols>' text frames in, raw PTY bytes out as
  binary frames β€” no parser on either side. Login shell starts cd'ed into
  the workspace folder.
- WebServer.jl: /terminal upgrades route to the PTY bridge, everything
  else to the notebook protocol as before.
- Land UI: '⌨ Terminal' toggle pill in the tab strip (strip now always
  visible), bottom bubble panel with xterm.js (esm.sh) + fit addon,
  theme pulled from Pluto CSS vars, drag-to-resize height, open state +
  height persisted. Panel stays mounted when hidden so the shell lives on.
- Verified headless: WS client β†’ shell echo round-trip + cwd. βœ“

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A dock-toggle pill (β—¨/⬓) next to the terminal toggle flips the panel
between bottom (height-resizable) and right (width-resizable). The frames
and panel share one DOM structure with a flipped flex-direction, so
switching docks never remounts the editor iframes. Dock side, panel
width/height, open state, and sidebar prefs all persist in localStorage
(per-browser UI prefs β€” survives reloads and server restarts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shell now belongs to a terminal-session id (tid, stable in
localStorage), not the websocket: a hard refresh detaches, the shell keeps
running server-side, and the next connection replays a 200KB scrollback
ring and reattaches β€” tmux semantics without tmux. Multiple sockets can
attach to one tid (mirrored terminals). Shells end only when they exit or
the server stops; the registry cleans up after exited shells.

Tab strip: tabs scroll horizontally in their own sub-strip; the ⌨ Terminal
and dock toggles are pinned at the far right and always visible no matter
how many notebooks are open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A πŸ—‚ button in the sidebar header brings back the Open Folder screen as an
overlay (with a back button). Picking a folder while notebooks are running
asks for confirmation, shuts them down (files stay on disk, outputs cached
in sidecars), clears the tabs, and switches the workspace at runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- frontend/img/plutoland.svg: the PlutoLand mark, derived from Pluto's β€”
  the exact three-circle totem (same fills and strokes as logo.svg),
  landed on a sandy island with sprouts and water shimmer, a flag planted
  on top. Used in the sidebar header and the workspace opener.
- Sidebar header rebuilt as a flex row (logo Β· title+root Β· buttons) β€”
  the πŸ—‚ and ⟨ buttons no longer collide with the title at any width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The tab strip now lives INSIDE the editor card (slim top bar with a
  hairline rule): tab pills stay distinct, but visually belong to the
  notebook card β€” no more floating row + uneven top gap, and the empty
  state fills the card right under the bar.
- Pills/toggles recolored for the card background; active tab keeps the
  accent ring. Terminal toggles remain pinned right of the scrolling strip.
- Sidebar title ellipsizes instead of sliding under the header buttons at
  narrow widths (the remaining header wonk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Plain files in the workspace tree now open as editor TABS alongside
  notebooks: a pane built on Pluto's bundled CodeMirror
  (imports/CodemirrorPlutoSetup.js) with language support by extension
  (jl/md/toml/css/js/html/py) and syntax colors wired to Pluto's
  --cm-color-* theme variables. Save button + Ctrl/Cmd+S, dirty indicator,
  unsaved-changes confirm on close. Backend: GET /api/v1/file (UTF-8 text,
  ≀2MB) and POST /api/v1/file/save (atomic tmp+mv) behind the secret.
  Editing a notebook file this way flows through the watcher like any
  agent edit β€” cells go stale in its notebook tab.
- New PlutoLand mark: minimal. Pluto's exact three-circle stack standing
  at a waterline, reflected in the water β€” nothing added, the place is
  implied. Replaces the busier island+flag version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mark is now NOTHING but Pluto's three circles (identical fills,
strokes, radii, overlap ratio), rearranged from the floating vertical
stack into a mound: two on the ground, one resting on top. No island, no
flag, no water β€” the configuration itself is the land.

All floating cards (sidebar bubbles, editor card, terminal panel) get a
hairline --rule-color ring + deeper drop shadow so the notebook panel's
boundary reads clearly against the page in both themes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The aside column no longer scrolls (that was clipping the bubbles' hairline
rings and shadows at the edges): the workspace tree card scrolls internally
as before, and the running list scrolls within a capped height. Header and
footer stay fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
+ New notebook, the ☰ sidebar-reopen pill, and the opener's recent-workspace
cards now wear the same hairline --rule-color ring + shadow as every other
floating card β€” no more borderless-looking bubbles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hover actions in the tree (considered context menus and header-only
buttons; hover actions won β€” direct manipulation where the eye already is,
discoverable, touch-friendly, no new menu machinery):
- '+' on folder rows and the Workspace header: prompt for a name β€” ending
  in .jl creates a real Pluto notebook (/new + /move, opens as a tab),
  anything else creates an empty file (POST /api/v1/file/new) opened in a
  file tab.
- 'βœ•' on file/notebook rows: confirm dialog (explicit: permanent, no
  trash), then POST /api/v1/file/delete β€” a running notebook is shut down
  first and its .pluto-cache.toml sidecar is removed with it. Any open tab
  for the deleted path closes.
Agents already have this power via the filesystem; the tree poll picks up
their changes β€” this gives humans parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
land.html now serves img/plutoland.svg (the landed dots) as its icon, with
the old PNG only as a fallback for browsers without SVG favicon support.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
create_in/delete_entry were defined above refresh while listing it in
their dependency arrays β€” evaluating the deps at render hit the temporal
dead zone ('Cannot access refresh before initialization'), crashing the
whole component into a blank page. The callbacks now sit below refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
github-actions Bot and others added 28 commits July 7, 2026 14:27
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…#15)

Three stylesheets reference copy-outline.svg; on a cold cache all three
resolves saw 'no file' and raced parallel writeFile calls onto one
cache path β€” parcel could read a half-written (empty) asset, and the
corrupt-files check failed the Bundle run (release branch would have
shipped a 0-byte icon). Downloads are now deduped per cache path
(in-flight promise map), written to a unique temp file and renamed into
place, and an empty cached file counts as a miss. The CI check also
names the offending files instead of a bare exit 1.

Build-infra only: the registered package never contains frontend-dist,
so no release is needed.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…irely (#16)

With code execution disabled β€” Safe Preview (waiting_for_permission,
how the workspace hub opens notebooks until a human grants execution)
or a dead worker (no_process) β€” update_save_run! silently skips
running, and POST /api/v1/notebook/run counted 0 errored cells and
returned "RESULT: ok (N cells ran)", exit 0. An unattended agent
believed its cells executed when nothing did.

The endpoint now answers 409 with the process state and what to do
about it (grant execution in the browser / reopen with execution
allowed / `restart` for a dead worker); both CLIs surface it as a
non-zero exit.

Verified live: Safe Preview run β†’ 409 + exit 2; after granting
execution β†’ runs, exit 0. test/collab_cli.sh 13/13.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Validate remote installations before launch, fail closed on notebook sync errors, scope terminal tabs per workspace, migrate legacy PlutoSpace names, and tighten collab CLI discovery and argument handling.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix: prevent duplicate terminal paste in Safari

* test: ensure terminal paste avoids clipboard prompt

* docs: describe clipboard fix across browsers
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix: avoid duplicate terminal on refresh

* test: initialize terminal refresh state before mount
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…older (#24)

The sidebar showed two folders and nothing else on a workspace holding a `.venv`. `_workspace_entries`
walked depth-first while spending one shared 2000-entry budget, so the first directory it reached could
spend all of it before any sibling was looked at β€” and the `break` inside a fused
`for want_dir in (true, false), name in names` loop left both loops, cancelling the file pass whenever
directories exhausted the budget. Which folders survived came down to `sort(readdir(...))` being ASCII.

The walk is breadth-first now: every entry of a folder, directories and files, is listed before anything
descends into a subfolder, so shallow entries win the budget and the workspace root is always listed in
full. A folder the walk stopped short of carries a "… not listed" marker rather than looking complete.
Common dependency directories (`.venv`, `__pycache__`, `.tox`, …) join the skiplist.

On top of that the sidebar loads one folder at a time. `GET /api/v1/workspace/listing?path=…` returns a
single folder's entries; `GET /api/v1/workspace` now returns the root listing plus the git branch, with
`?depth=N` still pre-walking for callers that want the whole shape at once; and the hub's 10s poll
re-reads exactly the folders currently expanded. A 14k-entry workspace polls 12KB instead of 760KB and
stays flat as it grows, the six-level depth cap is gone, and a normal tree produces no markers at all.

Two maintainer fixes are squashed in:

- The new confinement check compared `path` against `root * "/"`. On Windows β€” where `tamepath`
  (`abspath`) and the `joinpath` that builds every entry's path produce `\` β€” that answered "outside the
  workspace" for every folder below the root, so nothing could be expanded. It asks `splitpath` now:
  root's components must be a prefix of path's. Base then owns every separator, drive and UNC question,
  `/ws_other` stays outside `/ws`, and a `\` in a unix filename stays an ordinary character.
- The hub's `basename` split on `"/"` alone, so on Windows the tab titles, window title, recent
  workspaces list and the "New file in …/" prompt showed a full path instead of a name. It splits on
  both separators now, like the basename in `components/FilePicker.js`. Pre-existing on main, not
  introduced by this PR.

Tests: `test/WorkspaceTree.jl` is new and wired into `runtests.jl` β€” the regression itself, the
truncation marker, budget exhaustion stopping the walk, the skiplist, the depth limit, `depth=0`
reachability to arbitrary depth, and confinement including a `..` escape, a prefix-sharing sibling and
both path separators. `test/workspace_tree.sh` exercises the endpoints against a real server on an
oversized workspace, following the existing convention for the collab shell tests (run by hand).

Co-authored-by: Dale Black <dalejamesblack@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#26)

`GET /api/v1/browse` answered with bare directory names, so the hub had to join them itself, and it
built the breadcrumb bar by splitting `listing.path` on "/". On Windows a path like `C:\Users\me`
survives that split whole, so the bar rendered one crumb and rejoining put a `/` in front of it β€”
clicking it browsed to `/C:\Users\me`, which does not exist. The folder pills built paths the same
way and worked only because `tamepath` normalizes mixed separators on the way back in.

The endpoint now returns paths that are already joined, and the browser builds none:

- `entries` β€” each subfolder as `{name, path}`, joined with `joinpath` (replaces `dirs`)
- `crumbs` β€” the ancestors of `path`, from `splitpath`, each as `{name, path}`

The crumb separator keys off "is this the first component" rather than `name != "/"`, since the root
component is `C:\` on Windows.

Same reasoning as the confinement fix in #24: the server knows whether a path separates with `/` or
`\` and what sits at the front of it, so it is the side that should take paths apart and put them
back together.

Verified against a running server as well as in unit tests: the browse response carries joined
`entries` and a full `crumbs` chain, a subfolder listing resolves, and both `/etc` and a sibling
sharing the workspace's name prefix are refused with 403.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ort (#28)

* fix: recognize every official Pluto file extension, not just .jl

Pluto Desktop recommends `.plutojl` so that only notebooks get associated
with the app, and `pluto_file_extensions` lists several more (`.pluto.jl`,
`.nb.jl`, `.pljl`, …). SpaceStation only ever looked for `.jl`, so those
notebooks appeared in the workspace sidebar as plain files and opened in
the text editor instead of as notebooks. Closes #27.

The extension list already existed in `src/notebook/path helpers.jl` β€” it
was simply unused. `_is_pluto_notebook_file` now calls the canonical
`endswith_pluto_file_extension` rather than hardcoding `.jl`. The header on
line 1 still decides: an extension only gets a file considered, so an
ordinary `.jl` script stays a file.

Creating a notebook had the same assumption in two places, and there the
server cannot be asked β€” the file does not exist yet:

  - the hub's "new notebook" / "new file in folder" prompts turned
    `notes.plutojl` into `notes.plutojl.jl`, and treated a name ending in
    anything but `.jl` as a plain text file.
  - the editor's rename box offered `notes.plutojl.jl (new)` as a
    completion, since only `.jl` short-circuited its suggestion loop.

Both now share `frontend/common/PlutoFileExtensions.js`. The hub prefers
the list served at `/api/v1/config` so it follows the server it is talking
to, falling back to the bundled copy; a test asserts that copy still equals
the Julia list, so the two cannot drift.

Tests cover every extension in the list, in both directions (header present
=> notebook, header absent => file). They fail on exactly the six non-`.jl`
extensions without the fix.

* feat: show the workspace folder name under the wordmark

Working on several projects at once means several browser windows that all
say "SpaceStation" and nothing else, so telling them apart meant clicking
into one. The sidebar header now carries the workspace's folder name under
the wordmark, with the full path as its tooltip.

`basename(workspace.root)` is the same name the tab title already uses, so
the window title and the header agree. A root with no basename (a drive
root) falls back to the path itself.

The `.workspace-root` rule this uses was already in land.css β€” styled and
never rendered by anything. Only the markup was missing.

Reported by goerz on Discourse.

* feat: let the terminal's colours be switched independently of the page theme

The integrated terminal was dark whatever the page theme was β€” light.css and
dark.css shipped identical `--terminal-bg`/`--terminal-fg` values, both dark.

Rather than derive the terminal's colours from the page theme, this makes them
their own preference: a β˜€/☾ button on the terminal tab strip switches between
light and dark, and the choice is remembered in localStorage next to the other
hub preferences ("spacestation terminal scheme"). A light UI with a dark
terminal is a common setup, so tying the two together would have replaced one
wrong assumption with another.

Dark keeps xterm's default ANSI palette and only sets background/foreground,
so it looks exactly as it always has. Light has to name all sixteen colours β€”
xterm's bright defaults (yellow #fce94f, cyan #34e2e2, white #eeeeec) are close
to unreadable on a pale ground β€” and uses GitHub's light ANSI set.

Switching recolours the running terminal in place: the scheme reaches the
xterm instance through a ref rather than the mount effect's deps, so changing
it never tears down the websocket or the live shell. The ref also covers a
change arriving while the dynamic imports are still in flight.

Verified in a browser: dark -> light -> reload (persisted, applied on a fresh
mount) -> back to dark, with coloured shell output legible in both.

Reported by goerz on Discourse; the button-and-preference shape is Dale's call.

* fix: typeset math and highlight code in Safe preview outputs

Safe preview rendered its outputs through `RawHTMLContainer`, which bailed out
at `if (sanitize_html) return` before reaching `apply_enhanced_markup_features`.
That early return is there to stop notebook-provided <script>s from running and
bonds from being wired up β€” the whole point of Safe preview β€” but the markup
pass is not part of that. It typesets LaTeX, highlights code blocks and adds the
markdown copy button, all over the DOM DOMPurify has already cleaned. So a
notebook in Safe preview showed unhighlighted code, no copy buttons, and math
that had never been typeset.

Math was the least reliable symptom rather than the most: MathJax is set up with
`startup.typeset`, so its own one-shot pass over the document rescues whatever
happens to have rendered by the time it loads. On a dev server pulling MathJax
from a CDN that pass lands late and the math looks fine; where MathJax is served
locally and loads fast β€” the offline bundle β€” it runs before the outputs arrive
over the websocket and the TeX stays raw. Same for anything that re-renders
after startup. Highlighting and copy buttons, having no such fallback, were
broken every time.

This matters more in SpaceStation than upstream because notebooks open lazily
and their cached outputs are restored, so Safe preview is the normal way a
notebook is first seen rather than a rare state.

Measured before/after on a notebook with inline math, display math and a julia
block: highlighted spans 0 -> 2, copy buttons 0 -> 1, mjx containers 2 -> 2.
Granting execution afterwards leaves all three counts unchanged, so the markup
pass is not applied twice.

The new E2E case fails without this change. All four safe_preview tests pass.

Reported by goerz on Discourse; the bug is upstream Pluto's.

* fix: scope the auth cookie by the port the browser used, not the port we bound

Opening a second workspace logged the first one out: every request it made with
its cookie came back 403, and refreshing it did the same to the second. The
report was an SSH remote plus any other workspace, but the cause is not SSH
specific.

Cookies are scoped by host + name + path β€” never by port β€” so `secret_cookie_name`
put the port in the cookie's NAME to keep servers on one host apart. It used the
port we bound. A tunnel is exactly the case where that is not the port the browser
dialled: `ssh -L 45200:127.0.0.1:1234` means the browser says `localhost:45200`
while the server believes it is on `1234`. And since every SpaceStation defaults to
`port_hint = 1234`, the local hub and *every* remote node all bound 1234 and all
claimed the name `pluto_secret_1234` on `localhost`. Whichever page loaded last
overwrote the others' cookie; the rest 403'd until refreshed, which then evicted
this one. Hence the ping-pong.

The name now comes from the `Host` header β€” the authority the browser can actually
name, which is what its cookie jar is keyed on β€” falling back to the bound port
when there is no usable `Host` (non-browser clients, which carry no cookies).
`origin_matches_host` already reads `Host` for the same reason.

Reproduced with two servers both bound to port 7777 (one on 127.0.0.1, one on
::1) with a forwarder standing in for the tunnel, driven through one cookie jar:
before, step 4 and step 7 were 403; after, all eight steps are 200, both cookies
coexist in the jar, and a tampered cookie is still refused.

* fix: give each SSH host a stable local tunnel port

The browser addresses a remote workspace as `http://localhost:<local_port>/`, so
that port is the workspace's identity to an open tab, a bookmark or a reload. It
was handed out with `listenany(45200)` β€” "the first free port at this moment" β€”
which makes it a function of arrival order rather than of the host:

  - reconnect a host after its tunnel died and it could come back on a different
    port, so every tab already open on the old one was dead for good. That is the
    "close the workspace tab and reopen it from homebase" ritual.
  - worse, a host reconnecting while another's port sat idle could inherit that
    port, silently pointing the other host's open tabs at the wrong machine.

A host now gets the same port every time. Unseen hosts start from a hash of the
name so two hosts rarely contend, and the mapping is written to
`tunnel-ports.tsv` (0600, beside the connection files) so it survives a hub
restart β€” the tunnels die with the hub but the tabs do not, and they only need
the port to come back.

A port promised to another host is refused even while it is free, which is
exactly when a newcomer would otherwise take it over. `_stable_hash` is spelled
out rather than using `Base.hash`, which is not promised to be stable across
Julia versions β€” every host would silently move ports on upgrade.

Tests cover: a host keeping its port, two hosts never sharing, a newcomer whose
preferred port belongs to a disconnected host being refused it, and a corrupt
map not taking the tunnel down.

* feat: supervise SSH tunnels so a closed lid recovers on its own

`ssh -N -L` runs as a child process and nothing watched it. With
ServerAliveInterval=15 and ServerAliveCountMax=4 it gives up about a minute
after the network stops answering β€” which is exactly what closing a laptop
does. The remote server itself survives (nohup'd and disowned), so the work is
still there; only the path to it is gone.

`open_remote_session!` already rebuilds a dead tunnel, but only when something
calls it, and the only caller was the Connect button. So waking up meant:
reopen homebase, find the host, click connect, then reopen the workspace tab.

A watchdog now checks every ready session every 5s β€” the ssh child still alive,
and the local port still answering /ping β€” and re-runs the ordinary connect
path when it is not. That path is idempotent and already handles the remote
server having died too, and since the previous commit it lands on the same
local port every time, so a tab left open across the gap starts working again
by itself.

Failures back off 5s β†’ 120s: a node that is off for the weekend must not cost
an SSH round trip every 5s. A session that comes back healthy forgets its
backoff. Sessions the user explicitly disconnected are dropped from the
registry, so they are never resurrected. The state goes to "tunneling" with
"connection lost β€” reconnecting", rather than leaving a stale "ready" on screen
while nothing works.

Tested by standing a socket up on the tunnel port and killing it: healthy
sessions are left alone, a dead one flips state and schedules a retry, and a
second pass is held off by the backoff. Ran 3x to check for flakiness.

* feat: wait out a lost connection instead of erroring, and reattach on startup

Two halves of the same problem: a workspace whose server it cannot reach.

Frontend. The workspace poll turned every failure into a permanent error banner,
including the one that is not an error β€” a rejected fetch, meaning nothing
answered at all. For a remote workspace that is just what a closed laptop looks
like. It now shows a "Reconnecting…" card instead, polls its own origin, and
reloads as soon as anything answers. The reload is deliberate rather than only
clearing the banner: each notebook iframe holds its own dead websocket, and a
reload is the single action that revives all of them. A response that arrives and
is bad is still reported as an error, as before.

It also probes on `focus`, `visibilitychange` and `online` rather than waiting out
the 10s poll, so reopening the laptop reconnects at once β€” coming back from sleep
does not reliably fire `online` on its own.

Backend. The set of hosts you are attached to is now remembered in
`active-remotes.tsv` (0600) and reattached on startup, each landing on its stable
port from the previous commit. Without this, a hub restart left every tunnel down
until you reconnected by hand β€” and a hard refresh in a tab left open across a
reboot hits the browser's own "site can't be reached", where none of our code
runs. Restoring is in the grain of the existing design: the remote servers are
deliberately left running when the hub goes away, so this only reopens the door to
work that is still there. Explicit disconnects are forgotten, so they never come
back; restores are capped at 8 hosts and fail quietly, with the watchdog retrying.

Verified in a browser against a server behind a stand-in tunnel: killing the
tunnel raises the card and no error banner, and restarting it makes the page
reload itself and stay usable.

* feat: hold the workspace port while the tunnel is down, so a reload is never a dead end

The previous commits let a workspace tab wait out a lost connection, but only if
the tab was still loaded. Reload it while the tunnel is down β€” a hard refresh,
reopening the browser, restoring a session β€” and `ssh -L` no longer owned the
port, nothing answered, and the browser showed its own "this site can't be
reached". None of our code runs in that page, so the tab could do nothing for
itself; the only way out was to reopen the workspace from homebase.

While a tunnel is down the hub now takes the port over and answers 503 with a
page that names the host, says the work is still running on it, and reloads once
the real server is back. A hard refresh therefore lands on our page. The port is
taken the moment the watchdog sees the tunnel is dead, released just before
`ssh -L` binds it again, and also held when a reconnect attempt fails outright β€”
so the window in which nothing answers is as small as we can make it.

Two details that are load-bearing:

  - The status stays 503. `_local_ping_ok` treats only 200 as healthy, so a
    placeholder that looked healthy would convince the watchdog there was nothing
    to fix and the reconnect would never run again.
  - The response sets Content-Length. Left to stream chunked, Chrome aborts the
    navigation (net::ERR_ABORTED) and shows its error page anyway β€” defeating the
    whole point. curl accepts the chunked response, so this only reproduces in a
    real browser; found by driving it with puppeteer.

Releasing the port before choosing it also matters: `stable_tunnel_port` would
otherwise see our own placeholder as an occupied port and move the host
somewhere else, losing the stability the tab depends on.

Verified end to end: with the tunnel down, a hard load returns 503 and our
"Reconnecting to <host>…" page; restoring the tunnel makes it reload itself back
into the live workspace.

* feat: name the culprit when a duplicate ssh_config entry shadows the live one

OpenSSH uses the FIRST value it finds for each keyword across every block that
matches a host. Tools that write a config entry per compute job β€” HPC3 Launcher,
and most SLURM helpers β€” append a new block each time a node is reallocated, so
the alias ends up defined several times and the OLDEST definition is the one in
force. Connections then go through a jump host whose job finished weeks ago.

The alias still resolves, so ssh does not complain; it just fails to connect,
looking exactly like "your keys are wrong" or "the hop is slow". Both of the
messages we showed for that sent the user to look at keys or at the network,
when the answer was three lines up in a file we could have read.

A failed first contact now appends what we can establish, e.g.

  Your ~/.ssh/config defines `hpc3-gpu-m54-01` 8 times with different ProxyJump
  values. SSH uses the FIRST one it finds, so this connection went through
  `hpc_login_53624817`, while the last block names `hpc_login_55364482`. …

The effective value comes from `ssh -G`, which resolves the config without
connecting β€” far safer than re-implementing Match/Include/wildcard precedence.
It stays silent unless the situation is unambiguous AND actionable: several
blocks, disagreeing ProxyJumps, and ssh landing on one that is not the last
written. If ssh already resolves to the newest block the duplication is harmless
today, and if the effective value came from somewhere we did not read we do not
understand the file well enough to advise on it.

Diagnosis only. Editing somebody's ~/.ssh/config is not ours to do.

Checked against a real config that had the bug: it names the right hosts, the
right jump, and the right replacement, and says nothing about a host with one
block. Checked against the same config after cleanup: no false positives.

* perf: halve the tunnel watchdog period to 2s

This poll is the only thing that notices the failure that actually happens. A
sleeping laptop freezes the ssh child rather than killing it, so on wake it is
alive with dead TCP and can sit for up to a minute β€” ServerAliveInterval 15 x
ServerAliveCountMax 4 β€” accepting connections and resetting them instantly.
Watching for the process to exit would therefore be watching for a signal that
arrives long after it is useful, which is why that idea was dropped in favour of
simply looking more often.

Until we notice, a reload gets a browser error whichever way the tunnel died
(measured: an unbound port refuses in 0.3ms, a bound one whose upstream is gone
resets in 1ms β€” both instant, neither ours). So the period is the exposure, and
halving it halves the worst case.

It costs nothing. The probe is a local connect and a dead one comes back in
about a millisecond, and iterations run one after another, so a slow probe
delays the next tick rather than piling up.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…#32)

Switching between terminal tabs left the previous terminal with its bottom
rows cut off and a dead scrollbar until the panel width was dragged.

Root cause: inactive terminal bodies are hidden with display:none while
their shells keep streaming output. xterm's Viewport keeps syncing its
scroll geometry against the hidden element β€” offsetHeight measures 0, the
browser clamps scrollTop writes to 0 and drops the element's scroll
position outright. On reveal, FitAddon.fit() is a no-op when the proposed
rows/cols are unchanged (always, on a plain tab switch β€” every tab shares
the same panel), so nothing repaired the corrupt state. A width drag
changed the column count, forcing a real resize whose _afterResize
re-syncs the viewport β€” which is why it "fixed" the tab.

Fix, at the single refit() choke point so it covers tab switches, the
terminal-as-editor-tab pane, and the closed panel alike: record the view's
position (lines above the bottom) when a terminal goes hidden, and on
reveal restore it, force viewport.syncScrollArea(true), and repaint β€”
exactly what a real resize does internally. The _core.viewport reach-
through is version-safe: the xterm import is pinned to 5.5.0 and FitAddon
itself uses _core the same way.


Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…e installers (#34)

* feat: experimental Deno desktop shell (desktop/)

A native desktop app for SpaceStation built on Deno Desktop (deno >= 2.9):
a thin shell that boots the Julia server and shows it in a native window.

- desktop/boot.ts: Julia discovery (juliaup first; SPACESTATION_JULIA
  override), project resolution (SPACESTATION_PROJECT -> source checkout ->
  managed env bootstrapped from the General registry on first run), free
  port pick, readiness via /ping, access secret from SpaceStation's
  connection file, graceful SIGTERM shutdown.
- desktop/splash.ts + main.ts: boot splash served by Deno.serve (the
  desktop runtime wires it to the startup window), live log tail, then
  navigate to the secret URL; errors return the window to the splash.
- desktop/smoke.ts: headless end-to-end check (no window) - passes against
  the real server: boot -> ready -> secret -> hub 200 -> desktop:true ->
  clean shutdown.
- New SPACESTATION_DESKTOP flag: /api/v1/config serves desktop:true; the
  hub opens workspaces in-place (one webview window, no browser tabs -
  same mechanism as tunneled servers) while keeping the SSH sections
  visible. Home button returns in-place likewise.
- deno.json tasks: dev (HMR), smoke, build/.dmg/.msi-dir/.AppImage
  targets; .app bundle build verified on macOS arm64.

Experimental: not wired into releases; no icon; unsigned; webview
clipboard/download quirks untested. See desktop/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: keep desktop-mode workspaces inside the app window

Launching the built .app opened workspaces in the system browser, for two
reasons:

1. The compiled app fell through to the managed environment and installed
   SpaceStation from the General registry β€” a release that predates the
   desktop flag entirely, so the old hub spawned child-server tabs which
   the webview routed to the default browser. Builds now bake buildinfo
   (gen_buildinfo.ts, restored after): the checkout path β€” used directly
   when it exists on the running machine β€” and the git url+rev, which the
   managed environment installs pinned (a marker file upgrades a stale
   env instead of trusting `import`).

2. The hub had remaining new-tab escapes in desktop mode: running-
   workspace cards and SSH ready pills (target="_blank" + homebase
   fragment) and the connect_local/connect_remote auto-open
   (window.open). All now navigate in place under the desktop flag, read
   through a ref in the polls so a mid-poll flag flip is honoured. The
   shell also appends ?desktop=1 to the URL so the hub knows it is inside
   the desktop synchronously, closing the pre-config-fetch race.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: desktop workspaces run as real child servers; harden webview msgpack reads

Three issues from desktop testing:

1. Returning Home showed the just-opened workspace as not running. Root
   cause: desktop mode opened workspaces in-place on the main server, so
   nothing was registered as a running child β€” the launcher's live 3s
   poll was correct, not stale. Desktop now uses the same child-server
   model as the browser: connect_local spawns the workspace, the single
   window NAVIGATES to it (homebase fragment kept as the way back,
   ?desktop=1 appended), Home navigates back to the launcher in place,
   and the workspace stays alive on the Running Workspaces list.

2. "Failed to unpack message NotReadableError" opening a notebook: the
   editor websocket received binary frames as Blobs, and WKWebView's
   file-backed Blob reads can fail. The socket now uses
   binaryType = "arraybuffer" (skips the Blob I/O and a copy on every
   platform).

3. Native menu: Edit roles so macOS routes clipboard shortcuts into the
   webview, plus View > Reload (Cmd+R) and Back to Launcher (Cmd+Shift+L)
   as conveniences β€” nothing requires them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* feat: deck β€” Warp-style tabs for the desktop shell

The desktop window now shows the deck: a tab strip in the title-bar area
(transparentTitlebar) with the Launcher pinned first and each workspace
as a tab, every tab a live iframe served from the shell's /deck page.

This replaces flag-delivered desktop detection, which proved fragile: a
reattached child server spawned by an earlier browser session had no
SPACESTATION_DESKTOP env, and ?desktop=1 didn't survive the auth
redirect β€” so its hub ran browser paths and window.open escaped to the
system browser. Hub pages now detect desktop mode STRUCTURALLY (framed
by the deck: window.self !== window.top) and drive the deck over
postMessage: workspaces open as tabs (deduped per server), Home focuses
the Launcher tab, closing a tab leaves the workspace running. Tabs
survive Cmd+R via sessionStorage; iframes stay alive across switches.
Frames carry clipboard-read/write allowances for the terminal.

Menu "Back to Launcher" now focuses the deck's Launcher tab (executeJs),
falling back to navigating to /deck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* feat: app icon, one-surface header, OS-standard menu only

- App icon: the SpaceStation planets mark rasterized from
  frontend/img/spacestation.svg at 1024px -> icons/spacestation.icns
  (iconutil, all sizes + @2x) for macOS and the 1024 PNG for Linux,
  wired via desktop.app.icons. Windows .ico still TODO.
- Header: empty window title + transparentTitlebar so the native bar
  blends into the deck - one dark surface with the traffic lights top-
  left and the tabs directly under. Exact Warp-style (tabs ON the
  traffic-light row) is not achievable yet: measured innerHeight is
  identical with and without transparentTitlebar, so content never
  extends under the bar, and frameless drops the native chrome;
  documented in the README to revisit as the API grows.
- Removed the deck's "+" tab button (workspaces are added and removed
  from the Launcher tab) and the View menu items with their
  accelerators: no app-specific shortcuts - OS-standard roles only
  (Edit roles stay: macOS routes clipboard shortcuts through the menu).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: full-bleed app icon; tighter deck header

The .icns was built from a letterboxed SVG render (the SVG's intrinsic
100x100 size kept qlmanage from scaling, leaving the mark in the top-
left quarter of a padded canvas) β€” the dock showed a white square with
a tiny logo. Rasterize with width/height forced to 1024 so the mark
fills the frame, and rebuild all iconset sizes from it.

Tabs at the exact traffic-light level remain impossible in current Deno
Desktop: the webview host sets titlebarAppearsTransparent and
titleVisibility but never NSWindowStyleMaskFullSizeContentView, so web
content cannot extend under the 28px bar (and frameless drops the
native chrome). Shrink the strip to 30px flush under it so the header
stays one tight dark surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* feat: Warp-style header via AppKit FFI; proper dark-tile app icon

Tabs now sit ON the traffic-light row. Deno Desktop never sets
NSWindowStyleMaskFullSizeContentView, so the shell sets it itself via
libobjc FFI (macos_titlebar.ts). AppKit is main-thread-only and our JS
runs elsewhere β€” a direct setStyleMask: SIGTRAPs β€” so the mutation rides
a pure-ObjC trampoline: performSelectorOnMainThread with
setValuesForKeysWithDictionary, KVC unboxing NSNumbers into the scalar
setters on the main thread. Verified: styleMask reads back 0x800f and
the WKWebView (contentView) fills the whole window frame. On success
the shell opens /deck?inset=1 and the strip lays out on the title-bar
row (84px for the traffic lights); otherwise the compact below-bar
layout stays. Non-mac and any failure fall through cleanly.

App icon rebuilt as a macOS-style tile: dark rounded square (#16141f,
matching the app's surfaces) with the planets mark inlined as VECTOR
into the tile SVG (referencing the PNG composited it on white) and
centered at uniform scale β€” fixes the washed-out look and the aspect.
Source SVG kept at icons/spacestation-icon.svg.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: deck tab same-site auth, tab centering, halo-free icon

- Workspace tabs 403'd on every API call: the servers' auth cookie is
  SameSite=Strict and the child/tunnel URLs use "localhost" while the
  deck lives on 127.0.0.1 β€” different SITES, so the iframe loaded via
  ?secret but the cookie was withheld. open_tab now normalizes loopback
  hostnames to the deck's own, keeping every tab same-site (the same
  reason the Launcher tab always worked).
- Inset tab strip is now 28px β€” the native title-bar height β€” so the
  pills center on the traffic lights' row.
- Icon: rendered via AppKit (CoreSVG) instead of qlmanage, whose
  thumbnailer blended the tile edge toward white β€” that was the halo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: fullscreen auto-hiding tab strip; Apple-grid icon proportions

- Fullscreen: the deck strip now hides with the native chrome and
  slides down when the cursor hits a 6px hot zone at the top (a fixed
  overlay β€” mouse moves inside the iframes never reach the deck
  document), mirroring the macOS menu-bar gesture. Fullscreen is
  detected as window == screen size, which holds because content
  extends under the title bar.
- Icon: the tile filled 94% of the canvas; Apple's icon grid is an
  ~824px squircle centered in 1024, and macOS force-masks
  non-conforming icons β€” that mismatch was the "warped" dock look.
  Rebuilt on the official grid (824px, rx 185, 100px margins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: detect fullscreen via the NSWindow style bit (notched Macs)

The size heuristic (window == screen) never held on notched MacBooks:
fullscreen there is the screen MINUS the notch band, indistinguishable
from a zoomed window β€” so the strip never auto-hid. The shell now
exposes /fullscreen, reading NSWindowStyleMaskFullScreen over the same
libobjc channel (property reads are safe off the main thread), and the
deck asks on every resize, keeping the exact-size check as the
fallback for plain displays and other platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: poll fullscreen state instead of trusting webview resize events

The NSWindow fullscreen-bit read is verified correct (probed: mask
0x400f during fullscreen, titled bit kept), but the deck only asked on
webview resize events, whose timing around the fullscreen transition
proved unreliable. The deck now polls /fullscreen every 1.5s (plus on
resize for responsiveness) β€” a local GET, effectively free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: tab strip rides the native fullscreen overlay as one chrome

The native overlay (menu bar + title-bar band on hover at the screen
top) and our strip revealed independently β€” and worse, the overlay is a
system window that steals the mouse, so its appearance fired our strip's
mouseleave and hid the tabs exactly when the native chrome showed.

The shell now measures the overlay itself: it's a borderless full-width
system window stacked over ours, so its frame yields both visibility
and how far it has slid down (overlay_px). /fullscreen returns that,
the deck polls it (250ms while fullscreen), and the strip reveals WITH
the overlay, positioned right below its bottom edge, retracting with
it. The in-window hover zone stays as the secondary trigger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: identify the fullscreen overlay by class, not geometry

The loose geometry match (any visible borderless full-width window)
latched onto an unrelated helper window and pinned the strip revealed
mid-content. Probing shows the toolbar overlay window doesn't even
exist in the window list until it slides out β€” so require the class
name (NSToolbarFullScreenWindow family, matched on "FullScreen") plus
visibility and alpha. No match degrades to hover-only reveal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: keep the deck strip always visible β€” no fullscreen auto-hide

Auto-hiding the strip in sync with the native fullscreen overlay broke
more ways than it worked (notch geometry, the overlay stealing the
mouse and firing our mouseleave, unrelated helper windows fooling the
window-list detection). Removed entirely: the strip is fixed chrome at
the top, identical windowed and fullscreen. Also removes the
/fullscreen endpoint and the overlay-tracking FFI, leaving
macos_titlebar.ts with just the under-titlebar extension that works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* feat: Launch Station β€” Julia version picker, one-click installs, don't-ask preference

The desktop shell now handles every Julia situation on launch:

- Power users: a picker (styled as the SpaceStation launcher β€” same
  card/pills/headings/logo lifted from land.css and themes/dark.css)
  lists every installed juliaup channel with version and default badge,
  preselecting the saved or default channel; Enter or one click boots
  `julia +channel`. juliaup state is read from its juliaup.json
  metadata, never parsed from CLI output.
- One-click installs: curated channels (release, lts, 1.12, 1.11, 1.10)
  not yet installed run `juliaup add` first, streaming into the splash
  log.
- Complete newbies: no Julia at all shows a single "Install Julia"
  button that bootstraps juliaup via the official installer script
  (macOS/Linux; Windows points at julialang.org for now), then boots.
- VS Code-style opt-out: "Always use this version β€” don't ask at
  launch" persists in app-data settings.json; future launches boot
  straight in. SpaceStation β†’ "Julia Version…" (plain menu item, no
  accelerator) reopens the picker; switching stops the old server and
  boots the new Julia, with a restart warning and a Cancel back to the
  deck when a server is already running.

Shell plumbing: julia.ts (settings + juliaup metadata + detection),
theme.ts (shared shell-page styling + inlined planets mark β€” the splash
now matches too), boot.ts grows idle/installing-julia phases and
BootOptions {channel, add_channel, bootstrap}, splash.ts becomes the
routed UI server (/launch, /api/julia, /api/julia/launch), and main.ts
swaps server instances on version switch behind stable closures.

Verified: julia +channel selects correctly, /api/julia payload renders
all channels, headless smoke passes end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* feat: Launch Station shows the real juliaup catalog β€” updates included

The picker previously offered a tiny curated list; now it reflects what
juliaup actually knows (`juliaup list`, tolerantly parsed and cached β€”
the one juliaup datum without a metadata file):

- Installed channels show pending updates ("1.12.6 β†’ 1.12.7") with an
  "update to X" badge-button: the row alone launches what you have, the
  badge runs `juliaup update <channel>` first (progress on the splash).
- "Get another version" offers the alias channels (release, lts, beta,
  rc, nightly) and the six most recent minor channels not installed,
  plus a free-text box with autocomplete over every concrete version
  (1.6+, ~100 entries) β€” typing "1.12.7" installs and launches exactly
  that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* feat: app-wide light/dark/auto toggle (hub + editors, browser + desktop)

One appearance choice β€” system / light / dark β€” now themes the whole
app. Pluto's themes are @media (prefers-color-scheme) blocks, which no
class can override, so common/ColorScheme.js rewrites those rules'
media conditions via CSSOM (all / not all / original), remembering each
rule's original identity in a WeakMap so the cycle round-trips. The
choice persists in localStorage and propagates LIVE to every
same-origin page via storage events: notebook editor iframes inside the
hub, standalone editor tabs, and the welcome page (side-effect import
in editor.js / index.js).

The toggle (◐ / β˜€ / ☾) sits beside the terminal controls in the
workspace tab strip and in the launcher card's corner. The integrated
terminal's own scheme toggle stays deliberately separate β€” a light UI
with a dark terminal is a legitimate preference. JS-side dark decisions
(CodeMirror dark flags in CellInput, FilePicker, ProjectTomlEditor, the
hub file editor) now route through prefers_dark(), which honours the
override before the OS preference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* ci: cross-platform desktop testing, packaging, signing, release artifacts

.github/workflows/Desktop.yml:

- test matrix (macOS / Windows / Linux): typecheck the shell, then the
  headless smoke test boots the REAL stack on each OS β€” Julia, the
  SpaceStation server, /ping readiness, the connection-file secret, the
  authenticated hub page, desktop config, clean shutdown. Trust comes
  from booting the whole thing on every platform, not from stubs.
- build matrix: .app (arm64 + intel), Windows directory bundle, Linux
  AppImage; uploaded as CI artifacts on every run and attached to the
  GitHub release when one is published β€” so release-please releases
  ship desktop downloads automatically once this branch lands on main.
- macOS signing + notarization run when the repo has the Developer ID
  secrets (documented in desktop/README.md), and skip cleanly
  otherwise (ad-hoc-signed bundles). Windows Authenticode is TODO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: appearance toggle lives on the launcher only, applies to all workspaces

The workspace tab strip had the app-wide toggle sitting next to the
terminal's own scheme toggle β€” two competing controls. The app toggle
now lives ONLY on the launcher card, and its choice reaches every
workspace immediately despite workspaces being separate origins (their
own ports, so localStorage can't carry it): the desktop deck
rebroadcasts a color-scheme message to every tab (and to tabs opened
later), and workspace links carry a ?scheme= seed for browser tabs.
ColorScheme.js accepts both (message listener + URL seed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* feat: bundle juliaup's portable build β€” installer-free Julia bootstrap

The bundle now ships juliaup's PORTABLE build (a static Rust pair:
juliaup + the julialauncher shim named `julia`) for its target,
fetched at build time (vendor_juliaup.ts, pinned to juliaup 1.22.2,
musl for Linux) and materialized into the app-data dir on first use
(executables can't run from the compiled VFS). Consequences:

- A machine with NO Julia needs no installer script: "Install Julia"
  is now `vendored juliaup add release` β€” Windows included (the
  previous Windows TODO is gone; the curl|sh script remains only as a
  fallback for bundles built without vendor/).
- Channel selection works without any user-installed juliaup: the
  vendored launcher resolves +channel against ~/.julia/juliaup.
- juliaup_bin() search: ~/.juliaup, brew paths, then vendored.

Also fixes the Windows CI build failure: import.meta URL .pathname
yields "/D:/…" on Windows, which git/tar reject β€” gen_buildinfo.ts,
vendor_juliaup.ts, and boot.ts's checkout detection now use the
OS-native import.meta.dirname.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* feat: desktop's managed install also registers the spacestation CLI

The managed bootstrap now runs Pkg.Apps.add alongside Pkg.add (same
source spec: pinned git rev or registry), registering the real
`spacestation` executable in ~/.julia/bin per Project.toml's [apps] β€”
so desktop users get the CLI for free. Best-effort (try/catch): the
app never depends on it. The reverse stays deliberately one-way:
Pkg.Apps.add installs only the CLI, never the desktop shell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* feat: release installers (.dmg/.msi/.AppImage) with install guide; README desktop section

Borrowing the patterns proven by MolloiLab/hpc3-launcher:

- Desktop.yml now builds real installers β€” SpaceStation-mac-arm64.dmg,
  mac-x64.dmg, win-x64.msi, linux-x64.AppImage β€” attaches them to
  published releases (workflow now has contents: write), and a
  release-notes job appends an install guide to the release body: the
  per-platform download table plus the one-time unsigned-build
  workarounds (macOS xattr -dr com.apple.quarantine / Privacy &
  Security "Open Anyway"; Windows SmartScreen "More info β†’ Run anyway";
  Linux chmod +x) until signing credentials exist.
- Main README gains the desktop-app section under Install & run: the
  download table, the same first-launch steps, the no-Julia-required
  pitch, and the one-way CLI note.
- New deno tasks build:dmg-intel and build:msi. --compress dropped from
  dmg builds: it appends a self-extracting payload that fails codesign,
  and dmg compresses internally anyway. Verified: the arm64 .dmg builds
  locally (36MB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: CLI on PATH after desktop install; theme override reaches late stylesheets

- Downloaded-app installs now finish the CLI job: after the server is
  ready, the shell idempotently adds ~/.julia/bin to the user's shells β€”
  a guarded export line in .zshrc/.bashrc/.profile (skipped per-file
  when anything already references .julia/bin), or the User PATH
  registry value on Windows via [Environment]::SetEnvironmentVariable
  (never setx, which truncates and flattens). Pkg.Apps installs the
  spacestation shim but only prints a PATH hint; now new terminals just
  work.
- The light/dark override missed stylesheets that arrive AFTER page
  load β€” notebook cell outputs inject their own <style> tags with their
  own prefers-color-scheme blocks (PlutoUI's TableOfContents being the
  visible case: dark panel on a light page). A MutationObserver now
  re-applies the override (idempotent via the rule WeakMap) whenever
  style/link nodes enter the document, including link loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: pin the UA color-scheme and shadow-root styles to the theme override

Full inspection (puppeteer against the live editor: dark OS emulated,
light override forced) found the real root cause of the washed-out
logs, invisible bottom-bar labels, and dark ToC: the CSSOM media
rewrite governs AUTHOR styles only β€” the page still declared dark
support, so the UA rendered ITS side (default text color, form
controls) for the dark OS: white UA text on light author backgrounds.
Fix: apply() now also sets the color-scheme property on :root, which
overrides the meta and pins UA defaults to the override.

Also: the rewrite walks shadow roots and adopted stylesheets (one
component sheet was unreachable before), and the stylesheet
MutationObserver narrows added nodes via instanceof Element (fixes the
checkJs errors from the previous commit).

Verified end-to-end with the inspection harness: zero unreached
prefers-color-scheme rules; body/logs/bottom-bar compute to
black-on-white under forced light on a dark OS; screenshot eyeballed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

* fix: narrow TreeWalker nodes to Element for checkJs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
The Desktop workflow's `release: published` trigger can never fire:
release-please creates releases with GITHUB_TOKEN (no PAT configured),
and events created with GITHUB_TOKEN don't trigger other workflows β€”
v0.4.0 published with no installers attached. Explicit
workflow_dispatch API calls from GITHUB_TOKEN ARE allowed, so the
ReleasePlease workflow now dispatches Desktop.yml with the new tag
(needs actions: write), and Desktop.yml accepts a release_tag input
that routes the attach + release-notes steps to that release. The
input also allows manual backfills (v0.4.0 first).


Claude-Session: https://claude.ai/code/session_01H5PgyMtD9J7h8pmm5DvcWZ

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…name (#37)

deno desktop derives the bundle name from the -o filename, so building
straight to the release asset name produced SpaceStation-mac-arm64.app
inside the dmg β€” users dragged that into /Applications and got an app
named SpaceStation-mac-arm64 in Launchpad. Same for the msi product and
the AppImage.

Builds now always emit the clean product name (SpaceStation.dmg / .msi /
.AppImage) and CI renames the FILE afterwards for the platform-suffixed
release asset, so the installed application is SpaceStation everywhere.

Verified locally: dist/SpaceStation.dmg mounts to SpaceStation.app plus
the Applications symlink.

Also adds a hard rule to .claude/CLAUDE.md: no AI attribution (session
links, Co-Authored-By, generated-with footers) in commits, PRs,
comments, or release notes.
ReleasePlease dispatches the Desktop workflow at the same moment the
release commit lands on main, so the dispatched run and the ordinary
push run shared the concurrency group desktop-<ref> and cancelled each
other β€” which is how v0.4.0's installer backfill died. The group now
includes the release tag, so release builds never race CI builds.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…#40)

The first-launch commands were inline code inside a table cell, so the
xattr quarantine command had no copy button and wrapped badly. The
release-notes job now emits a proper install section: a plain download
table, then per-OS steps with fenced code blocks (which GitHub renders
with a copy button), plus a collapsible no-Terminal alternative for
macOS.

The job also REPLACES a previously appended guide instead of skipping
when one exists, so wording fixes reach releases that already have one.
README updated to match.
Clicking "update to X" changed nothing visible β€” it set internal state
and left the button reading "Launch", so there was no way to tell
whether it had registered or what pressing Launch would now do.

Selection now runs through one function that owns the row highlight,
the badge's active state and the button's label together, so they can
never disagree: picking an update fills the badge and the button reads
"Update & Launch"; picking an install reads "Install & Launch";
clicking a plain row clears both back to "Launch". The badge also
carries a tooltip naming the exact juliaup command.

The underlying action was already correct (juliaup update <channel>,
streamed to the splash) β€” only the feedback was missing.

Verified headlessly: label Launch -> Update & Launch on badge click,
exactly one active badge, and state clears when another row is picked.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
#43)

A downloaded build reports "SpaceStation is damaged and can't be
opened" β€” macOS's wording for any app not signed with a paid Developer
ID. Clearing the download quarantine alone was not enough on Apple
Silicon; the app also has to be re-signed locally (ad-hoc) before it
will launch, which is what actually clears the dialog.

The macOS steps in the release-notes guide and the README now give both
commands, explain that nothing is really damaged, say not to run the app
from the mounted disk image, and note that both steps disappear once
releases are notarized. Dropped the right-click/Open Anyway fallback: it
does not work for the "damaged" case, so offering it just wastes a
user's time.
The Windows app started and then did nothing: no window, no taskbar entry, and a
process that never exited, so every click left another one behind (issue #55).

WebView2 defaults its user-data folder to `<exe>.WebView2`, beside the binary. The
MSI installs into %ProgramFiles%\SpaceStation and deno desktop hard-codes
ProgramFiles64Folder with no per-user option, so on a standard account that path is
read-only: creating the WebView2 environment failed, the window was never created,
and nothing treated that as fatal. Microsoft documents the case β€” an unpackaged app
in a protected install directory must name its own user-data folder.

desktop/webview2.ts now points WEBVIEW2_USER_DATA_FOLDER at
%LOCALAPPDATA%\SpaceStation\WebView2 before the first window exists. That reaches the
loader because our code runs inside the webview host process: the app executable IS
the laufey_webview host, which loads the Deno runtime and only then runs main.ts,
while the WebView2 environment is created later on the first BrowserWindow.

Also, so this cannot recur silently:

- main.ts gets a liveness watchdog. The runtime navigates the startup window to the
  shell's own server, so a healthy webview fetches a page within seconds; if nothing
  ever arrives the shell now says why and exits non-zero instead of idling forever
  behind Deno.serve, which is what produced the pile of invisible processes.
- window_smoke.ts is a headed companion to smoke.ts: it opens a real window and makes
  the webview prove it rendered by fetching a beacon. The window layer had never once
  been executed in CI, which is why this shipped.
- Desktop.yml runs that headed smoke on macOS and Windows, and now installs the .msi
  it just built and launches it. Because GitHub's Windows runners are administrators
  with UAC disabled, Program Files is writable there and the bug would not reproduce
  on its own, so the step revokes its own write access to the install directory first
  and then asserts the profile landed under LOCALAPPDATA and not beside the binary.

Two smaller Windows bugs found along the way:

- home_dir() preferred HOME over USERPROFILE. Git Bash/MSYS set HOME to somewhere
  Julia's homedir() never looks, and the two must agree β€” the server writes its
  connection file under homedir() and boot.ts reads the access secret back out of it.
- juliaup_info() split JULIA_DEPOT_PATH on ":", which severs the drive letter on
  Windows and turned "C:\Users\me\.julia" into "C".

build:win passes --icon because deno decodes desktop.app.icons.macos (.icns) even
when targeting Windows and fails the build; that task was broken. The .ico is added
and declared, though deno 2.9.5 embeds no Windows icon yet.
@Dale-Black

Copy link
Copy Markdown
Author

Opened against the wrong repository by mistake β€” this targets the GroupTherapyOrg/SpaceStation.jl fork, not Pluto.jl. Closing; sorry for the noise.

@Dale-Black Dale-Black closed this Aug 27, 2026
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.

Reload browser after pluto restart is confusing

3 participants