Files
tty7/mobile/src/api.ts
T
l0ng-aiandClaude 4c8c317b91 feat(mobile): files, changes, a custom key bar, and a steadier connection (#1029)
The phone can now hand a pane a photo or file, read what an agent changed,
answer its prompts with a tap, and keep its link across leaving a pane.

Gateway and protocol:
- `Open::Upload` puts a file (up to 20 MB) in the directory the desktop
  keeps pasted images in, under a name a shell takes unquoted, and answers
  with its path. Panes on this machine only for now.
- `Open::Diff` answers with the working tree's changes against HEAD and its
  untracked files, for the directory a pane is in.
- Both are one-shot streams; an older gateway drops them unanswered, which
  the client reports as "update tty7".

App:
- Attach button in the message box: the uploaded path goes into the draft.
- Changes sheet from a pane's menu: a block per file, lines coloured.
- Settings → Key bar: remove, reorder and add keys, from a catalog or
  written out (`/compact\r`, `^C`), across pages; reset to the default.
- An agent's numbered choices show as buttons while it waits.
- Pinch to zoom, tap a link to open it, find in the scrollback, a Copy
  button for text a program copies (OSC 52), and an optional Face ID lock.
- Swipe from the left edge to go back; the iOS WebView has no page stack.
- One workspace at a time on a machine, picked from a row of chips.
- Agent avatars in their own colours.
- Each screen's watch is its own: a second watch no longer ends the first,
  which showed as "Can't reach" and a reconnect after leaving a pane.
- Dialing no longer holds the session lock, so Forget works while a
  machine is still connecting; its confirmation is an in-app sheet, since
  `window.confirm` shows nothing in the iOS WebView.
- iOS 15 minimum, as App Store Connect will require from April 2027.
- `scripts/testflight.sh` archives, signs for the App Store at export (the
  team has no devices for a development profile) and uploads.

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-30 12:30:04 +08:00

165 lines
4.9 KiB
TypeScript

// The frontend's view of the Rust side: typed wrappers over its commands, and
// the shapes that cross the bridge. The wire types mirror tty7-mobile-proto;
// its tests pin the tags these match on.
import { Channel, invoke } from "@tauri-apps/api/core";
export interface Host {
id: string;
name: string;
relay?: string | null;
addrs: string[];
}
export type AgentStatus = "idle" | "working" | "waiting" | "done";
export interface AgentView {
kind: string;
status: AgentStatus;
message?: string | null;
}
export interface PaneView {
id: number;
title: string;
cwd?: string | null;
agent?: AgentView | null;
}
export interface TabView {
id: string;
name: string;
hibernated: boolean;
panes: PaneView[];
}
export interface WorkspaceView {
id: string;
name: string;
tabs: TabView[];
}
export interface Tree {
host: string;
workspaces: WorkspaceView[];
/** Machines the desktop is linked to over SSH. Absent from older gateways. */
remotes?: RemoteView[];
}
export interface RemoteView {
key: string;
name: string;
connected: boolean;
error?: string | null;
/** Up, but its first read has not come back: workspaces unknown, not absent. */
pending?: boolean;
workspaces: WorkspaceView[];
}
export interface LinkInfo {
path: "direct" | "relay" | "connecting";
rtt_ms: number;
}
export type TreeMsg =
| { type: "tree"; tree: Tree }
| { type: "link"; link: LinkInfo }
| { type: "error"; message: string }
| { type: "closed" };
export type PaneEvent =
| { type: "size"; cols: number; rows: number }
| { type: "cwd"; path: string }
| { type: "agent"; agent: AgentView | null }
| { type: "exited"; code: number | null }
| { type: "error"; message: string }
/** Whether the pane runs at this phone's size; `refused` says why not. */
| { type: "lease"; held: boolean; refused?: string | null };
export const hosts = () => invoke<Host[]>("hosts");
export const pair = (code: string, deviceName: string) =>
invoke<Host>("pair", { code, deviceName });
export const forget = (hostId: string) => invoke<void>("forget", { hostId });
/** Sets the style of what the page does not draw: status bar, keyboard. */
export const appearance = (style: "system" | "light" | "dark") => invoke<void>("appearance", { style });
export function watch(hostId: string, onMsg: (msg: TreeMsg) => void) {
const onEvent = new Channel<TreeMsg>();
onEvent.onmessage = onMsg;
return invoke<number>("watch", { hostId, onEvent });
}
/** Ends a watch by the id `watch` resolved with. */
export const unwatch = (watch: number) => invoke<void>("unwatch", { watch });
export const refresh = (watch: number) => invoke<void>("refresh", { watch });
/** Output arrives as ArrayBuffers of raw terminal bytes, events as objects. */
/** `machine` is a remote's key, or null for the paired machine itself. */
export function paneOpen(
hostId: string,
machine: string | null,
paneId: number,
onOutput: (bytes: Uint8Array) => void,
onEvent: (event: PaneEvent) => void,
) {
const channel = new Channel<ArrayBuffer | PaneEvent>();
channel.onmessage = (msg) => {
if (msg instanceof ArrayBuffer) onOutput(new Uint8Array(msg));
else onEvent(msg);
};
return invoke<number>("pane_open", { hostId, machine, paneId, onOutput: channel });
}
export interface TabCreated {
tab_id: string;
pane_id: number;
}
export const tabNew = (
hostId: string,
machine: string | null,
workspaceId: string,
cwd: string | null,
size: { cols: number; rows: number } | null,
) => invoke<TabCreated>("tab_new", { hostId, machine, workspaceId, cwd, size });
export const paneInput = (handle: number, data: string) =>
invoke<void>("pane_input", { handle, data });
/** Run the pane at `size`, the phone's grid; `null` gives it back. */
export const paneLease = (handle: number, size: { cols: number; rows: number } | null) =>
invoke<void>("pane_lease", { handle, size });
/** The largest file the gateway takes (`tty7_mobile_proto::MAX_UPLOAD`),
* checked here too so a big video is refused before it is read in. */
export const MAX_UPLOAD = 20 * 2 ** 20;
/** Sends a file to the machine a pane runs on; resolves with its path there.
* The bytes go as the raw body; the rest rides in headers, encoded, since a
* header is ASCII and a file name need not be. */
export const upload = (hostId: string, machine: string | null, name: string, bytes: Uint8Array) =>
invoke<string>("upload", bytes, {
headers: {
"x-host": encodeURIComponent(hostId),
"x-name": encodeURIComponent(name),
"x-machine": encodeURIComponent(machine ?? ""),
},
});
export interface Diff {
root: string;
patch: string;
untracked: string[];
truncated: boolean;
}
/** What has changed in the repository `cwd` is in. */
export const diff = (hostId: string, machine: string | null, cwd: string) =>
invoke<Diff>("diff", { hostId, machine, cwd });
export const paneClose = (handle: number) => invoke<void>("pane_close", { handle });