void; + onTour: () => void; + onError: (e: string) => void; +}) { + const [step, setStep] = useState("machine"); + const [answers, setAnswers] = useState({ + name: "", + role: "main", + reaper: true, + live: true, + method: "cloud", + }); + const [backup, setBackup] = useState(null); + const [kinds, setKinds] = useState(null); + + useEffect(() => { + backupView().then( + (v) => { + setBackup(v); + // A machine set up before (Replay First Run) starts from what it uses. + if (v.lan) set({ method: "lan" }); + else if (v.github) set({ method: "github" }); + }, + () => {}, + ); + syncPrefsView().then(setKinds, () => {}); + }, []); + + function set(change: Partial) { + setAnswers((a) => ({ ...a, ...change })); + } + + const at = STEPS.indexOf(step); + const next = () => setStep(STEPS[Math.min(at + 1, STEPS.length - 1)]); + const back = () => setStep(STEPS[Math.max(at - 1, 0)]); + + function finish() { + // The questions stand in for the tour on a first run; it stays one click away. + markTourSeen(); + onScan(answers); + } + + const body: Record = { + machine: { + title: "Let's set up Patch Pilot", + intro: ( + <> + Patch Pilot keeps every machine in your rig matching: plug-ins, DAW files and settings. A few quick + questions, then it looks at what's on this machine.{" "} + + + ), + content: , + }, + daws: { + title: "Which DAWs do you use here?", + intro: "Patch Pilot reads their templates, scripts, presets and settings. Untick one this machine doesn't have.", + content: , + }, + method: { + title: "How should your machines share files?", + intro: + "New and changed templates, scripts and presets reach your other machines on their own. Plug-ins, sample libraries and licenses never copy: Patch Pilot lists what to install instead.", + content: ( + + ), + }, + what: { + title: "What should stay the same on every machine?", + intro: + answers.method === "later" + ? "These apply once you turn syncing on. Untick anything each machine should keep its own." + : "Untick anything each machine should keep its own. Deleted files and audio or MIDI settings never sync.", + content: , + }, + ready: { + title: "Ready when you are", + intro: "Here's what happens. Everything can be changed later in Settings.", + content: , + }, + }; + const b = body[step]; + const last = step === "ready"; + + return ( +
+
+ +

+ {at + 1} of {STEPS.length} +

+

{b.title}

+ {b.intro &&

{b.intro}

} +
+ {/* Not a form: the folder picker and GitHub sign-in inside have buttons of their own. */} +
+ {b.content} +
+ {at > 0 && ( + + )} +
+
+
+ ); +} + +function MachineStep({ + answers, + onChange, + onEnter, +}: { + answers: FirstRunAnswers; + onChange: (a: Partial) => void; + onEnter: () => void; +}) { + return ( + <> +
+ + onChange({ name: e.target.value })} + onKeyDown={(e) => e.key === "Enter" && onEnter()} + placeholder="Studio PC" + className={inputClass} + /> +
+
+ Is it your main machine? + + onChange({ role: "main" })} + /> + onChange({ role: "other" })} + /> + onChange({ role: "later" })} + /> + +
+ + ); +} + +function DawStep({ answers, onChange }: { answers: FirstRunAnswers; onChange: (a: Partial) => void }) { + return ( + + onChange({ reaper: on })} + /> + onChange({ live: on })} + /> + {!answers.reaper && !answers.live && ( +

+ Neither: Patch Pilot still keeps your plug-ins and plug-in presets matching. +

+ )} +
+ ); +} + +function MethodStep({ + answers, + onChange, + backup, + onBackup, + onError, +}: { + answers: FirstRunAnswers; + onChange: (a: Partial) => void; + backup: BackupView | null; + onBackup: (v: BackupView) => void; + onError: (e: string) => void; +}) { + const [busy, setBusy] = useState(false); + const [changing, setChanging] = useState(false); + + async function chooseFolder(service: string | null, folder: string | null) { + setBusy(true); + try { + await setBackupFolder(service, folder); + await setKeepInSync(true); + onBackup(await backupView()); + setChanging(false); + } catch (e) { + onError(String(e)); + } finally { + setBusy(false); + } + } + + async function usedGitHub() { + try { + await setKeepInSync(true); + onBackup(await backupView()); + } catch (e) { + onError(String(e)); + } + } + + const cloud = cloudInUse(backup); + const setup = + answers.method === "cloud" && backup ? ( + cloud && !changing ? ( + setChanging(true)}> + Change + + } + /> + ) : ( + setChanging(false) : undefined} + hint="Pick the same service on each machine. Patch Pilot keeps a Patch Pilot Backup folder there." + /> + ) + ) : answers.method === "github" && backup ? ( + backup.github && backup.settings.keep_in_sync ? ( + + ) : ( + onChange({ method: "cloud" })} onError={onError} /> + ) + ) : answers.method === "lan" ? ( +

+ {backup?.lan + ? "Already syncing over your home network." + : "Turns on once this machine is linked to your others, right after the scan. Your computer may ask to allow Patch Pilot on the local network."} +

+ ) : null; + + return ( + + {SYNC_METHODS.map((m) => ( +
+ onChange({ method: m.id })} + /> + {answers.method === m.id && setup &&
{setup}
} +
+ ))} + onChange({ method: "later" })} + /> +
+ ); +} + +function Done({ text, action }: { text: string; action?: ReactNode }) { + return ( + + ✓ {text} + {action} + + ); +} + +/** The cloud service in use, when syncing goes through a cloud folder (not GitHub or the home network). */ +function cloudInUse(v: BackupView | null): string | null { + if (!v?.dir || !v.settings.keep_in_sync || v.github || v.lan) return null; + return v.clouds.find((c) => c.path === v.settings.folder)?.name ?? "your folder"; +} + +/** + * Choose what syncs in a few plain groups rather than 25 kinds. A group is + * ticked while all of its kinds for the DAWs picked are on; Settings has the + * full list for anything finer. + */ +const GROUPS: { id: string; label: string; kinds: string[] }[] = [ + { id: "templates", label: "Templates", kinds: ["reaper/track-templates", "reaper/project-templates", "ableton/templates"] }, + { + id: "scripts", + label: "Scripts and devices", + kinds: ["reaper/scripts", "reaper/startup-action", "ableton/m4l", "ableton/remote-scripts"], + }, + { + id: "presets", + label: "Presets, chains and racks", + kinds: [ + "presets", + "reaper/fx-chains", + "reaper/fx-presets", + "ableton/defaults", + "ableton/presets", + "ableton/clips", + "ableton/grooves", + "ableton/tunings", + ], + }, + { id: "keys", label: "Shortcuts, menus and mouse", kinds: ["reaper/key-map", "reaper/menus", "reaper/mouse"] }, + { id: "look", label: "Themes", kinds: ["reaper/themes", "reaper/theme-settings", "reaper/toolbar-icons"] }, + { + id: "library", + label: "Library layout", + kinds: ["ableton/collections", "ableton/tags", "ableton/places", "ableton/options"], + }, +]; + +const GROUP_ICON = { + templates: "doc", + scripts: "code", + presets: "knob", + keys: "key", + look: "sun", + library: "folder", +} as const; + +function forDaws(kinds: SyncKind[], a: FirstRunAnswers): SyncKind[] { + return kinds.filter((k) => (k.app === "Reaper" ? a.reaper : k.app === "Ableton Live" ? a.live : true)); +} + +function WhatStep({ + answers, + kinds, + onKinds, + onError, +}: { + answers: FirstRunAnswers; + kinds: SyncKind[] | null; + onKinds: (k: SyncKind[]) => void; + onError: (e: string) => void; +}) { + const shown = useMemo(() => (kinds ? forDaws(kinds, answers) : []), [kinds, answers]); + if (!kinds) return null; + + async function toggle(ids: string[], on: boolean) { + onKinds(kinds!.map((k) => (ids.includes(k.id) ? { ...k, on } : k))); + try { + let latest = kinds!; + for (const id of ids) latest = await setSyncKind(id, on); + onKinds(latest); + } catch (e) { + onError(String(e)); + } + } + + const groups = GROUPS.map((g) => ({ ...g, members: shown.filter((k) => g.kinds.includes(k.id)) })).filter( + (g) => g.members.length > 0, + ); + return ( + + {groups.map((g) => ( + k.label).join(", ")]} + checked={g.members.every((k) => k.on)} + onPick={(on) => toggle(g.members.map((k) => k.id), on)} + /> + ))} +

