Skip to content

Commit 1272936

Browse files
PressF4meclaude
andcommitted
Vendor 48944dd: the Monitor's "Waiting" screen now actually waits
The reported failure: a Viewer scans the web Monitor's QR and is told the pairing code has expired, on a code that was just minted. v2.0.6 fixed a real code-rotation bug but not this one. Root cause is that the web Monitor was never connected. openWatchPair() set the channel, minted a code, drew the QR and showed "Waiting for the Viewer" with a spinner — and opened no socket. The relay learns a room's ticket only from a station hello, so nothing was registered, and admit() turned the Viewer away. It reported that as 4008 "pairing expired", which is the one thing it was not: the code was fine, the Monitor was off. The Viewer rescanned the same QR and looped. Pressing "Start watching now" was what actually connected, which nothing on a screen claiming to be waiting suggests. app-as-Monitor was never affected — the native shell runs its own pairing screen and connects itself. Reproduced against a container at the protocol level: a viewer hello into a room no station has registered closed 4008 "pairing expired", byte for byte what a genuinely wrong ticket gets. - start() gained opts.stayPut: all of its work (camera, ticket, socket, room) with the caller's screen left up. openWatchPair() uses it, so the screen is honest and a scan right then pairs. Both stand down under the native shell. - "Start watching now" is now only the move to the live view; leaving the screen tears the Monitor down rather than abandoning a live camera. - 4010 "monitor offline" splits "no Monitor has ever registered this room" from 4008's "this ticket does not match", with its own sentence in both locales. The Android app needs no change for it — an unknown reason for a viewer already lands on its monitor-offline screen. Verified in an isolated container: the 4008/4010 split end to end, 17 structural checks including that the QR's `t` and the registered hashT are the same secret, the 12-check pairing-rotation suite from v2.0.6 still green in both directions, and the v2.0.5/v2.0.6 origin gate unchanged (127.0.0.1, [::1] and `Origin: null` admitted, https://evil.example still 403). rendezvous/room.js has the same 4008 conflation and additionally counts it as an auth failure against the room's lockout. Not touched here — that is the cloud relay and needs a wrangler deploy. Operators pick this up with: docker compose pull && docker compose up -d No Android app rebuild is needed. Co-Authored-By: Claude Opus <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uct7Y3wVjJHTuX5F31a3dZ
1 parent 92e761c commit 1272936

4 files changed

Lines changed: 90 additions & 11 deletions

File tree

‎app/.source-commit‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
6c46e7d042ed9eb25a44506209ff540553e75562
1+
48944ddf3318338869da59c4a4b1e2375336babc

‎app/public/app.js‎

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -769,15 +769,39 @@ function tickWatchPair() {
769769
refreshWatchPair();
770770
}
771771

