Skip to content

Commit e9ae1a1

Browse files
Make the phone a mirror, and publishing to the tailnet a button
Both from beta.1 feedback. **One button instead of a terminal command.** Publishing the portal to the tailnet was a `tailscale serve --bg <port>` line the user had to run themselves. The reasoning was that the step deserved to be *visible* — it's what makes the journal reachable from another device. But visible and manual aren't the same thing, so `portal_serve` / `portal_unserve` now run it, the button still says exactly what it runs, it shows the resulting *.ts.net URL, and it's reversible. Tailscale's own refusals ("not logged in", "HTTPS must be enabled in the admin console") are shown verbatim rather than flattened into a generic failure, because that message is the fix. **The phone mirrors the desktop.** /m was a four-button subset — dose, note, combo, Companion — and you couldn't even start a session without walking to the desk. It's now Now / Journal / Combo / Look up / Talk: start and end sessions, log doses, edit and delete them, notes with the crisis scan, browse history, the substance log, the DoseWiki dose tables, corpus search, and your catalogue. This changed **no permissions**: every command the mirror needs was already in `EXPOSED`. The allowlist still excludes wiping the journal, the passphrase, backups, Obsidian sync, the Ollama installers, and the filesystem commands — a phone is the device you lose. The new `portal_serve`/`portal_unserve` are excluded too: a phone may not publish or unpublish its own access. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e97225c commit e9ae1a1

11 files changed

Lines changed: 570 additions & 145 deletions

File tree

ROADMAP.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,11 @@ non-negotiables, each with a test:
197197
- **Transport swap**`src/lib/api.ts` picks Tauri `invoke` on the desktop and
198198
`fetch` on the phone (`src/lib/portal.ts`). One function; the 1,900-line UI never
199199
learned about it. **`api.ts` remains the only file allowed to import `invoke`.**
200-
- **Mobile route**`/m`: dose, note, combo, Companion. A phone-shaped *subset*, not
201-
the desktop page made responsive.
200+
- **Mobile route**`/m`: Now (start/end a session, log a dose, edit or delete one,
201+
notes with the crisis scan), Journal (history + substance log), Combo, Look up (dose
202+
table + corpus prose + your catalogue), Talk. A phone-shaped **mirror** of the
203+
desktop, re-laid out for one hand — not the desktop page made responsive. It started
204+
as a four-button subset; beta feedback was that the subset was the wrong call.
202205
- **PWA shell**`static/manifest.webmanifest` + Apple meta tags: home-screen icon
203206
and standalone chrome. The **service worker is Phase 3b**, deliberately absent.
204207
- **Auth** — a 256-bit bearer token, compared in constant time, paired by scanning a
@@ -215,11 +218,18 @@ non-negotiables, each with a test:
215218
commands, the filesystem commands, and the `portal_*` commands themselves stay
216219
unreachable.
217220

218-
**Still to do for 3a:** the user runs `tailscale serve --bg <port>` themselves — the
219-
app detects Tailscale, shows the exact command, and shows the resulting `*.ts.net`
220-
URL, but does not run it for them. That's deliberate for now (it's the step that makes
221-
the journal reachable from another device, and it should be visible), but a one-click
222-
version behind a confirmation is a reasonable follow-up.
221+
**Publishing to the tailnet is one button** (`portal_serve` / `portal_unserve`, wired to
222+
Settings → Phone access). It was originally a command for the user to run by hand, on the
223+
theory that the step deserved to be visible; beta feedback was that this is too much to
224+
ask of an end user, and the honest fix is to keep it *visible* — the button says what it
225+
runs, shows the resulting `*.ts.net` URL, and is reversible — rather than to keep it
226+
*manual*. Tailscale's own refusals ("not logged in", "HTTPS must be enabled in the admin
227+
console") are surfaced verbatim, because that message is the fix.
228+
229+
**Note the asymmetry:** the phone mirrors the journal, but it cannot mirror the *portal's
230+
own controls*. `portal_serve`, `portal_unserve`, `portal_enable`, and `portal_disable` are
231+
not in `EXPOSED` — a phone may not publish, unpublish, or reconfigure its own access. That
232+
decision is made at the desk.
223233

