Skip to content

fix: give Windows its own font list, link opener and shell quoting - #125

Merged
omartelo merged 2 commits into
mainfrom
fix/windows-fonts-and-links
Jul 28, 2026
Merged

omartelo merged 2 commits into
mainfrom
fix/windows-fonts-and-links

Conversation

@omartelo

@omartelo omartelo commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator

Four Windows bugs in two services, all from the same habit: code that composes a
command line assuming Linux. The first two showed up as WARN lines in a
Windows lich.log while chasing #124; looking for the third turned up the
fourth.

msg="rpc: call failed" method=fonts.List err="fc-list failed: exec: \"fc-list\": executable file not found in %PATH%"
msg="rpc: call failed" method=system.OpenExternal err="exec: \"xdg-open\": executable file not found in %PATH%"

1 — Fonts

fonts.List shelled out to fc-list, so on Windows Settings › Font offered
only the bundled default and whatever was already selected.

Windows now enumerates SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts
under both HKLM and HKCU — the same database DirectWrite, and so Chromium,
resolves against — via golang.org/x/sys/windows/registry (already a direct
dependency; go.mod is untouched). A missing per-user key is an empty list, not
an error.

Registry value names are <family>[ <face>] (<format>), and one entry may carry
several families joined by &. The parsing folds a family's faces onto one
entry so the picker offers Arial rather than four Arials. The face-word list
is deliberately short — Black, Light and Semibold are absent, because
Arial Black and Segoe UI Light are families in their own right. The ceiling
is named in the code: a family shipping no regular face is offered under its
trimmed name, and the terminal falls back to the default when Chromium cannot
resolve it.

2 — Links

OpenExternal hardcoded xdg-open even though internal/system already had a
per-OS seam (openDefault, for files). It now goes through that seam, which
gains an openURL alongside.

macOS needed its own: openDefault is open -t, which forces the text
editor
— right for a source file, wrong for a link.

Windows uses rundll32 url.dll,FileProtocolHandler rather than the cmd /c start used for files. cmd parses metacharacters out of its command line
before start sees them, so https://x/?a=1&b=2 would end the command at the
& and run b=2.

3 — A filename could run a command

That same cmd /c start carried the file path in openDefault, and there the
metacharacter is not ours — it is whatever the file is called.

OpenInEditor falls back to openDefault when neither $VISUAL nor $EDITOR
is set. Its only caller is the FilesPanel, which lists git status for the
checkout — and checking out someone else's branch is what lich is for. Go's
syscall.EscapeArg quotes an argument only when it holds a space, tab or quote,
so & went through bare and cmd cut the command there:

cmd /c start "" C:\repo\a&calc.txt     ->  start "" C:\repo\a  &  calc

relpath.Validate does not close this — it rejects rooted paths and traversal,
which is all it claims to do.

Windows now opens the file with explorer, which takes the path as a plain
argument with no shell in between. Not the rundll32 used for URLs above:
FileProtocolHandler URL-decodes, so a file named a%20b.txt would open the
wrong path.

4 — Every Windows path with a space was broken anyway

Looking for #3's blast radius turned this up. The terminal-editor branch of
OpenInEditor builds a command line for the session's shell and quoted it
with shquote, whose own package doc says:

It assumes an sh/bash/zsh-style shell; cmd.exe (experimental Windows) quotes
differently, which is why the setup wrapper skips Windows entirely.

The setup wrapper skips Windows. This call site did not. cmd.exe does not strip
single quotes, so with EDITOR=nvim a Windows session was pasted:

nvim 'C:\Users\First Last\repo\a.txt'

and nvim opened 'C:\Users\First. No attacker needed — most Windows home
directories have a space in them.

Quoting now goes through the same per-OS seam as the openers: shquote on
Unix, double quotes on Windows. cmd cannot express three cases, and they are
refused rather than half-escaped — a " (nothing puts one back inside a quoted
run), a % (expanded inside quotes too), and a trailing backslash (escapes the
closing quote for the target's own argv parser). A refused path opens with the
default handler, which after #3 is shell-free.

Shape

Pure rules — the registry value parsing, the cmd quoting — live in the untagged
files and are tested on every OS, following windowsBrowserCandidates in
internal/chromium. Only the registry read and the launcher choice are per-OS.

Test plan

  • gofmt -l . and go vet ./... clean
  • go test ./... — 536 pass
  • Cross-compile loop GOOS=linux|darwin|windows — go build + go vet clean
  • biome check, vitest run (501 pass) — frontend untouched, run for the gate
  • New tests: TestParseRegistryFamilies, TestTrimFontFacesKeepsALoneFaceWord,
    TestOpenExternalPassesURLAsOneArgument, TestQuoteCmdPath,
    TestOpenDefaultKeepsThePathOutOfAShell
  • ci:os — TestListSmoke is the real registry read and can no longer skip
    on Windows; TestOpenInEditorTerminalReturnsCommand and
    TestOpenInEditorFallsBackWhenTheShellCannotQuote assert the cmd.exe branch
    there
  • Manual on Windows: Settings › Font lists installed families; a
    pull-request link opens in the browser; a file under a path with a space
    opens in nvim; a file named a&calc.txt opens instead of running calc

🤖 Generated with Claude Code

Two services reached for Linux binaries on every platform.

fonts.List shelled out to fc-list, which Windows has no equivalent of, so the
font picker offered only the bundled default and the current selection. Windows
now enumerates the Fonts registry key, machine-wide and per-user, folding a
family's faces onto one entry. The value-name parsing is pure and sits in the
untagged file, so it is tested everywhere; only the registry read is per-OS.

OpenExternal hardcoded xdg-open even though the package already had a per-OS
seam for opening files. It now goes through that seam. Windows opens a URL with
rundll32 rather than the `cmd /c start` used for files: cmd strips
metacharacters off its command line before start sees them, so a `&` in a query
string would end the command and run what followed it.
@omartelo omartelo added the ci:os Runs the backend suite on Windows and macOS runners label Jul 28, 2026
Two more places where Windows got a POSIX assumption, both in OpenInEditor and
both reachable from a filename a checked-out branch chose.

The default opener ran `cmd /c start`. cmd reads & | < > as command separators
and Go quotes an argument only when it holds a space, so opening a file named
"a&calc.txt" with no $EDITOR set ran calc. explorer takes the path as a plain
argument instead, with no shell in between.

The terminal-editor line was quoted with shquote, whose own package doc says it
is POSIX-only and is why the worktree setup wrapper skips Windows — this call
site did not. cmd.exe does not strip single quotes, so every path with a space
(C:\Users\First Last\...) reached the editor cut in half. Quoting is now the
session shell's, behind the same per-OS seam as the openers.

cmd cannot express a path holding " or %, or one ending in a backslash: the
quote cannot be put back, % expands inside quotes, and the backslash escapes the
closing quote. Those are refused, and the file opens with the default handler —
a line that would run something else is worse than not using the editor.
@omartelo omartelo changed the title fix: give Windows its own font list and link opener fix: give Windows its own font list, link opener and shell quoting Jul 28, 2026
@omartelo
omartelo merged commit 75f98fd into main Jul 28, 2026
4 checks passed
@omartelo
omartelo deleted the fix/windows-fonts-and-links branch July 28, 2026 21:20
@omartelo omartelo mentioned this pull request Jul 30, 2026
3 of 4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:os Runs the backend suite on Windows and macOS runners

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant