123456789101112131415import {16 type Component,17 Show,18 createSignal,19 onCleanup,20 onMount,21} from "solid-js";22import { sendMessage } from "../../shared/bridge";23import { DesktopBird, MobileBird } from "../components/PairingBirds";24import { PairingQrCode } from "../components/PairingQrCode";25import { PairingProRequired } from "../components/PairingProRequired";26import { SyncModeSelector } from "../components/SyncModeSelector";2728export interface PairDeviceViewProps {29 isPro: boolean;30 onBack(): void;31 onLearnPro(): void;32 33 onProChanged?: () => void;34}3536type SharePhase = "opening" | "invitation" | "sas" | "paired" | "error";3738export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {39 const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");40 const [invitation, setInvitation] = createSignal("");41 const [sourceSas, setSourceSas] = createSignal("");42 const [pendingSasId, setPendingSasId] = createSignal("");43 const [receiverName, setReceiverName] = createSignal("New device");44 const [shareError, setShareError] = createSignal("");45 const [changingRelay, setChangingRelay] = createSignal(false);4647 async function startShareFlow(): Promise<void> {48 setSharePhase("opening");49 setShareError("");50 setInvitation("");51 setSourceSas("");52 try {53 const response = (await sendMessage({54 type: "START_ADOPTION_SOURCE",55 })) as {56 ok: boolean;57 data?: { invitation?: string };58 error?: string;59 };60 if (!response.ok || typeof response.data?.invitation !== "string") {61 throw new Error(response.error ?? "adoption_source_failed");62 }63 setInvitation(response.data.invitation);64 setSharePhase("invitation");65 } catch (error) {66 setShareError(error instanceof Error ? error.message : "adoption_source_failed");67 setSharePhase("error");68 }69 }7071 async function stopShareFlow(): Promise<void> {72 await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);73 }7475 76 async function restartShareFlow(): Promise<void> {77 await stopShareFlow();78 await startShareFlow();79 }8081 function answerSas(ok: boolean): void {82 const id = pendingSasId();83 if (!id) return;84 setPendingSasId("");85 void sendMessage({86 type: "SOURCE_SAS_DECISION",87 payload: { id, ok },88 }).catch(() => undefined);89 if (!ok) {90 setShareError("adoption_rejected");91 setSharePhase("error");92 }93 }9495 onMount(() => {96 void startShareFlow();9798 99 100 101 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {102 const handler = (msg: { type?: string; payload?: unknown }) => {103 if (msg?.type === "ADOPTION_SOURCE_SAS") {104 const p = msg.payload as {105 id?: string;106 sas?: string;107 receiverDeviceName?: string;108 } | undefined;109 if (typeof p?.sas === "string") {110 setSourceSas(p.sas);111 setPendingSasId(typeof p.id === "string" ? p.id : "");112 setReceiverName(113 typeof p.receiverDeviceName === "string" && p.receiverDeviceName114 ? p.receiverDeviceName115 : "New device",116 );117 setSharePhase("sas");118 }119 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {120 const p = msg.payload as {121 status?: "complete" | "error";122 error?: string;123 } | undefined;124 if (p?.status === "complete") {125 setPendingSasId("");126 setSharePhase("paired");127 } else if (p?.status === "error") {128 setPendingSasId("");129 setShareError(p.error ?? "adoption_failed");130 setSharePhase("error");131 }132 }133 };134 chrome.runtime.onMessage.addListener(handler);135 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));136 }137138 139 140 onCleanup(() => {141 const id = pendingSasId();142 if (id) {143 void sendMessage({144 type: "SOURCE_SAS_DECISION",145 payload: { id, ok: false },146 }).catch(() => undefined);147 }148 if (sharePhase() !== "paired") {149 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });150 }151 });152 });153154 const backArrow = (155 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">156 <path d="M10 3 L5 8 L10 13" />157 </svg>158 );159160 161 const birdStage = (phase: SharePhase) => (162 <div class={`peach-pair-stage ${phase === "sas" || phase === "paired" ? "act-handshake" : "act-listening"}`} aria-hidden="true">163 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">164 <DesktopBird pose={phase === "paired" ? "happy" : "chirping"} />165 </div>166 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">167 <MobileBird pose={phase === "paired" ? "happy" : "attentive"} />168 </div>169 </div>170 );171172 function errorCopy(): string {173 switch (shareError()) {174 case "vault_locked":175 return "Unlock Peach first, then try again.";176 case "sync_disabled":177 return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";178 case "private_relay_unset":179 return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";180 default:181 return "The secure pairing session ended. Start again with a fresh code.";182 }183 }184185 function liveText(): string {186 switch (sharePhase()) {187 case "opening": return "Opening a secure pairing room";188 case "invitation": return "Scan this QR on the empty device";189 case "sas": return "Confirm the code matches";190 case "paired": return "Device added";191 case "error": return "Could not add the device";192 }193 }194195 return (196 <div197 class="peach-pair-pair peach-desktop-pair peach-adoption-source"198 data-phase={sharePhase()}199 role="dialog"200 aria-label="Add a device to this vault"201 >202 <header class="peach-pair-head">203 <button204 class="peach-pair-back"205 aria-label="Back"206 onClick={() => { void stopShareFlow(); props.onBack(); }}207 >208 {backArrow}209 </button>210 <div class="peach-pair-title">Add a new device</div>211 <div class="peach-pair-head-spacer" />212 </header>213214 {birdStage(sharePhase())}215216 <Show when={sharePhase() === "opening"}>217 <div class="peach-desktop-searching">218 <div class="peach-pair-headline">Opening a secure pairing room…</div>219 <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>220 </div>221 </Show>222223 <Show when={sharePhase() === "invitation"}>224 <div class="peach-adoption-source-invitation">225 <Show when={!changingRelay()}>226 <div class="peach-pair-sub">227 This extension has the vault. Scan this QR on the empty device,228 then compare the 8-digit safety code.229 </div>230 <PairingQrCode value={invitation()} size={224} />231 </Show>232 {233234235}236 <SyncModeSelector237 compact238 onExpand={setChangingRelay}239 onChange={() => void restartShareFlow()}240 />241 </div>242 </Show>243244 <Show when={sharePhase() === "paired"}>245 <div class="peach-desktop-paired">246 <div class="peach-pair-headline">Device added</div>247 <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>248 <div class="peach-pair-action">249 <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>250 </div>251 </div>252 </Show>253254 <Show when={sharePhase() === "error"}>255 <Show256 when={shareError() === "ADOPT_PRO_REQUIRED"}257 fallback={258 <div class="peach-desktop-error">259 <div class="peach-pair-headline">Could not add the device</div>260 <div class="peach-pair-sub">{errorCopy()}</div>261 <div class="peach-pair-action">262 <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>263 <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>264 </div>265 </div>266 }267 >268 <div class="peach-desktop-error peach-desktop-error--pro">269 <PairingProRequired270 onRetry={() => void startShareFlow()}271 onOpenPro={props.onLearnPro}272 />273 </div>274 </Show>275 </Show>276277 <Show when={sharePhase() === "sas"}>278 <div class="peach-desktop-searching">279 <div class="peach-pair-headline">Compare the 8-digit safety code</div>280 <div class="peach-pair-sub">281 If {receiverName()} shows the same code, tap “They match” on that282 device too. Approving gives that device a complete copy of this283 vault, including existing passwords, passkeys, notes, and recovery284 access.285 </div>286 <div class="peach-adoption-sas" aria-label="Pairing confirmation code">287 <strong>{sourceSas()}</strong>288 </div>289 <div class="peach-pair-action">290 <button class="peach-btn peach-btn-ghost" onClick={() => answerSas(false)}>291 They don't match292 </button>293 <button class="peach-btn peach-btn-primary" onClick={() => answerSas(true)}>294 They match295 </button>296 </div>297 </div>298 </Show>299300 <div class="peach-sr-only" aria-live="polite">{liveText()}</div>301 </div>302 );303};