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 { AdoptionConsentModal } from "../components/AdoptionConsentModal";28import { PairingQrCode } from "../components/PairingQrCode";29import { SyncModeSelector } from "../components/SyncModeSelector";3031export interface PairDeviceViewProps {32 isPro: boolean;33 onBack(): void;34 onLearnPro(): void;35 36 onProChanged?: () => void;37}3839type SharePhase = "opening" | "invitation" | "sas" | "connecting" | "paired" | "error";4041export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {42 const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");43 const [invitation, setInvitation] = createSignal("");44 const [sourceSas, setSourceSas] = createSignal("");45 const [receiverName, setReceiverName] = createSignal("New device");46 const [shareError, setShareError] = createSignal("");47 const [changingRelay, setChangingRelay] = createSignal(false);48 const [showTextCode, setShowTextCode] = createSignal(false);49 const [copied, setCopied] = createSignal(false);5051 async function startShareFlow(): Promise<void> {52 setSharePhase("opening");53 setShareError("");54 setInvitation("");55 setSourceSas("");56 setCopied(false);57 try {58 const response = (await sendMessage({59 type: "START_ADOPTION_SOURCE",60 })) as {61 ok: boolean;62 data?: { invitation?: string };63 error?: string;64 };65 if (!response.ok || typeof response.data?.invitation !== "string") {66 throw new Error(response.error ?? "adoption_source_failed");67 }68 setInvitation(response.data.invitation);69 setSharePhase("invitation");70 } catch (error) {71 setShareError(error instanceof Error ? error.message : "adoption_source_failed");72 setSharePhase("error");73 }74 }7576 async function stopShareFlow(): Promise<void> {77 await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);78 }7980 81 async function restartShareFlow(): Promise<void> {82 await stopShareFlow();83 await startShareFlow();84 }8586 async function verifySourcePassword(password: string): Promise<boolean> {87 const response = (await sendMessage({88 type: "ADOPTION_SOURCE_CONFIRM",89 payload: { password },90 })) as { ok: boolean; data?: { valid?: boolean } };91 return response.ok && response.data?.valid === true;92 }9394 onMount(() => {95 void startShareFlow();9697 98 99 100 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {101 const handler = (msg: { type?: string; payload?: unknown }) => {102 if (msg?.type === "ADOPTION_SOURCE_SAS") {103 const p = msg.payload as {104 sas?: string;105 receiverDeviceName?: string;106 } | undefined;107 if (typeof p?.sas === "string") {108 setSourceSas(p.sas);109 setReceiverName(110 typeof p.receiverDeviceName === "string" && p.receiverDeviceName111 ? p.receiverDeviceName112 : "New device",113 );114 setSharePhase("sas");115 }116 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {117 const p = msg.payload as {118 status?: "complete" | "error";119 error?: string;120 } | undefined;121 if (p?.status === "complete") setSharePhase("paired");122 else if (p?.status === "error") {123 setShareError(p.error ?? "adoption_failed");124 setSharePhase("error");125 }126 }127 };128 chrome.runtime.onMessage.addListener(handler);129 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));130 }131132 133 134 onCleanup(() => {135 if (sharePhase() !== "paired") {136 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });137 }138 });139 });140141 const backArrow = (142 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">143 <path d="M10 3 L5 8 L10 13" />144 </svg>145 );146147 148 const birdStage = (active: boolean) => (149 <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">150 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">151 <DesktopBird pose="chirping" />152 </div>153 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">154 <MobileBird pose="attentive" />155 </div>156 </div>157 );158159 function errorCopy(): string {160 switch (shareError()) {161 case "vault_locked":162 return "Unlock Peach first, then try again.";163 case "sync_disabled":164 return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";165 case "private_relay_unset":166 return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";167 default:168 return "The secure pairing session ended. Start again with a fresh code.";169 }170 }171172 function liveText(): string {173 switch (sharePhase()) {174 case "opening": return "Opening a secure pairing room";175 case "invitation": return "Scan this code on your other device";176 case "sas": return "Confirm the code matches";177 case "connecting": return "Waiting for the new device";178 case "paired": return "Device added";179 case "error": return "Could not add the device";180 }181 }182183 return (184 <div185 class="peach-pair-pair peach-desktop-pair peach-adoption-source"186 data-phase={sharePhase()}187 role="dialog"188 aria-label="Add a device to this vault"189 >190 <header class="peach-pair-head">191 <button192 class="peach-pair-back"193 aria-label="Back"194 onClick={() => { void stopShareFlow(); props.onBack(); }}195 >196 {backArrow}197 </button>198 <div class="peach-pair-title">Add a new device</div>199 <div class="peach-pair-head-spacer" />200 </header>201202 {birdStage(sharePhase() === "sas" || sharePhase() === "connecting")}203204 <Show when={sharePhase() === "opening"}>205 <div class="peach-desktop-searching">206 <div class="peach-pair-headline">Opening a secure pairing room…</div>207 <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>208 </div>209 </Show>210211 <Show when={sharePhase() === "invitation"}>212 <div class="peach-adoption-source-invitation">213 <Show when={!changingRelay()}>214 <div class="peach-pair-headline">215 {showTextCode() ? "Enter this in Peach" : "Scan this in Peach"}216 </div>217 {218219220}221 <Show222 when={showTextCode()}223 fallback={224 <>225 <div class="peach-pair-sub">226 Open Peach on your phone, choose “Scan a code”, and point it here.227 </div>228 <PairingQrCode value={invitation()} size={176} />229 </>230 }231 >232 {233}234 <div class="peach-pair-sub">235 Type or paste this into Peach on your other device. The whole236 code is a relay address and a single-use token.237 </div>238 <textarea239 id="pairing-invitation-manual"240 class="peach-pair-invitation-text"241 readonly242 rows={4}243 aria-label="Pairing invitation"244 onFocus={(event) => (event.target as HTMLTextAreaElement).select()}245 >{invitation()}</textarea>246 </Show>247 <button248 class="peach-btn peach-btn--ghost peach-btn--sm"249 onClick={() => {250 setCopied(false);251 setShowTextCode(!showTextCode());252 }}253 >254 {showTextCode() ? "Show the QR instead" : "Show a code instead"}255 </button>256 <Show when={showTextCode()}>257 <button258 class="peach-btn peach-btn--subtle peach-btn--sm"259 onClick={() => {260 void navigator.clipboard?.writeText(invitation()).catch(() => undefined);261 setCopied(true);262 }}263 >{copied() ? "copied" : "copy"}</button>264 </Show>265 </Show>266 {267268269}270 <SyncModeSelector271 compact272 onExpand={setChangingRelay}273 onChange={() => void restartShareFlow()}274 />275 </div>276 </Show>277278 <Show when={sharePhase() === "connecting"}>279 <div class="peach-desktop-searching">280 <div class="peach-pair-headline">Waiting for the new device…</div>281 <div class="peach-pair-sub">The vault key stays put until that device confirms the same code too.</div>282 </div>283 </Show>284285 <Show when={sharePhase() === "paired"}>286 <div class="peach-desktop-paired">287 <div class="peach-pair-headline">Device added</div>288 <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>289 <div class="peach-pair-action">290 <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>291 </div>292 </div>293 </Show>294295 <Show when={sharePhase() === "error"}>296 <div class="peach-desktop-error">297 <div class="peach-pair-headline">Could not add the device</div>298 <div class="peach-pair-sub">{errorCopy()}</div>299 <div class="peach-pair-action">300 <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>301 <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>302 </div>303 </div>304 </Show>305306 <Show when={sharePhase() === "sas"}>307 <AdoptionConsentModal308 receiverDeviceName={receiverName()}309 sas={sourceSas()}310 verifyMasterPassword={verifySourcePassword}311 onCancel={() => {312 void sendMessage({313 type: "ADOPTION_SOURCE_CONFIRM",314 payload: { cancel: true },315 });316 setShareError("adoption_rejected");317 setSharePhase("error");318 }}319 onApprove={async () => {320 setSharePhase("connecting");321 }}322 />323 </Show>324325 <div class="peach-sr-only" aria-live="polite">{liveText()}</div>326 </div>327 );328};