No description
  • TypeScript 45.3%
  • JavaScript 23.4%
  • HTML 17.5%
  • CSS 10.1%
  • Shell 2%
  • Other 1.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-14 14:26:29 +09:00
design feat: give the grid one floating control instead of two 2026-08-22 02:11:53 +09:00
docs feat: give the grid one floating control instead of two 2026-08-22 02:11:53 +09:00
nix Read the default gateway from the deployment, not the build (#2) 2026-10-14 13:23:48 +09:00
public Follow felis-web-component to felis v0.1.0 (#1) 2026-09-26 20:58:46 +09:00
scripts Follow felis-web-component to felis v0.1.0 (#1) 2026-09-26 20:58:46 +09:00
src Mark an unreachable host in the session list instead of raising a toast (#4) 2026-10-14 14:26:29 +09:00
test feat: give the grid one floating control instead of two 2026-08-22 02:11:53 +09:00
.gitignore feat: connect real sessions — broker core, tab store, and the one-bar shell 2026-08-07 23:17:27 +09:00
flake.lock List each host on its own, so a sleeping one cannot hide the rest (#3) 2026-10-14 14:02:49 +09:00
flake.nix Read the default gateway from the deployment, not the build (#2) 2026-10-14 13:23:48 +09:00
index.html chore: rename pepe to felis-web 2026-08-09 10:53:06 +09:00
package-lock.json chore: stop writing this machine's name into the dev server 2026-08-15 10:55:20 +09:00
package.json feat: serve over HTTPS, so the secure-context APIs exist at all 2026-08-15 12:29:34 +09:00
README.md Read the default gateway from the deployment, not the build (#2) 2026-10-14 13:23:48 +09:00
tsconfig.json chore: stop writing this machine's name into the dev server 2026-08-15 10:55:20 +09:00
tsconfig.node.json chore: stop writing this machine's name into the dev server 2026-08-15 10:55:20 +09:00
vite.config.ts feat: serve over HTTPS, so the secure-context APIs exist at all 2026-08-15 12:29:34 +09:00

felis-web

A mobile web terminal client — a phone-first PWA over felis-web-component's <felis-terminal> component and its multiplexed felis-broker.v1 gateway.

felis-web-component is the terminal; felis-web is the app around it — tabs, a customizable shortcut bar, a touch model, settings, and an installable shell.

Open design/templates/shell.html in a browser to see the whole screen; the pages are plain HTML over one stylesheet and need no build.

Develop

felis-web consumes felis-web-component as a file: dependency, so felis-web-component has to be built first — the .js and web/pkg/ that felis-web imports are gitignored build artifacts of that repo:

cd ../felis-web-component && nix develop -c sh -c '
  cargo build --lib --target wasm32-unknown-unknown --release \
  && wasm-bindgen target/wasm32-unknown-unknown/release/felis_web_component.wasm --out-dir web/pkg --target web \
  && tsgo'

Then:

nix develop
npm install
npm run dev

The dev server binds every interface on port 5180, because the only honest test of this app is a real phone. It answers to this machine's own hostname, to .local and to .ts.net; reaching it by any other name — through a reverse proxy, say — needs that name declared, because Vite rejects Host headers it does not know and this server holds a shell credential:

FELIS_WEB_PORT=5180 FELIS_WEB_HOSTS=proxy.example.com,other.name npm run dev

HTTPS

A service worker, navigator.clipboard, Screen Wake Lock and crypto.randomUUID are secure-context APIs — off localhost they are absent, not merely permission-gated — so the installable PWA does not exist over plain HTTP however well the page renders. tailscale cert provisions a publicly trusted certificate for this machine's tailnet name, which a phone both resolves and already trusts:

sudo tailscale set --operator=$USER   # once, so refreshes need no root
npm run cert                          # mint into $XDG_RUNTIME_DIR/felis-web-tls
npm run dev:tls                       # https://<host>.<tailnet>.ts.net:5180

TLS is opt-in and never inferred from the certificate being on disk: plain HTTP is not the lesser mode here — it is what the browser suite requires, and switching the whole dev loop over on the appearance of a file would break npm test from a directory nobody was editing. Asking for TLS without a readable pair is a hard failure rather than a silent downgrade. npm run rig reads the same FELIS_WEB_TLS switch, because a page served over https may not open a ws:// socket — the two ends cannot be allowed to disagree.

Test

npm test              # 32 unit checks, then 50 in a browser
npm run test:unit     # label and state parsing only, no browser, no daemon
npm run test:android  # the same 50 on the phone itself, over adb

The browser suite drives a real app against a real daemon, so it needs one running behind a gateway. npm run rig boots both — a throwaway daemon and a gateway on port 8781 — and prints the gateway and token to enter on a phone's connect screen. It runs in the foreground and tears them down on Ctrl-C:

npm run rig

With that up, and the dev server:

ORIGIN=http://host:5180 TOKEN=… FELIS_SOCKET=/run/user/1000/felis-web-test/d.sock npm test

All three have defaults. ORIGIN is this machine's own hostname on FELIS_WEB_PORT — deliberately not localhost, which is a secure context and would hide the whole class of failure the suite exists to catch. The daemon socket and the token come from a throwaway rig at $XDG_RUNTIME_DIR/felis-web-test/ — not from felis's own default socket, because the suite creates and kills sessions and must never be pointed at the daemon you are working in. FELIS_WEB_RIG moves it.

GATEWAY overrides the gateway the app would pick for itself (ws://<the page's own host>:8771), so the suite can be aimed at one running on a test port while a phone stays pointed at another. It has to be paired with the FELIS_SOCKET of the daemon that gateway is attached to — the suite now refuses to continue if the two turn out to be different daemons, which is the one rig failure that otherwise reads as a green run.

For test:android, ORIGIN has to be an address the phone can reach.

Nearly nothing is unit-tested, on purpose — the reasoning is in docs/design.md, along with why a green suite is not the same as a looked-at screen.

Deploy

One machine runs two things: the gateway, and a web server for the built app. Every other machine only needs felis-daemon and an ssh login the gateway's user can reach — the gateway names them with --host, and their daemons come up on first use.

  • The gateway runs as a user service from felis-web-gateway's home-manager or NixOS module, which documents it.
  • The app is static files. Serve it over HTTPS: the installable app needs a secure context (see "HTTPS" above).

The app reads /config.json beside it at startup. Without one, it looks for its gateway on the page's own host, port 8771; behind a reverse proxy that serves both on one origin, name the path instead, so the connect screen needs only the token. The flake's module (NixOS or nix-darwin) writes that file next to the app and hands back the directory to serve, so a different gateway is a config change rather than a rebuild:

imports = [ felis-web.nixosModules.default ];
services.felis-web.settings.gateway = "/gateway";

services.caddy.virtualHosts."felis.example".extraConfig = ''
  handle /gateway {
    reverse_proxy 127.0.0.1:8771
  }
  handle {
    root * ${config.services.felis-web.root}
    file_server
  }
'';

A gateway on loopback starts without a token, because nothing off the machine can reach it — but a proxy in front of it changes that, so give it tokenFile and origins anyway.

Status

Usable. Connects to a felis-broker.v1 gateway with a token, keeps tabs across a dead radio, and carries:

  • Sessions — a list per daemon the gateway knows, swipe to close or kill, hold to peek at what one is showing without switching to it.
  • Touch — scroll, select and mouse-reporting modes, with the mode visible in the chrome and following the running program by default.
  • Scrollback — search that seeks the viewport to a hit, and OSC 133 prompt jumps.
  • The dial — the one control that floats over the grid. A tap goes back to the newest output; holding it fans out the prompt jumps, search and copy-last-output, and releasing on one fires it. It replaced a stacked pair of jump buttons, which was twice the cover for one idea — see docs/design.md.
  • Keys — a customizable shortcut bar with prefix modifiers, so holding Ctrl and typing a letter reaches every control key rather than the four that have buttons. A paste button, because the phone's soft keyboard has no paste key and the grid is not a text field the OS's own paste menu appears over.
  • Gestures — hold and drag to move the cursor along the line or back through history; hold and let go for the actions sheet under the thumb; flick sideways for the next session; double tap for Tab; pinch for the text size. All five in scroll mode only, all five written out in settings, because a gesture nobody was told about does not exist. What the other phone terminals do, and why these five, is in docs/design.md.
  • Links — a tap on a URL, a path or a SHA the program printed, because a phone points well and types badly. OSC 8 links work as themselves; everything else is matched by rules you can edit, and a tap copies, inserts or opens.
  • Chrome — six themes, with the whole app palette derived from the terminal's own, and a PWA shell: manifest, service worker, safe areas.
  • The face — 0xProto Nerd Font Mono, served by the app. The terminal component ships no font on purpose, so without one a phone renders the grid in its system monospace and every Nerd Font glyph a prompt draws comes out as tofu. Bold and italic are fetched only if a program emits them.
  • Text size — the cell, not the page: settings steps it and the daemon is told, so smaller text is more columns rather than the same wrapped lines drawn smaller. The row says how many columns the screen will get, since that is the thing being chosen.

HTTPS is reachable now (see above), which is what the secure-context APIs — service worker, clipboard, wake lock — were waiting on, and the app now runs on a phone as an installed PWA.

A survey of what Termux, Blink, Termius, Secure ShellFish and Prompt 3 put on top of a terminal turned into the gesture layer above, and the gestures in turn found three bugs that had been reachable by hand all along — a sheet opened by a gesture firing one of its own rows, a finger that left the terminal never being released, and select mode handing the pinch to the browser. A fourth — Chrome holding the first tap after a flick that switched session — went when the flick got an axis lock, which it needed anyway: judged only at its release a sideways flick was not exclusive with the scroll it travelled through, so one thumb got both. All of it is written up in docs/design.md.

The touch model has now been felt, which it never had been — every constant behind it came from arithmetic in a desktop browser. A thumb accepted the distance and the friction and rejected the ending: because the viewport moves in whole rows, the decay's last row used to arrive up to a second after the one before it. That is fixed and now has a check of its own; the reasoning is in What a thumb found.

What still has not happened is the suite on the phone — the checks below have only ever run against emulated touch. The roadmap at the end of docs/design.md says what is left.