+ Shortcuts move between PC and Mac keys on their own. Settings › What syncs has each kind on its own. +

+
+ ); +} + +function Summary({ answers, backup }: { answers: FirstRunAnswers; backup: BackupView | null }) { + const daws = [answers.reaper && "Reaper", answers.live && "Ableton Live"].filter(Boolean) as string[]; + const cloud = cloudInUse(backup); + const synced = + answers.method === "cloud" + ? cloud + ? { tone: "ok" as const, text: `Files stay in sync through ${cloud}.` } + : { tone: "warn" as const, text: "No cloud folder picked yet. Go Back, or pick one later in Settings." } + : answers.method === "github" + ? backup?.github && backup.settings.keep_in_sync + ? { tone: "ok" as const, text: `Files stay in sync through GitHub (${backup.github}).` } + : { tone: "warn" as const, text: "GitHub isn't set up yet. Go Back, or finish later in Settings." } + : answers.method === "lan" + ? { tone: "accent" as const, text: "Files sync over your home network once linked." } + : { tone: "dim" as const, text: "Syncing stays off. Turn it on in Settings › Sync." }; + return ( + + + {answers.role === "main" && } + {(answers.role === "other" || answers.method === "lan") && ( + + )} + + + + ); +} + +/** + * After the first scan: the answers that need a scanned machine. Returns the + * lines to show, and whether to open the Link dialog (and on which side). + */ +export async function applyFirstRun( + a: FirstRunAnswers, + overview: Overview, +): Promise<{ lines: string[]; link: "show" | "enter" | null }> { + const lines: string[] = []; + const kinds = await syncPrefsView().catch(() => [] as SyncKind[]); + for (const [daw, app, on, name] of [ + ["reaper", "Reaper", a.reaper, "Reaper"], + ["live", "Ableton Live", a.live, "Ableton Live"], + ] as const) { + if (on) continue; + await setDawWatched(daw, false); + for (const k of kinds.filter((k) => k.app === app && k.on)) await setSyncKind(k.id, false); + lines.push(`${name} isn't watched here, and its files don't sync to this machine. Change it in Settings.`); + } + if (a.role === "main") { + if (!overview.golden) { + await makeGolden(); + lines.push("This machine is your Primary Rig. Compare measures your other machines against it."); + } else if (!overview.golden.from_this_machine) { + lines.push("You already have a Primary Rig. Make this machine it from This machine if you like."); + } + } + if (a.method === "lan") { + try { + localStorage.setItem(LAN_KEY, "pending"); + } catch { + // Blocked storage: Home network… under Settings › Sync still turns it on. + } + } + const link = a.role === "other" ? "enter" : a.method === "lan" ? "show" : null; + return { lines, link }; +} + +/** + * The home network answer, once this machine is linked: sync over it and keep + * files in sync. Returns a line to show, or null when nothing was waiting. + */ +export async function finishLanIfPending(): Promise { + let pending = false; + try { + pending = localStorage.getItem(LAN_KEY) === "pending"; + } catch { + return null; + } + if (!pending) return null; + await lanUse(); + await setKeepInSync(true); + try { + localStorage.removeItem(LAN_KEY); + } catch { + // Already used; a second try only says so again. + } + return "Linked, and syncing over your home network. Files from your other machines arrive while both are on."; +} diff --git a/app/src/GitHub.tsx b/app/src/GitHub.tsx index 371bf3c..06d2c50 100644 --- a/app/src/GitHub.tsx +++ b/app/src/GitHub.tsx @@ -22,10 +22,13 @@ export function GitHubSetup({ onUsed, onCancel, onError, + bare = false, }: { onUsed: () => void; onCancel: () => void; onError: (e: string) => void; + /** Leave out the line on what GitHub sync is, where the choice above already says it. */ + bare?: boolean; }) { const [view, setView] = useState(null); const [busy, setBusy] = useState(false); @@ -66,10 +69,12 @@ export function GitHubSetup({ return (
-

- Sync through a private GitHub repo instead of a cloud folder. Every change stays in the repo's history. Big - files still need a cloud folder. -

+ {!bare && ( +

+ Sync through a private GitHub repo instead of a cloud folder. Every change stays in the repo's history. + Big files still need a cloud folder. +

+ )} {!view.login ? ( view.device ? ( diff --git a/app/src/Link.tsx b/app/src/Link.tsx index 2890088..f9ad79d 100644 --- a/app/src/Link.tsx +++ b/app/src/Link.tsx @@ -23,8 +23,17 @@ export function SyncBadge({ status, onClick }: { status: SyncStatus | null; onCl ); } -export function LinkDialog({ status, onClose }: { status: SyncStatus | null; onClose: () => void }) { - const [mode, setMode] = useState({ kind: "choose" }); +export function LinkDialog({ + status, + onClose, + start = "choose", +}: { + status: SyncStatus | null; + onClose: () => void; + /** "enter" opens on typing a code (a new machine joining its main one, from the first-run questions). */ + start?: "choose" | "enter"; +}) { + const [mode, setMode] = useState(start === "enter" ? { kind: "enter" } : { kind: "choose" }); const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); diff --git a/app/src/SyncMethods.tsx b/app/src/SyncMethods.tsx new file mode 100644 index 0000000..8a70793 --- /dev/null +++ b/app/src/SyncMethods.tsx @@ -0,0 +1,90 @@ +import { Glyph, TONE_TINT, type GlyphName } from "./ui"; + +/** + * The three ways linked machines share files (decisions #70, #73, #94), with + * what each is good at and what it costs, in one place so the first-run + * questions and any explainer elsewhere say the same thing (decision #121). + */ +export type SyncMethod = "lan" | "cloud" | "github"; + +export interface MethodInfo { + id: SyncMethod; + name: string; + icon: GlyphName; + good: string; + limit: string; +} + +export const SYNC_METHODS: MethodInfo[] = [ + { + id: "cloud", + name: "A cloud folder (Dropbox, OneDrive, iCloud…)", + icon: "cloud", + good: "Works anywhere, and machines don't need to be on at the same time. The easiest if you already use one.", + limit: "Uses that service's space, and the same service has to be on every machine.", + }, + { + id: "lan", + name: "Your home network, no cloud", + icon: "link", + good: "Machines copy straight from each other, encrypted. No account, nothing stored online.", + limit: "Both machines must be on and on the same network at once. Big files still need a cloud folder.", + }, + { + id: "github", + name: "A private GitHub repo", + icon: "code", + good: "Keeps every version of every file, and works anywhere.", + limit: "Needs a GitHub account and a token. Files over 95 MB are left out.", + }, +]; + +/** A row of choices, one picked: an icon tile, a name, and two dim lines. */ +export function ChoiceRow({ + icon, + name, + lines, + checked, + onPick, + type = "radio", + group, +}: { + icon: GlyphName; + name: string; + lines: string[]; + checked: boolean; + onPick: (on: boolean) => void; + type?: "radio" | "checkbox"; + group?: string; +}) { + return ( + + ); +} diff --git a/app/src/Tour.tsx b/app/src/Tour.tsx index a3c7af9..0858a44 100644 --- a/app/src/Tour.tsx +++ b/app/src/Tour.tsx @@ -28,6 +28,11 @@ export function resetTour() { } } +/** Count the tutorial as seen: the first-run questions stand in for it (decision #121). */ +export function markTourSeen() { + markSeen(); +} + function markSeen() { try { localStorage.setItem(SEEN_KEY, "seen"); diff --git a/app/src/screens/Welcome.tsx b/app/src/screens/Welcome.tsx index a78a72e..125be0c 100644 --- a/app/src/screens/Welcome.tsx +++ b/app/src/screens/Welcome.tsx @@ -1,50 +1,6 @@ -import { useState } from "react"; -import { Button, inputClass, Mark, Notice, Panel } from "../ui"; +import { Mark } from "../ui"; -export function Welcome({ onScan, onTour }: { onScan: (name: string) => void; onTour: () => void }) { - const [name, setName] = useState(""); - return ( -
-
- -

Let's see what's on this machine

-

- Patch Pilot keeps every machine in your rig matching.{" "} - -

-
- - - - - -
{ - e.preventDefault(); - onScan(name); - }} - > - - setName(e.target.value)} - placeholder="Studio PC" - className={inputClass} - /> - -
-
- ); -} +// The first-run screen is the questions in FirstRun.tsx (decision #121). /** The logo in a small framed square, over the first-run screens' titles. */ export function AppMark() { diff --git a/docs/decisions.md b/docs/decisions.md index 30fc4a1..0820e4e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -125,3 +125,4 @@ Claude: never undo an Accepted decision without adding a new line that supersede | 118 | 2026-10-31 | **Backups are signed with the rig key** (Joshua chose it after the security audit). Each backup writes `Backups/.sig` beside its index: `ppsig1:` + a BLAKE3 keyed hash of the index's exact bytes under a key derived from the rig key (`RigKey::sign_backup`), written just before the index; an unchanged backup is signed in place. Keep files in sync (`autosync::sync_in_with`) takes a linked machine's newest backup that checks; a machine's unsigned backups (before 0.4.55) are taken only until it has sent a signed one (`SyncState::signed`), and one signed under another key never. A newer backup someone else drops in, even stamped years ahead, can't hide the machine's signed ones. Home network and GitHub pushes send the `.sig` before the index. Sync with and Restore stay manual and unchanged. Sync & backup says when a machine's latest backup was left alone. | The backup folder (a cloud folder or GitHub repo) was the one place sync trusted plain text: a forged backup naming a linked machine could place Reaper scripts and the SWS startup action on every machine with no click. | Accepted | | 119 | 2026-11-01 | **The tray popover opens in the taskbar corner on Windows** (amends #35; Joshua: clicking Patch Pilot in the tray opens it in an awkward spot). Centring it above the icon left it floating mid-screen when the icon sat in the hidden-icons overflow, which opens above the taskbar. On Windows it now sits in the corner of the icon's screen's work area on the taskbar's side (found from where the work area stops short of the screen; an auto-hidden taskbar goes by the icon's half of the screen), right-aligned, 12 px from the taskbar and the edge like Windows 11's flyouts; top, left and right taskbars get the matching corner. macOS is unchanged: centred under the menu bar icon, inside the screen. Positions are worked out at the target screen's scale and allow for the invisible resize border Windows counts in a window's position (`tray::place`, unit-tested). | Accepted | | 120 | 2026-11-03 | **Releases also show on the main repo** (Joshua made `PatchPilotSync/patch-pilot` public and asked for releases on its page; amends #32). After the Release workflow publishes to `patch-pilot-releases`, a new job calls `.github/workflows/mirror-release.yml`, which copies the release to the main repo with the same tag, title and notes, attaching only the installers people download (`*_aarch64.dmg`, `*_x64.dmg`, `*_x64-setup.exe`). The updater endpoint, update packages, `latest.json` and `catalog.json` stay on `patch-pilot-releases`. The copy is made as a draft, then published once every installer is attached; it is marked Latest only when its tag is the releases repo's latest, so backfilling an older tag (run the workflow from the Actions tab) never displaces the newest. It uses only the built-in token (the releases repo is public). The README's badge and download link now point to the main repo's releases. | Installed apps already read `patch-pilot-releases/releases/latest/download/latest.json`, so moving the updater would strand every existing install; copying keeps them working while the public repo's page shows each release. Installers only keeps the page clean. | Accepted | +| 121 | 2026-11-06 | **First run is a few setup questions, not a tour** (Joshua: a questionnaire that asks how to sync, explains each way's benefits and limits, which DAWs and what to keep in sync, and makes getting going as easy as possible; amends #36 and #91). The Welcome screen becomes five short steps before the first scan (`app/src/FirstRun.tsx`): name this machine and whether it's the main one; Reaper and/or Ableton Live; how files travel (a cloud folder, the home network, a private GitHub repo, or decide later), each with one line on what it's good at and one on its limits (`app/src/SyncMethods.tsx`, the shared wording); what stays the same, as six plain groups over Choose what syncs's kinds (only the DAWs picked); then a summary and Scan this machine. Answers set real settings: a cloud folder or GitHub repo is picked in place and keeping files in sync goes on; after the scan an unticked DAW is no longer watched and its kinds stop syncing, "main" makes the scan the Primary Rig (unless one exists), "not the main one" opens Link your machines on Enter a code, and the home network goes on (`lan_use` + keep in sync) as soon as the machine is linked. The tour is no longer shown on a first run (the questions count as seeing it; How it works still opens it); machines scanned before this still see it once. | A new tester got lost finding how sync works across Settings, This machine and the tour; asking once, up front, and doing the setup there removes the hunt. Everything stays changeable in Settings. | Accepted | diff --git a/docs/strategy.md b/docs/strategy.md index 4ce0b26..ff2d2b5 100644 --- a/docs/strategy.md +++ b/docs/strategy.md @@ -251,6 +251,7 @@ Status: **Shipped** = in a published release. **On main** = merged, goes out in | App | Tray / menu bar popover that expands into the main window | Shipped (v0.1.0) | | App | Self-update with release notes; one-click release workflow to the public releases repo, copied to the main repo's Releases (decision #120) | Shipped (v0.1.0) | | App | First-run tour (5 steps, reopen from "How it works"); plain-language copy on every screen | Shipped (v0.2.0) | +| App | First-run setup questions: main machine, DAWs, how files sync (cloud folder / home network / GitHub, with benefits and limits), what stays the same; answers set the Primary Rig, DAWs, sync and linking (#121) | On main | | Always on | Opens at login hidden in the tray; rescans by itself when plug-in folders change (OS watchers, no polling) | Shipped (v0.2.0) | | Live sync | Link machines with a short code; live view of every machine in Compare; end-to-end encrypted relay on Cloudflare (server can't read anything); no accounts | Shipped (v0.2.0) | | Docs | README in plain language, refreshed for v0.4.30 (download, Mac fix, how syncing works) | Shipped |