123456789101112131415161718import {19 Component,20 Show,21 createSignal,22 onCleanup,23 onMount,24} from "solid-js";25import { sendMessage } from "../../shared/bridge";26import { DesktopBird, MobileBird } from "../components/PairingBirds";27import { PairingQrCode } from "../components/PairingQrCode";28import { SyncModeSelector } from "../components/SyncModeSelector";2930export interface PairDeviceViewProps {31 isPro: boolean;32 onBack(): void;33 onLearnPro(): void;34 35 onProChanged?: () => void;36}3738type SharePhase = "opening" | "invitation" | "sas" | "paired" | "error";3940export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {41 const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");42 const [invitation, setInvitation] = createSignal("");43 const [sourceSas, setSourceSas] = createSignal("");44 const [receiverName, setReceiverName] = createSignal("New device");45 const [shareError, setShareError] = createSignal("");46 const [changingRelay, setChangingRelay] = createSignal(false);47 const [showTextCode, setShowTextCode] = createSignal(false);48 const [copied, setCopied] = createSignal(false);4950 async function startShareFlow(): Promise<void> {51 setSharePhase("opening");52 setShareError("");53 setInvitation("");54 setSourceSas("");55 setCopied(false);56 try {57 const response = (await sendMessage({58 type: "START_ADOPTION_SOURCE",59 })) as {60 ok: boolean;61 data?: { invitation?: string };62 error?: string;63 };64 if (!response.ok || typeof response.data?.invitation !== "string") {65 throw new Error(response.error ?? "adoption_source_failed");66 }67 setInvitation(response.data.invitation);68 setSharePhase("invitation");69 } catch (error) {70 setShareError(error instanceof Error ? error.message : "adoption_source_failed");71 setSharePhase("error");72 }73 }7475 async function stopShareFlow(): Promise<void> {76 await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);77 }7879 80 async function restartShareFlow(): Promise<void> {81 await stopShareFlow();82 await startShareFlow();83 }8485 onMount(() => {86 void startShareFlow();8788 89 90 91 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {92 const handler = (msg: { type?: string; payload?: unknown }) => {93 if (msg?.type === "ADOPTION_SOURCE_SAS") {94 const p = msg.payload as {95 sas?: string;96 receiverDeviceName?: string;97 } | undefined;98 if (typeof p?.sas === "string") {99 setSourceSas(p.sas);100 setReceiverName(101 typeof p.receiverDeviceName === "string" && p.receiverDeviceName102 ? p.receiverDeviceName103 : "New device",104 );105 setSharePhase("sas");106 }107 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {108 const p = msg.payload as {109 status?: "complete" | "error";110 error?: string;111 } | undefined;112 if (p?.status === "complete") setSharePhase("paired");113 else if (p?.status === "error") {114 setShareError(p.error ?? "adoption_failed");115 setSharePhase("error");116 }117 }118 };119 chrome.runtime.onMessage.addListener(handler);120 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));121 }122123 124 125 onCleanup(() => {126 if (sharePhase() !== "paired") {127 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });128 }129 });130 });131132 const backArrow = (133 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">134 <path d="M10 3 L5 8 L10 13" />135 </svg>136 );137138 139 const birdStage = (phase: SharePhase) => (140 <div class={`peach-pair-stage ${phase === "sas" || phase === "paired" ? "act-handshake" : "act-listening"}`} aria-hidden="true">141 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">142 <DesktopBird pose={phase === "paired" ? "happy" : "chirping"} />143 </div>144 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">145 <MobileBird pose={phase === "paired" ? "happy" : "attentive"} />146 </div>147 </div>148 );149150 function errorCopy(): string {151 switch (shareError()) {152 case "vault_locked":153 return "Unlock Peach first, then try again.";154 case "sync_disabled":155 return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";156 case "private_relay_unset":157 return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";158 default:159 return "The secure pairing session ended. Start again with a fresh code.";160 }161 }162163 function liveText(): string {164 switch (sharePhase()) {165 case "opening": return "Opening a secure pairing room";166 case "invitation": return "Scan this code on your other device";167 case "sas": return "Confirm the code matches";168 case "paired": return "Device added";169 case "error": return "Could not add the device";170 }171 }172173 return (174 <div175 class="peach-pair-pair peach-desktop-pair peach-adoption-source"176 data-phase={sharePhase()}177 role="dialog"178 aria-label="Add a device to this vault"179 >180 <header class="peach-pair-head">181 <button182 class="peach-pair-back"183 aria-label="Back"184 onClick={() => { void stopShareFlow(); props.onBack(); }}185 >186 {backArrow}187 </button>188 <div class="peach-pair-title">Add a new device</div>189 <div class="peach-pair-head-spacer" />190 </header>191192 {birdStage(sharePhase())}193194 <Show when={sharePhase() === "opening"}>195 <div class="peach-desktop-searching">196 <div class="peach-pair-headline">Opening a secure pairing room…</div>197 <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>198 </div>199 </Show>200201 <Show when={sharePhase() === "invitation"}>202 <div class="peach-adoption-source-invitation">203 <Show when={!changingRelay()}>204 <div class="peach-pair-headline">205 {showTextCode() ? "Enter this in Peach" : "Scan this in Peach"}206 </div>207 {208209210}211 <Show212 when={showTextCode()}213 fallback={214 <>215 <div class="peach-pair-sub">216 Open Peach on your phone, choose “Scan a code”, and point it here.217 </div>218 <PairingQrCode value={invitation()} size={176} />219 </>220 }221 >222 {223}224 <div class="peach-pair-sub">225 Type or paste this into Peach on your other device. The whole226 code is a relay address and a single-use token.227 </div>228 <textarea229 id="pairing-invitation-manual"230 class="peach-pair-invitation-text"231 readonly232 rows={4}233 aria-label="Pairing invitation"234 onFocus={(event) => (event.target as HTMLTextAreaElement).select()}235 >{invitation()}</textarea>236 </Show>237 <button238 class="peach-btn peach-btn--ghost peach-btn--sm"239 onClick={() => {240 setCopied(false);241 setShowTextCode(!showTextCode());242 }}243 >244 {showTextCode() ? "Show the QR instead" : "Show a code instead"}245 </button>246 <Show when={showTextCode()}>247 <button248 class="peach-btn peach-btn--subtle peach-btn--sm"249 onClick={() => {250 void navigator.clipboard?.writeText(invitation()).catch(() => undefined);251 setCopied(true);252 }}253 >{copied() ? "copied" : "copy"}</button>254 </Show>255 </Show>256 {257258259}260 <SyncModeSelector261 compact262 onExpand={setChangingRelay}263 onChange={() => void restartShareFlow()}264 />265 </div>266 </Show>267268 <Show when={sharePhase() === "paired"}>269 <div class="peach-desktop-paired">270 <div class="peach-pair-headline">Device added</div>271 <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>272 <div class="peach-pair-action">273 <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>274 </div>275 </div>276 </Show>277278 <Show when={sharePhase() === "error"}>279 <div class="peach-desktop-error">280 <div class="peach-pair-headline">Could not add the device</div>281 <div class="peach-pair-sub">{errorCopy()}</div>282 <div class="peach-pair-action">283 <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>284 <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>285 </div>286 </div>287 </Show>288289 <Show when={sharePhase() === "sas"}>290 <div class="peach-desktop-searching">291 <div class="peach-pair-headline">Confirm this code on your phone</div>292 <div class="peach-pair-sub">293 If {receiverName()} shows the same code, tap “They match” there. Peach294 will finish only after both devices save the pairing.295 </div>296 <div class="peach-adoption-sas" aria-label="Pairing confirmation code">297 <strong>{sourceSas()}</strong>298 </div>299 </div>300 </Show>301302 <div class="peach-sr-only" aria-live="polite">{liveText()}</div>303 </div>304 );305};