SyncPairing.tsx

7.4 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  onClose: () => void;7  onPaired: (device: PairedDevice) => void;8}910function Spinner() {11  return <span class="peach-pairing-spinner" />;12}1314export function SyncPairingView(props: SyncPairingProps) {15  const [step, setStep] = createSignal<PairingStep>({ stage: "idle" });1617  const [discoveredPeers, setDiscoveredPeers] = createSignal<DiscoveredPeer[]>([]);18  const [discoveryActive, setDiscoveryActive] = createSignal(false);19  const [discoveryError, setDiscoveryError] = createSignal<string | null>(null);20  const [pairingPeerId, setPairingPeerId] = createSignal<string | null>(null);2122  onMount(() => {23    void startDiscovery();24  });2526  onCleanup(() => {27    if (discoveryActive()) void sendMessage({ type: "STOP_PEER_DISCOVERY" });28  });2930  function handleRuntimeMessage(msg: unknown) {31    const m = msg as { type?: string; peer?: DiscoveredPeer; deviceId?: string };32    if (m?.type === "SYNC_PEER_DISCOVERED" && m.peer) {33      const peer = m.peer;34      setDiscoveredPeers((prev) => {35        if (prev.some((p) => p.deviceId === peer.deviceId)) return prev;36        return [...prev, peer];37      });38    }39    if (m?.type === "SYNC_PEER_LOST" && m.deviceId) {40      const id = m.deviceId;41      setDiscoveredPeers((prev) => prev.filter((p) => p.deviceId !== id));42    }43  }4445  async function startDiscovery() {46    chrome.runtime.onMessage.addListener(handleRuntimeMessage);47    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));4849    const res = await sendMessage({ type: "START_PEER_DISCOVERY" });50    const response = res as { ok: boolean; error?: string };51    if (response.ok) {52      setDiscoveryActive(true);53      setDiscoveryError(null);54    } else {55      setDiscoveryError(response.error ?? "discovery_failed");56    }57  }5859  async function handlePairWithPeer(peer: DiscoveredPeer) {60    setPairingPeerId(peer.deviceId);61    setStep({ stage: "connecting", peerName: peer.deviceName });62    const res = await sendMessage({63      type: "INITIATE_PAIR_WITH_PEER",64      payload: { peer },65    });66    const response = res as { ok: boolean; error?: string };67    setPairingPeerId(null);68    if (!response.ok) {69      setStep({ stage: "error", message: response.error ?? "pair_failed" });70      return;71    }72    const paired: PairedDevice = {73      deviceId: peer.deviceId,74      displayName: peer.deviceName,75      x25519PublicKey: peer.x25519PublicKey,76      pairedAt: Date.now(),77    };78    setStep({ stage: "done", device: paired });79    props.onPaired(paired);80  }8182  return (83    <div class="peach-overlay-view ov-enter-right">84      <div class="peach-overlay-header">85        <button86          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"87          onClick={props.onClose}88          aria-label="Close"89        >90          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">91            <path d="M6 1L1 6L6 11" />92          </svg>93        </button>94        <span class="peach-overlay-title">add a device</span>95        <div class="peach-overlay-spacer" />96      </div>9798      <div class="peach-pairing-body">99        <Show when={step().stage === "idle"}>100          <div class="peach-pairing-content peach-pairing-discover">101            <p class="peach-pairing-headline">Add a device</p>102            <Show when={discoveryError()}>103              <p class="peach-pairing-error">104                {discoveryError() === "pro_required"105                  ? "Sync requires Peach Pro."106                  : "Native sync host is not installed yet."}107              </p>108            </Show>109            <Show when={!discoveryError() && discoveredPeers().length === 0}>110              <p class="peach-pairing-status">111                <Spinner />112                Searching for Peach on your local network…113              </p>114            </Show>115            <For each={discoveredPeers()}>116              {(peer) => (117                <div class="peach-pairing-peer-card">118                  <div class="peach-pairing-peer-info">119                    <span class="peach-pairing-peer-name">{peer.deviceName}</span>120                    <span class="peach-pairing-peer-meta">121                      {peer.alreadyPaired ? "Already paired ✓" : "Found on network"}122                    </span>123                  </div>124                  <Show when={!peer.alreadyPaired}>125                    <button126                      class="peach-btn peach-btn--accent peach-btn--sm"127                      onClick={() => void handlePairWithPeer(peer)}128                      disabled={pairingPeerId() !== null}129                    >130                      {pairingPeerId() === peer.deviceId ? "…" : "Pair →"}131                    </button>132                  </Show>133                </div>134              )}135            </For>136            <p class="peach-pairing-subline peach-pairing-subline--center">137              Open Peach on the other device. Both devices need to be on the138              same WiFi network.139            </p>140          </div>141        </Show>142143        <Show when={step().stage === "connecting" || step().stage === "verifying"}>144          <div class="peach-pairing-content">145            <Spinner />146            <p class="peach-pairing-status">147              {step().stage === "connecting" ? "Connecting…" : "Verifying…"}148            </p>149          </div>150        </Show>151152        <Show when={step().stage === "done"}>153          <div class="peach-pairing-content">154            <div class="peach-pairing-success-icon">155              <svg width="32" height="32" viewBox="0 0 32 32" fill="none">156                <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />157                <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />158              </svg>159            </div>160            <p class="peach-pairing-headline">Paired!</p>161            <p class="peach-pairing-subline peach-pairing-subline--center">162              {(step() as { stage: "done"; device: PairedDevice }).device.displayName}163            </p>164            <button165              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"166              onClick={props.onClose}167            >168              Done169            </button>170          </div>171        </Show>172173        <Show when={step().stage === "error"}>174          <div class="peach-pairing-content">175            <div class="peach-pairing-error-icon">176              <svg width="32" height="32" viewBox="0 0 32 32" fill="none">177                <circle cx="16" cy="16" r="14" stroke="var(--peach-breach)" stroke-width="1.5" />178                <path d="M11 11L21 21M21 11L11 21" stroke="var(--peach-breach)" stroke-width="1.5" stroke-linecap="round" />179              </svg>180            </div>181            <p class="peach-pairing-headline">Pairing failed</p>182            <p class="peach-pairing-subline peach-pairing-subline--center peach-pairing-error-msg">183              {(step() as { stage: "error"; message: string }).message}184            </p>185            <button186              class="peach-btn peach-btn--ghost peach-btn--md"187              onClick={() => setStep({ stage: "idle" })}188            >189              Try again190            </button>191          </div>192        </Show>193      </div>194    </div>195  );196}