1import { createSignal, onCleanup, onMount, Show } from "solid-js";2import { sendMessage } from "../App";3import type { PairedDevice } from "../../shared/sync-types";45interface SyncPairingProps {6 onClose: () => void;7 onPaired: (device: PairedDevice) => void;8}910type Mode = "show" | "enter";1112function Spinner() {13 return <span class="peach-pairing-spinner" />;14}1516function formatCode(code: string): string {17 if (code.length <= 3) return code;18 return `${code.slice(0, 3)} ${code.slice(3)}`;19}2021export function SyncPairingView(props: SyncPairingProps) {22 const [mode, setMode] = createSignal<Mode>("show");23 const [code, setCode] = createSignal<string | null>(null);24 const [expiresAt, setExpiresAt] = createSignal<number | null>(null);25 const [showError, setShowError] = createSignal<string | null>(null);2627 const [enteredCode, setEnteredCode] = createSignal("");28 const [connecting, setConnecting] = createSignal(false);29 const [enterError, setEnterError] = createSignal<string | null>(null);3031 const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);3233 let expiryTimer: ReturnType<typeof setTimeout> | null = null;3435 function clearExpiryTimer() {36 if (expiryTimer !== null) {37 clearTimeout(expiryTimer);38 expiryTimer = null;39 }40 }4142 function handleRuntimeMessage(msg: unknown) {43 const m = msg as { type?: string; payload?: { deviceName?: string } };44 if (m?.type === "PAIRING_COMPLETED" && m.payload?.deviceName) {45 46 const device: PairedDevice = {47 deviceId: m.payload.deviceName,48 displayName: m.payload.deviceName,49 x25519PublicKey: "",50 pairedAt: Date.now(),51 };52 clearExpiryTimer();53 setCode(null);54 setExpiresAt(null);55 setPairedDevice(device);56 props.onPaired(device);57 }58 }5960 async function startShow() {61 setShowError(null);62 setCode(null);63 setExpiresAt(null);64 clearExpiryTimer();65 const res = await sendMessage({ type: "START_PAIRING_SERVER" });66 const response = res as {67 ok: boolean;68 error?: string;69 data?: { token: string; expiresAt: number };70 };71 if (!response.ok || !response.data) {72 setShowError(73 response.error === "pro_required"74 ? "Sync requires Peach Pro."75 : "Couldn't start the pairing server.",76 );77 return;78 }79 setCode(response.data.token);80 setExpiresAt(response.data.expiresAt);81 const remaining = Math.max(0, response.data.expiresAt - Date.now());82 expiryTimer = setTimeout(() => {83 setCode(null);84 setExpiresAt(null);85 setShowError("Code expired — tap to refresh.");86 }, remaining);87 }8889 async function stopShow() {90 clearExpiryTimer();91 setCode(null);92 setExpiresAt(null);93 await sendMessage({ type: "STOP_PAIRING_SERVER" });94 }9596 async function refreshCode() {97 await stopShow();98 await startShow();99 }100101 async function switchTo(target: Mode) {102 if (mode() === target) return;103 if (target === "enter") {104 await stopShow();105 setEnterError(null);106 setEnteredCode("");107 setMode("enter");108 } else {109 setEnterError(null);110 setMode("show");111 await startShow();112 }113 }114115 function onEnterInput(e: InputEvent) {116 const target = e.currentTarget as HTMLInputElement;117 const raw = target.value.replace(/[^0-9]/g, "").slice(0, 6);118 setEnteredCode(formatCode(raw));119 if (enterError()) setEnterError(null);120 }121122 function digitsEntered(): string {123 return enteredCode().replace(/\D/g, "");124 }125126 async function connect() {127 const token = digitsEntered();128 if (token.length !== 6) return;129 setConnecting(true);130 setEnterError(null);131 const res = await sendMessage({132 type: "CONNECT_WITH_CODE",133 payload: { token },134 });135 setConnecting(false);136 const response = res as { ok: boolean; error?: string; data?: { deviceName?: string } };137 if (!response.ok) {138 const msg =139 response.error === "no peer responded to pairing code" ||140 (response.error ?? "").includes("resolve failed")141 ? "Couldn't find that device. Make sure both are on the same WiFi."142 : response.error === "pair_rejected"143 ? "The other device declined."144 : response.error === "invalid_code"145 ? "Enter all six digits."146 : "Couldn't connect. Try again.";147 setEnterError(msg);148 return;149 }150 const name = response.data?.deviceName ?? "Peach";151 const device: PairedDevice = {152 deviceId: name,153 displayName: name,154 x25519PublicKey: "",155 pairedAt: Date.now(),156 };157 setPairedDevice(device);158 props.onPaired(device);159 }160161 onMount(() => {162 chrome.runtime.onMessage.addListener(handleRuntimeMessage);163 void startShow();164 });165166 onCleanup(() => {167 chrome.runtime.onMessage.removeListener(handleRuntimeMessage);168 clearExpiryTimer();169 170 171 172 });173174 return (175 <div class="peach-overlay-view ov-enter-right">176 <div class="peach-overlay-header">177 <button178 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"179 onClick={() => {180 181 182 183 void sendMessage({ type: "STOP_PAIRING_SERVER" });184 props.onClose();185 }}186 aria-label="Close"187 >188 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">189 <path d="M6 1L1 6L6 11" />190 </svg>191 </button>192 <span class="peach-overlay-title">add a device</span>193 <div class="peach-overlay-spacer" />194 </div>195196 <div class="peach-pairing-body">197 <Show when={pairedDevice()}>198 {(device) => (199 <div class="peach-pairing-content">200 <div class="peach-pairing-success-icon">201 <svg width="32" height="32" viewBox="0 0 32 32" fill="none">202 <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />203 <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />204 </svg>205 </div>206 <p class="peach-pairing-headline">Paired!</p>207 <p class="peach-pairing-subline peach-pairing-subline--center">208 {device().displayName}209 </p>210 <button211 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"212 onClick={props.onClose}213 >214 Done215 </button>216 </div>217 )}218 </Show>219220 <Show when={!pairedDevice() && mode() === "show"}>221 <div class="peach-pairing-content peach-pairing-code-screen">222 <p class="peach-pairing-headline">Add a device</p>223224 <Show when={code()} fallback={225 <Show when={!showError()} fallback={226 <button227 class="peach-pairing-code peach-pairing-code--expired"228 onClick={() => void refreshCode()}229 >230 refresh231 </button>232 }>233 <div class="peach-pairing-code peach-pairing-code--loading">234 <Spinner />235 </div>236 </Show>237 }>238 {(c) => (239 <div class="peach-pairing-code" aria-label="pairing code">240 {formatCode(c())}241 </div>242 )}243 </Show>244245 <p class="peach-pairing-subline peach-pairing-subline--center">246 Open Peach on your other device, tap247 <br />248 Add a device, and enter this code.249 </p>250 <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>251252 <Show when={showError()}>253 <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>254 </Show>255256 <button257 class="peach-pairing-toggle"258 onClick={() => void switchTo("enter")}259 >260 I have a code to enter →261 </button>262 </div>263 </Show>264265 <Show when={!pairedDevice() && mode() === "enter"}>266 <div class="peach-pairing-content peach-pairing-code-screen">267 <p class="peach-pairing-headline">Add a device</p>268269 <input270 class="peach-pairing-code-input"271 type="text"272 inputmode="numeric"273 autocomplete="off"274 autocorrect="off"275 spellcheck={false}276 placeholder="000 000"277 value={enteredCode()}278 onInput={onEnterInput}279 disabled={connecting()}280 aria-label="enter pairing code"281 />282283 <button284 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"285 onClick={() => void connect()}286 disabled={digitsEntered().length !== 6 || connecting()}287 >288 {connecting() ? "Connecting…" : "Connect →"}289 </button>290291 <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>292293 <Show when={enterError()}>294 <p class="peach-pairing-error peach-pairing-error--muted">{enterError()}</p>295 </Show>296297 <button298 class="peach-pairing-toggle"299 onClick={() => void switchTo("show")}300 disabled={connecting()}301 >302 ← Show my code instead303 </button>304 </div>305 </Show>306 </div>307 </div>308 );309}