Skip to content

Commit a4366c3

Browse files
PressF4meclaude
andcommitted
Vendor c6b44bc: persist the ticket and the role, add Viewer mute
The reconnect bug: neither role kept its pairing ticket past the tab it was set in, so hangUp()'s reload, a browser refresh, or just reopening a channel from the list always looked like the code had expired to the relay — it refuses a hello with no ticket exactly like it refuses a stranger. Both roles now persist it per channel (tawny.token.<id>) and read it back on start(). The role bug: opening a channel always asked Monitor-or-Viewer again, even on a device that only ever plays one part for it. Remembered per channel (tawny.role.<id>) now, with a "Switch role" row-menu action for a phone being repurposed. Added a Viewer-side Mute (purely local playback, independent of talk- back and the Monitor's own mic). README: led with the actual point of this container — a bridge so whoever is watching never needs the app installed at all, not just a browser-Viewer-only tool. Both ends can be the same web page too. Verified on the built image: /healthz, /setup, /setup.json all 200; served app.js/i18n.js/index.html carry every change (index.html fetched past the setup gate with the tawny_setup_done skip cookie). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uct7Y3wVjJHTuX5F31a3dZ
1 parent b6f29ce commit a4366c3

5 files changed

Lines changed: 118 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,21 @@ Tailscale.
88
nothing hosted by anyone else. You point an old phone at the pet and watch from
99
a browser; the only thing that ever sees the stream is you.
1010

11-
The **Monitor** is the Tawny Android app on an old phone pointed at your best friend, or your pet. The
12-
**Viewer** is a browser on a laptop, desktop, or another phone. This container
13-
serves the Viewer's page and joins your tailnet so the two ends can find each
14-
other; the video and audio go straight between them and never pass through it.
11+
**What this is mainly for: a bridge.** The Monitor phone runs the Tawny Android
12+
app, pointed at your pet. Whoever's watching doesn't need the app at all — they
13+
open the `https://…ts.net` address this container publishes, in a plain
14+
browser, on anything signed into your tailnet: a laptop, someone else's phone,
15+
a shared family tablet. No app store, no install, no account, for them. That's
16+
the point of running this at all — the app download that's easy for you is a
17+
wall for a grandparent, a house-sitter, or a phone you don't control.
18+
19+
It doesn't stop there, though. Both ends can just as well be this same web
20+
page — open it on two phones, pick Monitor on one and Viewer on the other, and
21+
it works exactly the same way with nothing installed on either. Use whichever
22+
mix fits the room: app on the Monitor and a browser Viewer (the common case),
23+
or browser on both. The container serves either role and joins your tailnet so
24+
the two ends can find each other; the video and audio go straight between them
25+
and never pass through it.
1526

1627
The image is pulled from `ghcr.io/pressf4me/tawny` — nothing to build, and the
1728
only file you need from this repo is `docker-compose.yml`.
@@ -134,13 +145,14 @@ outside. Tailscale is how this is meant to run; LAN-only is a fallback.
134145

135146
## Two ways to self-host
136147

137-
This container is for **browser Viewers** over your Tailscale; the Monitor phone
138-
stays stock.
148+
This container is the bridge: the app as the Monitor, and a **browser Viewer**
149+
— any device, no install — over your Tailscale. It works the other way and
150+
both-ways too; either role can be a browser, either can be the app.
139151

140-
For **phone-to-phone remote pairing** — both ends the Tawny app on different
141-
networks — see the `rendezvous/` service in the Tawny app repo: a Cloudflare
142-
Worker / Deno signalling introducer. The two paths are independent and can run
143-
together.
152+
For **phone-to-phone remote pairing without this container at all** — both ends
153+
the Tawny app, on different networks — see the `rendezvous/` service in the
154+
Tawny app repo: a Cloudflare Worker / Deno signalling introducer. The two paths
155+
are independent and can run together.
144156

145157
---
146158

‎app/.source-commit‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
c187445417f240dcaec6def4220e9e6b732bcb38
1+
c6b44bce6b2a630c6ef646b671ff1e8cef1294ba