224234
### Phase 3b — offline capture. Lets you log while the Mac is asleep or off-tailnet.
225235
The journal is **append-only** in practice (a dose/note is a new row), so an outbox

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "field-notes",
3-
"version": "0.4.0-beta.1",
3+
"version": "0.4.0-beta.2",
44
"description": "Offline harm-reduction journal & trip-sitting workstation.",
55
"type": "module",
66
"private": true,

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "field_notes"
3-
version = "0.4.0-beta.1"
3+
version = "0.4.0-beta.2"
44
description = "Offline harm-reduction journal & trip-sitting workstation"
55
authors = ["sparkly-quasar"]
66
# Licensed under the PolyForm Noncommercial License 1.0.0 — see ../LICENSE.

src-tauri/src/commands.rs

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -969,9 +969,12 @@ pub struct TailscaleStatus {
969969
pub installed: bool,
970970
/// The tailnet hostname to reach this machine on, if we could read one.
971971
pub host: Option<String>,
972-
/// The exact command that publishes the portal to the tailnet. We show it
973-
/// rather than silently running it — it is the step that makes the journal
974-
/// reachable from another device, and the user should see it happen.
972+
/// Tailscale is already proxying the tailnet to the portal's port.
973+
pub serving: bool,
974+
/// The tailnet URL the phone reaches, once we're serving.
975+
pub url: Option<String>,
976+
/// The equivalent command, for anyone who would rather run it themselves or
977+
/// wants to see what the button does. `portal_serve` runs exactly this.
975978
pub serve_command: Option<String>,
976979
}
977980

@@ -987,10 +990,31 @@ fn tailscale_bin() -> Option<String> {
987990
PATHS.iter().find(|p| Path::new(p).exists()).map(|p| p.to_string())
988991
}
989992

993+
/// Run a `tailscale` subcommand, returning its stderr as the error. Tailscale's own
994+
/// messages are the useful ones here ("HTTPS must be enabled in the admin console",
995+
/// "not logged in"), and a generic "failed to publish" would throw them away.
996+
fn tailscale_run(bin: &str, args: &[&str]) -> Result<String, String> {
997+
let out = std::process::Command::new(bin)
998+
.args(args)
999+
.output()
1000+
.map_err(|e| format!("Couldn't run Tailscale: {e}"))?;
1001+
if out.status.success() {
1002+
return Ok(String::from_utf8_lossy(&out.stdout).trim().to_string());
1003+
}
1004+
let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
1005+
Err(if msg.is_empty() { "Tailscale refused, without saying why.".into() } else { msg })
1006+
}
1007+
9901008
#[tauri::command]
9911009
pub fn portal_tailscale(portal: State<'_, Portal>) -> TailscaleStatus {
9921010
let Some(bin) = tailscale_bin() else {
993-
return TailscaleStatus { installed: false, host: None, serve_command: None };
1011+
return TailscaleStatus {
1012+
installed: false,
1013+
host: None,
1014+
serving: false,
1015+
url: None,
1016+
serve_command: None,
1017+
};
9941018
};
9951019

9961020
let host = std::process::Command::new(&bin)
@@ -1003,12 +1027,44 @@ pub fn portal_tailscale(portal: State<'_, Portal>) -> TailscaleStatus {
10031027
(!dns.is_empty()).then_some(dns)
10041028
});
10051029

