Skip to content

feat(mobile): sleep and wake a desktop from the Android home screen - #4

Merged
tebiggs merged 13 commits into
mainfrom
feature/android-desktop-power
Oct 25, 2026
Merged

tebiggs merged 13 commits into
mainfrom
feature/android-desktop-power

Conversation

@tebiggs

@tebiggs tebiggs commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Waking a machine that sleeps most of the time meant leaving the app. Its environment also kept showing "Reconnecting" while the app retried against a machine that was asleep.

What changed

  • Home chip (Android only). Environments with sleep/wake turned on get a status chip above the thread list, also shown when Home has no threads to list: Awake, Asleep, Waking…, Going to sleep…, Checking… or Can't connect. Tapping it opens a sheet with one Awake switch. The chip is hidden when no environment is set up.

  • Settings. Settings → Environments → environment → Sleep and wake has an on/off switch, a "Wake through" dropdown listing your other connected environments, the machine's MAC address, an optional local address, and optional custom wake and sleep commands.

  • Built-in wake. The phone opens a short-lived terminal on the chosen environment, which must be on the same LAN. That terminal sends a Wake-on-LAN broadcast to the MAC address, using python3 on Linux and macOS or PowerShell on Windows.

  • Built-in sleep. Runs on the machine itself over its own connection, in the foreground: systemctl suspend, pmset sleepnow, or SetSuspendState on Windows. A non-zero exit is reported as a failure, and on Windows so is SetSuspendState returning false. A call still running 5 s after its input reached the shell counts as sent, since some OS sleep calls return only after the machine wakes.

  • Launch check. With a local address set (host or host:port, port 3773 by default), the "Wake through" environment checks the machine once per app launch: a 1 s connection to its T3 port, then the environment id from /.well-known/t3/environment. If nothing answers, the chip shows Asleep within a few seconds instead of after the 20–35 s it takes the phone's own connection to time out and the grace period to run. The phone keeps trying in the meantime: its next failure pauses the loop, and connecting overrules the check. A refused connection, another server, or a relay without python3 changes nothing. Later outages keep the grace period, because the relay can't tell a sleeping machine from a restarting server. The machine needs T3's Network access on and its port open to the relay.

  • Custom commands. Either custom command replaces the built-in one and runs on the "Wake through" environment, for example a lease-aware wrapper script.

  • How commands run. Through the existing terminal RPCs, with the command passed in an environment variable. The runner:

    • uses PowerShell input on Windows and exec sh -c elsewhere;
    • reattaches after a dropped connection without opening a second shell;
    • doesn't fail when a write's reply is lost; if the reattach snapshot shows a running shell that never echoed the input, it writes the input again, up to two more times;
    • reads the exit code from the live event or the reattach snapshot;
    • closes the terminal and deletes its history, with the close capped at 5 s;
    • gives built-in sleeps their own terminal thread, and before each one closes what earlier sleeps on that machine couldn't close once it stopped answering.

    The server is unchanged.

  • Asleep detection. Status comes from reachability. A machine that was awake counts as asleep after about 20 s of continuous connection failures. Only retryable failures count: a blocked connection (rejected credentials, missing permission) shows "Can't connect" instead. Its reconnect loop is then paused without switching the environment off, so its threads stay listed. While the app is open, it rechecks once on foreground and then once a minute. After a wake it shortens backoff waits for up to 2.5 min, but never interrupts an attempt that is still connecting. Switched-off or removed environments are never reconnected by this code: a queued resume checks the environment's enabled flag right before and right after connecting, and disconnects again if it was switched off in between. A pause left over from a race is undone.

  • Sleep confirmation. A sent sleep, built-in or custom, doesn't mark the machine Asleep straight away. It shows "Going to sleep…" until the connection fails or a live-session probe (every 5 s) goes unanswered. If the machine is still answering after 45 s, the sheet shows "It didn't go to sleep." and reconnects keep running. A failed probe while the phone itself is offline doesn't count.

  • Sleep safety. Sleeping first probes the live session. A machine that fell asleep keeps reading as connected until its socket times out, so a tap on a stale Awake switch rechecks instead of sending a sleep. The command is chosen before the probe; if the power settings or the environment's on/off state changed during it, nothing is sent. It asks for confirmation only when threads are running on that machine.

  • Header. A power-managed environment that isn't connected is left out of the header's "Reconnecting" status.

The code is in apps/mobile/src/features/power/. Outside that folder, state/workspaceModel.ts gets the header change and persistence/mobile-preferences.ts gets the new environmentPower preference. iOS, web and desktop are unchanged.

Testing

  • Unit tests cover the state machine, including the sleep confirmation (sent → connection fails → asleep and paused; sent → still connected past 45 s → error, not paused; a probe that goes unanswered), the launch check (silent → Asleep at once, paused on the next failure, overruled by a connection), blocked connections, stale wake results, command and target selection (no built-in fallback without settings), the reattach rewrite rule, and MAC and local-address parsing. The header projection has a new test. Mobile type-check and lint pass.
  • I ran the built-in POSIX wake through the same sh -c path against a local UDP listener. It sends a correct 102-byte magic packet.
  • I ran the built-in launch check through the same path from a Linux relay. Against an awake Windows desktop on the LAN with Network access on, it exited 0 in 78 ms; with the wrong environment id, a refused port, or an unresolvable name it exited 2 (unknown); against an address that drops packets it exited 3 (silent) after 1 s.
  • Not tested: the PowerShell paths (Windows wake relay and Windows sleep; a Windows relay skips the launch check), the foreground sleep commands on real machines, the launch check against a sleeping machine, and the runner's write-retry and settle paths, which need a live terminal and a dropped connection. The queued-resume race fix has no automated test.
  • Manually tested on Android with an earlier build, using custom commands: wake and sleep through the relay worked. Adversarial reviews with GPT-6.1 Sol: seven issues fixed in 7f67cbd, four in 7e83ea9, and four rounds on the launch-check work found twelve more, all fixed by 6714f4a. The last round found only the empty-state chip issue, which wasn't re-reviewed after its fix. The built-in commands haven't been tried on a device yet.

