relay-companion 0.1.71 → 0.1.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/relay.js +5 -2
- package/overlay/inbox.html +269 -0
- package/overlay/main.cjs +173 -1
- package/overlay/preload.cjs +5 -0
- package/package.json +1 -1
- package/src/account.js +65 -0
- package/src/config.js +14 -6
package/bin/relay.js
CHANGED
|
@@ -28,6 +28,7 @@ import { pillStatusPath, waitForPillReady } from "../src/pill-control.js";
|
|
|
28
28
|
import { liveToolRequirement, requiredLiveHosts, shouldRequireLiveTools } from "../src/setup-activation.js";
|
|
29
29
|
import { openRelay, openTask } from "../src/materializer.js";
|
|
30
30
|
import { runClaudeHook } from "../src/claude-hook.js";
|
|
31
|
+
import { normalizePairingCode, persistPairedAccount } from "../src/account.js";
|
|
31
32
|
import { resetCompanionStateForAccount } from "../src/notifications.js";
|
|
32
33
|
import { finishSetupOpenRelay, normalizeSetupHost, setupOpenRelayToken, setupOpenStatus } from "../src/setup-open.js";
|
|
33
34
|
|
|
@@ -95,8 +96,10 @@ async function cmdPair(flags, { promptForDefaults = true } = {}) {
|
|
|
95
96
|
}
|
|
96
97
|
writeConfig({ apiUrl: url, webUrl: appUrl });
|
|
97
98
|
const client = new RelayClient({ url });
|
|
98
|
-
const res = await client.registerDevice({ pairingCode: code, name, platform: process.platform });
|
|
99
|
-
|
|
99
|
+
const res = await client.registerDevice({ pairingCode: normalizePairingCode(code), name, platform: process.platform });
|
|
100
|
+
// The same persistence the pill's Settings tab uses (src/account.js), so the
|
|
101
|
+
// stored credential shape can never drift between the two pairing surfaces.
|
|
102
|
+
persistPairedAccount({ apiUrl: url, webUrl: appUrl, deviceName: name, registration: res });
|
|
100
103
|
resetCompanionStateForAccount({ user: res.user, deviceId: res.deviceId });
|
|
101
104
|
console.log(`Paired as ${res.user.name} <${res.user.email}>. This device is now connected to Relay.`);
|
|
102
105
|
}
|
package/overlay/inbox.html
CHANGED
|
@@ -647,6 +647,62 @@
|
|
|
647
647
|
}
|
|
648
648
|
.sent-chip.read { background:rgba(46,160,67,.14); color:#1a7f37; border:1px solid rgba(46,160,67,.28); }
|
|
649
649
|
.sent-chip.unread { background:transparent; color:var(--muted-2); border:1px solid rgba(31,26,23,.18); }
|
|
650
|
+
|
|
651
|
+
/* ---- Settings tab ---- */
|
|
652
|
+
/* The account block is flat content on the sheet, like rows: serif identity,
|
|
653
|
+
sans metadata, a tinted avatar disc. Depth only ever comes from tint and
|
|
654
|
+
the reused .open-actions light-built panel — never from borders. */
|
|
655
|
+
.sv-head { display:flex; align-items:center; justify-content:space-between; padding:10px 16px 8px; }
|
|
656
|
+
.sv-title { font-family:var(--serif); font-size:16px; font-weight:500; color:var(--ink); }
|
|
657
|
+
.sv-account { display:flex; align-items:center; gap:12px; padding:4px 16px 0; }
|
|
658
|
+
.sv-avatar {
|
|
659
|
+
flex:0 0 auto; width:38px; height:38px; border-radius:50%;
|
|
660
|
+
background:var(--accent-soft); color:var(--accent);
|
|
661
|
+
font-size:13px; font-weight:600; letter-spacing:.02em;
|
|
662
|
+
display:flex; align-items:center; justify-content:center;
|
|
663
|
+
}
|
|
664
|
+
.sv-id { min-width:0; display:flex; flex-direction:column; }
|
|
665
|
+
.sv-name { font-family:var(--serif); font-size:16px; font-weight:500; color:var(--ink); line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
666
|
+
.sv-email { font-size:11.5px; color:var(--muted); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
667
|
+
.sv-device { font-size:11px; color:var(--muted-2); margin-top:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
668
|
+
.sv-copy { padding:2px 16px 0; font-size:12px; line-height:1.5; color:var(--muted); }
|
|
669
|
+
.sv-actions { margin:14px 12px 0; }
|
|
670
|
+
/* armed sign-out: the same flat menu row, flipped to the danger ink */
|
|
671
|
+
.oa-item.sv-signout-armed { color:#b4332a; }
|
|
672
|
+
.oa-item.sv-signout-armed:hover { background:rgba(180,51,42,.07); }
|
|
673
|
+
.oa-item.sv-signout-armed:active { background:rgba(180,51,42,.12); }
|
|
674
|
+
/* inline pairing-code entry: mono, tracked, uppercase — the code is the content */
|
|
675
|
+
.sv-pair { display:flex; gap:8px; margin:12px 16px 0; align-items:center; }
|
|
676
|
+
.sv-code {
|
|
677
|
+
flex:1 1 auto; min-width:0; appearance:none;
|
|
678
|
+
border:1px solid rgba(31,26,23,.14); border-radius:9px; padding:8px 11px;
|
|
679
|
+
font-family:var(--mono); font-size:13px; letter-spacing:.14em; text-transform:uppercase;
|
|
680
|
+
color:var(--ink); background:#fff; outline:none;
|
|
681
|
+
transition:border-color .15s var(--settle), box-shadow .15s var(--settle);
|
|
682
|
+
}
|
|
683
|
+
.sv-code::placeholder { font-family:var(--sans); letter-spacing:0; text-transform:none; color:var(--muted-3); }
|
|
684
|
+
.sv-code:focus { border-color:rgba(48,85,102,.42); box-shadow:0 0 0 3px rgba(48,85,102,.08); }
|
|
685
|
+
.sv-connect {
|
|
686
|
+
flex:0 0 auto; appearance:none; border:0; background:var(--accent); color:#fff;
|
|
687
|
+
font-family:var(--sans); font-size:12.5px; font-weight:600; padding:8px 15px; border-radius:9px;
|
|
688
|
+
cursor:pointer; transition:opacity .2s var(--settle);
|
|
689
|
+
}
|
|
690
|
+
.sv-connect:hover { opacity:.9; }
|
|
691
|
+
.sv-connect:disabled { opacity:.4; cursor:default; }
|
|
692
|
+
.sv-hint { padding:7px 16px 0; font-size:11px; line-height:1.45; color:var(--muted-2); }
|
|
693
|
+
.sv-err { margin-top:8px; padding:0 16px; }
|
|
694
|
+
.sv-note { padding:10px 16px 0; font-size:11.5px; color:#1a7f37; line-height:1.4; }
|
|
695
|
+
.sv-version { padding:20px 16px 12px; font-family:var(--mono); font-size:10.5px; color:var(--muted-3); }
|
|
696
|
+
/* signed-out call-to-action on the Relays empty state — quiet filled pill (th-count language) */
|
|
697
|
+
.sv-signin-banner {
|
|
698
|
+
appearance:none; border:0; cursor:pointer; margin-top:2px;
|
|
699
|
+
font-family:var(--sans); font-size:12px; font-weight:600; letter-spacing:.01em;
|
|
700
|
+
color:var(--accent); background:color-mix(in srgb, var(--accent) 9%, transparent);
|
|
701
|
+
box-shadow:inset 0 0 0 0.5px color-mix(in srgb, var(--accent) 14%, transparent);
|
|
702
|
+
border-radius:999px; padding:7px 15px;
|
|
703
|
+
transition:background-color .2s var(--settle);
|
|
704
|
+
}
|
|
705
|
+
.sv-signin-banner:hover { background:color-mix(in srgb, var(--accent) 15%, transparent); }
|
|
650
706
|
</style>
|
|
651
707
|
</head>
|
|
652
708
|
<body>
|
|
@@ -684,6 +740,7 @@
|
|
|
684
740
|
</button>
|
|
685
741
|
<button class="tab" type="button" data-view="sent">Sent</button>
|
|
686
742
|
<button class="tab" type="button" data-view="contacts">Contacts</button>
|
|
743
|
+
<button class="tab" type="button" data-view="settings">Settings</button>
|
|
687
744
|
<button class="mark-all-read gone" id="markAllRead" type="button">Mark all as read</button>
|
|
688
745
|
</nav>
|
|
689
746
|
|
|
@@ -697,6 +754,7 @@
|
|
|
697
754
|
</svg>
|
|
698
755
|
<div class="t1">No relays yet</div>
|
|
699
756
|
<div class="t2">Questions, results, and notices will land here.</div>
|
|
757
|
+
<button class="sv-signin-banner gone" id="signInBanner" type="button">Sign in to Relay</button>
|
|
700
758
|
</div>
|
|
701
759
|
</section>
|
|
702
760
|
|
|
@@ -767,6 +825,9 @@
|
|
|
767
825
|
<div class="cv-list" id="cvList"></div>
|
|
768
826
|
<div id="contactsEmpty" class="cv-empty gone">No contacts yet.<br>Add someone, or your agent saves them as you relay.</div>
|
|
769
827
|
</section>
|
|
828
|
+
|
|
829
|
+
<!-- Settings view (account card + sign-out / switch-account; rendered by JS) -->
|
|
830
|
+
<section class="view hidden" id="settingsView"></section>
|
|
770
831
|
</div>
|
|
771
832
|
</div>
|
|
772
833
|
|
|
@@ -811,6 +872,8 @@
|
|
|
811
872
|
const thBackEl = document.getElementById("thBack");
|
|
812
873
|
const thDetailNameEl = document.getElementById("thDetailName");
|
|
813
874
|
const thHistoryEl = document.getElementById("thHistory");
|
|
875
|
+
const settingsViewEl = document.getElementById("settingsView");
|
|
876
|
+
const signInBannerEl = document.getElementById("signInBanner");
|
|
814
877
|
|
|
815
878
|
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
816
879
|
const EXPANDED = { w: 344, h: 524 };
|
|
@@ -2582,6 +2645,201 @@
|
|
|
2582
2645
|
applyView();
|
|
2583
2646
|
}
|
|
2584
2647
|
|
|
2648
|
+
// ---------- Settings view ----------
|
|
2649
|
+
// The account card + sign-out / switch-account lifecycle. Renders from its own
|
|
2650
|
+
// relay:accountInfo load (like Contacts), never from inbox pushes, so a typed
|
|
2651
|
+
// pairing code is never rebuilt away mid-entry. Switch/sign-out succeed by
|
|
2652
|
+
// RESTARTING the pill into the new account, so the success note is brief.
|
|
2653
|
+
let settingsInfo = null; // last relay:accountInfo payload (null while loading)
|
|
2654
|
+
let settingsLoadSeq = 0;
|
|
2655
|
+
let svPairOpen = false; // inline code-entry row revealed
|
|
2656
|
+
let svDraft = ""; // pairing-code text preserved across re-renders
|
|
2657
|
+
let svBusy = false; // pair / sign-out in flight
|
|
2658
|
+
let svNote = ""; // success note while the pill restarts
|
|
2659
|
+
let svError = "";
|
|
2660
|
+
let svSignOutArmed = false;
|
|
2661
|
+
let svSignOutTimer = null;
|
|
2662
|
+
let svFocusCodeOnce = false;
|
|
2663
|
+
|
|
2664
|
+
function resetSignOutArm() {
|
|
2665
|
+
svSignOutArmed = false;
|
|
2666
|
+
if (svSignOutTimer) { clearTimeout(svSignOutTimer); svSignOutTimer = null; }
|
|
2667
|
+
}
|
|
2668
|
+
function svPairRowHtml() {
|
|
2669
|
+
return `
|
|
2670
|
+
<div class="sv-pair" data-stop="1">
|
|
2671
|
+
<input class="sv-code" id="svCode" type="text" inputmode="text" autocomplete="off" spellcheck="false"
|
|
2672
|
+
maxlength="16" placeholder="Pairing code" value="${esc(svDraft)}" ${svBusy ? "disabled" : ""} />
|
|
2673
|
+
<button class="sv-connect" type="button" id="svConnect" ${svBusy ? "disabled" : ""}>${svBusy ? "Connecting…" : "Connect"}</button>
|
|
2674
|
+
</div>
|
|
2675
|
+
<div class="sv-hint">Your browser opened the Relay setup page — paste the pairing code it shows.</div>`;
|
|
2676
|
+
}
|
|
2677
|
+
function renderSettings() {
|
|
2678
|
+
if (activeView !== "settings") return;
|
|
2679
|
+
const info = settingsInfo;
|
|
2680
|
+
if (!info) {
|
|
2681
|
+
settingsViewEl.innerHTML = '<div class="td-quiet">Loading account…</div>';
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
let html = "";
|
|
2685
|
+
if (info.paired) {
|
|
2686
|
+
const name = info.name || info.email || "Relay account";
|
|
2687
|
+
html += `
|
|
2688
|
+
<div class="sv-head"><span class="sv-title">Your account.</span></div>
|
|
2689
|
+
<div class="sv-account">
|
|
2690
|
+
<span class="sv-avatar">${esc(cvInitials(info.name, info.email))}</span>
|
|
2691
|
+
<span class="sv-id">
|
|
2692
|
+
<span class="sv-name">${esc(name)}</span>
|
|
2693
|
+
${info.email ? `<span class="sv-email">${esc(info.email)}</span>` : ""}
|
|
2694
|
+
<span class="sv-device">This device · ${esc(info.deviceName || "")}</span>
|
|
2695
|
+
</span>
|
|
2696
|
+
</div>
|
|
2697
|
+
<div class="open-actions sv-actions" data-stop="1" role="menu">
|
|
2698
|
+
<button class="oa-item oa-primary" type="button" role="menuitem" id="svSwitch" ${svBusy ? "disabled" : ""}>Switch Account…</button>
|
|
2699
|
+
<div class="oa-sep" role="separator"></div>
|
|
2700
|
+
<button class="oa-item${svSignOutArmed ? " sv-signout-armed" : ""}" type="button" role="menuitem" id="svSignOut" ${svBusy ? "disabled" : ""}>${svSignOutArmed ? "Sign Out — click again to confirm" : "Sign Out"}</button>
|
|
2701
|
+
</div>`;
|
|
2702
|
+
} else {
|
|
2703
|
+
html += `
|
|
2704
|
+
<div class="sv-head"><span class="sv-title">Sign in to Relay.</span></div>
|
|
2705
|
+
<div class="sv-copy">This device isn’t connected to an account yet. Sign in from your browser, then enter the pairing code here.</div>
|
|
2706
|
+
<div class="open-actions sv-actions" data-stop="1" role="menu">
|
|
2707
|
+
<button class="oa-item oa-primary" type="button" role="menuitem" id="svSwitch" ${svBusy ? "disabled" : ""}>Sign In to Relay</button>
|
|
2708
|
+
</div>`;
|
|
2709
|
+
}
|
|
2710
|
+
if (svPairOpen) html += svPairRowHtml();
|
|
2711
|
+
if (svNote) html += `<div class="sv-note">${esc(svNote)}</div>`;
|
|
2712
|
+
html += `<div class="row-err sv-err" id="svErr">${esc(svError)}</div>`;
|
|
2713
|
+
html += `<div class="sv-version">${esc(info.version ? `Relay ${info.version}` : "Relay")}</div>`;
|
|
2714
|
+
settingsViewEl.innerHTML = html;
|
|
2715
|
+
wireSettings();
|
|
2716
|
+
}
|
|
2717
|
+
function wireSettings() {
|
|
2718
|
+
for (const z of settingsViewEl.querySelectorAll('[data-stop="1"]')) {
|
|
2719
|
+
z.addEventListener("click", (e) => e.stopPropagation());
|
|
2720
|
+
}
|
|
2721
|
+
const switchEl = document.getElementById("svSwitch");
|
|
2722
|
+
if (switchEl) switchEl.addEventListener("click", beginPairFlow);
|
|
2723
|
+
const signOutEl = document.getElementById("svSignOut");
|
|
2724
|
+
if (signOutEl) signOutEl.addEventListener("click", onSignOutClick);
|
|
2725
|
+
const connectEl = document.getElementById("svConnect");
|
|
2726
|
+
if (connectEl) connectEl.addEventListener("click", submitPairCode);
|
|
2727
|
+
const codeEl = document.getElementById("svCode");
|
|
2728
|
+
if (codeEl) {
|
|
2729
|
+
// Same focusability contract as the contact form / quick reply: the window
|
|
2730
|
+
// may take keyboard focus only while a text field needs it.
|
|
2731
|
+
codeEl.addEventListener("focus", () => { if (window.relay.setFocusable) window.relay.setFocusable(true); });
|
|
2732
|
+
codeEl.addEventListener("blur", () => { if (activeView !== "contacts" && window.relay.setFocusable) window.relay.setFocusable(false); });
|
|
2733
|
+
codeEl.addEventListener("mousedown", () => setInteractive(true));
|
|
2734
|
+
codeEl.addEventListener("input", () => {
|
|
2735
|
+
svDraft = codeEl.value;
|
|
2736
|
+
svError = "";
|
|
2737
|
+
const errEl = document.getElementById("svErr");
|
|
2738
|
+
if (errEl) errEl.textContent = ""; // clear inline, no rebuild mid-typing
|
|
2739
|
+
});
|
|
2740
|
+
codeEl.addEventListener("keydown", (ev) => {
|
|
2741
|
+
if (ev.key === "Enter") { ev.preventDefault(); submitPairCode(); }
|
|
2742
|
+
});
|
|
2743
|
+
if (svFocusCodeOnce) {
|
|
2744
|
+
svFocusCodeOnce = false;
|
|
2745
|
+
if (window.relay.setFocusable) window.relay.setFocusable(true);
|
|
2746
|
+
setTimeout(() => codeEl.focus(), 30);
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
// Switch account / sign in: open the pairing page in the browser and reveal
|
|
2751
|
+
// the inline code-entry row in the same motion.
|
|
2752
|
+
function beginPairFlow() {
|
|
2753
|
+
if (svBusy) return;
|
|
2754
|
+
resetSignOutArm();
|
|
2755
|
+
svError = "";
|
|
2756
|
+
svNote = "";
|
|
2757
|
+
svPairOpen = true;
|
|
2758
|
+
svFocusCodeOnce = true;
|
|
2759
|
+
if (window.relay.openUrl) window.relay.openUrl((settingsInfo && settingsInfo.setupPath) || "/app/setup");
|
|
2760
|
+
renderSettings();
|
|
2761
|
+
}
|
|
2762
|
+
async function submitPairCode() {
|
|
2763
|
+
if (svBusy) return;
|
|
2764
|
+
const codeEl = document.getElementById("svCode");
|
|
2765
|
+
const code = (codeEl ? codeEl.value : svDraft).trim();
|
|
2766
|
+
if (!code) {
|
|
2767
|
+
svError = "Enter the pairing code from your browser.";
|
|
2768
|
+
renderSettings();
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
svBusy = true;
|
|
2772
|
+
svError = "";
|
|
2773
|
+
renderSettings();
|
|
2774
|
+
let res = null;
|
|
2775
|
+
try {
|
|
2776
|
+
res = await window.relay.pairWithCode({ code });
|
|
2777
|
+
} catch (err) {
|
|
2778
|
+
res = { ok: false, error: (err && err.message) || "Could not connect this device." };
|
|
2779
|
+
}
|
|
2780
|
+
svBusy = false;
|
|
2781
|
+
if (res && res.ok) {
|
|
2782
|
+
svDraft = "";
|
|
2783
|
+
svPairOpen = false;
|
|
2784
|
+
svNote = res.email ? `Connected as ${res.email} — restarting Relay…` : "Connected — restarting Relay…";
|
|
2785
|
+
} else {
|
|
2786
|
+
svError = (res && res.error) || "Could not connect this device.";
|
|
2787
|
+
}
|
|
2788
|
+
renderSettings();
|
|
2789
|
+
}
|
|
2790
|
+
// Sign out is destructive-ish (this device stops receiving relays), so the
|
|
2791
|
+
// button arms on the first click and only a second click within 4s commits.
|
|
2792
|
+
async function onSignOutClick() {
|
|
2793
|
+
if (svBusy) return;
|
|
2794
|
+
if (!svSignOutArmed) {
|
|
2795
|
+
svSignOutArmed = true;
|
|
2796
|
+
if (svSignOutTimer) clearTimeout(svSignOutTimer);
|
|
2797
|
+
svSignOutTimer = setTimeout(() => {
|
|
2798
|
+
svSignOutTimer = null;
|
|
2799
|
+
svSignOutArmed = false;
|
|
2800
|
+
renderSettings();
|
|
2801
|
+
}, 4000);
|
|
2802
|
+
renderSettings();
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2805
|
+
resetSignOutArm();
|
|
2806
|
+
svBusy = true;
|
|
2807
|
+
svError = "";
|
|
2808
|
+
svNote = "";
|
|
2809
|
+
renderSettings();
|
|
2810
|
+
let res = null;
|
|
2811
|
+
try {
|
|
2812
|
+
res = await window.relay.signOut();
|
|
2813
|
+
} catch (err) {
|
|
2814
|
+
res = { ok: false, error: (err && err.message) || "Could not sign out." };
|
|
2815
|
+
}
|
|
2816
|
+
svBusy = false;
|
|
2817
|
+
if (res && res.ok) svNote = "Signed out — restarting Relay…";
|
|
2818
|
+
else svError = (res && res.error) || "Could not sign out.";
|
|
2819
|
+
renderSettings();
|
|
2820
|
+
}
|
|
2821
|
+
async function loadSettings() {
|
|
2822
|
+
const seq = ++settingsLoadSeq;
|
|
2823
|
+
let info = null;
|
|
2824
|
+
try {
|
|
2825
|
+
info = window.relay.accountInfo ? await window.relay.accountInfo() : null;
|
|
2826
|
+
} catch {}
|
|
2827
|
+
if (seq !== settingsLoadSeq) return; // navigated / reloaded while fetching
|
|
2828
|
+
const prevSig = JSON.stringify(settingsInfo);
|
|
2829
|
+
settingsInfo = info && info.ok !== false ? info : { paired: false, name: "", email: "", deviceName: "", version: "" };
|
|
2830
|
+
// Unchanged info: leave the DOM alone so a focused code input (and its
|
|
2831
|
+
// caret) survives the refresh that happens on every visit to the tab.
|
|
2832
|
+
if (JSON.stringify(settingsInfo) !== prevSig) renderSettings();
|
|
2833
|
+
}
|
|
2834
|
+
if (signInBannerEl) signInBannerEl.addEventListener("click", (e) => {
|
|
2835
|
+
e.stopPropagation();
|
|
2836
|
+
activeView = "settings";
|
|
2837
|
+
syncTabs();
|
|
2838
|
+
applyView();
|
|
2839
|
+
renderAll();
|
|
2840
|
+
beginPairFlow(); // the banner IS the sign-in affordance: browser + code row
|
|
2841
|
+
});
|
|
2842
|
+
|
|
2585
2843
|
// ---------- views / tabs ----------
|
|
2586
2844
|
function syncTabs() {
|
|
2587
2845
|
// taskDetail belongs to Sent; the thread-detail view belongs to Relays —
|
|
@@ -2595,6 +2853,13 @@
|
|
|
2595
2853
|
sentViewEl.classList.toggle("hidden", activeView !== "sent");
|
|
2596
2854
|
taskDetailViewEl.classList.toggle("hidden", activeView !== "taskDetail");
|
|
2597
2855
|
contactsViewEl.classList.toggle("hidden", activeView !== "contacts");
|
|
2856
|
+
settingsViewEl.classList.toggle("hidden", activeView !== "settings");
|
|
2857
|
+
if (activeView === "settings") {
|
|
2858
|
+
renderSettings(); // paint immediately from the last known info…
|
|
2859
|
+
loadSettings(); // …then refresh it from config
|
|
2860
|
+
} else {
|
|
2861
|
+
resetSignOutArm(); // leaving the view disarms the confirm state
|
|
2862
|
+
}
|
|
2598
2863
|
if (activeView === "threads") {
|
|
2599
2864
|
// Entered only by clicking a conversation row (threadDetailId set there).
|
|
2600
2865
|
renderThreads();
|
|
@@ -2630,6 +2895,10 @@
|
|
|
2630
2895
|
|
|
2631
2896
|
function renderAll() {
|
|
2632
2897
|
syncDismissControl();
|
|
2898
|
+
// Signed out: the Relays empty state carries a slim sign-in affordance.
|
|
2899
|
+
if (signInBannerEl) {
|
|
2900
|
+
signInBannerEl.classList.toggle("gone", !payload.account || payload.account.paired !== false);
|
|
2901
|
+
}
|
|
2633
2902
|
// Relays badge/count reflect only the items that show in the Relays tab
|
|
2634
2903
|
// (the clean split keeps task_request / share_approval out of this count).
|
|
2635
2904
|
const unread = (payload.relays || []).filter((r) => r.unread && isRelayListKind(r)).length;
|
package/overlay/main.cjs
CHANGED
|
@@ -228,6 +228,24 @@ function loadRelayModules() {
|
|
|
228
228
|
return relayModulesPromise;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// Account lifecycle deps (ESM, lazy like the modules above): src/account.js is
|
|
232
|
+
// the shared pair/sign-out config persistence, src/notifications.js owns the
|
|
233
|
+
// packet-store reset that re-stages the new account's inbox cleanly.
|
|
234
|
+
let accountModulesPromise = null;
|
|
235
|
+
function loadAccountModules() {
|
|
236
|
+
if (!accountModulesPromise) {
|
|
237
|
+
const accountUrl = pathToFileURL(path.join(__dirname, "..", "src", "account.js")).href;
|
|
238
|
+
const notificationsUrl = pathToFileURL(path.join(__dirname, "..", "src", "notifications.js")).href;
|
|
239
|
+
accountModulesPromise = Promise.all([import(accountUrl), import(notificationsUrl)])
|
|
240
|
+
.then(([account, notifications]) => ({ account, notifications }))
|
|
241
|
+
.catch((error) => {
|
|
242
|
+
accountModulesPromise = null;
|
|
243
|
+
throw error;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return accountModulesPromise;
|
|
247
|
+
}
|
|
248
|
+
|
|
231
249
|
let sentStagerPromise = null;
|
|
232
250
|
function loadSentStager() {
|
|
233
251
|
if (!sentStagerPromise) {
|
|
@@ -332,6 +350,118 @@ function account() {
|
|
|
332
350
|
};
|
|
333
351
|
}
|
|
334
352
|
|
|
353
|
+
function pillVersion() {
|
|
354
|
+
try {
|
|
355
|
+
return String(require("../package.json").version || "");
|
|
356
|
+
} catch {
|
|
357
|
+
return "";
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The Settings account card: who this pill is signed in as, on which device,
|
|
362
|
+
// running which build. deviceName is remembered from pairing when available.
|
|
363
|
+
function accountInfo() {
|
|
364
|
+
const cfg = readConfigFile();
|
|
365
|
+
const user = cfg.user || {};
|
|
366
|
+
return {
|
|
367
|
+
ok: true,
|
|
368
|
+
paired: Boolean(deviceToken()),
|
|
369
|
+
name: user.name || "",
|
|
370
|
+
email: user.email || "",
|
|
371
|
+
deviceName: String(cfg.deviceName || "").trim() || os.hostname(),
|
|
372
|
+
version: pillVersion(),
|
|
373
|
+
setupPath: "/app/setup",
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// After an account change the daemon must restart into the new credentials, or
|
|
378
|
+
// it keeps polling (and staging) as the OLD account until its next launch.
|
|
379
|
+
function restartCompanionDaemon() {
|
|
380
|
+
try {
|
|
381
|
+
let cmd = null;
|
|
382
|
+
let args = null;
|
|
383
|
+
if (process.platform === "darwin") {
|
|
384
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
|
|
385
|
+
cmd = "/bin/launchctl";
|
|
386
|
+
args = ["kickstart", "-k", `gui/${uid}/work.relay.companion`];
|
|
387
|
+
} else if (process.platform === "win32") {
|
|
388
|
+
cmd = "cmd";
|
|
389
|
+
args = ["/c", 'schtasks /End /TN "Relay Companion Daemon" & schtasks /Run /TN "Relay Companion Daemon"'];
|
|
390
|
+
}
|
|
391
|
+
if (!cmd) return;
|
|
392
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
393
|
+
child.on("error", (error) => console.error("[overlay] daemon restart spawn failed:", error && error.message));
|
|
394
|
+
child.unref();
|
|
395
|
+
} catch (error) {
|
|
396
|
+
console.error("[overlay] daemon restart failed:", error && error.message);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Relaunch the pill itself so every cache (sent, contacts, attention queue,
|
|
401
|
+
// renderer state) is rebuilt from the new account. launchd / the Scheduled Task
|
|
402
|
+
// respawns it; app.relaunch() covers a bare `relay pill` run. Delayed a beat so
|
|
403
|
+
// the invoking IPC reply reaches the renderer first.
|
|
404
|
+
function relaunchPillSoon() {
|
|
405
|
+
setTimeout(() => {
|
|
406
|
+
try {
|
|
407
|
+
app.relaunch();
|
|
408
|
+
} catch (error) {
|
|
409
|
+
console.error("[overlay] relaunch failed:", error && error.message);
|
|
410
|
+
}
|
|
411
|
+
app.exit(0);
|
|
412
|
+
}, 600);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Switch account: register this device against the pairing code, persist the
|
|
416
|
+
// fresh credentials with the SAME shape `relay pair` writes, wipe the local
|
|
417
|
+
// packet store for the new account, then restart the daemon and the pill.
|
|
418
|
+
async function pairWithCode(input) {
|
|
419
|
+
try {
|
|
420
|
+
const raw = input && typeof input === "object" ? input.code : input;
|
|
421
|
+
const [{ account: accountMod, notifications }, { RelayClient }] = await Promise.all([
|
|
422
|
+
loadAccountModules(),
|
|
423
|
+
loadRelayModules(),
|
|
424
|
+
]);
|
|
425
|
+
const code = accountMod.normalizePairingCode(raw);
|
|
426
|
+
if (!code) return { ok: false, error: "Enter the pairing code from your browser." };
|
|
427
|
+
const deviceName = accountMod.deviceNameForPairing(readConfigFile());
|
|
428
|
+
const client = new RelayClient();
|
|
429
|
+
const res = await client.registerDevice({ pairingCode: code, name: deviceName, platform: process.platform });
|
|
430
|
+
accountMod.persistPairedAccount({ deviceName, registration: res });
|
|
431
|
+
notifications.resetCompanionStateForAccount(
|
|
432
|
+
{ user: res.user, deviceId: res.deviceId, force: true },
|
|
433
|
+
{ statePath: STATE_PATH },
|
|
434
|
+
);
|
|
435
|
+
restartCompanionDaemon();
|
|
436
|
+
relaunchPillSoon();
|
|
437
|
+
return { ok: true, email: (res.user && res.user.email) || "" };
|
|
438
|
+
} catch (error) {
|
|
439
|
+
const message = error && error.message ? error.message : String(error);
|
|
440
|
+
console.error("[overlay] pair with code failed:", message);
|
|
441
|
+
return { ok: false, error: message };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Sign out: drop the credentials (keeping URLs + device name), wipe the local
|
|
446
|
+
// packet store, then restart the daemon and the pill into the signed-out state.
|
|
447
|
+
async function signOutAccount() {
|
|
448
|
+
try {
|
|
449
|
+
const { account: accountMod, notifications } = await loadAccountModules();
|
|
450
|
+
accountMod.persistSignedOutAccount();
|
|
451
|
+
notifications.resetCompanionStateForAccount(
|
|
452
|
+
{ user: null, deviceId: "", force: true },
|
|
453
|
+
{ statePath: STATE_PATH },
|
|
454
|
+
);
|
|
455
|
+
restartCompanionDaemon();
|
|
456
|
+
relaunchPillSoon();
|
|
457
|
+
return { ok: true };
|
|
458
|
+
} catch (error) {
|
|
459
|
+
const message = error && error.message ? error.message : String(error);
|
|
460
|
+
console.error("[overlay] sign out failed:", message);
|
|
461
|
+
return { ok: false, error: message };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
335
465
|
// Turn an actionUrl (absolute https, or a relative path) into an absolute URL.
|
|
336
466
|
function absoluteUrl(pathOrUrl) {
|
|
337
467
|
const value = String(pathOrUrl || "").trim();
|
|
@@ -546,6 +676,7 @@ async function markAllVisibleRelaysRead() {
|
|
|
546
676
|
let sentCache = [];
|
|
547
677
|
let sentLoadedOnce = null;
|
|
548
678
|
async function refreshSent() {
|
|
679
|
+
if (!deviceToken()) return sentCache; // signed out: nothing to fetch, no 401 log storm
|
|
549
680
|
try {
|
|
550
681
|
const client = await relayClient();
|
|
551
682
|
const res = await client.sent();
|
|
@@ -569,6 +700,7 @@ async function refreshTasks() {
|
|
|
569
700
|
tasksCache = [];
|
|
570
701
|
return tasksCache;
|
|
571
702
|
}
|
|
703
|
+
if (!deviceToken()) return tasksCache; // signed out: skip the poll entirely
|
|
572
704
|
try {
|
|
573
705
|
const client = await relayClient();
|
|
574
706
|
const res = await client.listTasks();
|
|
@@ -594,6 +726,7 @@ function ensureTasksLoaded() {
|
|
|
594
726
|
let contactsCache = [];
|
|
595
727
|
let contactsLoadedOnce = null;
|
|
596
728
|
async function refreshContacts() {
|
|
729
|
+
if (!deviceToken()) return contactsCache; // signed out: skip the poll entirely
|
|
597
730
|
try {
|
|
598
731
|
const client = await relayClient();
|
|
599
732
|
const res = await client.listContacts();
|
|
@@ -1817,6 +1950,12 @@ function openUrlTarget(url) {
|
|
|
1817
1950
|
return;
|
|
1818
1951
|
}
|
|
1819
1952
|
const target = absoluteUrl(url);
|
|
1953
|
+
// Sandboxed harness runs must never pop the user's real browser (the same
|
|
1954
|
+
// contract that keeps them from launching Claude/Codex).
|
|
1955
|
+
if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") {
|
|
1956
|
+
console.error("[overlay] test seam: suppressed external open:", target);
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1820
1959
|
shell.openExternal(target).catch((error) => console.error("[overlay] open failed:", error && error.message));
|
|
1821
1960
|
}
|
|
1822
1961
|
|
|
@@ -1825,7 +1964,21 @@ function openUrlTarget(url) {
|
|
|
1825
1964
|
function anchorTopRight() {
|
|
1826
1965
|
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) || screen.getPrimaryDisplay();
|
|
1827
1966
|
const wa = display.workArea;
|
|
1828
|
-
|
|
1967
|
+
const anchor = { x: wa.x + wa.width - WIN.width - MARGIN, y: wa.y + MARGIN, width: WIN.width, height: WIN.height };
|
|
1968
|
+
if (process.env.RELAY_OVERLAY_TEST === "1") {
|
|
1969
|
+
// Sandboxed harness runs must never materialize under the user's parked
|
|
1970
|
+
// cursor: a pointer resting on the card holds notifications open BY DESIGN
|
|
1971
|
+
// (macOS banners do the same), which would wedge every dwell-based scenario
|
|
1972
|
+
// when the mouse happens to sit in the top-right corner. Flip the sandbox
|
|
1973
|
+
// window to the opposite edge instead. Production placement is unchanged.
|
|
1974
|
+
try {
|
|
1975
|
+
const p = screen.getCursorScreenPoint();
|
|
1976
|
+
const overlaps =
|
|
1977
|
+
p.x >= anchor.x && p.x < anchor.x + anchor.width && p.y >= anchor.y && p.y < anchor.y + anchor.height;
|
|
1978
|
+
if (overlaps) anchor.x = wa.x + MARGIN;
|
|
1979
|
+
} catch {}
|
|
1980
|
+
}
|
|
1981
|
+
return anchor;
|
|
1829
1982
|
}
|
|
1830
1983
|
|
|
1831
1984
|
// Show the overlay window. Electron's forwarded-mousemove stream (the thing that
|
|
@@ -1955,6 +2108,12 @@ let hitTimer = null;
|
|
|
1955
2108
|
let hitIgnoring = true; // mirrors the last setIgnoreMouseEvents call
|
|
1956
2109
|
let hitHover = false;
|
|
1957
2110
|
let lastHostProbeAt = 0;
|
|
2111
|
+
// Harness seam: a sandboxed e2e run shares the screen with the user's REAL
|
|
2112
|
+
// mouse, and a pointer that happens to rest on the card rect legitimately
|
|
2113
|
+
// holds notifications open (the macOS-banner rule) — wedging every dwell-based
|
|
2114
|
+
// scenario. The harness drives only synthetic input, so in this mode the hit
|
|
2115
|
+
// test ignores the physical cursor entirely. Never set outside the harness.
|
|
2116
|
+
const HIT_TEST_POINTER_BLIND = process.env.RELAY_OVERLAY_TEST_IGNORE_POINTER === "1";
|
|
1958
2117
|
|
|
1959
2118
|
function applyIgnore(next) {
|
|
1960
2119
|
if (next === hitIgnoring) return;
|
|
@@ -1995,6 +2154,14 @@ function scheduleHit(ms) {
|
|
|
1995
2154
|
function hitTick() {
|
|
1996
2155
|
hitTimer = null;
|
|
1997
2156
|
if (!win || win.isDestroyed()) return;
|
|
2157
|
+
if (HIT_TEST_POINTER_BLIND) {
|
|
2158
|
+
// Interactive everywhere, hover never asserted: synthetic-input runs must
|
|
2159
|
+
// not have their dwell/fold behavior steered by the user's parked mouse.
|
|
2160
|
+
applyIgnore(false);
|
|
2161
|
+
applyHover(false);
|
|
2162
|
+
scheduleHit(POLL_HIDDEN_MS);
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
1998
2165
|
if (!win.isVisible()) {
|
|
1999
2166
|
// A hidden window must never hold clicks, and must not cost a cursor read.
|
|
2000
2167
|
applyIgnore(true);
|
|
@@ -2415,6 +2582,11 @@ ipcMain.handle("relay:contacts", () => readContacts());
|
|
|
2415
2582
|
ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
|
|
2416
2583
|
ipcMain.handle("relay:contactDelete", (_e, input) => deleteContactFromBook(input));
|
|
2417
2584
|
|
|
2585
|
+
// Settings tab: account card + the sign-out / switch-account lifecycle.
|
|
2586
|
+
ipcMain.handle("relay:accountInfo", () => accountInfo());
|
|
2587
|
+
ipcMain.handle("relay:pairWithCode", (_e, input) => pairWithCode(input));
|
|
2588
|
+
ipcMain.handle("relay:signOut", () => signOutAccount());
|
|
2589
|
+
|
|
2418
2590
|
// The overlay is normally focusable:false so it never steals keyboard focus from
|
|
2419
2591
|
// Claude/Codex. Text fields (the contact form, quick reply) need focus, so the renderer
|
|
2420
2592
|
// asks us to grant it while a field is open and revoke it the moment the form closes.
|
package/overlay/preload.cjs
CHANGED
|
@@ -46,6 +46,11 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
46
46
|
contactSave: (input) => ipcRenderer.invoke("relay:contactSave", input),
|
|
47
47
|
contactDelete: (input) => ipcRenderer.invoke("relay:contactDelete", input),
|
|
48
48
|
|
|
49
|
+
// settings / account (switch + sign-out relaunch the pill on success)
|
|
50
|
+
accountInfo: () => ipcRenderer.invoke("relay:accountInfo"),
|
|
51
|
+
pairWithCode: (input) => ipcRenderer.invoke("relay:pairWithCode", input),
|
|
52
|
+
signOut: () => ipcRenderer.invoke("relay:signOut"),
|
|
53
|
+
|
|
49
54
|
|
|
50
55
|
// window plumbing
|
|
51
56
|
setFocusable: (v) => ipcRenderer.send("relay:setFocusable", v),
|
package/package.json
CHANGED
package/src/account.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { readConfig, writeConfigObject } from "./config.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Account lifecycle for the companion: the ONE config-write shape shared by
|
|
6
|
+
* `relay pair` (bin/relay.js cmdPair) and the pill's Settings tab, so switching
|
|
7
|
+
* accounts from either surface persists identical credentials. The pure shapes
|
|
8
|
+
* are exported separately from the fs-touching persist helpers for unit tests.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pairing codes are 8 chars from an unambiguous uppercase alphabet
|
|
13
|
+
* (services/devices.ts). The server only trims + uppercases, so typed
|
|
14
|
+
* "abcd-efgh" / "ABCD EFGH" variants are folded here before the request.
|
|
15
|
+
*/
|
|
16
|
+
export function normalizePairingCode(raw) {
|
|
17
|
+
return String(raw ?? "")
|
|
18
|
+
.replace(/[\s-]+/g, "")
|
|
19
|
+
.toUpperCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The device name a re-pair should register: the remembered one, else the hostname. */
|
|
23
|
+
export function deviceNameForPairing(config = readConfig()) {
|
|
24
|
+
const stored = String((config && config.deviceName) || "").trim();
|
|
25
|
+
return stored || os.hostname();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The post-registration config: everything the old config had, plus the fresh
|
|
30
|
+
* credentials. apiUrl/webUrl/deviceName are only written when explicitly given
|
|
31
|
+
* (the pill switches accounts without touching the URLs it was launched with).
|
|
32
|
+
*/
|
|
33
|
+
export function pairedAccountConfig(existing, { apiUrl, webUrl, deviceName, registration } = {}) {
|
|
34
|
+
const res = registration || {};
|
|
35
|
+
return {
|
|
36
|
+
...(existing || {}),
|
|
37
|
+
...(apiUrl ? { apiUrl } : {}),
|
|
38
|
+
...(webUrl ? { webUrl } : {}),
|
|
39
|
+
...(deviceName ? { deviceName } : {}),
|
|
40
|
+
deviceToken: res.deviceToken || "",
|
|
41
|
+
deviceId: res.deviceId || "",
|
|
42
|
+
user: res.user || null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sign-out clears the credential set — user, deviceToken, and the deviceId that
|
|
48
|
+
* belongs to that token — while preserving apiUrl/webUrl, the device name, the
|
|
49
|
+
* companion mode, and any other settings on the file.
|
|
50
|
+
*/
|
|
51
|
+
export function signedOutAccountConfig(existing) {
|
|
52
|
+
const next = { ...(existing || {}) };
|
|
53
|
+
delete next.user;
|
|
54
|
+
delete next.deviceToken;
|
|
55
|
+
delete next.deviceId;
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function persistPairedAccount({ apiUrl, webUrl, deviceName, registration } = {}) {
|
|
60
|
+
return writeConfigObject(pairedAccountConfig(readConfig(), { apiUrl, webUrl, deviceName, registration }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function persistSignedOutAccount() {
|
|
64
|
+
return writeConfigObject(signedOutAccountConfig(readConfig()));
|
|
65
|
+
}
|
package/src/config.js
CHANGED
|
@@ -23,7 +23,9 @@ export function configDir() {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function configPath() {
|
|
26
|
-
|
|
26
|
+
// RELAY_CONFIG (a full file path) matches the overlay's readConfigFile
|
|
27
|
+
// resolution, so a sandboxed pill and these helpers agree on ONE file.
|
|
28
|
+
return process.env.RELAY_CONFIG || path.join(configDir(), "config.json");
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export function readConfig() {
|
|
@@ -51,19 +53,25 @@ export function readConfig() {
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Replace config.json with exactly `next` (no merge). This is the primitive that
|
|
58
|
+
* lets sign-out REMOVE keys — writeConfig's patch merge can only add or overwrite.
|
|
59
|
+
*/
|
|
60
|
+
export function writeConfigObject(next) {
|
|
61
|
+
const file = configPath();
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
58
63
|
// Atomic write: a crash mid-write must not truncate config.json and wipe the
|
|
59
64
|
// device token (which would put the daemon into a launchd crash loop).
|
|
60
|
-
const file = configPath();
|
|
61
65
|
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
62
66
|
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
63
67
|
fs.renameSync(tmp, file);
|
|
64
68
|
return next;
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
export function writeConfig(patch) {
|
|
72
|
+
return writeConfigObject({ ...readConfig(), ...patch });
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
export function apiUrl() {
|
|
68
76
|
return (
|
|
69
77
|
process.env.RELAY_API_URL ||
|