12345678910111213141516import {17 type Component,18 Show,19 createSignal,20 onCleanup,21 onMount,22} from "solid-js";23import { sendMessage } from "../../shared/bridge";24import { DesktopBird, MobileBird } from "../components/PairingBirds";25import { PairingQrCode } from "../components/PairingQrCode";26import { PairingProRequired } from "../components/PairingProRequired";27import { SyncModeSelector } from "../components/SyncModeSelector";2829export interface PairDeviceViewProps {30 isPro: boolean;31 onBack(): void;32 onLearnPro(): void;33 34 onProChanged?: () => void;35}3637type SharePhase = "opening" | "invitation" | "sas" | "paired" | "error";3839export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {40 const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");41 const [invitation, setInvitation] = createSignal("");42 const [sourceSas, setSourceSas] = createSignal("");43 const [pendingSasId, setPendingSasId] = createSignal("");44 const [receiverName, setReceiverName] = createSignal("New device");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 } | undefined;110 if (typeof p?.sas === "string") {111 setSourceSas(p.sas);112 setPendingSasId(typeof p.id === "string" ? p.id : "");113 setReceiverName(114 typeof p.receiverDeviceName === "string" && p.receiverDeviceName115 ? p.receiverDeviceName116 : "New device",117 );118 setSharePhase("sas");119 }120 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {121 const p = msg.payload as {122 status?: "complete" | "error";123 error?: string;124 } | undefined;125 if (p?.status === "complete") {126 setPendingSasId("");127 setSharePhase("paired");128 } else if (p?.status === "error") {129 setPendingSasId("");130 setShareError(p.error ?? "adoption_failed");131 setSharePhase("error");132 }133 }134 };135 chrome.runtime.onMessage.addListener(handler);136 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));137 }138139 140 141 onCleanup(() => {142 const id = pendingSasId();143 if (id) {144 void sendMessage({145 type: "SOURCE_SAS_DECISION",146 payload: { id, ok: false },147 }).catch(() => undefined);148 }149 if (sharePhase() !== "paired") {150 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });151 }152 });153 });154155 const backArrow = (156 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">157 <path d="M10 3 L5 8 L10 13" />158 </svg>159 );160161 162 const birdStage = (phase: SharePhase) => (163 <div class={`peach-pair-stage ${phase === "sas" || phase === "paired" ? "act-handshake" : "act-listening"}`} aria-hidden="true">164 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">165 <DesktopBird pose={phase === "paired" ? "happy" : "chirping"} />166 </div>167 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">168 <MobileBird pose={phase === "paired" ? "happy" : "attentive"} />169 </div>170 </div>171 );172173 function errorCopy(): string {174 switch (shareError()) {175 case "vault_locked":176 return "Unlock Peach first, then try again.";177 case "sync_disabled":178 return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";179 case "private_relay_unset":180 return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";181 default:182 return "The secure pairing session ended. Start again with a fresh code.";183 }184 }185186 function liveText(): string {187 switch (sharePhase()) {188 case "opening": return "Opening a secure pairing room";189 case "invitation": return "Scan this QR on the empty device";190 case "sas": return "Confirm the code matches";191 case "paired": return "Device added";192 case "error": return "Could not add the device";193 }194 }195196 return (197 <div198 class="peach-pair-pair peach-desktop-pair peach-adoption-source"199 data-phase={sharePhase()}200 role="dialog"201 aria-label="Add a device to this vault"202 >203 <header class="peach-pair-head">204 <button205 class="peach-pair-back"206 aria-label="Back"207 onClick={() => { void stopShareFlow(); props.onBack(); }}208 >209 {backArrow}210 </button>211 <div class="peach-pair-title">Add a new device</div>212 <div class="peach-pair-head-spacer" />213 </header>214215 {birdStage(sharePhase())}216217 <Show when={sharePhase() === "opening"}>218 <div class="peach-desktop-searching">219 <div class="peach-pair-headline">Opening a secure pairing room…</div>220 <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>221 </div>222 </Show>223224 <Show when={sharePhase() === "invitation"}>225 <div class="peach-adoption-source-invitation">226 <Show when={!changingRelay()}>227 <div class="peach-pair-headline">Pair a device</div>228 <div class="peach-pair-sub">229 This extension has the vault. Scan this QR on the empty device,230 then compare the 8-digit safety code.231 </div>232 <PairingQrCode value={invitation()} size={176} />233 </Show>234 {235236237}238 <SyncModeSelector239 compact240 onExpand={setChangingRelay}241 onChange={() => void restartShareFlow()}242 />243 </div>244 </Show>245246 <Show when={sharePhase() === "paired"}>247 <div class="peach-desktop-paired">248 <div class="peach-pair-headline">Device added</div>249 <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>250 <div class="peach-pair-action">251 <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>252 </div>253 </div>254 </Show>255256 <Show when={sharePhase() === "error"}>257 <Show258 when={shareError() === "ADOPT_PRO_REQUIRED"}259 fallback={260 <div class="peach-desktop-error">261 <div class="peach-pair-headline">Could not add the device</div>262 <div class="peach-pair-sub">{errorCopy()}</div>263 <div class="peach-pair-action">264 <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>265 <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>266 </div>267 </div>268 }269 >270 <div class="peach-desktop-error peach-desktop-error--pro">271 <PairingProRequired272 onRetry={() => void startShareFlow()}273 onOpenPro={props.onLearnPro}274 />275 </div>276 </Show>277 </Show>278279 <Show when={sharePhase() === "sas"}>280 <div class="peach-desktop-searching">281 <div class="peach-pair-headline">Compare the 8-digit safety code</div>282 <div class="peach-pair-sub">283 If {receiverName()} shows the same code, tap “They match” on that284 device too. Peach will only finish after both devices confirm.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};