Built with Claude Opus 5.5 in Claude Code, running inside T3 Code.

🤖 Generated with Claude Code

tebiggs and others added 13 commits October 21, 2026 04:02
A power-managed environment shows a status chip above the thread list.
Tapping it opens a sheet with one Awake switch. Wake and sleep commands
run in a short-lived terminal on another connected environment on the
same LAN (for example `pc-power wake` on an always-on box), so the server
is unchanged.

Status comes from reachability: when a power-managed environment fails
to connect it is treated as asleep and its reconnect loop is paused,
without switching the environment off, so its threads stay listed. The
app checks again on foreground and retries every few seconds after a
wake. Sleeping asks for confirmation only when threads are running on
that machine.

Configured per environment in Settings -> Environments (Android only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
…f outage

A single failed reconnect marked a power-managed machine asleep and paused
its connection, so a dropped socket on app resume or a server restart left
the chip on Asleep until the next tap. A machine that was awake now counts
as asleep only after 20 s of continuous failures, and while the app is open
an asleep machine gets one connection attempt a minute.

Sleeping now probes the live session first. A machine that fell asleep
keeps reading as connected until its socket times out, so a tap on a stale
Awake switch rechecks the connection instead of sending a sleep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…op-power

# Conflicts:
#	apps/mobile/src/Stack.tsx
#	apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx
#	apps/mobile/src/state/workspace.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wake and sleep defaults called `pc-power`, a script that only exists
on one machine. Wake now sends a Wake-on-LAN broadcast from the chosen
relay (python3 on Linux/macOS, PowerShell on Windows) to a MAC address
set in Settings, and sleep runs on the machine itself over its own
connection (systemctl, pmset or SetSuspendState, started after a short
delay so the exit status arrives first). Custom commands still replace
either one and run on the relay.

Review fixes:
- Wake retries only cut a backoff wait short; they no longer interrupt an
  attempt that is still connecting.
- A pause that lands after a late connected update is undone instead of
  leaving the chip on Checking with reconnects stopped.
- Rechecks and cleanup never reconnect a switched-off or removed
  environment.
- The terminal runner uses PowerShell on Windows, reattaches after a
  dropped connection, reads the exit from the reattach snapshot, and
  bounds terminal cleanup.
- A missed pre-sleep probe no longer skips the 20 s grace period later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The built-in sleep runs the OS call in the foreground, so a refusal
  comes back as a failed exit. A call still running 5 s after its input
  reached the shell counts as sent.
- A sent sleep, built-in or custom, marks the machine asleep only once
  its connection fails or a live probe goes unanswered. After 45 s of
  answering it shows "It didn't go to sleep." and keeps reconnecting.
- Sleep resolves its target before the pre-sleep probe and aborts if the
  settings or the environment's enabled state changed meanwhile.
  sleepTarget without settings is an error, never the built-in sleep.
- A queued resume checks the registry entry's enabled flag before and
  after connecting, so it can't reconnect a switched-off environment.
- A lost terminal-write reply no longer fails the action. The reattach
  snapshot rewrites the input only when the shell never echoed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sleeping machine took 20-35 s to show as asleep after the app started:
its first connection attempt has to time out, and then the 20 s grace
period for brief outages runs.

With an optional local address in Power settings, the "Wake through"
machine checks that address once per launch, through the same throwaway
terminal the wake uses. It connects to the machine's T3 port with a 1 s
timeout and confirms the environment id from the public descriptor. On
silence the chip shows Asleep right away while the reconnect loop keeps
running: one more failure pauses it, and connecting makes it awake. A
refused connection, another server, or a relay without python3 changes
nothing. Later outages keep the grace period, since the check can't tell
a sleeping machine from a restarting server.

Test fixtures no longer name a specific wrapper script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…connections

- A blocked connection (rejected credentials, missing permission) no
  longer counts as sleep; it shows "Can't connect" and clears any
  earlier failures or asleep guess, since the machine answered.
- Wake, sleep and probe results that land after the machine connected,
  or after power control was turned off, are ignored. Turning power
  control off always resumes the reconnect loop, including mid-wake.
- Throwaway terminal ids get a random suffix, so commands started in the
  same millisecond on one relay don't share a terminal.
- A launch check result is dropped if the settings changed while it ran,
  and a settings change allows a new check. A silent check after a
  failed connection pauses at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…minals

- A wake whose machine answers with a blocked connection ends there and
  shows "Can't connect", instead of timing out into Asleep.
- A launch check result applies only while the machine's own connection
  is still trying, and never after it connected once this launch.
- Each built-in sleep first closes the power terminals and history that
  earlier sleeps on that machine couldn't close after it stopped
  answering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Built-in sleep terminals use their own terminal thread, so clearing
  the ones earlier sleeps left can't close other power commands running
  on that machine.
- A live probe that fails while the phone is offline no longer confirms
  a sleep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Home's empty states skipped the thread list header that holds the
chips, so with no threads, or an empty filter on Android, a sleeping
machine had no way to be woken from Home.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant