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";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" | "connecting" | "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 [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 async function verifySourcePassword(password: string): Promise<boolean> {82 const response = (await sendMessage({83 type: "ADOPTION_SOURCE_CONFIRM",84 payload: { password },85 })) as { ok: boolean; data?: { valid?: boolean } };86 return response.ok && response.data?.valid === true;87 }8889 onMount(() => {90 void startShareFlow();9192 93 94 95 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {96 const handler = (msg: { type?: string; payload?: unknown }) => {97 if (msg?.type === "ADOPTION_SOURCE_SAS") {98 const p = msg.payload as {99 sas?: string;100 receiverDeviceName?: string;101 } | undefined;102 if (typeof p?.sas === "string") {103 setSourceSas(p.sas);104 setReceiverName(105 typeof p.receiverDeviceName === "string" && p.receiverDeviceName106 ? p.receiverDeviceName107 : "New device",108 );109 setSharePhase("sas");110 }111 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {112 const p = msg.payload as {113 status?: "complete" | "error";114 error?: string;115 } | undefined;116 if (p?.status === "complete") setSharePhase("paired");117 else if (p?.status === "error") {118 setShareError(p.error ?? "adoption_failed");119 setSharePhase("error");120 }121 }122 };123 chrome.runtime.onMessage.addListener(handler);124 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));125 }126127 128 129 onCleanup(() => {130 if (sharePhase() !== "paired") {131 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });132 }133 });134 });135136 const backArrow = (137 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">138 <path d="M10 3 L5 8 L10 13" />139 </svg>140 );141142 143 const birdStage = (active: boolean) => (144 <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">145 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">146 <DesktopBird pose="chirping" />147 </div>148 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">149 <MobileBird pose="attentive" />150 </div>151 </div>152 );153154 function errorCopy(): string {155 switch (shareError()) {156 case "vault_locked":157 return "Unlock Peach first, then try again.";158 case "sync_disabled":159 return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";160 case "private_relay_unset":161 return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";162 default:163 return "The secure pairing session ended. Start again with a fresh code.";164 }165 }166167 function liveText(): string {168 switch (sharePhase()) {169 case "opening": return "Opening a secure pairing room";170 case "invitation": return "Scan this code on your other device";171 case "sas": return "Confirm the code matches";172 case "connecting": return "Waiting for the new device";173 case "paired": return "Device added";174 case "error": return "Could not add the device";175 }176 }177178 return (179 <div180 class="peach-pair-pair peach-desktop-pair peach-adoption-source"181 data-phase={sharePhase()}182 role="dialog"183 aria-label="Add a device to this vault"184 >185 <header class="peach-pair-head">186 <button187 class="peach-pair-back"188 aria-label="Back"189 onClick={() => { void stopShareFlow(); props.onBack(); }}190 >191 {backArrow}192 </button>193 <div class="peach-pair-title">Add a new device</div>194 <div class="peach-pair-head-spacer" />195 </header>196197 {birdStage(sharePhase() === "sas" || sharePhase() === "connecting")}198199 <Show when={sharePhase() === "opening"}>200 <div class="peach-desktop-searching">201 <div class="peach-pair-headline">Opening a secure pairing room…</div>202 <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>203 </div>204 </Show>205206 <Show when={sharePhase() === "invitation"}>207 <div class="peach-adoption-source-invitation">208 <Show when={!changingRelay()}>209 <div class="peach-pair-headline">Scan this in Peach</div>210 <div class="peach-pair-sub">211 Open Peach on your phone, choose “Scan a code”, and point it here.212 The code carries only the relay address and a single-use token.213 </div>214 <PairingQrCode value={invitation()} size={188} />215 </Show>216 {217218219}220 <SyncModeSelector221 compact222 onExpand={setChangingRelay}223 onChange={() => void restartShareFlow()}224 />225 </div>226 </Show>227228 <Show when={sharePhase() === "connecting"}>229 <div class="peach-desktop-searching">230 <div class="peach-pair-headline">Waiting for the new device…</div>231 <div class="peach-pair-sub">The vault key stays put until that device confirms the same code too.</div>232 </div>233 </Show>234235 <Show when={sharePhase() === "paired"}>236 <div class="peach-desktop-paired">237 <div class="peach-pair-headline">Device added</div>238 <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>239 <div class="peach-pair-action">240 <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>241 </div>242 </div>243 </Show>244245 <Show when={sharePhase() === "error"}>246 <div class="peach-desktop-error">247 <div class="peach-pair-headline">Could not add the device</div>248 <div class="peach-pair-sub">{errorCopy()}</div>249 <div class="peach-pair-action">250 <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>251 <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>252 </div>253 </div>254 </Show>255256 <Show when={sharePhase() === "sas"}>257 <AdoptionConsentModal258 receiverDeviceName={receiverName()}259 sas={sourceSas()}260 verifyMasterPassword={verifySourcePassword}261 onCancel={() => {262 void sendMessage({263 type: "ADOPTION_SOURCE_CONFIRM",264 payload: { cancel: true },265 });266 setShareError("adoption_rejected");267 setSharePhase("error");268 }}269 onApprove={async () => {270 setSharePhase("connecting");271 }}272 />273 </Show>274275 <div class="peach-sr-only" aria-live="polite">{liveText()}</div>276 </div>277 );278};