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 [peerFingerprint, setPeerFingerprint] = createSignal("");45 const [shareError, setShareError] = createSignal("");46 const [changingRelay, setChangingRelay] = createSignal(false);4748 async function startShareFlow(): Promise<void> {49 setSharePhase("opening");50 setShareError("");51 setInvitation("");52 setSourceSas("");53 try {54 const response = (await sendMessage({55 type: "START_ADOPTION_SOURCE",56 })) as {57 ok: boolean;58 data?: { invitation?: string };59 error?: string;60 };61 if (!response.ok || typeof response.data?.invitation !== "string") {62 throw new Error(response.error ?? "adoption_source_failed");63 }64 setInvitation(response.data.invitation);65 setSharePhase("invitation");66 } catch (error) {67 setShareError(error instanceof Error ? error.message : "adoption_source_failed");68 setSharePhase("error");69 }70 }7172 async function stopShareFlow(): Promise<void> {73 await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);74 }7576 77 async function restartShareFlow(): Promise<void> {78 await stopShareFlow();79 await startShareFlow();80 }8182 function answerSas(ok: boolean): void {83 const id = pendingSasId();84 if (!id) return;85 setPendingSasId("");86 void sendMessage({87 type: "SOURCE_SAS_DECISION",88 payload: { id, ok },89 }).catch(() => undefined);90 if (!ok) {91 setShareError("adoption_rejected");92 setSharePhase("error");93 }94 }9596 onMount(() => {97 void startShareFlow();9899 100 101 102 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {103 const handler = (msg: { type?: string; payload?: unknown }) => {104 if (msg?.type === "ADOPTION_SOURCE_SAS") {105 const p = msg.payload as {106 id?: string;107 sas?: string;108 receiverDeviceName?: string;109 peerFingerprint?: string;110 } | undefined;111 if (typeof p?.sas === "string") {112 setSourceSas(p.sas);113 setPendingSasId(typeof p.id === "string" ? p.id : "");114 setReceiverName(115 typeof p.receiverDeviceName === "string" && p.receiverDeviceName116 ? p.receiverDeviceName117 : "New device",118 );119 setPeerFingerprint(typeof p.peerFingerprint === "string" ? p.peerFingerprint : "");120 setSharePhase("sas");121 }122 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {123 const p = msg.payload as {124 status?: "complete" | "error";125 error?: string;126 } | undefined;127 if (p?.status === "complete") {128 setPendingSasId("");129 setSharePhase("paired");130 } else if (p?.status === "error") {131 setPendingSasId("");132 setShareError(p.error ?? "adoption_failed");133 setSharePhase("error");134 }135 }136 };137 chrome.runtime.onMessage.addListener(handler);138 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));139 }140141 142 143 onCleanup(() => {144 const id = pendingSasId();145 if (id) {146 void sendMessage({147 type: "SOURCE_SAS_DECISION",148 payload: { id, ok: false },149 }).catch(() => undefined);150 }151 if (sharePhase() !== "paired") {152 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });153 }154 });155 });156157 const backArrow = (158 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">159 <path d="M10 3 L5 8 L10 13" />160 </svg>161 );162163 164 const birdStage = (phase: SharePhase) => (165 <div class={`peach-pair-stage ${phase === "sas" || phase === "paired" ? "act-handshake" : "act-listening"}`} aria-hidden="true">166 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">167 <DesktopBird pose={phase === "paired" ? "happy" : "chirping"} />168 </div>169 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">170 <MobileBird pose={phase === "paired" ? "happy" : "attentive"} />171 </div>172 </div>173 );174175 function errorCopy(): string {176 switch (shareError()) {177 case "vault_locked":178 return "Unlock Peach first, then try again.";179 case "sync_disabled":180 return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";181 case "private_relay_unset":182 return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";183 default:184 return "The secure pairing session ended. Start again with a fresh code.";185 }186 }187188 function liveText(): string {189 switch (sharePhase()) {190 case "opening": return "Opening a secure pairing room";191 case "invitation": return "Scan this QR on the empty device";192 case "sas": return "Confirm the code matches";193 case "paired": return "Device added";194 case "error": return "Could not add the device";195 }196 }197198 return (199 <div200 class="peach-pair-pair peach-desktop-pair peach-adoption-source"201 data-phase={sharePhase()}202 role="dialog"203 aria-label="Add a device to this vault"204 >205 <header class="peach-pair-head">206 <button207 class="peach-pair-back"208 aria-label="Back"209 onClick={() => { void stopShareFlow(); props.onBack(); }}210 >211 {backArrow}212 </button>213 <div class="peach-pair-title">Add a new device</div>214 <div class="peach-pair-head-spacer" />215 </header>216217 {birdStage(sharePhase())}218219 <Show when={sharePhase() === "opening"}>220 <div class="peach-desktop-searching">221 <div class="peach-pair-headline">Opening a secure pairing room…</div>222 <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>223 </div>224 </Show>225226 <Show when={sharePhase() === "invitation"}>227 <div class="peach-adoption-source-invitation">228 <Show when={!changingRelay()}>229 <div class="peach-pair-headline">Pair a device</div>230 <div class="peach-pair-sub">231 This extension has the vault. Scan this QR on the empty device,232 then compare the 8-digit safety code.233 </div>234 <PairingQrCode value={invitation()} size={176} />235 </Show>236 {237238239}240 <SyncModeSelector241 compact242 onExpand={setChangingRelay}243 onChange={() => void restartShareFlow()}244 />245 </div>246 </Show>247248 <Show when={sharePhase() === "paired"}>249 <div class="peach-desktop-paired">250 <div class="peach-pair-headline">Device added</div>251 <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>252 <div class="peach-pair-action">253 <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>254 </div>255 </div>256 </Show>257258 <Show when={sharePhase() === "error"}>259 <Show260 when={shareError() === "ADOPT_PRO_REQUIRED"}261 fallback={262 <div class="peach-desktop-error">263 <div class="peach-pair-headline">Could not add the device</div>264 <div class="peach-pair-sub">{errorCopy()}</div>265 <div class="peach-pair-action">266 <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>267 <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>268 </div>269 </div>270 }271 >272 <div class="peach-desktop-error peach-desktop-error--pro">273 <PairingProRequired274 onRetry={() => void startShareFlow()}275 onOpenPro={props.onLearnPro}276 />277 </div>278 </Show>279 </Show>280281 <Show when={sharePhase() === "sas"}>282 <div class="peach-desktop-searching">283 <div class="peach-pair-headline">Compare the 8-digit safety code</div>284 <div class="peach-pair-sub">285 If {receiverName()} shows the same code, tap “They match” on that286 device too. Session ID {peerFingerprint()}. Approving gives that287 device a complete copy of this vault, including existing passwords,288 passkeys, notes, and recovery access.289 </div>290 <div class="peach-adoption-sas" aria-label="Pairing confirmation code">291 <strong>{sourceSas()}</strong>292 </div>293 <div class="peach-pair-action">294 <button class="peach-btn peach-btn-ghost" onClick={() => answerSas(false)}>295 They don't match296 </button>297 <button class="peach-btn peach-btn-primary" onClick={() => answerSas(true)}>298 They match299 </button>300 </div>301 </div>302 </Show>303304 <div class="peach-sr-only" aria-live="polite">{liveText()}</div>305 </div>306 );307};