‎app/public/app.js‎

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,10 @@ function renderChannels() {
596596

597597
const cue = document.createElement('span');
598598
cue.className = 'ch-cue';
599-
cue.textContent = 'Choose Monitor or Viewer';
599+
const remembered = roleFor(ch);
600+
cue.textContent = remembered === 'station' ? 'Resume as Monitor'
601+
: remembered === 'viewer' ? 'Resume as Viewer'
602+
: 'Choose Monitor or Viewer';
600603

601604
const fp = document.createElement('span');
602605
fp.className = 'ch-fp';
@@ -635,6 +638,8 @@ function deleteChannel(ch) {
635638
localStorage.removeItem(`tawny.sas.${ch.id}`);
636639
localStorage.removeItem(`tawny.bond.${ch.id}`);
637640
localStorage.removeItem(`tawny.paired.${ch.id}`);
641+
localStorage.removeItem(`tawny.token.${ch.id}`);
642+
localStorage.removeItem(`tawny.role.${ch.id}`);
638643
} catch {}
639644
renderChannels();
640645
toast('Monitor removed');
@@ -658,6 +663,11 @@ $('#row-rename').addEventListener('click', () => {
658663
closeRowMenu();
659664
if (ch) openEditor(ch);
660665
});
666+
$('#row-switch-role')?.addEventListener('click', () => {
667+
const ch = menuChannel;
668+
closeRowMenu();
669+
if (ch) openRole(ch, true);
670+
});
661671
$('#row-delete').addEventListener('click', () => {
662672
const ch = menuChannel;
663673
closeRowMenu();
@@ -705,16 +715,23 @@ $('#editor-save').addEventListener('click', saveEditor);
705715
$('#editor-cancel').addEventListener('click', () => { closeSheet(el.editor); S.editing = null; });
706716
el.editorName.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveEditor(); });
707717

708-
function openRole(ch) {
718+
/**
719+
* `force` skips the remembered role — the "Switch role" row-menu action uses
720+
* it, so a household that repurposes a phone is not stuck asking Tawny to
721+
* forget first.
722+
*/
723+
function openRole(ch, force = false) {
709724
S.channel = ch;
725+
const remembered = !force && roleFor(ch);
726+
if (remembered) { start(remembered); return; }
710727
el.roleName.textContent = ch.name;
711728
note(el.roleNote, '');
712729
show(el.role);
713730
}
714731

715732
$('#role-back').addEventListener('click', () => { S.channel = null; show(el.channels); });
716-
$('#pick-station').addEventListener('click', () => start('station'));
717-
$('#pick-viewer').addEventListener('click', () => start('viewer'));
733+
$('#pick-station').addEventListener('click', () => { setRoleFor(S.channel, 'station'); start('station'); });
734+
$('#pick-viewer').addEventListener('click', () => { setRoleFor(S.channel, 'viewer'); start('viewer'); });
718735

719736
// ------------------------------------------------------- first-run setup
720737

@@ -771,6 +788,7 @@ function tickWatchPair() {
771788

772789
async function openWatchPair() {
773790
S.channel = watcherChannel();
791+
setRoleFor(S.channel, 'station');
774792
// Same rule as openPair(): never open on a code that has already lapsed.
775793
if (pairCodeLeft() <= 0 && !S.nativeShell) newPairCode();
776794
// refreshWatchPair() first, because pairLink() is what mints S.token — and
@@ -952,6 +970,40 @@ function fillRelayHint(box, code) {
952970

953971
const PAIRED_KEY = () => `tawny.paired.${S.channel?.id}`;
954972
const BOND_KEY = () => `tawny.bond.${S.channel?.id}`;
973+
// The pairing ticket itself — the shared secret a Monitor mints once and every
974+
// Viewer must present. Neither role used to keep it past the tab it was set
975+
// in: a browser Monitor minted a fresh one on every page load, and a browser
976+
// Viewer only ever had one moment (adopt()'s URL fragment) to learn it. Either
977+
// alone made hangUp()'s reload, a browser refresh, or just reopening a channel
978+
// from the list indistinguishable from the code having expired — the relay
979+
// refuses a hello with no ticket (or the wrong one) the same way it refuses a
980+
// stranger. Persisted per channel, it survives all three.
981+
const TOKEN_KEY = () => `tawny.token.${S.channel?.id}`;
982+
function savedToken() {
983+
let v = null;
984+
try { v = localStorage.getItem(TOKEN_KEY()); } catch {}
985+
return TICKET_RE.test(v || '') ? v : null;
986+
}
987+
function saveToken(token) {
988+
if (!S.channel || !TICKET_RE.test(token || '')) return;
989+
try { localStorage.setItem(TOKEN_KEY(), token); } catch {}
990+
}
991+
992+
// Which job this device does for a given channel, remembered so opening it a
993+
// second time goes straight to that screen instead of asking again — a phone
994+
// left as the household's Viewer is the Viewer every time, not a question.
995+
// Explicit `ch` argument (not S.channel) because renderChannels() reads this
996+
// for every row in the list, not just the one currently open.
997+
const ROLE_KEY = (ch) => `tawny.role.${ch?.id}`;
998+
function roleFor(ch) {
999+
let v = null;
1000+
try { v = localStorage.getItem(ROLE_KEY(ch)); } catch {}
1001+
return v === 'station' || v === 'viewer' ? v : null;
1002+
}
1003+
function setRoleFor(ch, role) {
1004+
if (!ch) return;
1005+
try { localStorage.setItem(ROLE_KEY(ch), role); } catch {}
1006+
}
9551007
// One Monitor holds three phones; twice that is room for a household that has
9561008
// re-installed a couple of times, and small enough that a stolen code cannot
9571009
// quietly enrol an army.
@@ -1032,7 +1084,7 @@ function pairLink() {
10321084
// exist by the time the QR is drawn or the link would hand out a `t` the
10331085
// Monitor then replaced. Only the browser mints one: inside the native shell
10341086
// the ticket comes from the shell, and pairLink() is not the screen in use.
1035-
if (!S.token && !S.nativeShell) S.token = newKey();
1087+
if (!S.token && !S.nativeShell) S.token = savedToken() || newKey();
10361088
const frag = new URLSearchParams({ k: S.channel.key, n: S.channel.name, r: 'viewer' });
10371089
if (S.token) frag.set('t', S.token);
10381090
frag.set('c', code.c);
@@ -1360,6 +1412,10 @@ function adopt(raw) {
13601412
}
13611413
renderChannels();
13621414
S.channel = ch;
1415+
// Remember the ticket this scan just handed us — this device's only chance
1416+
// to learn it — so reconnecting later never depends on rescanning.
1417+
if (S.token) saveToken(S.token);
1418+
setRoleFor(ch, 'viewer');
13631419
S.pending = 'viewer';
13641420
el.joinName.textContent = ch.name;
13651421
note(el.joinNote, lastRelayNote || '');
@@ -3677,7 +3733,13 @@ async function start(role, opts = {}) {
36773733
// could only work with ticket checking turned off. It mints its own now, and
36783734
// this must happen before fetchIce() asks /turn for credentials and before
36793735
// pairLink() builds the fragment that hands it on.
3680-
if (role === 'station' && !S.token) S.token = newKey();
3736+
if (role === 'station' && !S.token) S.token = savedToken() || newKey();
3737+
// A Viewer resuming this channel from the list (not a fresh scan) has no
3738+
// token in memory — it only ever lived in this session's S. Pull back
3739+
// whatever it last learned, so "click Viewer" reconnects instead of dialling
3740+
// in with nothing to present and being told the code expired.
3741+
if (role === 'viewer' && !S.token) S.token = savedToken();
3742+
if (S.token) saveToken(S.token);
36813743
S.relayBase = null; // a new session gives the user's own relay a fresh try
36823744
S.roomId = await roomIdFor(S.channel.key);
36833745
diag(`start role=${role} room=${S.roomId} lan=${S.signalUrl || '-'} ` +
@@ -4234,6 +4296,19 @@ $('#btn-mute').addEventListener('click', () => {
42344296
btn.querySelector('span:last-child').textContent = S.micOn ? 'Mute mic' : 'Unmute mic';
42354297
});
42364298

4299+
// Viewer: silence what plays back here. Purely local — the Monitor keeps
4300+
// transmitting and nobody else's session is touched, so muting to check a
4301+
// noisy room does not also mute talk-back or a chime landing at the far end.
4302+
$('#btn-mute-viewer')?.addEventListener('click', () => {
4303+
const btn = $('#btn-mute-viewer');
4304+
el.remoteAudio.muted = !el.remoteAudio.muted;
4305+
const muted = el.remoteAudio.muted;
4306+
btn.setAttribute('aria-pressed', String(muted));
4307+
press(btn, muted);
4308+
btn.querySelector('span:last-child').textContent = muted
4309+
? TawnyT.t('w_ctl_unmute') : TawnyT.t('w_ctl_mute_listen');
4310+
});
4311+
42374312
// Dim mode. The overlay used to be only a black <div> laid over the page: on
42384313
// an LCD the backlight stayed wherever the user had left it, and on an AMOLED
42394314
// the panel still drove every pixel while the compositor, the preview decode

‎app/public/i18n.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
w_ctl_hang_up: 'Hang up',
2222
w_ctl_flip_camera: 'Flip camera',
2323
w_ctl_mute_mic: 'Mute mic',
24+
w_ctl_mute_listen: 'Mute',
25+
w_ctl_unmute: 'Unmute',
2426
w_ctl_dim_screen: 'Dim screen',
2527
w_ctl_end_call: 'End call',
2628
// --- chimes ---
@@ -104,6 +106,8 @@
104106
w_ctl_hang_up: 'Colgar',
105107
w_ctl_flip_camera: 'Girar',
106108
w_ctl_mute_mic: 'Silenciar',
109+
w_ctl_mute_listen: 'Silenciar',
110+
w_ctl_unmute: 'Activar sonido',
107111
w_ctl_dim_screen: 'Atenuar',
108112
w_ctl_end_call: 'Terminar',
109113
w_chime_bark: 'Juguete de perro',

‎app/public/index.html‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,10 @@ <h1 class="hero-title">Pairing code accepted</h1>
504504
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5.6h8l-1.3 3.8H9.3L8 5.6Z"/><path d="M9.3 9.4h5.4v10.2a1.8 1.8 0 0 1-1.8 1.8h-1.8a1.8 1.8 0 0 1-1.8-1.8V9.4Z"/><path d="M9.7 13.4h4.6"/><g class="beam"><path d="M12 3.4V1.6"/><path d="M6.9 4.5 5.7 3"/><path d="m17.1 4.5 1.2-1.5"/></g></svg></span>
505505
<span data-i18n="w_ctl_light">Light</span>
506506
</button>
507+
<button id="btn-mute-viewer" class="ctl" type="button" aria-pressed="false">
508+
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4Z"/><line class="slash" x1="3" y1="3" x2="21" y2="21"/></svg></span>
509+
<span data-i18n="w_ctl_mute_listen">Mute</span>
510+
</button>
507511
<button id="btn-cam" class="ctl" type="button">
508512
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10.5 5-2.5v8l-5-2.5"/></svg></span>
509513
<span data-i18n="w_ctl_my_camera">My camera</span>
@@ -632,6 +636,12 @@ <h2 id="row-menu-title">Channel</h2>
632636
</span>
633637
<span>Rename</span>
634638
</button>
639+
<button id="row-switch-role" class="menu-item" type="button">
640+
<span class="menu-ico" aria-hidden="true">
641+
<svg viewBox="0 0 24 24"><path d="M17 2.1 21 6l-4 3.9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 21.9 3 18l4-3.9"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
642+
</span>
643+
<span>Switch role</span>
644+
</button>
635645
<button id="row-delete" class="menu-item danger" type="button">
636646
<span class="menu-ico" aria-hidden="true">
637647
<svg viewBox="0 0 24 24"><path d="M5 7h14M10 7V4.5h4V7m-8 0 1 13h10l1-13"/></svg>

0 commit comments

Comments
 (0)