Commit Graph
34 Commits
Author SHA1 Message Date
l0ng-ai 2e8a43a35e fix(settings): pass the host-boundary guard and follow the default keys
The passphrase box checks which key file is on this machine with
std::fs::metadata, which the host-boundary guard rejected; allowlist it
beside the existing std::fs::read entry for the same client-side key.

An empty key field now resolves to the ~/.ssh defaults build_spec_inner
offers, so a default encrypted key can be given a passphrase from the
form. The key is also re-resolved when host or user change, since they
fill %h/%r in the path. Drop the unused SettingsForget string.
2026-09-14 18:32:55 +08:00
ayamirandl0ng-ai 59dbe83913 feat(macos): add default terminal integration (#818)
* feat(macos): add default terminal integration

* fix(macos): route external opens through the layout pull

Five holes in the LaunchServices path, all on the way from a URL to a tab.

The `ssh:` arm handed the raw URL back to `parse_quick_connect`, which
reads a bare `user@host:port` typed into Quick Connect. Everything a URL
carries past the authority landed in the wrong field: `ssh://h:2200/`
parsed its port as `2200/` and was dropped on the floor, `ssh://h/srv`
became the host `h/srv`, and the percent escapes `url` was added for were
never decoded. Read the authority off the parsed URL instead.

`x-man-page://3/printf` is Apple's sectioned form, and taking the host as
the page name ran `man 3`, which asks the user what page they wanted.
Section and page are now both carried.

A window that is pulling its layout is one `Adopt::IfEmpty` will not adopt
into, so a tab inserted while the pull is out comes back as the whole
workspace — the failure `then_open` already exists to avoid. Both the
script/man path and the SSH path inserted straight into a freshly restored
window, so `then_open` becomes a list of parked requests and carries a
command or an SSH link as well as a folder. A cold `ssh://` link also went
through `open_at` directly, claiming a fresh workspace and leaving the
restored one detached and unannounced; it takes the shared restore now.

`new_tab_running` wrote the command whether or not a tab opened, so a
failed spawn typed a script path and a newline into whatever pane was
focused before — a shell mid-line, or an agent.

Left alone deliberately: an `ssh://` link still connects without a
confirmation, which is a product call rather than a defect.

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-09-09 17:05:58 +08:00
l0ng-ai e743005321 fix(links): green the Windows test and the host boundary
The traceback test located the path by its first `/`. A Windows temp
path keeps the forward slash it was built with, so the search landed
three quarters of the way along the path and the expected span was 63
columns off. Look for the whole path instead. The detection itself was
right all along; only the expectation was wrong, which is why the
Windows job was already red before the review fixes landed.

The executable check reads the local filesystem from `ui::`, which the
host boundary forbids on sight. It is only reachable once
`host_id.is_local()` has answered, so it goes on the allowlist with
that as its reason.

Claude-Session: https://claude.ai/code/session_01NE3M5Q94Jyxmj5Rdm9bcg4
2026-09-09 16:33:42 +08:00
l0ng-ai 47e25ef854 fix(ci): judge a Mach-O's signature by codesign's exit status (#696)
`codesign -dv` spells its signature line differently per posture:
`Signature=adhoc` for an ad-hoc or linker signature, `Signature size=8968`
for a Developer ID one with a timestamp. The check matched the literal
`Signature=`, which the second spelling does not contain.

While the script only pointed at the standalone tty7-server, which is
ad-hoc signed, that was invisible. #692 pointed it at the bundle's
tty7-app, tty7 and tty7-updater as well, and those are Developer ID
signed whenever the signing secrets are present. Pull requests do not see
the secrets, so every PR run took the ad-hoc branch and passed; the first
build that signed for real — the nightly — failed on all three binaries,
printing `CodeDirectory`, `Signature size=8968` and a Developer ID
`TeamIdentifier` as its proof they carried no signature. The binaries
were signed, notarized and stapled; only the assertion was wrong.

Exit status has no such split: 0 for anything signed, 1 with `code object
is not signed at all` for anything not, verified against all three
postures. The output is still captured so the failure message carries it.
2026-08-20 14:00:40 +08:00
webdev edfea5b830 ci(macos): assert every Mach-O in the bundle is the arch it ships as (#687) (#692)
A macOS 26 user opened the Apple Silicon build and was told it "contains
Intel parts" (#687). Downloading what is actually published — v26.8.2,
v26.8.3 and the nightly after #605 — and reading every file's Mach-O
header says otherwise: the three binaries under Contents/MacOS are thin
arm64, nothing else in the bundle is Mach-O at all, and tty7-app's load
commands are all /System/Library/Frameworks and /usr/lib. The build is
right today. The likeliest reading of the warning is macOS pinning an
x86_64 program someone ran in a pane on tty7.app as the responsible
process — the same attribution bundle-macos.sh already documents for
TCC — and that belongs on the issue, not in this change.

What does belong here is that nothing would have caught it if the report
had been right. assert-macho.sh knows how to say "this is a 64-bit
Mach-O for <arch>, it links only what macOS ships, and it is signed", and
since #605 it has said it — about the standalone tty7-server asset, and
only that. It has never been pointed at anything inside the .app. A
helper built without --target on an Intel runner, a dylib dragged in
from /opt/homebrew, a universal binary from a toolchain that decided to
be helpful: each would have zipped, notarized and shipped, and the first
check would have been a user's Finder.

So check the bundle, in bundle-macos.sh, where release.yml and
nightly.yml both build it. After the signing block — assert-macho.sh
insists on a signature, and this way one pass covers Developer ID and
adhoc alike — and before the update zip and the DMG, so a bundle that
fails never becomes an artifact, and before the `mv` that dissolves
dist/tty7.app. First the binaries the script staged itself: tty7-app,
tty7 and, when it is packaged, tty7-updater, each through
assert-macho.sh at the full standard the server asset is held to. That
also leaves every shipped binary's load commands in the release log,
which is where the next report of this kind gets answered from. Then a
sweep of every file in the bundle: `file` says which are Mach-O of any
kind, `lipo -archs` names the slices in each, and the answer has to be
exactly the matrix arch. Any other name is the wrong build; two names is
a universal binary, which is what the report described. lipo judges
rather than a parse of `file`'s prose because Apple's `file` and
upstream libmagic word the arch differently and lipo's slice names do
not move. A sweep that finds fewer Mach-Os than the binaries staged
above fails as well, so a changed wording cannot quietly turn it into a
no-op.

On a Developer ID build this runs after notarization, which spends a few
minutes of notary time on a bundle that was never going to ship. Cheap
next to carrying a second copy of the block inside each signing branch.

Deliberately not a fix for what the reporter saw, if it is the
child-process attribution: no check at build time can speak for a binary
the user runs inside a pane. What it guarantees is narrower and worth
having — the bundle named arm64 contains nothing but arm64, and a release
where that stops being true fails on the runner.

Validated with bash -n and shellcheck, and by running the sweep — and
the whole script in its adhoc posture — on Linux against fake bundles
with file, lipo, otool, codesign, ditto and hdiutil stubbed: a clean
bundle passes and packages; a wrong-arch updater, a universal tty7-app,
a stray x86_64 dylib, an arm64e nested bundle and an empty bundle each
fail and name the file, and nothing is zipped after a failure. Not yet
run on a Mac; the next nightly is what answers that.
2026-08-20 09:30:42 +08:00
webdev ac3c95a647 feat(update): install verified Linux AppImage releases in app (#306) (#652)
The last platform from #306: a Linux install running as an AppImage can
now download, verify, and apply a release from inside the app, through
the same tty7-updater helper the macOS (#309) and Windows (#330) paths
use. Tarball and distro installs are deliberately untouched — they keep
the named-package hint and the release page, because replacing a file a
package manager may own is not this code's call to make.

The installed artifact is one file, the path $APPIMAGE names, so the
install is the simplest of the three platforms: stage the download
beside the image (two renames only stay atomic on one filesystem),
verify, swap, relaunch, and restore the preserved previous image if the
new one does not survive its launch grace. What is Linux-shaped about
it is the mount: the image the GUI runs from is FUSE-mounted by the
AppImage runtime and torn down when the app exits, which is the moment
the installer starts working — so the GUI copies the helper out of the
mount into staging and runs the copy, the way the Windows path runs a
private copy because Setup replaces the installed one. The daemon is
left running throughout, as on macOS: nothing on Linux locks a running
executable's file, and the panes it serves are the reason the update
restarts only the GUI. The swap also carries the installed image's own
mode onto its replacement, so a 0700 image stays private and the
download's missing execute bit never reaches the installation.

Verification holds the issue's requirements with what an unsigned ELF
can offer: the bytes must match the release's checksums.txt, the file
must actually be a type-2 AppImage — a mis-published asset fails with a
name instead of at launch — and the image must state the version it
claims. That statement is new: bundle-appimage.sh stamps
X-AppImage-Version into the desktop entry, and the updater reads it
back with one --appimage-extract, answered by the runtime before any
application code and without FUSE. The same pass requires the new image
to bundle its own tty7-updater, because an image without one would
install fine and then be the last version that ever could.

release.yml and nightly.yml now build the updater on the Linux leg and
bundle it into the AppImage, and both check the packaged image for the
same facts the updater checks on a user's machine — helper present,
version stamped — so a packaging mistake fails the workflow instead of
the update. The first release carrying this can only bootstrap: images
already installed predate the helper and keep the manual hint, so the
first complete in-app update is the release after it.
2026-08-16 17:43:52 +08:00
b2d73ec68b feat(update): update an all-users Windows install through one UAC prompt (#562)
* fix(update): surface a failed install instead of silently re-prompting (#540)

The GUI quits as soon as tty7-updater is spawned, so an install that
failed inside the helper left a trace only in update.log — and because
launching the helper had already cleared the prompt state, the next
check offered the same version again, and again. The failure mode the
user saw was an app that nagged about an update it could not install.

The helper now writes update-outcome.json beside update.json on every
terminal path it can still reach, and the next GUI launch folds it into
the update state: a failure shows in Settings with the installer's own
reason until dismissed and stops the version from re-prompting on its
own; a success at the running version retires a failure an earlier
attempt recorded. A leftover result that exists but cannot be parsed is
reported rather than dropped — something ran, and "unreadable" is a
result too.

The same change moves the config directory off the environment and onto
the command line (--config-dir). An elevated child process does not
inherit the spawner's environment, so TTY7_CONFIG_DIR would have fallen
back to the administrator's config directory exactly in the
over-the-shoulder case — the groundwork this lays for #504. The updater
re-exports the variable for the helper children it spawns itself, so
the relaunched app keeps answering for the same config directory.

* feat(update): update an all-users Windows install through one UAC prompt (#504)

An Inno install under C:\Program Files could not be replaced in place:
the updater ran the release Setup as the signed-in user, which either
installed a second, per-user copy beside the real one or let Inno
re-launch itself elevated — a bare UAC prompt for an unsigned
executable in %TEMP%, seconds after the GUI had vanished. So the
layout was refused outright and told to download by hand.

It now updates itself, with the split the design in #504 settled on:
one UAC prompt covering two privileged stages, and one watcher that
is never elevated at all.

- The GUI probes the *installed* updater for the new verbs by running
  it ("capabilities"), so a side-loaded or downgraded binary answers
  for itself instead of being trusted by version number. An updater
  that predates the verbs exits with a usage error, and the install
  falls back to pointing at the release page exactly as before — the
  first release carrying this still updates the old way, and the one
  after it updates itself.
- The prompt dialog says the UAC prompt is coming before the app
  quits, and stops offering "Install on Next Launch": nobody is there
  to answer a prompt before the first window exists. The same guard
  keeps a staged plan from being armed for the next launch, and
  apply_pending_at_launch leaves an elevation-needing plan staged
  rather than raising a windowless prompt at boot.
- "Install now" spawns the watcher first (medium integrity, the
  signed-in user's token, so the relaunched app is never elevated),
  then ShellExecuteEx "runas" on the installed updater — the trust
  root a medium-integrity process cannot rewrite. Everything the
  elevated half needs crosses as command-line arguments, because an
  over-the-shoulder child inherits neither the environment nor the
  user's profile. The package's expected SHA-256 crosses the same
  way, from the checksums the GUI already holds in memory, so a
  payload and its checksums file cannot be rewritten together behind
  the IL boundary.
- The privileged first stage re-verifies the payload against that
  digest, pins its helper byte-for-byte to the installed updater,
  stages both in a fresh administrator-only %ProgramData% directory
  (an explicit SDDL DACL, swept of stale directories first), and only
  then runs the install stage — which runs Setup silently, writes the
  outcome file, and never touches the app binary itself. The watcher
  follows the chain through the status file and pid liveness
  (ERROR_ACCESS_DENIED from OpenProcess still means "alive" across
  accounts), then relaunches the app de-elevated and probes that it
  actually came up.
- Declining the UAC prompt is not an error: the watcher is reaped,
  nothing ran elevated, and the staged package simply waits in
  Settings.

Persisted plans from before this protocol serde-default a plan
version that is_usable rejects, so a stale plan is discarded instead
of failing against a helper that would not understand its arguments.
The installer script's explorer-menu registration gains skipifsilent:
a silent run *is* this update path, and launching the app there would
write the menu into the administrator's hive under over-the-shoulder
elevation.

One note on the test suite: ui::remote_connect's
a_routed_auth_prompt_carries_the_machine_that_raised_it fails under
parallel test execution on this machine both with and without this
change — a pre-existing flake, unrelated.

* fix(update): run the UAC request off the UI thread

Real-machine verification of the elevated chain caught this on the
first click: ShellExecuteExW pumps the calling thread's message loop
while the shell raises the consent prompt (its change notifications
re-enter the window), and from the UI thread that re-enters gpui with
its App already borrowed — the process aborts on a RefCell
double-borrow before anything ever elevates. The launch — watcher
spawn included, so the pairing stays atomic — now runs on the
background executor, and only the bookkeeping (quit / decline /
failure) comes back to the UI thread.

* fix(update): throttle a failed version instead of retiring it (#540)

Per the review on #540: a failed install must not keep the version
retired via last_prompted — record last_prompted plus a fresh
remind_after deadline (the same three days "Later" uses), so the
version asks again once the reminder expires. should_prompt already
treats "last_prompted matches, reminder expired" as prompt-again, so
no logic change is needed there, and the pinned
a_failure_lets_the_version_prompt_again test still holds.

Also write update-outcome.json *before* relaunching the previous app
on the macOS/Windows/portable non-elevated paths: the GUI that comes
up next is exactly the process that absorbs the outcome, and it used
to be relaunched before the failure existed on disk. The elevated
chain is unchanged — its watcher already waited for the file.

* fix(update): let only the elevated updater's own image name the trust root

Three holes on the privileged side of the #504 chain, all of the same
shape: a value that decides what runs elevated was taken from the
medium-integrity caller.

- `elevated-stage` pinned the staged helper against
  `<install-dir>\tty7-updater.exe`, where `<install-dir>` is a
  command-line argument. Both halves of that comparison were the
  caller's to choose: name a directory holding two copies of any
  binary and the pin passes, then stage 2 runs it elevated. The stage
  now derives the installation from its own image — UAC pointed the
  prompt at `{app}\tty7-updater.exe`, so `current_exe` is the one path
  nothing below the boundary could have written — and passes that on
  to stage 2. A caller that named a different directory only gets a
  line in the log.

- The staging directory's DACL let no standard user in, but its parent
  did: `%ProgramData%` grants Users the right to create directories,
  and the creator owns what it creates. A pre-created
  `%ProgramData%\tty7` gave its owner delete-child over the
  administrator-only staging inside it — enough to rename the verified
  staging aside and drop an identical name of their own into the gap
  between the digest check and the execute. The root is now created
  with the same protected descriptor, taking down whatever holds the
  name first; `CreateDirectoryW` applies a descriptor only when it is
  the one creating the directory, so succeeding is the proof. The
  per-run sweep goes with it — the root's removal takes the leftovers.

- The GUI aimed the prompt at the updater the *plan* named, and
  `update.json` sits in the user's config directory. It now aims at
  the installation this process runs from, so the binary the prompt
  names is the binary that starts.

Also quote the elevated command line the way `CommandLineToArgvW`
reads it back: a backslash escapes only in front of a quote, so a
config directory ending in one used to escape its own closing quote
and swallow every argument after it, `--result-file` — the file the
watcher waits on — included.

* test(update): pin the elevated stage's trust root to its own image

A regression test for the shape of the hole rather than the hole: if
`installed_root` ever goes back to reading an argument, the pin the
elevated stage runs before executing the staged helper stops meaning
anything, and nothing else in the suite would notice.

* fix(update): bring tty7 back when the elevated chain never reports

The watcher's two timeouts returned without relaunching. Every other
way out of the chain ends with the app back on screen, but a stage 1
that died before writing its status or its outcome — killed, crashed,
an AppInfo service that never delivered it — left the user with the
GUI already quit, nothing to replace it, and nothing said. Same for an
install still running an hour later.

Both paths now end the way the others do: an outcome the watcher wrote
itself, then the relaunch. The synthesized outcome is written whether
or not the relaunch succeeds, which also closes the same gap on the
pre-existing "the elevated updater exited without recording a result"
path — the next launch can name what happened instead of silently
offering the version again.

What kept those paths from relaunching was the risk of a second window
beside a GUI that is still up: a declined prompt leaves this process
running, and the kill that reaps its watcher can lose. The watcher now
takes the GUI's pid and opens a handle to it at startup — while the
GUI is provably alive, since it is sitting in ShellExecuteExW waiting
on the prompt — so the number cannot be recycled out from under it.
Before relaunching, a GUI that is still alive is waited out for 30
seconds: one that is quitting (a chain that failed fast can beat it
out the door) is gone well inside that and gets its relaunch, one that
is staying is recognized as staying and gets neither a relaunch nor a
failure record it did not earn. A live process always answers to its
own pid, so the check cannot be wrong in the direction that
double-launches.

Also give the Japanese elevation notice its closing 。

* fix(update): poll the parent out across the elevation account boundary

Under an over-the-shoulder elevation the install stage runs as the
administrator, and OpenProcess on the signed-in user's GUI answers
ERROR_ACCESS_DENIED - the same boundary pid_alive already documents from
the watcher's side. wait_for_exit treated that as a fatal error, so the
chain recovered and reported a failure before Setup ever ran. The wait
now degrades to polling the pid until it stops answering, bounded so a
recycled pid cannot hold the install hostage forever.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: l0ng-ai <l0ng-ai@users.noreply.github.com>
2026-08-13 18:13:20 +08:00
l0ng-aiandl0ng-ai 6c26b35acc fix(control): bump the dialect to v6, and publish a tty7-server for macOS (#605)
* fix(control): bump the dialect to v6 so an out-of-date server says so

The control dialect has been renamed, extended and cut since it was
last numbered, all of it against CONTROL_VERSION 5: the machine tree
replaced WorkspaceList/Get/Put/Delete with WorkspaceTree, MachineGet
and the tab/pane verbs, GitStream arrived with its chunk and end
events, and ReplyOk::Attached and FileMeta went away.

A peer left behind by any of that still answers the hello, because the
number it answers with still matches. It is also still sitting at the
path the installer looks for, tty7-server-c5p5, so a client decides it
already has the server it needs. Then the first call reaches a variant
the peer has never heard of, the frame fails to decode, and the read
loop takes the whole link down with it. What the user sees is a remote
workspace that opens with no tabs and a git detail pane that never
fills, with nothing anywhere saying why.

Moving the number puts all three guards back: the hello is refused with
the message that names the old build, the remote binary is looked for
at c6p5 and installed rather than trusted, and a stale local daemon
gets the restart prompt it should have been getting all along.

Document the rule next to the constant while it is fresh: move it when
a variant is added or removed. The feature strings only cover what a
peer can safely ignore, and a request it cannot decode is not that.

* feat(remote): publish a tty7-server for macOS hosts

A remote workspace has been Linux-only for no reason anyone chose: the
installer derives the asset name from `uname -sm`, and the only names it
knew were the two musl builds. A Mac on the other end of an SSH profile
got "a remote tty7 workspace needs a Linux host" and stopped there.

Publish the two Apple slices alongside them and teach the installer to
ask for them. `Darwin arm64` and `Darwin x86_64` now map to
tty7-server-macos-aarch64 and tty7-server-macos-x86_64; everything past
that point already worked, because nothing under it was ever Linux-
specific — the install path is POSIX, the upload is SFTP, and the
dialect probe runs the binary before trusting it.

The machine names are matched per system rather than by architecture
alone. Linux says aarch64 on one distribution and arm64 on the next,
while a Mac only ever says arm64, so honouring Linux's spellings under
Darwin would be guessing at output no Mac produces.

Static linking is not the instrument on macOS — Apple ships no static
libSystem — so assert-macho.sh stands in for assert-static.sh with the
guarantee that actually matters: every dependency resolves under
/usr/lib or /System/Library, so nothing the destination Mac lacks can be
picked up from a build runner, and the binary carries the signature
arm64 refuses to run without.

Not signed or notarized beyond that, deliberately. The binary is never
downloaded by the Mac that runs it: the client fetches it, verifies it
against checksums.txt and writes it over SFTP, which sets no quarantine
attribute, so Gatekeeper is not in the path.

ASSET_X86_64 and ASSET_AARCH64 become ASSET_LINUX_*, which is what they
always meant and could not keep meaning next to a macOS pair.

* fix(ci): sign the x86_64 macOS server, and stop the guard flaking on it

Two faults the first green run hid from each other.

The linker ad-hoc signs the arm64 slice because Apple Silicon will not
execute anything unsigned, and leaves x86_64 bare. That is fine on an
Intel Mac, but the x86_64 server is also what an Apple Silicon box gets
when it asks through a Rosetta shell, and handing that machine an
unsigned binary is a guess about Rosetta nobody needs to make. Sign both
slices ad-hoc in the workflow — no identity, no secrets, nothing to do
with the notarized signing the GUI bundles get.

The guard that caught it was itself unreliable: `codesign -dv | grep -q`
under `pipefail` reports failure whenever grep wins the race, because -q
exits on the first match and the writer takes SIGPIPE. Small output means
the writer usually finishes first, which is why the arm64 job passed and
x86_64 failed on the same signed-or-not question. Capture into a variable
and match afterwards, the way the release workflow already does it.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-13 11:47:27 +08:00
l0ng-aiandl0ng-ai bf9c57dec7 fix(ssh): let a rejected stored credential ask again (#519)
* fix(ssh): let a rejected stored passphrase ask again (#486)

Saving the wrong passphrase for an encrypted key locked that key out
permanently. `passphrase_submit` wrote `SetKeyPassphrase` on the
"remember" checkbox alone — before the daemon had tried the secret, since
`apply_keychain_write` runs ahead of `respond_active` — and
`try_identity_file` treated a stored passphrase as final: a decrypt
failure with one went straight to "could not decrypt identity file", with
no prompt and nothing in the UI that could let go of it.

The daemon now says so. `AuthPromptKind::KeyPassphrase` grows a
`rejected` flag, and a stored passphrase that does not open the file
falls through to the interactive prompt carrying it, so the typed answer
still gets its attempt. A passphrase the user typed this time keeps the
hard failure — that is a wrong answer, not stale state. The sheet renders
the warning line the password sheet already had, and a rejected prompt
answered without "remember" now emits `DeleteKeyPassphrase`, mirroring
the password idiom exactly.

The flag is a `#[serde(default)]` field on a struct variant of an
externally tagged enum, which is compatible in both directions: an older
peer never sets it and serde ignores fields it does not know. So
`PROTOCOL_VERSION` deliberately does not move — the remote-server
handshake gates on it, and a bump would turn away older servers over a
field they can safely ignore. `protocol.rs`'s compat test pins both
directions.

Also: deleting an SSH profile now drops the key-passphrase entries no
other profile still references, which is what `delete_profile_confirmed`'s
own comment already claimed to do but only ever did for the password.

* fix(ssh): stop replaying a stale password at keyboard-interactive (#487)

`try_keyboard_interactive` answered a password-shaped round from the
keychain, marked the stored password spent whether or not it had been
used, and returned on the first `Failure` — so the `MAX_ROUNDS` loop
never got a second pass with the stored password withheld. The same dead
secret went out on every reconnect and the user was never once asked to
type a different one; `ki_submit` always emitted `KeychainWrite::None`,
so nothing could clear it either.

`collect_ki_answers` now reports where its answers came from, and only a
round that actually sent the stored password spends it — which also fixes
an OTP-then-password flow that was refusing the stored password for no
reason, its first round having burned the allowance on a code. On a
rejection whose last round came from the keychain, and where the server
still offers the method, the request is started over with the stored
password withheld, so the next round reaches the prompt. That retry is
bounded twice over: the restart spends the stored password, so no second
restart can qualify, and the round counter it shares with the
info-request loop caps the method either way. The failure text now says
which of the two was turned down.

Scope, honestly: the only live scenario is auth mode Auto against a
server offering keyboard-interactive but not password, with a stored
password for that endpoint — a profile pinned to KeyboardInteractive gets
`password: None` and always prompts, and Password never tries KI. Whether
the symptom shows also depends on the server: OpenSSH ends a rejected
kbdint request with USERAUTH_FAILURE (symptom holds), while a device that
re-issues an InfoRequest in the same request already reached the prompt.

`AuthPromptKind::KeyboardInteractive` grows a `#[serde(default)]`
`stored_rejected`, same both-directions compatibility as `KeyPassphrase`'s
`rejected` and the same reason `PROTOCOL_VERSION` stays put. The sheet
shows the warning line and, on submit, forgets the rejected password.

That needed an endpoint the KI prompt does not carry, which also fixed a
bug next door: `raise_routed_auth` called `from_prompt(.., None, false)`,
so every routed password write was keyed to port 22 regardless of the real
port and the rejected self-heal could never fire there. `PendingAuth` now
carries the endpoint and the auto-supplied flag, read straight off the
route's `NativeSshSpec`.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-11 21:40:49 +08:00
l0ng-aiandl0ng-ai c216b389ae fix(macos): size the DMG ourselves, and stop blaming the runner's disk (#477)
The nightly channel has been frozen since 06:32 on 2026-08-10: every run
dies in bundle-macos.sh with "hdiutil: create failed - No space left on
device", on macos-15-intel, after the build, the signing and the
notarization have all succeeded.

The host disk was never full. #476 read that message as the runner running
out of room and freed space for it; the `df -h` it added to prove the point
disproved it instead — 105 GiB available, and the run failed anyway. The
path in the error is under /Volumes/tty7, which is the image being created,
not the runner: the volume ran out, not the disk.

`hdiutil create -srcfolder` sizes the image from the bytes it is about to
copy and does not cover what the filesystem spends carrying them, so a
bundle that fits by measurement still runs the volume dry partway through
the copy. It is a threshold rather than a cliff, which is why this began
without anyone touching packaging: the binaries grew over edfadb7..fafcaa0,
the x86_64 pair is the larger one and crossed it first, and arm64 kept
building fine just underneath.

Ask for the room explicitly — twice the content plus 64 MiB. The image is
compressed on the way out, so the slack is nearly free: on a stage of this
shape, 127 MiB of empty volume cost 672 KiB in the published DMG.

Also drop #476's deletion of the build tree. It was paying for a problem
that did not exist, and the bill was rust-cache finding nothing to save and
every macOS build recompiling the dependency graph. The `mv` from that
commit stays: a second full copy of the bundle is genuinely redundant, and
nothing reads dist/tty7.app after this point.

Verified locally against a staged bundle of the real shape (73 MiB, 101
files): the image is created, mounts with every file present, and detaches
clean. The remaining unknown is only whether CI agrees, which the next
nightly answers.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 22:31:23 +08:00
l0ng-aiandl0ng-ai 05ed9fa4ff ci(macos): give the DMG somewhere to go on a runner that ran out of disk (#476)
Three nightlies in a row died in bundle-macos.sh with "hdiutil: create
failed - No space left on device", across two different commits, always on
macos-15-intel and never on arm64. The build, the signing and the
notarization all succeed; the volume simply cannot hold the disk image on
top of everything already staged on it.

At `hdiutil create` the volume carries the whole release `target/` tree, the
signed dist/tty7.app, the compressed update zip, a second full copy of the
bundle under dist/dmg-stage, and the image being written. Two of those five
are avoidable:

Stage the bundle with `mv` instead of `cp -R`. Nothing reads dist/tty7.app
after this point — the updater ships the zip, nightly.yml verifies that zip
by extracting it elsewhere, and release.yml knows tty7.app only as an
intermediate to keep out of the upload globs.

Drop the build tree before the image is written. Every binary it produced is
already inside the bundle and no later step in either workflow reads it. The
cost is that rust-cache finds little left to save and the next macOS build
recompiles the dependency graph — a slower nightly, against no nightly at
all. `df -h` runs first so the next person to touch this has the number.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 21:32:28 +08:00
l0ng-ai 1df43b72b5 feat(files): copy dropped files into the folder they were dropped on (#458)
* feat(files): copy dropped files into the folder they were dropped on

The Files panel has only ever been a drag *source* — a row dragged into a
terminal inserts its path. Nothing on the tree ever registered a drop, so
a file dragged in from the desktop did nothing at all, not even a
highlight. Closes #453.

The drop is the whole gesture: files land where the cursor was, not
somewhere a dialog asks about afterwards. A folder row takes them itself,
a file row stands in for the folder holding it — "next to this one" — and
the space the rows do not cover belongs to the top of the tree. The
placeholder inside an empty folder takes a drop too; it is the only thing
drawn there, and letting it fall through to the root would put files
somewhere the cursor never was. A row under the cursor wins over the
column, which is what gpui's innermost-first dispatch already does.

The copy itself goes through the `Host` the tree is listing, so a remote
workspace reads here and writes there. Locally it is `fs::copy`, which is
what keeps the executable bit that `write_file` would drop; remotely the
bytes ride one control frame, and a file too big for that is refused with
the advice to use SFTP rather than half-sent.

Names already taken are asked about before anything is written, and the
answer governs the whole drop — a half-done copy would have to be undone
to honour a "no". Replacing a folder replaces it rather than merging into
it. A drag let go where it started is a miss, not an error, so it says
nothing.

* fix(sftp): list the directory again once an upload lands

An upload is written to `<name>.tty7-upload-<hex>` and renamed into place
at the very end. The browser listed the directory the moment the transfer
was handed to the daemon, so it caught that temporary name — and nothing
ever listed again, so a finished upload sat on screen as a file with a
hash glued to its name until the directory was navigated by hand.

The premature listing is gone, and the panel now remembers the job ids it
started: once one stops running — done, failed, cancelled, or dropped off
the job list entirely — the directory is listed once more. Two uploads in
flight settle independently, so the second one finishing does not depend
on the first.

* docs(changelog): note the SFTP upload listing fix

* ci(host-boundary): allow the source side of a file drop, and stop scanning two files as empty

The Files panel now copies dropped files in, and what the desktop hands
over is by construction a path on the desktop's own machine: reading it is
a local read even when the tree being dropped on is remote. The
destination side goes through `Host`, and the one `std::fs::copy` that
touches a destination sits inside a branch already gated on
`host.id().is_local()`.

While adding that entry: `attr` starts unset, which awk reads as 0, so a
file whose first line is `mod something` matched `attr == NR - 1` and cut
its body at line 0. `head -n -1` then errored and the file was scanned as
empty — `src/terminal/mod.rs` and `src/ui/tray/mod.rs` both open that way,
and the guard had been blind to both. Neither contains a violation, so
seeing them is free.
2026-08-10 12:41:17 +08:00
l0ng-aiandl0ng-ai 741c863c2c fix(windows): make the install directory actually replaceable before updating (#403)
* fix(windows): make the install directory actually replaceable before updating

The updater stopped the daemon and started the Inno installer the moment
the daemon's endpoint disappeared — but the endpoint going away is not
the same event as the images being released. The ConPTY hosts
(OpenConsole.exe) are the daemon's children, not the shells', so the
per-pane kill never reached them, and the daemon's exit(0) skipped every
destructor that would have closed them; they kept the installed
OpenConsole.exe open for seconds after --stop-daemon returned. Silent
Setup then hit the lock, took the suppressed dialog's default (Abort),
and the updater's recovery relaunched the old build — "updated,
restarted, still the old version". A daemon that died without cleaning
up made it permanent: its orphaned hosts survive indefinitely, which is
the DeleteFile-code-5 users hit even after "closing everything".

Reproduced both shapes in isolation before fixing: with a pane open,
--stop-daemon returned ~1s in while OpenConsole.exe stayed locked for
another ~1.4s; after taskkill on the daemon, the orphaned host held the
lock forever.

The shutdown now finishes what it starts, at every layer that can be
the last one standing:

  * The daemon reaps its remaining descendants and waits for them
    before exiting, while the endpoint — the signal stop() watches —
    is still up.
  * stop() reads the pidfile before asking, and waits for that process
    to actually exit after the endpoint goes, not just stop listening.
  * The recorded-daemon reap waits for the images to be released
    instead of returning on the async TerminateProcess.
  * stop_for_update(dir) — reached via --stop-daemon
    --update-install-dir, which PrepareToInstall and the portable
    updater now pass — also terminates anything still running from the
    installation directory (the orphan case no pidfile can name) and
    only returns once the .exe/.dll images there open for writing,
    naming the holdouts in the error if they never do.
  * The updater runs that clearing itself before invoking Setup, so a
    directory that cannot be cleared fails with a cause in update.log
    and relaunches the previous build, instead of Inno's bare
    "DeleteFile failed; code 5".

The update dialog on Windows also told a macOS truth — "the background
service keeps running, so whatever is open in your panes survives".
Windows cannot replace a running daemon's image, so its install path
stops the service; the dialog now says so.

* fix(windows): tighten the install-dir clearing per review

- An image that fails to canonicalize stays in the lock check instead of
  being silently skipped; only a positive match against the caller's own
  running image is excluded.
- reap_recorded_daemon shares one deadline across the whole tree via a
  new winproc::terminate_and_wait_all, which stop_for_update and
  reap_descendants_of now use too — one implementation of "terminate,
  then wait, bounded overall" instead of three.
- [UninstallRun] passes --update-install-dir "{app}" like
  PrepareToInstall, so uninstalling after a daemon crash gets the same
  orphaned-ConPTY-host cleanup as upgrading.

* fix(update): close three gaps the update audit found

- macOS updater: wait for the parent by watching getppid() reparent to
  launchd instead of polling kill(pid, 0), which a recycled pid could
  satisfy forever. The kill loop remains only for a hand-run updater.
- Windows: a new update guard (config-dir update.lock, held by the
  updater from daemon stop to relaunch) makes ensure_running refuse to
  spawn a daemon mid-install, so a tty7 CLI call or manual launch can no
  longer relock the images the installer is replacing. Stale guards —
  dead writer or past the TTL — are shed on sight.
- Windows portable: the update backup now carries an incomplete marker
  from before the first file moves until the replacement lands. At
  launch the app reports a backup still carrying it as an interrupted
  update (the installation may mix two versions; the old files are
  preserved), and silently removes marker-less backups a finished
  update failed to delete past an antivirus hold.

* fix(update): verify the guard's writer by start time, and guard manual Setup runs

Review round three, both findings and all three minors:

- The guard no longer expires a live, verified holder: a pid is believed
  to be the writer only if the process behind it started before the
  guard was written (winproc::creation_time via GetProcessTimes), which
  is what tells a genuine holder from a recycled pid. The TTL now bounds
  only the unverifiable case, so an install slowed past ten minutes by
  an antivirus sweep keeps its protection.
- Manual Setup runs get the guard too: the --stop-daemon
  --update-install-dir helper holds it in its parent's name — the Setup
  or uninstaller that keeps replacing files after the helper returns —
  and it goes stale when that parent exits. ensure_running gained five
  seconds of patience so the post-install "Launch tty7" click, racing
  Setup's own exit, gets its daemon instead of an error.
- processes_running_from also matches images against the canonicalized
  install-dir spelling (junction, subst, 8.3 given form).
- reconcile_portable_backups reports every interrupted backup, not the
  first.
- The unix signal-and-wait loop now reuses wait_for_recorded_exit.

* style: rustfmt

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 11:04:15 +08:00
Gabiandl0ng-ai e47b49dfdd fix(bundle): declare macOS TCC privacy keys for child processes (#323)
* fix(bundle): declare macOS TCC privacy keys for child processes

tty7 currently ships no NS*UsageDescription keys and no data-access
entitlements, so macOS falls back to a repeated "access other apps' data"
prompt whenever a child process (shell, coding agent, mole, etc.) touches a
protected folder such as ~/Library/Containers, Mail, Messages, or Calendar.
kitty and Kaku both declare these privacy intents, which converts the prompt
into a single, clear one-time grant.

Add the folder/volume usage descriptions and the matching personal-information
and device entitlements to the macOS bundle so the app behaves like its
terminal peers.

* fix(bundle): rework TCC usage strings per review

- Correct problem statement: describe child-process-denied-without-prompt
  instead of the Full Disk Access framing (no NS*UsageDescription key exists
  for that class).
- Add the full usage-string set (camera, microphone, contacts, calendars,
  reminders, photos, location, motion, local network, bluetooth, speech
  recognition, system administration, apple events), kitty-style wording.
- Use macOS spellings: NSCalendarsFullAccessUsageDescription /
  NSRemindersFullAccessUsageDescription / NSLocationUsageDescription.
- Drop every entitlement that has no matching usage string; keep only
  com.apple.security.automation.apple-events.
- Restore trailing newline at EOF in bundle-macos.sh.
- Document the Full Disk Access manual-grant requirement in docs/features.md.

* docs: rewrite macOS privacy as feature notes (en + zh-CN)

* fix(bundle): drop the apple-events entitlement, tidy the privacy docs

The entitlement did not do what its comment claimed. Nothing in tty7 or in
gpui's mac platform layer sends an Apple event, and it would not help the
case this change is about either: the hardened-runtime automation check runs
against the process actually sending the event, which is the pane's child
carrying its own signature. What TCC reads off tty7.app is the usage string
in Info.plist, which stays. Entitlements are per-executable and never
inherited, so granting this one only widened what injected code could reach
under an identity that already holds disable-library-validation.

Docs: spell out the four Full Disk Access paths instead of running them
together as one nested path, drop motion from the user-facing list (Core
Motion has no macOS implementation, though the key stays for kitty parity),
and place the section identically in the English and Chinese files.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 11:15:22 +08:00
l0ng-aiandl0ng-ai e3dded4be3 fix(release): keep the Inno payload for the package verifier to read (#368)
`verify-windows-package.ps1` reads the Inno staging directory to check what
lands in {app} — the compiled setup.exe cannot be read back without
innoextract, which the runners do not carry. But `bundle-windows.ps1` deleted
that directory as its last act, so the verifier has failed on every Windows
build since the check arrived in #330: "the Inno staging directory is missing".

Nightly has been red for two nights (2026-08-05, 2026-08-06) and a stable
release would fail the same way — release.yml runs the same step.

Both workflows already expected the directory to survive: their upload steps
name it among the dist/ intermediates the asset globs deliberately skip. So
this drops the removal rather than teaching the verifier to tolerate an absent
payload, which would retire the check it was added to make.

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

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-06 13:14:08 +08:00
l0ng-aiandl0ng-ai 3ae340408b fix(windows): bundle Microsoft's ConPTY so panes can answer color queries (#360)
* fix(windows): bundle Microsoft's ConPTY so panes can answer color queries

The in-box conhost swallows a pane process's OSC 11 background query: it
never reaches tty7's emulator and no reply is ever written back, so
applications that choose a light or dark UI from the terminal background
render a dark UI under a light theme.

tty7 already answers OSC 10/11/12 from the live theme, so nothing was
missing but a pseudoconsole that forwards the question. Microsoft ships one
as a redistributable, and portable-pty already prefers a sideloaded
conpty.dll over kernel32's, so this is packaging rather than code: the pair
goes beside tty7-app.exe, where the DLL search path finds it.

Measured on Windows 11 26200, same binary, only the pair added beside it:

  in-box conhost: the terminal side never sees the query; the client times
                  out with no reply
  bundled ConPTY: the terminal side sees ESC]11;?BEL and a real pane reads
                  back rgb:efef/f1f1/f5f5 under catppuccin_latte, which is
                  the preset's exact background

The two files are one supported unit, so the release verifier fails a
package that carries only one, a mismatched pair, or the MIT notice-less
DLL. They also join PORTABLE_MANAGED_ROOTS, without which the updater would
reject every portable archive that contains them; they are deliberately not
required by verify_portable_payload, since tty7 runs without them and a
packaging slip should fail the release rather than a user's update.

build.rs stages the pair beside cargo's output so a development build does
not quietly run on the in-box host, and the daemon logs which pseudoconsole
it got.

Closes #345

* fix(windows): restage the bundled ConPTY when it goes missing

Watching only the vendored sources meant a staged copy that left the target
directory stayed gone: the build script was cached, so it never ran again to
put it back, and the build silently fell back to the in-box conhost. Cargo
treats a rerun-if-changed path that does not exist as changed, so naming the
destinations makes the staging self-healing.

Found by deleting target/debug/conpty.dll and watching the next build not
bring it back.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-06 11:36:02 +08:00
l0ng-aiandl0ng-ai 2fa518a767 refactor(settings): rescope the About page (#350)
About had grown three sections that change system state and that nobody
looks for under "About": a PATH install, a registry write, and a daemon
restart. Two of them move out.

The `tty7` CLI goes to Agents. That page already describes tty7 <-> agent
integration in one direction (hooks reporting session status); the CLI is
the other direction, and its own description leads with "so scripts and
coding agents can drive tty7". The Loading and Unavailable arms there no
longer return early, since the CLI toggle is about this GUI's own host
rather than whichever machine the hook rows describe.

The Windows Explorer context menu goes to the installer, which is where
VS Code and Git for Windows put theirs: writing shell verbs is an
install-time decision, not a runtime preference. A task checkbox drives
new `--register-explorer-menu` / `--unregister-explorer-menu` flags, so
the key layout stays in core::explorer_context_menu instead of being
copied into the .iss. `status()` existed only to paint the settings UI
and goes with it. The uninstaller unregisters unconditionally: an install
that registered once and was later upgraded without the box ticked still
holds keys that would otherwise point at a deleted exe.

Server restart stays — it is about the app itself.

Also fixes localization the About section had skipped: eight hardcoded
English strings in the update block now have keys, and the orphaned
SettingsCheckUpdatesDesc key (which still claimed "tty7 never updates
itself", contradicted by the macOS in-app updater) is reused for a
one-line description in place of a 60-word account of the updater's
internals.

Finally, terminology in the Chinese UI. hook, agent, worktree, diff and
fork are read and spoken in English by Chinese developers, so translating
them lost more than it gained. Scrollback was worse than a style
question: 回滚 means rollback, the opposite direction. 窗格 for pane is
kept — that one is standard.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-05 17:42:21 +08:00
603bca171e feat(updater): add windows updates and cross-platform nightly support (#330)
* feat(updater): add windows online updates

* feat(updater): support online updates for windows portable zip builds

f

* feat(updater): support online updates for nightly build

* fix(updater): strengthen post-download update verification

* feat(updater): support explicit stable and nightly channel switching

* fix(i18n): localize update settings ui

* fix(settings): prevent slider value labels from wrapping

* feat(updater): drop the nightly channel, refuse all-users Windows installs

Follow-up to the Windows updater work on this branch, applying maintainer
review.

Nightly is a build channel, not an update channel. The updater consults
`/releases/latest` again and nothing else, so it behaves on Windows exactly
as it already does on macOS: a Nightly build is offered the stable release
that supersedes it and graduates out of the prerelease, and no rolling
prerelease can become a source of code that gets executed on a user's
machine. Removed with it: the `UpdateChannel` enum and its version-string
inference, the `tags/nightly` query, the cross-channel version-ordering
bypass, the Settings → About channel row, the rolling-tag
`update-manifest.json` and the i18n keys that only served them.
`parse_version` and `is_update_available` are byte-identical to main again.

Nightly builds are untouched, and still carry tty7-updater plus the macOS
update archive — a Nightly user needs a working helper to reach the stable
release that replaces their build.

An all-users Windows installation is no longer updated in place. Running the
release Setup silently as the signed-in user cannot replace
`C:\Program Files\tty7`: Inno resolves `{autopf}` to `%LocalAppData%\Programs`
and installs a second copy beside the real one, or re-launches itself
elevated and puts a bare UAC prompt for an unsigned executable in `%TEMP%` in
front of a user whose GUI just vanished. tty7 declines both and points at the
release page. Detection reads Inno's own `HKLM` state for the frozen AppId and
independently probes whether the directory accepts writes, so a relocated or
pruned installation is caught too; the decision is a pure function with unit
tests, and it is re-checked before the download as well as during it.

Release and Nightly now verify the Windows packages they just built, mirroring
the macOS update-archive step: the install marker, tty7-updater.exe, the ZIP
layout the updater will accept and the PE versions it will demand. Every fact
the updater checks on the user's machine after downloading is checked here
instead, so a packaging mistake fails the build.

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:27:37 +08:00
618855cf4a fix(windows): brand toast notifications with a tty7 AUMID (#340)
* fix(windows): brand toast notifications with a tty7 AUMID (#339)

* fix(windows): only write the toast shortcut where it is ours to write

The AUMID shortcut was rewritten on every launch, which broke two cases
the review caught on a real machine.

An elevated install owns `%ProgramData%\...\tty7.lnk`, so writing a
per-user copy listed "tty7" twice in the Start Menu and left an orphan
pointing at a deleted exe once the uninstaller had removed only its own.
And `cargo run` repointed the installed shortcut at `target\debug`,
permanently, for anyone who both installs tty7 and builds it.

So decide before writing. An all-users shortcut settles the question by
itself — branded if the installer stamped our AUMID on it, otherwise we
stay on the PowerShell identity, because the alternative is littering a
Start Menu we cannot clean up. Otherwise we refresh the single per-user
`tty7.lnk` Inno's default install owns anyway, and only when it is not
already ours, and never from a cargo build directory. A dev build still
brands the process for taskbar grouping, and still gets branded toasts
when an install left a stamped shortcut behind — Windows asks that the
AUMID be registered, not that it point at the process using it.

Reading a shortcut back needs `IShellLinkW::GetPath`, hence the
`Win32_Storage_FileSystem` feature; `SLGP_RAWPATH` keeps it from chasing
a moved target over the network.

Also close the window this opened. The shell indexes a new `.lnk`
asynchronously and, for an AUMID it has not seen, `Toast::show()`
reports success and drops the toast — measured, it does not return an
error. A shortcut we wrote seconds ago is therefore not yet proof of
anything, so toasts keep the PowerShell identity for half a minute after
we write one: ugly beats invisible.

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:09:07 +08:00
b7e08c7e11 feat(windows): add optional windows explorer context menus (#310)
* add CLI support for opening directories in new tabs

f

* feat(windows): add optional windows explorer context menus

f

* fix(gui): restore missing windows and reject lossy paths

* fix(windows): harden explorer menu registration and native path handling

* fix(cli): preserve native GUI paths on Windows

---------

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-03 15:23:59 +08:00
ayamir a6754b28bc feat(update): install verified macOS releases in app 2026-08-02 22:25:10 +08:00
l0ng-ai 0c9f4baa3a fix(cli): make the PATH install reversible, honest, and safe to migrate
Follow-up on the review of #277. Seven fixes, no change to what the feature
is for.

An AppImage copy is now claimed with a marker file instead of being inferred
from "am I an AppImage right now". Keying off the runtime meant that a user
who moved from the AppImage to the tarball hit their own copy, read it as
somebody else's binary, and never got another install for as long as that file
sat there.

The Windows uninstaller takes {app} back out of HKCU\Environment. Nothing did
before: the entry is written by the app at runtime, so Inno never knew it
existed and every uninstall grew the user's PATH by one dead entry. Unix has no
equivalent hook and still leaves its symlink behind; that is now stated in the
module docs rather than left to be discovered.

An occupied candidate directory no longer ends the scan, and every platform now
reports whether the install actually wins the lookup. `Occupied` on
/opt/homebrew/bin used to mean giving up while ~/.local/bin sat free, and
Windows — which appends to PATH and so never collides — reported `Installed`
even when an existing tty7 earlier on PATH kept beating it. A new
`InstalledShadowed` names the winner.

`cargo run --release` no longer repoints the developer's real tty7 at a build
tree. `cfg!(debug_assertions)` only covered the debug half of that.

The Windows registry PATH is read, matched, and written as UTF-16 throughout.
It went through `to_string_lossy` before, so a value the registry holds but
Rust cannot represent as a String would have been written back with U+FFFD in
place of its characters — the exact PATH corruption the surrounding code is
careful to avoid.

Two tests mutated $HOME and $PATH while the rest of the binary's tests ran
beside them, and src/ui/home.rs mutates $HOME too. `candidate_dirs` takes home
as a parameter, `place` takes its mode, and the PATH-joining and registry-
joining rules are pure functions — so no test in this module touches the
environment any more. 5 tests become 11, and the Windows joining logic is
covered on every platform.

Also: the config flag reaches Settings → About and both features docs instead
of being config.json-only, startup reads config.json once instead of twice, and
the CLI's strip failure warns like its sibling instead of being swallowed.
2026-07-31 16:49:22 +08:00
l0ng-ai c275960ceb feat(cli): ship the CLI in every installer and put it on PATH at launch
The `tty7` CLI was built by every release run and thrown away: all four
bundle scripts copied only `tty7-app`, and the upload glob covers `dist/`,
which the CLI never reached. Nothing put it on PATH either, so the
agent-facing half of the product was unreachable from a shipped install.

Bundle it on all four platforms, and have the GUI link it up itself rather
than hiding the step behind a menu item most people never find.

The install has two halves. The environment half prepends the CLI's
directory to this process's PATH before the daemon is spawned, so every
pane inherits it — that alone makes `tty7` work where agents actually run,
writes nothing to disk, and behaves the same everywhere. The on-disk half
symlinks into a directory already on PATH (Unix) or appends to
HKCU\Environment (Windows), and is allowed to fail.

Candidate directories are a fixed list intersected with PATH, not the first
writable entry on it: pyenv/rbenv/asdf/mise shim directories sit at the
front of PATH on many machines and are writable, and anything dropped there
is deleted on the next rehash — silently, days later.

Debug builds get the environment half only. `target/debug` holds a `tty7`
too, so otherwise a `cargo run` would repoint the developer's real `tty7`
at a debug binary, and each isolated dev-verify instance would rewrite the
PATH of the machine it is meant to stay away from.
2026-07-31 16:49:22 +08:00
l0ng-ai 3b68a42cb8 fix(installer): delete the pre-rename tty7.exe on upgrade
Builds before the tty7/tty7-app split installed the GUI as tty7.exe. Upgrading
only adds tty7-app.exe, so the old binary stays on disk — and a taskbar pin,
which Inno cannot rewrite the way it rewrites [Icons] shortcuts, still points at
it. The user keeps launching the previous version from their pinned icon,
against the same daemon endpoint as the new one.

[InstallDelete] runs after PrepareToInstall has stopped the daemon and released
the file lock, and before the new files land. A fresh install has nothing to
remove.
2026-07-31 13:05:16 +08:00
thomasandClaude Fable 5 10b6741368 refactor: rename the GUI binary to tty7-app, freeing tty7 for the CLI
The package name and every display name ("tty7" in menus, tray, .desktop
Name, CFBundleName, installer AppName, shortcuts) stay as they were; only
the executable file is now tty7-app / tty7-app.exe, per docs/cli-design.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
2026-07-31 09:28:00 +08:00
2a4b5c7f68 fix(release): name the server assets for whoever downloads them, not for cargo (#265)
`tty7-server-x86_64-unknown-linux-musl` was never a name anyone chose. Both
workflows staged the file as `tty7-server-${{ matrix.target }}`, so the build
triple went straight into a published filename — and the triple's *vendor*
field, for a Linux target with no particular vendor, is the literal word
`unknown`. It has been sitting on the releases page reading like a failed
lookup.

Of the triple's four fields only two say anything to whoever downloads this:
the architecture, which is what `asset_for_uname` picks by, and `musl`, which
is why one file runs on any distribution. So:

    tty7-server-x86_64-unknown-linux-musl  →  tty7-server-linux-x86_64-musl
    tty7-server-aarch64-unknown-linux-musl →  tty7-server-linux-aarch64-musl

`<os>-<arch>` in that order because that is what the GUI assets in the same
release already use (`tty7-<version>-linux-x86_64.tar.gz`). One release should
be one naming scheme; it was two.

The triple stays everywhere it really is a build target — `cargo zigbuild
--target`, the `target/<triple>/release` path, the rust-cache key, ci.yml's
matrix. The workflows now carry both: `target` for the build, `asset` for the
filename, deliberately not the same string.

This name is a contract with more than the release step, and all of it moves
together:

- `install::asset::{ASSET_X86_64, ASSET_AARCH64}`, which is what the client
  appends to a release URL.
- `bundle-windows.ps1`, which stages the musl binary for WSL. `wsl.rs` looks
  for `<dir>/<asset name>` with nothing translating, so the *filename* is as
  much a contract as the `server/` directory is — now said out loud in both
  places, along with the consequence for `TTY7_BUNDLED_SERVER_DIR`: a
  cross-compile has to be copied to the asset name, not left as `tty7-server`.
- The GUI's install prompt fixture, the checksum manifest fixtures, and the
  `MissingBundled` assertions.

Nothing globs the old shape: `gh release upload dist/*`, `checksums.txt`'s
`find`, and the installer's `server\*` are all name-agnostic.

A new test pins both names as literals — the module header already says this
naming is "a *literal* contract with the release workflow", and asserting the
consts against themselves asserted nothing. It also fails on the substring
`unknown`, since that word only ever arrived here by way of `matrix.target`,
and checks neither name contains the other, which is what
`checksums::expected_digest` says out loud that it relies on.

Compatibility: a stable client asks its own frozen tag, which keeps whichever
name it shipped with, so every released client keeps working. The rolling
`nightly` tag is replaced each night and its prune step drops assets the run
did not upload — so an *already installed* nightly client 404s on the server
download until it updates itself. Accepted deliberately; the next release is
what has to be right.

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:55:52 +08:00
l0ng-aiandl0ng-ai bed22d899e Keep workspaces whole: remote reopen/restart recovery, and cross-workspace restore guards (#257)
* feat(remote): keep a remote workspace whole across reopens and restarts

Reopening a remote workspace — or coming back to one whose `tty7-server`
had been replaced — landed on a screen of `tty7 — disconnected` panes with
their coding-agent conversations gone. Several independent holes added up
to that; this closes them together, and picks up the surrounding work the
same session produced.

**Telling a restarted server from a blinked link.** `ControlHelloOk` now
carries an `instance` minted once per server *process*. Nothing else in
the handshake changes across a restart — `build` and both dialect numbers
survive it — so a reconnect had no way to know its `pane_id`s were dead.
It does now: a different instance rebuilds the window from its layout
(same tabs and splits, fresh shells in the saved cwds) instead of
re-attaching to a process that is gone. An absent instance means *unknown*
and is never read as a restart.

**An attach can now fail.** `Attach` has no synchronous reply, so the
client returned `Ok` unconditionally and the daemon's `Error` frame was
read much later by the reader thread, which has no arm for it — the pane
then landed in the *link is down* state instead of falling back to a fresh
shell. The client now reads far enough into the reply to classify it on
the kind byte (the snapshot behind it can be megabytes) and hands those
bytes to the reader thread, so a successful attach loses none of its
replay. Local and remote attaches get different waits: the local one is on
the UI thread.

**The agent session survives to be resumed.** `TerminalView` raises
`AgentSessionChanged` when the pane's agent reports a new native session
id, so the layout on file catches up instead of waiting for the user to
happen to open a tab. A pane that is still connecting now carries its
agent through `PendingSpawn` — a save landing in that window used to write
`agent: null` over the record — and `land_pane` sends `--resume` when the
attach turned out to need a fresh shell.

**Ending sessions says so on file.** "End Sessions" kills the panes and
then drops their ids from the record, pushing the cleared layout to the
machine that owns it (design §10: the remote's copy wins, so a local-only
clear would be undone by the next open — the open this exists for).

**The new-tab dropdown lists the window's machine.** `Host::shells` and a
`Shells` control request (dialect v2) make the "+" menu a property of the
machine the window is bound to. A remote window filled from this
computer's `/etc/shells` offered `/bin/zsh` on a box whose zsh is
elsewhere, and every pick failed to spawn.

**An install reports its bytes.** The download and the SFTP upload each
report progress, relayed to the client over the routed connection as a
`RoutePrompt::InstallProgress`, and painted as a bar under the machine's
row in the switcher. ~8 MB across two hops behind the word "connecting…"
was indistinguishable from a hang.

**The installer compares dialects, not version strings.** `tty7-server
--protocol` prints what a binary speaks without starting it, so a connect
adopts an already-running server it can talk to rather than prompting
about a build difference and uploading 8 MB the machine did not need.

**Switcher.** A machine's `⋯` menu holds "New Workspace" (it was a row
under every machine, pushing the list a quarter of a card down) and a new
"Disconnect", which drops the connection and leaves the windows open and
read-only. The suspension lasts exactly as long as that machine has a
window on it.

Also drops three design/contract docs for the now-shipped remote-workspace
work.

* fix(session): stop one workspace's panes from being restored into another

A restart put a copy of one workspace's seven tabs — cwds, layout and
recorded agent sessions — in front of another workspace's own tabs, and
auto-resumed every one of those agents a second time: six `claude
--resume <id>` pairs running in parallel against the same conversations,
one set per window. The record-level corruption that seeded it is still
unattributed, but every mechanism that let it propagate, amplify, or go
unnoticed is closable, and this closes them.

**Panes now know their owner.** `Spawn` can carry the workspace the pane
is created for; the daemon stores it immutably and reports it in
`List`'s `PaneInfo.owner`. Restore refuses to re-attach a pane another
workspace owns (`pane_attachable`) — before this, a saved id landing on
somebody else's live pane attached silently, which is how one window
could pick up another's shells. The field rides a new `SPAWN_OWNED`
frame with a struct payload (the legacy spawn payloads are positional
tuples an old daemon cannot grow), gated on a new `pane-owner` feature
string: a client only sends it to a daemon that advertises it, so the
legacy kinds stay byte-for-byte what old daemons expect. A pane with no
recorded owner stays attachable by anyone — that is the pre-field
behavior, not a new risk.

**Saved pane ids are bound to the daemon process that issued them.**
`DaemonVersion` now carries an `instance` minted once per process (the
local twin of the control hello's), the GUI caches it at the
`ensure_running` handshake, and each local workspace records it as
`daemon_instance` beside its layout. Claiming a workspace whose ids came
from a different instance blanks them first: daemon pane ids restart
from 1, so after a reboot every saved id points at whatever unrelated
shell holds the number now, and the aliveness check cannot tell a
survivor from a squatter. A blank on either side means "cannot tell" and
never trips it. Unlike the duplicate-claim case below, this path keeps
the agent resume — the pane is genuinely gone with its daemon, and the
fresh shell resuming the conversation is the feature.

**A duplicate claim loses its agent resume along with its pane id.**
`dedupe_pane_ids` kept the loser's layout *and* its
`agent_session_id`, so the blanked leaves took restore's spawn-fresh
path and auto-typed `claude --resume` for conversations the winning
workspace's panes were still running — the doubling above. The winner
keeps the panes and the resume; the loser keeps only cwds.

**Cross-workspace saves are caught at the write.** Every terminal view
remembers the workspace whose window created it, and `save_session`
logs an error naming both ids if a window ever records a pane created
for a different workspace — the tripwire for the still-unattributed
seed corruption, so a recurrence is caught in the act instead of
reconstructed from `session.json` archaeology days later.

Wire compatibility both ways: `PaneInfo.owner`, `DaemonVersion.instance`
and `Workspace.daemon_instance` are `#[serde(default)]` struct fields
(old peers' JSON decodes, new fields are ignored by old readers), and
`SPAWN_OWNED` is feature-gated as above. `daemon_instance` is
client-owned in the design-§10 storage split — it names the local
daemon, and the field-census test pins the classification.

* fix(session): resume the agent when a local pane dies mid-restore

`session_to_pane` decided whether to send a coding agent's `--resume`
from `restore.is_none()` — i.e. from whether the pane looked alive when
the restore started. But `alive_panes_on` runs one `List` at the top of
the restore, while the attaches happen per leaf afterwards. A pane that
exited in between failed its attach, fell back to a fresh shell inside
`spawn_shell_terminal_in`, and then landed in the `restore.is_some()`
arm: an empty shell with its conversation dropped.

`ShellParts.restored` already answers this exactly, and the remote path
already reads it in `land_pane`. Carry it onto `TerminalView` so the
synchronous local path can read it too, and branch on that instead of
re-deriving the answer from a set that may be stale by the time it is
used.

No behaviour change on the paths that were already correct: a view that
was never restoring anything reports `restored: false`, which is the
same answer `restore.is_none()` gave them.

* fix(remote): check the server instance against the record, not just memory

A remote workspace's pane ids were only guarded against server restarts
by `RemoteLinks::instances`, an in-memory map. On the first connect after
the client starts, every machine is a first sighting, so `server_restarted`
answers false — and a `tty7-server` that was replaced while the client was
closed sails straight through. Its pane ids restart from 1, so the saved
ones now name unrelated shells, and the reconnect attaches to them: the
exact id-reuse failure the local side already guards against.

`Workspace::daemon_instance` was local-only for the stated reason that a
remote server's identity is tracked live per connection. That tracking is
correct but not sufficient — it cannot survive the client restart that
makes the question worth asking.

So the field now means the same thing on both sides: which process minted
the pane ids in this record. `WorkspaceStore::serving_instance` picks the
local daemon or the far machine's server depending on the workspace, and
`finish_attempt` compares it per workspace before deciding to re-attach or
rebuild. It stays client-owned: it records what *this* client last saw, so
two clients on one remote workspace each keep their own and neither may
overwrite the other's.

An unreachable machine still records nothing, which is what keeps a good
stamp from being erased with `None` — that would disarm the next check.

Also in these three files: the §N references to the deleted design docs,
cleaned up as part of the sweep in the following commit.

* docs: drop the references to the deleted design documents

The three documents this branch removed were cited ~280 times: `design
§10`, `contract §8`, `§17` and friends in comments, five references by
file path in code and manifests, five in CI workflows and one in the
release skill. Every one of them now points at nothing.

Rewritten rather than merely stripped, because most were not decoration:
"design §10 makes the remote's `workspaces.json` the authority" becomes a
statement in its own right, and the several that carried a Chinese phrase
from the document as their justification say the same thing in English
instead. Where the reference was purely parenthetical it is simply gone.

Not touched: `PRD §7.1`, `brief §8` and the like, which name documents
this branch did not remove and were already external before it, and the
`RFC 4648 §10` test-vector citation, which is a real specification.

The `host boundary` CI job loses `(§10.6)` from its name. It is not one of
the required checks, so branch protection is unaffected.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 19:15:19 +08:00
l0ng-ai 12d8cf549b fix(ci): read the AppImage version from [workspace.package]
The crate split left the root manifest leading with `version.workspace =
true`, so `grep -m1 '^version'` returned that line verbatim and the sed
fell through unchanged. Every AppImage since was named
`tty7-version.workspace = true-linux-x86_64.AppImage`, which broke the
nightly publish job at `sha256sum -c` — the space-split name resolved to
three missing files.

bundle-linux.sh, bundle-macos.sh and bundle-windows.ps1 were already
anchored on `= "`; this was the one that got missed, and the only one
without a guard to catch the miss.
2026-07-29 10:37:56 +08:00
l0ng-ai 208454e202 feat(remote): remote workspaces — a window that is one machine
Split the framework-free half of tty7 into `tty7-core` and add a headless
`tty7-server` built on it, so a workspace's filesystem, git and session state
can live on another machine while the GUI stays where it is.

- `crates/tty7-core`: wire protocol, session daemon, PTY, native SSH engine and
  the domain model, with no gpui dependency. Module paths are unchanged.
- `crates/tty7-server`: the same daemon with no GUI attached, linked fully
  static against musl and pushed onto the remote box. One dependency, on
  purpose — a second one the GUI also needs belongs in core.
- `Host` trait + `HostId`/`HostRegistry`: every fs/git/watch call a workspace
  makes goes through the machine it belongs to. `LocalHost` answers on this
  box, `RemoteHost` over a routed control connection.
- `ui::host_ops`: the GUI's single door to a `Host`. Host calls block, so all
  of them run on the background executor with the result landed on the UI
  thread; de-duplication, staleness and error reporting live here rather than
  at each call site. Enforced by a CI grep.
- Connect flow: home page → pick a configured SSH host → the machine's own
  workspace list → a window bound to one workspace on it. Workspace switcher
  groups by machine, this computer included.
- CI: static musl builds of `tty7-server` for x86_64/aarch64 via
  cargo-zigbuild, a host-boundary grep, and version stamping factored out of
  the nightly workflow. Both new jobs are non-required so branch protection
  does not wedge open PRs.

Design and the interface contract it was built to are in
`docs/2026-07-27-remote-workspace-{design,impl-contract}.md`.
2026-07-28 10:59:46 +08:00
l0ng-ai eb6c7ca3b3 fix(release): stop {tmp} from closing the Windows installer [Code] comment
The [Code] block comment used Pascal { } braces, but {tmp} inside it
closed the comment early, so ISCC parsed the trailing prose as code and
aborted with "'BEGIN' expected" — no Windows installer was produced.
Switch the comment to (* *) so brace-form constants stay literal.
2026-07-14 17:38:10 +08:00
90515d9fc3 fix(windows): stop the daemon before install/uninstall so it can replace tty7.exe (#72)
The persistent daemon (`tty7.exe --daemon`) is a detached background process
that outlives the GUI and is the running image of tty7.exe, so Windows locks
the file. An upgrade or uninstall then can't overwrite/remove the binary and
fails ("file in use" / reboot required) — the Restart Manager doesn't reliably
catch a no-window, DETACHED_PROCESS daemon in its own process group.

- spawn: extract the "stop the running daemon" half of `restart()` into a
  reusable `stop()` (Shutdown -> await exit -> pid reap fallback -> clear
  endpoint); `restart()` is now `stop()` + `ensure_running()`.
- main: add a `--stop-daemon` CLI entry that runs `stop()` and returns before
  any GUI init, so it never opens a window.
- installer: in PrepareToInstall, extract the *new* tty7.exe to {tmp} and run
  `--stop-daemon` (the new binary understands the flag; an old installed one
  would launch the GUI instead), releasing the lock before file copy. Mirror it
  in [UninstallRun]. Keep CloseApplications as a backstop but RestartApplications=no
  (the GUI respawns the daemon on next start).

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:09:00 +08:00
l0ng-aiandl0ng-ai b5beba6d80 feat(release): ship a Linux AppImage alongside the tarball (#55)
* feat(release): ship a Linux AppImage alongside the tarball

The Linux release was a bare, dynamically-linked binary built on
ubuntu-latest, so it only reliably ran on Ubuntu — Fedora/Arch users hit
missing/mismatched runtime libs. Add an AppImage that bundles the
x11/wayland/xkb/fontconfig/freetype libs so it launches across distros.

- bundle-appimage.sh: linuxdeploy populates an AppDir + deps, completions
  go beside the binary (usr/bin/completions, matching signature.rs's
  current_exe lookup), appimagetool packs it. Runs FUSE-less on CI.
- release.yml: new "Package Linux AppImage" step after the tarball,
  libfuse2/file added to the Linux deps, *.AppImage added to the upload
  list. The AppImage step avoids `rm -rf dist` so the tarball survives.
- README (en + zh): recommend the AppImage, keep the tarball as the bare
  fallback.

Note: glibc is not bundled, so ubuntu-latest still sets the glibc floor.

* fix(release): downscale AppImage icon to a resolution linuxdeploy accepts

linuxdeploy rejected the 1024x1024 app-icon.png (its valid list tops out at
512). Resize to 256x256 with ImageMagick's convert (added to the Linux apt
deps) before handing the icon to linuxdeploy.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-13 10:43:26 +08:00
l0ng-ai d0cf53522f feat(release): ship a Windows Inno Setup installer alongside the portable zip
bundle-windows.ps1 now compiles windows-installer.iss (ISCC is preinstalled
on windows-latest) from the same staged payload as the zip, producing
tty7-<version>-windows-x86_64-setup.exe: per-user install by default with an
all-users option, Start Menu shortcut, Apps uninstall entry, optional desktop
icon. Release workflow uploads the new artifact; READMEs and CHANGELOG updated.
2026-07-07 16:14:44 +08:00
l0ng-ai 22e1ab1694 tty7: a GPU-rendered, daemon-backed terminal in pure Rust
tty7 is split into two Rust processes: a persistent daemon that owns the
shells and a GPU-rendered client that talks to it over a local socket.
Because the shells live in the daemon, quitting and reopening the app
leaves the session intact — detach and reattach, no tmux required.

- Persistent sessions — the daemon holds the PTYs and child processes, so
  closing a window or swapping in a new build never takes a shell down.
- Performance — an 11 MB `cat` completes in 95 ms and DOOM-fire renders at
  888 fps; the daemon drains the PTY at device speed off the render path.
- Shell-aware — new tabs and splits open in the current working directory;
  zsh, bash, fish, and PowerShell are set up automatically.
- Enhanced prompt — inline completion, syntax highlighting, history, and
  in-terminal search, with rich flag/subcommand signatures for common tools.
- Tabs, resizable splits, a command palette, click-to-open links, desktop
  notifications, eight themes, and CJK/IME input.

Native builds for macOS, Windows, and Linux.
Built on Zed's gpui and Alacritty's VT core.
2026-07-06 21:54:27 +08:00