772-
function openWatchPair() {
772+
async function openWatchPair() {
773773
S.channel = watcherChannel();
774774
// Same rule as openPair(): never open on a code that has already lapsed.
775775
if (pairCodeLeft() <= 0 && !S.nativeShell) newPairCode();
776+
// refreshWatchPair() first, because pairLink() is what mints S.token — and
777+
// the ticket in the QR has to be the same secret start() then registers as
778+
// hashT, or the Viewer would present a `t` the relay has never hashed.
776779
refreshWatchPair();
777780
el.wpStatus.textContent = 'Waiting for the Viewer';
778781
show(el.watchPair);
779782
clearInterval(wpTicker);
780783
wpTicker = setInterval(tickWatchPair, 1000);
784+
785+
// ...and actually be waiting. This screen showed a QR, a spinner and the
786+
// words "Waiting for the Viewer" while opening no socket at all: the relay
787+
// only learns a room's ticket from a station hello, so until someone pressed
788+
// "Start watching now" there was no ticket registered, and a Viewer scanning
789+
// that QR was turned away at admission — as "pairing expired", about a code
790+
// that had never been wrong. The screen was lying about being ready; now it
791+
// is telling the truth.
792+
//
793+
// The camera opens here rather than on "Start watching now" because a warm
794+
// room is not enough on its own: the Monitor answers a Viewer's offer with
795+
// its live tracks (answerPeer attaches S.local), so a room held open with no
796+
// camera would pair and then show nothing. Acquiring media inside the offer
797+
// handler instead would put a permission prompt in the middle of a timed
798+
// handshake. Opening it when someone picks "The Monitor" — the role whose
799+
// whole job is to be the camera, on a screen that says it is waiting — is
800+
// the least surprising of the three.
801+
//
802+
// Not in the native shell: there the shell runs its own pairing screen and
803+
// connects itself, exactly as tickWatchPair() stands down for the same reason.
804+
if (!S.nativeShell) await start('station', { stayPut: true });
781805
}
782806

783807
$('#welcome-go').addEventListener('click', () => { note(el.setupNote, ''); show(el.setupRole); });
@@ -794,8 +818,28 @@ $('#setup-handheld').addEventListener('click', () => {
794818
openScanner();
795819
});
796820

797-
$('#wp-start').addEventListener('click', () => start('station'));
798-
$('#wp-later').addEventListener('click', () => show(el.channels));
821+
// The session is already up — openWatchPair() started it so the QR on that
822+
// screen actually works — so this is only the move to the live view and the
823+
// pairing sheet that lives there. start() again would re-open the camera.
824+
// The fallback covers a start() that bailed (no camera, an insecure origin)
825+
// and the native shell, which never auto-starts here.
826+
$('#wp-start').addEventListener('click', () => {
827+
clearInterval(wpTicker);
828+
wpTicker = null;
829+
if (S.role === 'station' && S.local) { show(el.live); openPair(); }
830+
else start('station');
831+
});
832+
833+
// Leaving this screen means the Monitor is not waiting for anyone after all, so
834+
// the camera and the room go with it. Without this, "Set up later" walked away
835+
// from a live camera with its light on and a room still held open at the relay.
836+
function leaveWatchPair() {
837+
clearInterval(wpTicker);
838+
wpTicker = null;
839+
if (S.role === 'station' && S.local) return hangUp();
840+
show(el.channels);
841+
}
842+
$('#wp-later').addEventListener('click', leaveWatchPair);
799843
el.wpCopy.addEventListener('click', async () => {
800844
try {
801845
await navigator.clipboard.writeText(pairLink());
@@ -806,7 +850,7 @@ el.wpCopy.addEventListener('click', async () => {
806850
});
807851
// The app bar's back affordance and the quiet "Set up later" underneath it
808852
// land in the same place; the bar is just the one a thumb reaches for first.
809-
$('#wp-back').addEventListener('click', () => show(el.channels));
853+
$('#wp-back').addEventListener('click', leaveWatchPair);
810854

811855
// The empty channel list offers the same route the welcome screen does,
812856
// rather than describing it and leaving the user to find it.
@@ -2005,7 +2049,9 @@ function openSignal(base, tag) {
20052049

20062050
// Fatal close codes: for a Handheld (one transport) the session is over;
20072051
// for the Watcher, a bad cloud socket must NOT tear down a healthy LAN one.
2008-
if (ev.code === 4003 || ev.code === 4004 || ev.code === 4008) {
2052+
// 4010 joins the fatal set: the relay is answering and has told us the
2053+
// room has no Monitor in it. Retrying into that changes nothing.
2054+
if (ev.code === 4003 || ev.code === 4004 || ev.code === 4008 || ev.code === 4010) {
20092055
if (S.role === 'station') {
20102056
// ...but not immediately fatal on the cloud leg. When this phone's
20112057
// radio blips, the socket dies without a close handshake and the
@@ -2038,12 +2084,20 @@ function openSignal(base, tag) {
20382084
return bail(
20392085
ev.code === 4003 ? FULL_MESSAGE
20402086
: ev.code === 4004 ? TawnyT.t('w_bail_already_running')
2087+
// 4010: the relay has no ticket for this room at all, so no Monitor
2088+
// has ever connected. The code in this person's hand is fine and
2089+
// rescanning it will not help — the other end is simply not running.
2090+
// This used to arrive as 4008 and be read out as "expired", which
2091+
// sent people back to the same QR over and over.
2092+
: ev.code === 4010 ? TawnyT.t('w_bail_monitor_offline')
20412093
// 4008 is the relay's own refusal — a ticket that no longer matches
20422094
// the room. Different cause from the Monitor's pairing gate, same
20432095
// thing to do about it, so it gets the same sentence and the same
20442096
// screen in the native shell.
20452097
: EXPIRED_MESSAGE,
2046-
ev.code === 4003 ? 'full' : ev.code === 4008 ? 'expired' : undefined
2098+
ev.code === 4003 ? 'full'
2099+
: ev.code === 4008 ? 'expired'
2100+
: ev.code === 4010 ? 'offline' : undefined
20472101
);
20482102
}
20492103

@@ -3598,7 +3652,16 @@ function cameraConstraints(wide = screenIsWide()) {
35983652
// bar. Matching the capture shape to how the phone is held (idealCaptureSize)
35993653
// is what keeps those bars small — or gone, when both ends face the same way.
36003654

3601-
async function start(role) {
3655+
/**
3656+
* Go live in `role`.
3657+
*
3658+
* `opts.stayPut` runs the whole of this — camera, ticket, socket, room —
3659+
* but leaves the caller's own screen showing instead of jumping to the live
3660+
* view. openWatchPair() needs exactly that: its QR is worthless unless this
3661+
* has run, because the relay only learns the room's ticket from a station
3662+
* hello, and a Viewer scanning before then is turned away at admission.
3663+
*/
3664+
async function start(role, opts = {}) {
36023665
if (!S.channel) return;
36033666
S.role = role;
36043667
S.pending = null;
@@ -3715,15 +3778,19 @@ async function start(role) {
37153778
const room = (S.channel.name || 'Pet camera').trim().replace(/\s*monitor$/i, '');
37163779
el.channel.textContent = room || 'Pet camera';
37173780

3718-
show(el.live);
3781+
// Not while the caller is showing a screen of its own — see opts.stayPut.
3782+
// Everything below still runs, so the room is warm either way.
3783+
if (!opts.stayPut) show(el.live);
37193784
status(TawnyT.t('w_status_connecting'), null);
37203785
tellNative('live', { role });
37213786
connectAll();
37223787

37233788
// Settle the light question now, on the track we have just opened, so the
37243789
// first Viewer to join is told the truth about this camera before it can
37253790
// press anything. It is one constraint write and nothing lights up.
3726-
if (role === 'station') { refreshTorchSupport(); openPair(); }
3791+
// The pairing sheet belongs to the live screen. A caller that stayed put is
3792+
// already showing its own QR and would get two.
3793+
if (role === 'station') { refreshTorchSupport(); if (!opts.stayPut) openPair(); }
37273794
}
37283795

37293796
// ---------------------------------------------------------- capture loss

‎app/public/i18n.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@
8888
w_bail_not_on_wifi: 'This phone is not on Wi-Fi yet. Connect it to Wi-Fi and try again.',
8989
w_bail_wifi_only_code: 'This code only works on the same Wi-Fi as the other phone. Put both phones on the same Wi-Fi and try again.',
9090
w_bail_already_running: 'This monitor is already running on another phone.',
91+
// The relay has no ticket for this room at all: the Monitor is not running,
92+
// which is a different thing from a code that stopped working. Saying
93+
// "expired" here sent people back to rescan the same QR for ever.
94+
w_bail_monitor_offline: 'The monitor is not online yet. Open Tawny on the monitor phone or computer and press Start, then scan again.',
9195
w_msg_expired: 'That pairing code has expired. Show a new code on the monitor phone and scan it again.',
9296
},
9397

@@ -160,6 +164,7 @@
160164
w_bail_not_on_wifi: 'Este teléfono aún no está en Wi-Fi. Conéctalo a Wi-Fi e inténtalo de nuevo.',
161165
w_bail_wifi_only_code: 'Este código solo funciona en la misma red Wi-Fi que el otro teléfono. Pon los dos teléfonos en la misma Wi-Fi e inténtalo de nuevo.',
162166
w_bail_already_running: 'Este monitor ya se está ejecutando en otro teléfono.',
167+
w_bail_monitor_offline: 'El monitor aún no está en línea. Abre Tawny en el teléfono o la computadora del monitor y pulsa Iniciar, luego vuelve a escanear.',
163168
w_msg_expired: 'Ese código de vinculación caducó. Muestra un código nuevo en el teléfono monitor y escanéalo otra vez.',
164169
},
165170
};

‎app/server.js‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1500,7 +1500,14 @@ wss.on('connection', (ws, req, ctx) => {
15001500

15011501
let register = null;
15021502
if (role === 'viewer') {
1503-
if (REQUIRE_TICKET && (!rec || sha256hex(msg.t) !== rec.hashT)) return ws.close(4008, 'pairing expired');
1503+
// Two very different failures used to share one code and one sentence.
1504+
// `!rec` means no Monitor has ever registered this room: nothing is wrong
1505+
// with the code in the Viewer's hand — the Monitor simply is not running.
1506+
// Telling that person "your pairing code expired" sends them back to
1507+
// rescan the same QR, for ever, which is exactly the loop reported. 4010
1508+
// says the true thing, and public/app.js has its own sentence for it.
1509+
if (REQUIRE_TICKET && !rec) return ws.close(4010, 'monitor offline');
1510+
if (REQUIRE_TICKET && sha256hex(msg.t) !== rec.hashT) return ws.close(4008, 'pairing expired');
15041511
} else { // station
15051512
if (rec?.auth && proof && proof !== rec.auth) return ws.close(4008, 'wrong channel key');
15061513
const mayRekey = proof !== null || !rec?.auth;

0 commit comments

Comments
 (0)