12345678910111213141516import {17 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 { AdoptionConsentModal } from "../components/AdoptionConsentModal";26import { PairingQrCode } from "../components/PairingQrCode";2728export interface PairDeviceViewProps {29 isPro: boolean;30 onBack(): void;31 onLearnPro(): void;32 33 onProChanged?: () => void;34}3536type SharePhase = "opening" | "invitation" | "sas" | "connecting" | "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 [receiverName, setReceiverName] = createSignal("New device");43 const [shareError, setShareError] = createSignal("");4445 async function startShareFlow(): Promise<void> {46 setSharePhase("opening");47 setShareError("");48 setInvitation("");49 setSourceSas("");50 try {51 const response = (await sendMessage({52 type: "START_ADOPTION_SOURCE",53 })) as {54 ok: boolean;55 data?: { invitation?: string };56 error?: string;57 };58 if (!response.ok || typeof response.data?.invitation !== "string") {59 throw new Error(response.error ?? "adoption_source_failed");60 }61 setInvitation(response.data.invitation);62 setSharePhase("invitation");63 } catch (error) {64 setShareError(error instanceof Error ? error.message : "adoption_source_failed");65 setSharePhase("error");66 }67 }6869 async function stopShareFlow(): Promise<void> {70 await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);71 }7273 async function verifySourcePassword(password: string): Promise<boolean> {74 const response = (await sendMessage({75 type: "ADOPTION_SOURCE_CONFIRM",76 payload: { password },77 })) as { ok: boolean; data?: { valid?: boolean } };78 return response.ok && response.data?.valid === true;79 }8081 onMount(() => {82 void startShareFlow();8384 85 86 87 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {88 const handler = (msg: { type?: string; payload?: unknown }) => {89 if (msg?.type === "ADOPTION_SOURCE_SAS") {90 const p = msg.payload as {91 sas?: string;92 receiverDeviceName?: string;93 } | undefined;94 if (typeof p?.sas === "string") {95 setSourceSas(p.sas);96 setReceiverName(97 typeof p.receiverDeviceName === "string" && p.receiverDeviceName98 ? p.receiverDeviceName99 : "New device",100 );101 setSharePhase("sas");102 }103 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {104 const p = msg.payload as {105 status?: "complete" | "error";106 error?: string;107 } | undefined;108 if (p?.status === "complete") setSharePhase("paired");109 else if (p?.status === "error") {110 setShareError(p.error ?? "adoption_failed");111 setSharePhase("error");112 }113 }114 };115 chrome.runtime.onMessage.addListener(handler);116 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));117 }118119 120 121 onCleanup(() => {122 if (sharePhase() !== "paired") {123 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });124 }125 });126 });127128 const backArrow = (129 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">130 <path d="M10 3 L5 8 L10 13" />131 </svg>132 );133134 135 const birdStage = (active: boolean) => (136 <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">137 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">138 <DesktopBird pose="chirping" />139 </div>140 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">141 <MobileBird pose="attentive" />142 </div>143 </div>144 );145146 function errorCopy(): string {147 switch (shareError()) {148 case "vault_locked":149 return "Unlock Peach first, then try again.";150 case "sync_disabled":151 return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";152 default:153 return "The secure pairing session ended. Start again with a fresh code.";154 }155 }156157 function liveText(): string {158 switch (sharePhase()) {159 case "opening": return "Opening a secure pairing room";160 case "invitation": return "Scan this code on your other device";161 case "sas": return "Confirm the code matches";162 case "connecting": return "Waiting for the new device";163 case "paired": return "Device added";164 case "error": return "Could not add the device";165 }166 }167168 return (169 <div170 class="peach-pair-pair peach-desktop-pair peach-adoption-source"171 data-phase={sharePhase()}172 role="dialog"173 aria-label="Add a device to this vault"174 >175 <header class="peach-pair-head">176 <button177 class="peach-pair-back"178 aria-label="Back"179 onClick={() => { void stopShareFlow(); props.onBack(); }}180 >181 {backArrow}182 </button>183 <div class="peach-pair-title">Add a new device</div>184 <div class="peach-pair-head-spacer" />185 </header>186187 {birdStage(sharePhase() === "sas" || sharePhase() === "connecting")}188189 <Show when={sharePhase() === "opening"}>190 <div class="peach-desktop-searching">191 <div class="peach-pair-headline">Opening a secure pairing room…</div>192 <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>193 </div>194 </Show>195196 <Show when={sharePhase() === "invitation"}>197 <div class="peach-adoption-source-invitation">198 <div class="peach-pair-headline">Scan this in Peach</div>199 <div class="peach-pair-sub">200 Open Peach on your phone, choose “Scan a code”, and point it here.201 The code carries only the relay address and a single-use token.202 </div>203 <PairingQrCode value={invitation()} size={188} />204 </div>205 </Show>206207 <Show when={sharePhase() === "connecting"}>208 <div class="peach-desktop-searching">209 <div class="peach-pair-headline">Waiting for the new device…</div>210 <div class="peach-pair-sub">The vault key stays put until that device confirms the same code too.</div>211 </div>212 </Show>213214 <Show when={sharePhase() === "paired"}>215 <div class="peach-desktop-paired">216 <div class="peach-pair-headline">Device added</div>217 <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>218 <div class="peach-pair-action">219 <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>220 </div>221 </div>222 </Show>223224 <Show when={sharePhase() === "error"}>225 <div class="peach-desktop-error">226 <div class="peach-pair-headline">Could not add the device</div>227 <div class="peach-pair-sub">{errorCopy()}</div>228 <div class="peach-pair-action">229 <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>230 <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>231 </div>232 </div>233 </Show>234235 <Show when={sharePhase() === "sas"}>236 <AdoptionConsentModal237 receiverDeviceName={receiverName()}238 sas={sourceSas()}239 verifyMasterPassword={verifySourcePassword}240 onCancel={() => {241 void sendMessage({242 type: "ADOPTION_SOURCE_CONFIRM",243 payload: { cancel: true },244 });245 setShareError("adoption_rejected");246 setSharePhase("error");247 }}248 onApprove={async () => {249 setSharePhase("connecting");250 }}251 />252 </Show>253254 <div class="peach-sr-only" aria-live="polite">{liveText()}</div>255 </div>256 );257};