SyncPairing.tsx

11.1 KB · raw

1import { createSignal, For, onCleanup, onMount, Show } from "solid-js";2import { sendMessage } from "../App";3import type { DiscoveredPeer, PairedDevice, PairingStep } from "../../shared/sync-types";45interface SyncPairingProps {6  mode: "initiate" | "accept";7  onClose: () => void;8  onPaired: (device: PairedDevice) => void;9}1011function Spinner() {12  return <span class="peach-pairing-spinner" />;13}1415export function SyncPairingView(props: SyncPairingProps) {16  const [step, setStep] = createSignal<PairingStep>({ stage: "idle" });17  const [codeInput, setCodeInput] = createSignal("");18  const [codeError, setCodeError] = createSignal<string | null>(null);1920  // Discover-and-tap state for the initiate flow. The background broadcasts21  // SYNC_PEER_DISCOVERED / SYNC_PEER_LOST as peers come and go on mDNS;22  // tapping a peer routes to INITIATE_PAIR_WITH_PEER.23  const [discoveredPeers, setDiscoveredPeers] = createSignal<DiscoveredPeer[]>([]);24  const [discoveryActive, setDiscoveryActive] = createSignal(false);25  const [discoveryError, setDiscoveryError] = createSignal<string | null>(null);26  const [pairingPeerId, setPairingPeerId] = createSignal<string | null>(null);2728  onMount(() => {29    if (props.mode !== "initiate") return;30    void startDiscovery();31  });3233  onCleanup(() => {34    if (discoveryActive()) void sendMessage({ type: "STOP_PEER_DISCOVERY" });35  });3637  function handleRuntimeMessage(msg: unknown) {38    const m = msg as { type?: string; peer?: DiscoveredPeer; deviceId?: string };39    if (m?.type === "SYNC_PEER_DISCOVERED" && m.peer) {40      const peer = m.peer;41      setDiscoveredPeers((prev) => {42        if (prev.some((p) => p.deviceId === peer.deviceId)) return prev;43        return [...prev, peer];44      });45    }46    if (m?.type === "SYNC_PEER_LOST" && m.deviceId) {47      const id = m.deviceId;48      setDiscoveredPeers((prev) => prev.filter((p) => p.deviceId !== id));49    }50  }5152  async function startDiscovery() {53    chrome.runtime.onMessage.addListener(handleRuntimeMessage);54    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));5556    const res = await sendMessage({ type: "START_PEER_DISCOVERY" });57    const response = res as { ok: boolean; error?: string };58    if (response.ok) {59      setDiscoveryActive(true);60      setDiscoveryError(null);61    } else {62      setDiscoveryError(response.error ?? "discovery_failed");63    }64  }6566  async function handlePairWithPeer(peer: DiscoveredPeer) {67    setPairingPeerId(peer.deviceId);68    setStep({ stage: "connecting", peerName: peer.deviceName });69    const res = await sendMessage({70      type: "INITIATE_PAIR_WITH_PEER",71      payload: { peer },72    });73    const response = res as { ok: boolean; error?: string };74    setPairingPeerId(null);75    if (!response.ok) {76      setStep({ stage: "error", message: response.error ?? "pair_failed" });77      return;78    }79    const paired: PairedDevice = {80      deviceId: peer.deviceId,81      displayName: peer.deviceName,82      x25519PublicKey: peer.x25519PublicKey,83      pairedAt: Date.now(),84    };85    setStep({ stage: "done", device: paired });86    props.onPaired(paired);87  }8889  async function handleConnect() {90    const code = codeInput().replace(/\D/g, "").slice(0, 6);91    if (code.length !== 6) {92      setCodeError("Enter a 6-digit code");93      return;94    }95    setCodeError(null);96    setStep({ stage: "connecting", peerName: "" });9798    const res = await sendMessage({99      type: "ACCEPT_PAIRING",100      payload: { code },101    });102    const response = res as { ok: boolean; data?: PairedDevice; error?: string };103104    if (!response.ok) {105      setStep({ stage: "error", message: response.error ?? "Connection failed" });106      return;107    }108    setStep({ stage: "verifying" });109110    if (response.data) {111      setStep({ stage: "done", device: response.data });112      props.onPaired(response.data);113    }114  }115116  function handleCodeInput(e: InputEvent) {117    const target = e.currentTarget as HTMLInputElement;118    const value = target.value.replace(/\D/g, "").slice(0, 6);119    setCodeInput(value);120    setCodeError(null);121  }122123  return (124    <div class="peach-overlay-view ov-enter-right">125      <div class="peach-overlay-header">126        <button127          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"128          onClick={props.onClose}129          aria-label="Close"130        >131          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">132            <path d="M6 1L1 6L6 11" />133          </svg>134        </button>135        <span class="peach-overlay-title">pair devices</span>136        <div class="peach-overlay-spacer" />137      </div>138139      <div class="peach-pairing-body">140        <Show when={props.mode === "initiate" && step().stage !== "done" && step().stage !== "error" && step().stage !== "connecting" && step().stage !== "verifying"}>141          <div class="peach-pairing-content peach-pairing-discover">142            <p class="peach-pairing-headline">Pair a device</p>143            <Show when={discoveryError()}>144              <p class="peach-pairing-error">145                {discoveryError() === "pro_required"146                  ? "Sync requires Peach Pro."147                  : "Native sync host is not installed yet."}148              </p>149            </Show>150            <Show when={!discoveryError() && discoveredPeers().length === 0}>151              <p class="peach-pairing-status">152                <Spinner />153                Searching for Peach on your local network...154              </p>155            </Show>156            <For each={discoveredPeers()}>157              {(peer) => (158                <div class="peach-pairing-peer-card">159                  <div class="peach-pairing-peer-info">160                    <span class="peach-pairing-peer-name">{peer.deviceName}</span>161                    <span class="peach-pairing-peer-meta">162                      {peer.alreadyPaired ? "Already paired ✓" : "Found on network"}163                    </span>164                  </div>165                  <Show when={!peer.alreadyPaired}>166                    <button167                      class="peach-btn peach-btn--accent peach-btn--sm"168                      onClick={() => void handlePairWithPeer(peer)}169                      disabled={pairingPeerId() !== null}170                    >171                      {pairingPeerId() === peer.deviceId ? "..." : "Pair →"}172                    </button>173                  </Show>174                </div>175              )}176            </For>177            <p class="peach-pairing-subline peach-pairing-subline--center">178              Make sure both devices are on the same Wi-Fi network.179            </p>180          </div>181        </Show>182183        <Show when={props.mode === "accept"}>184          <Show when={step().stage === "idle" || step().stage === "entering_code"}>185            <div class="peach-pairing-content">186              <p class="peach-pairing-headline">Pair with another device</p>187              <p class="peach-pairing-subline">188                Enter the 6-digit code shown on the other device.189              </p>190              <div class="peach-pairing-code-inputs">191                {Array.from({ length: 6 }, (_, i) => (192                  <input193                    class={`peach-pairing-code-box ${codeError() ? "has-error" : ""}`}194                    type="text"195                    inputMode="numeric"196                    maxLength={1}197                    value={codeInput()[i] ?? ""}198                    onInput={(e) => {199                      const current = codeInput();200                      const digit = (e.currentTarget as HTMLInputElement).value.replace(/\D/g, "").slice(-1);201                      const newValue = current.slice(0, i) + digit + current.slice(i + 1);202                      setCodeInput(newValue.slice(0, 6));203                      setCodeError(null);204                      if (digit && i < 5) {205                        const inputs = document.querySelectorAll(".peach-pairing-code-box");206                        (inputs[i + 1] as HTMLInputElement)?.focus();207                      }208                    }}209                    onKeyDown={(e) => {210                      if (e.key === "Backspace" && !codeInput()[i] && i > 0) {211                        const inputs = document.querySelectorAll(".peach-pairing-code-box");212                        (inputs[i - 1] as HTMLInputElement)?.focus();213                      }214                    }}215                  />216                ))}217              </div>218              <Show when={codeError()}>219                <p class="peach-pairing-error">{codeError()}</p>220              </Show>221              <button222                class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"223                onClick={() => void handleConnect()}224                disabled={codeInput().length !== 6}225              >226                Connect227              </button>228            </div>229          </Show>230231          <Show when={step().stage === "connecting" || step().stage === "verifying"}>232            <div class="peach-pairing-content">233              <Spinner />234              <p class="peach-pairing-status">235                {step().stage === "connecting" ? "Connecting..." : "Verifying..."}236              </p>237            </div>238          </Show>239        </Show>240241        <Show when={step().stage === "done"}>242          <div class="peach-pairing-content">243            <div class="peach-pairing-success-icon">244              <svg width="32" height="32" viewBox="0 0 32 32" fill="none">245                <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />246                <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />247              </svg>248            </div>249            <p class="peach-pairing-headline">Paired!</p>250            <p class="peach-pairing-subline peach-pairing-subline--center">251              {(step() as { stage: "done"; device: PairedDevice }).device.displayName}252            </p>253            <button254              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"255              onClick={props.onClose}256            >257              Done258            </button>259          </div>260        </Show>261262        <Show when={step().stage === "error"}>263          <div class="peach-pairing-content">264            <div class="peach-pairing-error-icon">265              <svg width="32" height="32" viewBox="0 0 32 32" fill="none">266                <circle cx="16" cy="16" r="14" stroke="var(--peach-breach)" stroke-width="1.5" />267                <path d="M11 11L21 21M21 11L11 21" stroke="var(--peach-breach)" stroke-width="1.5" stroke-linecap="round" />268              </svg>269            </div>270            <p class="peach-pairing-headline">Pairing failed</p>271            <p class="peach-pairing-subline peach-pairing-subline--center peach-pairing-error-msg">272              {(step() as { stage: "error"; message: string }).message}273            </p>274            <button275              class="peach-btn peach-btn--ghost peach-btn--md"276              onClick={() => setStep({ stage: "idle" })}277            >278              Try again279            </button>280          </div>281        </Show>282      </div>283    </div>284  );285}