1006-
let serve_command = portal
1007-
.status()
1008-
.port
1009-
.map(|p| format!("{bin} serve --bg {p}"));
1030+
let port = portal.status().port;
1031+
1032+
// Are we already proxying to *our* port? Tailscale may well be serving something
1033+
// else entirely; that isn't us, and turning it off isn't ours to do.
1034+
let serving = port.is_some_and(|p| {
1035+
tailscale_run(&bin, &["serve", "status", "--json"])
1036+
.map(|s| s.contains(&format!("127.0.0.1:{p}")))
1037+
.unwrap_or(false)
1038+
});
1039+
1040+
TailscaleStatus {
1041+
installed: true,
1042+
host: host.clone(),
1043+
serving,
1044+
url: (serving && host.is_some()).then(|| format!("https://{}/m", host.unwrap())),
1045+
serve_command: port.map(|p| format!("{bin} serve --bg {p}")),
1046+
}
1047+
}
1048+
1049+
/// Publish the portal to the tailnet: Tailscale terminates HTTPS on the tailnet and
1050+
/// proxies to our loopback port. This is the one button that makes the journal
1051+
/// reachable from another device, so it stays an explicit, reversible act — and it
1052+
/// refuses if the portal isn't actually running, rather than serving a dead port.
1053+
#[tauri::command]
1054+
pub fn portal_serve(portal: State<'_, Portal>) -> Result<TailscaleStatus, String> {
1055+
let bin = tailscale_bin().ok_or("Tailscale isn't installed on this Mac.")?;
1056+
let port = portal.status().port.ok_or("Turn on phone access first.")?;
1057+
tailscale_run(&bin, &["serve", "--bg", &port.to_string()])?;
1058+
Ok(portal_tailscale(portal))
1059+
}
10101060

1011-
TailscaleStatus { installed: true, host, serve_command }
1061+
/// Stop publishing. The portal itself keeps running on loopback — this only removes
1062+
/// the tailnet's route to it.
1063+
#[tauri::command]
1064+
pub fn portal_unserve(portal: State<'_, Portal>) -> Result<TailscaleStatus, String> {
1065+
let bin = tailscale_bin().ok_or("Tailscale isn't installed on this Mac.")?;
1066+
tailscale_run(&bin, &["serve", "--https=443", "off"])?;
1067+
Ok(portal_tailscale(portal))
10121068
}
10131069

10141070
// ---------- upstream contribution drafts ----------

src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ pub fn run() {
157157
commands::portal_disable,
158158
commands::portal_qr,
159159
commands::portal_tailscale,
160+
commands::portal_serve,
161+
commands::portal_unserve,
160162
commands::crisis_scan,
161163
commands::knowledge_search,
162164
commands::knowledge_status,

src-tauri/src/portal.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,10 +446,15 @@ mod tests {
446446
"ai_pull",
447447
"ai_start",
448448
"pw_update",
449-
// The portal may not reconfigure or disable itself.
449+
// The portal may not reconfigure or disable itself — including publishing
450+
// itself to the tailnet, which is a decision made at the desk.
450451
"portal_status",
451452
"portal_enable",
452453
"portal_disable",
454+
"portal_qr",
455+
"portal_tailscale",
456+
"portal_serve",
457+
"portal_unserve",
453458
];
454459
for c in forbidden {
455460
assert!(!EXPOSED.contains(&c), "`{c}` must not be reachable from the phone");

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Field Notes",
4-
"version": "0.4.0-beta.1",
4+
"version": "0.4.0-beta.2",
55
"identifier": "com.fieldnotes.journal",
66
"build": {
77
"beforeDevCommand": "npm run dev",

src/lib/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,13 +372,17 @@ export interface PortalStatus {
372372
export interface TailscaleStatus {
373373
installed: boolean;
374374
host: string | null;
375+
serving: boolean;
376+
url: string | null;
375377
serve_command: string | null;
376378
}
377379
export const portalStatus = () => invoke<PortalStatus>("portal_status");
378380
export const portalEnable = () => invoke<PortalStatus>("portal_enable");
379381
export const portalDisable = () => invoke<PortalStatus>("portal_disable");
380382
export const portalQr = (url?: string) => invoke<string>("portal_qr", { url });
381383
export const portalTailscale = () => invoke<TailscaleStatus>("portal_tailscale");
384+
export const portalServe = () => invoke<TailscaleStatus>("portal_serve");
385+
export const portalUnserve = () => invoke<TailscaleStatus>("portal_unserve");
382386

383387
// ---- erase all data / uninstall ----
384388
export const dataDir = () => invoke<string>("data_dir");

src/routes/+page.svelte

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
portalDisable,
4848
portalQr,
4949
portalTailscale,
50+
portalServe,
51+
portalUnserve,
5052
type PortalStatus,
5153
type TailscaleStatus,
5254
dbStatus,
@@ -168,6 +170,7 @@
168170
// The pairing QR carries the bearer token, so it is hidden until asked for —
169171
// it should not be sitting on screen behind you while you're screen-sharing.
170172
let showQr = $state(false);
173+
let serving = $state(false);
171174
let tailscaleUrl = $derived(
172175
ts?.host && portal.port ? `https://${ts.host}/m` : null,
173176
);
@@ -975,6 +978,25 @@
975978
showQr = true;
976979
}
977980
981+
/** Publish (or stop publishing) the portal to the tailnet. This is the step that
982+
* makes the journal reachable from another device, so it's one button and it's
983+
* reversible — and Tailscale's own refusal (not logged in, HTTPS not enabled in
984+
* the admin console) is shown verbatim, because that message is the fix. */
985+
async function toggleServe() {
986+
portalErr = null;
987+
serving = true;
988+
try {
989+
ts = ts?.serving ? await portalUnserve() : await portalServe();
990+
// The pairing URL changes with it, so any QR on screen is now stale.
991+
portalQrSvg = null;
992+
showQr = false;
993+
} catch (e) {
994+
portalErr = e instanceof Error ? e.message : String(e);
995+
} finally {
996+
serving = false;
997+
}
998+
}
999+
9781000
// ---- Upstream contribution ----
9791001
async function loadContrib() {
9801002
contribCands = await contributionCandidates();
@@ -1689,13 +1711,27 @@
16891711
</p>
16901712
{:else if !tailscaleUrl}
16911713
<p class="muted small">⚠️ Tailscale is installed but isn't logged in — sign in, then reopen this tab.</p>
1714+
{:else if ts.serving}
1715+
<p class="muted small">
1716+
Published to your tailnet. Your phone can reach <strong>{ts.url ?? tailscaleUrl}</strong> —
1717+
and nothing else can: it's your tailnet, encrypted end to end, and every request still
1718+
needs the paired token.
1719+
</p>
1720+
<button class="small-btn" disabled={serving} onclick={toggleServe}>
1721+
{serving ? "Working…" : "Stop publishing to my tailnet"}
1722+
</button>
16921723
{:else}
16931724
<p class="muted small">
1694-
One more step, and you run it yourself so you can see exactly what it does — it's the
1695-
command that publishes the portal to your tailnet:
1725+
One more step: publish the portal to your tailnet, so your phone can reach it.
1726+
Tailscale carries it, encrypted — this does not open anything to the internet or to
1727+
your local network. You can undo it here at any time.
1728+
</p>
1729+
<button class="primary small-btn" disabled={serving} onclick={toggleServe}>
1730+
{serving ? "Publishing…" : "Publish to my tailnet"}
1731+
</button>
1732+
<p class="muted small">
1733+
That runs <code>{ts.serve_command}</code>, if you'd rather do it yourself.
16961734
</p>
1697-
<pre class="draft">{ts.serve_command}</pre>
1698-
<p class="muted small">Then your phone opens <strong>{tailscaleUrl}</strong>.</p>
16991735
{/if}
17001736

17011737
{#if showQr && portalQrSvg}

0 commit comments

Comments
 (0)