diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 733b5ad..816bee3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,9 @@ jobs: - name: Zig version run: zig version + - name: Run macOS quickstart regression + run: bash ./platform/macos/scripts/quickstart-regression.sh + - name: Build host artifacts run: zig build diff --git a/README.md b/README.md index 05765dd..f34e79b 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ zig build ./zig-out/bin/spiderweb-control \ --auth-token \ workspace_up \ - '{"name":"Demo","vision":"Mounted workspace demo","template_id":"dev","activate":false}' + '{"name":"Demo","vision":"Mounted workspace demo","template_id":"just_try_it","activate":false}' # Mount that workspace into the local filesystem ./zig-out/bin/spiderweb-fs-mount \ diff --git a/platform/macos/README.md b/platform/macos/README.md index e891864..827e155 100644 --- a/platform/macos/README.md +++ b/platform/macos/README.md @@ -68,3 +68,20 @@ For background on Apple’s starting point, see [Building a passthrough file sys For shipping Spiderweb to a normal SIP-enabled Mac, see the release packaging guide in [RELEASE.md](/Users/deanocalver/Documents/Spider/Spiderweb/platform/macos/RELEASE.md). For the planned end-user installer and first-run flow, see [INSTALLER_UX.md](/Users/deanocalver/Documents/Spider/Spiderweb/platform/macos/INSTALLER_UX.md). + +## Quickstart Regression + +Run the onboarding regression checks with: + +```bash +./platform/macos/scripts/quickstart-regression.sh +``` + +That script compiles a temporary Swift harness against `SpiderwebAppController.swift` and verifies the quickstart state machine behaviors that are easy to regress: + +- switching presets resets the targeted workspace and drive +- persisted blocked quickstart state resumes cleanly on relaunch +- unrelated workspaces are not accidentally adopted during resume +- already-mounted native drive errors are treated as satisfied only when the drive is actually active + +The default Spiderweb first-run preset now uses the dedicated `just_try_it` workspace template instead of the broader `dev` template, so onboarding stays intentionally minimal even if the development template grows over time. diff --git a/platform/macos/RELEASE.md b/platform/macos/RELEASE.md index f23a172..a4db3eb 100644 --- a/platform/macos/RELEASE.md +++ b/platform/macos/RELEASE.md @@ -77,6 +77,7 @@ Optional flags: By default the script: - builds the Zig CLI payload for the current host architecture +- runs the macOS quickstart regression harness - archives and exports `Spiderweb.app` for `developer-id` distribution - builds a signed `spiderweb.fs` wrapper bundle - builds a signed flat installer package diff --git a/platform/macos/SpiderwebFSKitApp/ContentView.swift b/platform/macos/SpiderwebFSKitApp/ContentView.swift index 9b1098d..82764fe 100644 --- a/platform/macos/SpiderwebFSKitApp/ContentView.swift +++ b/platform/macos/SpiderwebFSKitApp/ContentView.swift @@ -6,6 +6,7 @@ struct ContentView: View { @State private var revealLocalTokens = false @State private var newWorkspaceName = "" @State private var newWorkspaceVision = "" + @State private var activeRecipe: SpiderwebRecipe? var body: some View { NavigationSplitView { @@ -35,6 +36,18 @@ struct ContentView: View { } message: { Text("This removes Spiderweb.app, the background service, native file system support, saved mounts, local Spiderweb data, and Spiderweb secrets from this Mac. Spiderweb will quit when uninstall begins.") } + .sheet(item: $activeRecipe) { recipe in + SpiderwebRecipeSheet( + recipe: recipe, + primaryAction: { + runRecipePrimaryAction(recipe) + }, + secondaryAction: recipe.secondaryButtonTitle == nil ? nil : { + runRecipeSecondaryAction(recipe) + } + ) + .environmentObject(controller) + } } private var header: some View { @@ -42,7 +55,7 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 6) { Text("Spiderweb") .font(.system(size: 30, weight: .semibold)) - Text("Native mounts, saved workspaces, and remote-node setup for macOS.") + Text("Start a workspace, connect devices, and manage Spiderweb drives on macOS.") .foregroundStyle(.secondary) } @@ -87,8 +100,206 @@ struct ContentView: View { private var overviewView: some View { VStack(alignment: .leading, spacing: 20) { - Text("Overview") - .font(.title2.weight(.semibold)) + VStack(alignment: .leading, spacing: 6) { + Text("Get Started") + .font(.title2.weight(.semibold)) + Text("Lead with one local workspace. Once the drive is working, come back for devices, packages, and remote flows.") + .foregroundStyle(.secondary) + } + + quickstartHeroPanel + overviewRecipesPanel + secondaryPathsPanel + quickstartNextStepsPanel + setupChecklistPanel + quickMountsPanel + } + } + + private var overviewRecipesPanel: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Useful Next Paths") + .font(.headline) + + Text("Spider is most approachable when each step has a job: start local, connect another machine, mount something remote, or contribute this Mac elsewhere.") + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 12) { + OnboardingRecipeCard( + eyebrow: "Remote Access", + title: "Mount an Existing Spiderweb", + summary: "Use this when a Spiderweb is already running somewhere else and you just want its workspace as a drive on this Mac.", + steps: [ + "Copy the remote Spiderweb URL and access token.", + "Pick the workspace id and local mount path.", + "Save the drive, then mount it from the Mounts screen." + ], + progress: SpiderwebRecipe.mountExistingSpiderweb.progress(using: controller), + buttonTitle: "Open Remote Drive Setup", + action: { + activeRecipe = .mountExistingSpiderweb + } + ) + OnboardingRecipeCard( + eyebrow: "Connect Devices", + title: "Let Another Mac Connect to This Spiderweb", + summary: "Run Spiderweb on this Mac, then share a network URL and access token so another Mac or tool can connect back here.", + steps: [ + "Install / Start Service on this Mac.", + "Reveal the local access tokens and copy a network URL.", + "Use that URL and token from another Mac, SpiderApp, or a saved remote drive." + ], + progress: SpiderwebRecipe.shareThisSpiderweb.progress(using: controller), + buttonTitle: "Open Local Access", + action: { + activeRecipe = .shareThisSpiderweb + } + ) + OnboardingRecipeCard( + eyebrow: "Contribute This Mac", + title: "Provide This Mac to a Remote Spiderweb", + summary: "Use an invite token to pair this Mac as a remote device so another Spiderweb can use its files or services.", + steps: [ + "Paste the remote control URL and invite token.", + "Choose what path this Mac should export.", + "Pair the Mac, then confirm it shows up in the remote workspace topology." + ], + progress: SpiderwebRecipe.provideThisMac.progress(using: controller), + buttonTitle: "Open Pairing Setup", + action: { + activeRecipe = .provideThisMac + } + ) + } + } + .padding(18) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + + private var quickstartHeroPanel: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 8) { + Text("Start Here") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text("Start Local Workspace") + .font(.system(size: 28, weight: .bold, design: .rounded)) + Text("Spiderweb will install what it needs, create a workspace, mount a drive under your home folder, and open the result in Finder.") + .font(.body) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(controller.quickstartStepTitle) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(NSColor.textBackgroundColor), in: Capsule()) + .foregroundStyle(controller.quickstartState?.isComplete == true ? .green : .secondary) + } + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + HeroFactCard( + title: "Default Path", + value: "Just Try It", + detail: "The fastest way to see Spiderweb as a drive on this Mac." + ) + HeroFactCard( + title: "Drive Location", + value: controller.quickstartDrivePath ?? "~/Spiderweb/", + detail: "A deterministic path you can open in Finder, editors, and tools." + ) + HeroFactCard( + title: "What You Get", + value: "Workspace + Drive", + detail: "A ready local workspace first, then SpiderApp for devices and packages." + ) + } + + if let drivePath = controller.quickstartDrivePath { + VStack(alignment: .leading, spacing: 4) { + Text("Drive Path") + .font(.subheadline.weight(.semibold)) + Text(drivePath) + .font(.system(.footnote, design: .monospaced)) + .textSelection(.enabled) + } + .padding(12) + .background(Color(NSColor.textBackgroundColor), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + Text(controller.quickstartDetail) + .font(.subheadline) + .foregroundStyle(.secondary) + + HStack(spacing: 10) { + if controller.quickstartState?.isComplete == true { + if controller.quickstartCanOpenSpiderApp { + Button(controller.quickstartPrimaryButtonTitle) { + controller.openSpiderApp() + } + .buttonStyle(.borderedProminent) + } + if controller.quickstartState?.result?.driveIssueSummary?.isEmpty == false { + Button("Retry Drive Mount") { + controller.startLocalWorkspaceQuickstart() + } + .buttonStyle(.bordered) + } + } else { + Button(controller.quickstartPrimaryButtonTitle) { + controller.startLocalWorkspaceQuickstart() + } + .buttonStyle(.borderedProminent) + .disabled(controller.isBusy) + } + + if controller.quickstartNeedsSystemApproval { + Button("Open System Settings") { + controller.openSystemSettings() + } + .buttonStyle(.bordered) + } + + if controller.quickstartCanRevealDrive { + Button("Reveal Drive") { + controller.revealQuickstartDrive() + } + .buttonStyle(.bordered) + } + } + + Text("Prefer a different first step? Use the paths below to mount an existing Spiderweb or connect this Mac to another workspace.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(24) + .background( + LinearGradient( + colors: [ + Color.accentColor.opacity(0.18), + Color(NSColor.controlBackgroundColor) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 22, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(Color.accentColor.opacity(0.16), lineWidth: 1) + ) + } + + private var setupChecklistPanel: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Need Help or Manual Setup?") + .font(.headline) + + Text("These details matter when you need approvals, want to troubleshoot the local runtime, or prefer a more manual path.") + .foregroundStyle(.secondary) LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) { StatusCard( @@ -102,9 +313,9 @@ struct ContentView: View { detail: controller.nativeStatusDetailText ) StatusCard( - title: "Saved Mounts", + title: "Saved Drives", value: "\(controller.savedMounts.count)", - detail: controller.mountedSavedMounts.isEmpty ? "No active native mounts right now." : "\(controller.mountedSavedMounts.count) mounted right now." + detail: controller.mountedSavedMounts.isEmpty ? "No active native drives right now." : "\(controller.mountedSavedMounts.count) mounted right now." ) StatusCard( title: "Remote Node", @@ -113,17 +324,6 @@ struct ContentView: View { ) } - setupChecklistPanel - setupChoices - quickMountsPanel - } - } - - private var setupChecklistPanel: some View { - VStack(alignment: .leading, spacing: 14) { - Text("Setup Checklist") - .font(.headline) - ChecklistRow( title: "Local background service", detail: controller.serviceStatus?.loaded == true @@ -155,10 +355,10 @@ struct ContentView: View { ) ChecklistRow( - title: "Mount local or remote workspaces", + title: "Mount local or remote drives", detail: controller.nativeStatus?.ready == true - ? "Native mounts are ready. You can save mounts in the Mounts section or create a local mount from This Mac." - : "Mounts become available once the file system is installed and enabled.", + ? "Native drives are ready. You can save drives in the Mounts section or create a local drive from This Mac." + : "Drives become available once the file system is installed and enabled.", isComplete: controller.nativeStatus?.ready == true, actionTitle: "Refresh", action: { controller.refresh() } @@ -168,12 +368,15 @@ struct ContentView: View { .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } - private var setupChoices: some View { + private var secondaryPathsPanel: some View { VStack(alignment: .leading, spacing: 14) { - Text("Choose a Path") + Text("Other Ways to Start") .font(.headline) - ForEach(SpiderwebOnboardingPath.allCases) { path in + Text("Use these when your first goal is remote access or contributing this Mac to another Spiderweb instead of starting locally.") + .foregroundStyle(.secondary) + + ForEach([SpiderwebOnboardingPath.remoteMount, SpiderwebOnboardingPath.remoteNode]) { path in Button { controller.highlightedOnboardingPath = path switch path { @@ -209,13 +412,73 @@ struct ContentView: View { } } + private var quickstartNextStepsPanel: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Keep Going") + .font(.headline) + + if controller.quickstartState?.isComplete != true { + Text("Finish the local workspace first. After that, use SpiderApp for devices, packages, recipes, and the richer workspace shell.") + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 10) { + if controller.quickstartCanOpenSpiderApp { + actionCard( + title: "Open SpiderApp", + detail: controller.quickstartNextStepDetail, + buttonTitle: "Open SpiderApp", + action: { + controller.openSpiderApp() + } + ) + } else { + actionCard( + title: "Use the Mounted Drive", + detail: controller.quickstartNextStepDetail, + buttonTitle: "Reveal Drive", + action: { + controller.revealQuickstartDrive() + } + ) + } + actionCard( + title: "Connect Machines", + detail: "Bring in another machine, or contribute this Mac to a remote Spiderweb, once you have seen the first drive working.", + buttonTitle: "Open Guide", + action: { + activeRecipe = .provideThisMac + } + ) + actionCard( + title: "Add Packages and Services", + detail: "Packages are Spider's installable capabilities. Older internal docs may call them venoms, but the user-facing flow should stay package-first.", + buttonTitle: "Open Guide", + action: { + activeRecipe = .packagesAndServices + } + ) + actionCard( + title: "Create More Drives", + detail: "Use Mounts for additional local or remote drives once the first workspace path makes sense.", + buttonTitle: "Open Drives", + action: { + controller.selectedSection = .mounts + } + ) + } + } + } + .padding(18) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + private var quickMountsPanel: some View { VStack(alignment: .leading, spacing: 14) { - Text("Quick Mounts") + Text("Saved Drives") .font(.headline) if controller.savedMounts.isEmpty { - Text("No saved mounts yet. Add one in the Mounts section.") + Text("No saved drives yet. Add one in the Mounts section.") .foregroundStyle(.secondary) } else { ForEach(controller.savedMounts) { mount in @@ -252,24 +515,40 @@ struct ContentView: View { private var mountsView: some View { VStack(alignment: .leading, spacing: 20) { HStack { - Text("Mounts") + Text("Drives") .font(.title2.weight(.semibold)) Spacer() - Button("New Local Mount") { + Button("New Local Drive") { controller.beginNewMount(kind: .local) } - Button("New Remote Mount") { + Button("New Remote Drive") { controller.beginNewMount(kind: .remote) } } + OnboardingRecipeCard( + eyebrow: "Recipe", + title: "Mount a Remote Workspace", + summary: "When another Spiderweb is already running, create a saved remote drive here so Finder, editors, and tools can use it like a local path.", + steps: [ + "Paste the remote Spiderweb URL and access token.", + "Set the workspace id and a mount path under ~/Spiderweb.", + "Save the drive, then mount or reveal it from this screen." + ], + progress: SpiderwebRecipe.mountExistingSpiderweb.progress(using: controller), + buttonTitle: "New Remote Drive", + action: { + activeRecipe = .mountExistingSpiderweb + } + ) + HStack(alignment: .top, spacing: 20) { VStack(alignment: .leading, spacing: 12) { - Text("Saved Mounts") + Text("Saved Drives") .font(.headline) if controller.savedMounts.isEmpty { - Text("Save local or remote mounts here so they’re easy to mount again from the menu bar.") + Text("Save local or remote drives here so they’re easy to mount again from the menu bar.") .foregroundStyle(.secondary) } else { ForEach(controller.savedMounts) { mount in @@ -328,10 +607,10 @@ struct ContentView: View { private var mountEditorPanel: some View { VStack(alignment: .leading, spacing: 12) { - Text(controller.mountEditor.editingID == nil ? "New Mount" : "Edit Mount") + Text(controller.mountEditor.editingID == nil ? "New Drive" : "Edit Drive") .font(.headline) - Picker("Mount kind", selection: $controller.mountEditor.kind) { + Picker("Drive kind", selection: $controller.mountEditor.kind) { Text("Local").tag(SpiderwebSavedMountKind.local) Text("Remote").tag(SpiderwebSavedMountKind.remote) } @@ -352,13 +631,13 @@ struct ContentView: View { if controller.mountEditor.kind == .remote { SecureField("Auth Token", text: $controller.mountEditor.authToken) } else { - Text("Local mounts use the local Spiderweb runtime token automatically.") + Text("Local drives use the local Spiderweb runtime token automatically.") .font(.footnote) .foregroundStyle(.secondary) } HStack { - Button("Save Mount") { + Button("Save Drive") { controller.saveMountDraft() } Button("Reset") { @@ -376,17 +655,123 @@ struct ContentView: View { Text("This Mac") .font(.title2.weight(.semibold)) + thisMacRecipesPanel localSpiderwebPanel remoteNodePanel } } + private var thisMacRecipesPanel: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Useful Configurations") + .font(.headline) + + Text("This is where Spiderweb shifts from one local drive into a distributed workspace: sharing access, pairing this Mac remotely, or creating additional local workspaces.") + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 12) { + OnboardingRecipeCard( + eyebrow: "Share This Host", + title: "Connect Another Mac to This Spiderweb", + summary: "Expose this Mac as the Spiderweb host another machine connects to.", + steps: [ + "Start the local service here.", + "Copy a network URL and access token from Local Access Tokens.", + "Use those on the other machine to connect and choose a workspace." + ], + progress: SpiderwebRecipe.shareThisSpiderweb.progress(using: controller), + buttonTitle: "Reveal Access Tokens", + action: { + activeRecipe = .shareThisSpiderweb + } + ) + OnboardingRecipeCard( + eyebrow: "Create Workspace", + title: "Set Up a Useful Local Workspace", + summary: "A workspace is the shared root that drives, packages, and tools attach to. Start with one clear purpose, then expand it.", + steps: [ + "Give the workspace a recognizable name.", + "Describe the goal in one sentence so later devices and tools make sense.", + "Create a saved drive from that workspace once it appears below." + ], + progress: SpiderwebRecipe.setupUsefulWorkspace.progress(using: controller), + buttonTitle: "Open Workspace Guide", + action: { + activeRecipe = .setupUsefulWorkspace + } + ) + OnboardingRecipeCard( + eyebrow: "Remote Pairing", + title: "Provide This Mac to Another Spiderweb", + summary: "Pair this Mac as a remote device so another Spiderweb can mount its exported path or use its services.", + steps: [ + "Paste the remote control URL and invite token.", + "Choose the export path and whether it should be read-only.", + "Pair this Mac and verify it appears in the remote Spiderweb." + ], + progress: SpiderwebRecipe.provideThisMac.progress(using: controller), + buttonTitle: "Open Pairing Guide", + action: { + activeRecipe = .provideThisMac + } + ) + } + } + .padding(18) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + + private func runRecipePrimaryAction(_ recipe: SpiderwebRecipe) { + activeRecipe = nil + switch recipe { + case .mountExistingSpiderweb: + controller.highlightedOnboardingPath = .remoteMount + controller.beginNewMount(kind: .remote) + controller.selectedSection = .mounts + case .shareThisSpiderweb: + revealLocalTokens = true + controller.selectedSection = .thisMac + case .provideThisMac: + controller.highlightedOnboardingPath = .remoteNode + controller.selectedSection = .thisMac + case .setupUsefulWorkspace: + controller.selectedSection = .thisMac + case .packagesAndServices: + if controller.quickstartCanOpenSpiderApp { + controller.openSpiderApp() + } else if controller.quickstartCanRevealDrive { + controller.revealQuickstartDrive() + } else { + controller.selectedSection = .overview + } + } + } + + private func runRecipeSecondaryAction(_ recipe: SpiderwebRecipe) { + activeRecipe = nil + switch recipe { + case .mountExistingSpiderweb: + revealLocalTokens = true + controller.selectedSection = .thisMac + case .shareThisSpiderweb: + controller.beginNewMount(kind: .remote) + controller.selectedSection = .mounts + case .provideThisMac: + controller.openSystemSettings() + case .setupUsefulWorkspace: + controller.selectedSection = .overview + controller.startLocalWorkspaceQuickstart() + case .packagesAndServices: + controller.selectedSection = .overview + } + } + private var localSpiderwebPanel: some View { VStack(alignment: .leading, spacing: 14) { Text("Local Spiderweb") .font(.headline) - Text("Run Spiderweb in the background on this Mac, manage local workspaces, and create saved local mounts.") + Text("Run Spiderweb in the background on this Mac, manage local workspaces, and create saved local drives.") .foregroundStyle(.secondary) HStack(spacing: 10) { @@ -408,7 +793,7 @@ struct ContentView: View { } } - Text("Install / Start Service sets up Spiderweb for this user. Install File System may ask for admin approval, and macOS still requires one manual enable step in System Settings before native mounts become ready.") + Text("Install / Start Service sets up Spiderweb for this user. Install File System may ask for admin approval, and macOS still requires one manual enable step in System Settings before native drives become ready.") .font(.footnote) .foregroundStyle(.secondary) @@ -431,7 +816,7 @@ struct ContentView: View { } if controller.mountableLocalWorkspaces.isEmpty { - Text("No local workspaces loaded yet. Create one below, then save a local mount from it.") + Text("No local workspaces loaded yet. Create one below, then save a local drive from it.") .foregroundStyle(.secondary) } else { Text("Local Workspaces") @@ -447,7 +832,7 @@ struct ContentView: View { } Spacer() HStack(spacing: 8) { - Button("Create Saved Mount") { + Button("Create Saved Drive") { controller.createSavedLocalMount(for: workspace) } Button("Delete Workspace", role: .destructive) { @@ -488,10 +873,10 @@ struct ContentView: View { private var localWorkspaceCreationPanel: some View { VStack(alignment: .leading, spacing: 12) { - Text("Create Workspace") + Text("Create a Useful Workspace") .font(.headline) - Text("Local mounts need a real Spiderweb workspace. Create one here, then save a local mount from the workspace list below.") + Text("Local drives need a real workspace first. Keep the first one simple and task-shaped, then add more drives, devices, and packages after the workspace exists.") .font(.footnote) .foregroundStyle(.secondary) @@ -714,6 +1099,256 @@ private func maskedToken(_ value: String?) -> String { return "\(value.prefix(4))...\(value.suffix(4))" } +private enum RecipeProgress { + case guide + case ready + case done + + var label: String { + switch self { + case .guide: return "Guide" + case .ready: return "Ready" + case .done: return "Done" + } + } + + var tint: Color { + switch self { + case .guide: return .secondary + case .ready: return .orange + case .done: return .green + } + } +} + +private enum SpiderwebRecipe: String, Identifiable { + case mountExistingSpiderweb + case shareThisSpiderweb + case provideThisMac + case setupUsefulWorkspace + case packagesAndServices + + var id: String { rawValue } + + var eyebrow: String { + switch self { + case .mountExistingSpiderweb: return "REMOTE ACCESS" + case .shareThisSpiderweb: return "SHARE THIS HOST" + case .provideThisMac: return "REMOTE PAIRING" + case .setupUsefulWorkspace: return "WORKSPACE GUIDE" + case .packagesAndServices: return "PACKAGES AND SERVICES" + } + } + + var title: String { + switch self { + case .mountExistingSpiderweb: return "Mount an Existing Spiderweb" + case .shareThisSpiderweb: return "Let Another Mac Connect to This Spiderweb" + case .provideThisMac: return "Provide This Mac to a Remote Spiderweb" + case .setupUsefulWorkspace: return "Set Up a Useful Workspace" + case .packagesAndServices: return "Add Packages and Services" + } + } + + var summary: String { + switch self { + case .mountExistingSpiderweb: + return "Use this when a Spiderweb is already running somewhere else and you just want its workspace as a drive on this Mac." + case .shareThisSpiderweb: + return "Run Spiderweb on this Mac, then share a network URL and access token so another Mac, tool, or saved drive can connect back here." + case .provideThisMac: + return "Use an invite token to pair this Mac as a remote device so another Spiderweb can use its files or services." + case .setupUsefulWorkspace: + return "A workspace is the shared root that drives, packages, and tools attach to. Start with one clear job, then expand it." + case .packagesAndServices: + return "Packages are Spider's installable capabilities. Older internal docs may call them venoms, but the user-facing flow should stay package-first." + } + } + + var steps: [String] { + switch self { + case .mountExistingSpiderweb: + return [ + "Copy the remote Spiderweb URL and access token.", + "Pick the workspace id and a mount path under ~/Spiderweb.", + "Save the drive, then mount or reveal it from the Drives screen." + ] + case .shareThisSpiderweb: + return [ + "Install / Start Service on this Mac so Spiderweb is running locally.", + "Reveal the local access tokens and copy a network URL plus the access token.", + "Use that URL and token from another Mac, SpiderApp, or a saved remote drive." + ] + case .provideThisMac: + return [ + "Paste the remote control URL and invite token from the other Spiderweb.", + "Choose what path this Mac should export and whether it should be read-only.", + "Pair the Mac, then confirm it shows up in the remote workspace topology." + ] + case .setupUsefulWorkspace: + return [ + "Give the workspace a recognizable name and one-sentence goal.", + "Create the workspace first, then create a saved drive from it once it appears below.", + "Only add more drives, devices, and packages after the workspace itself makes sense." + ] + case .packagesAndServices: + return [ + "Open SpiderApp once the first workspace and drive are working.", + "Use Capabilities to inspect installed packages and add only the next useful one.", + "When you see the word venom in older internal docs, read it as the older term for a package or capability." + ] + } + } + + var primaryButtonTitle: String { + switch self { + case .mountExistingSpiderweb: return "Open Remote Drive Setup" + case .shareThisSpiderweb: return "Open Local Access" + case .provideThisMac: return "Open Pairing Setup" + case .setupUsefulWorkspace: return "Open Workspace Setup" + case .packagesAndServices: return "Open Next Step" + } + } + + var secondaryButtonTitle: String? { + switch self { + case .mountExistingSpiderweb: return "Reveal Host Access" + case .shareThisSpiderweb: return "Open Remote Drives" + case .provideThisMac: return "Open System Settings" + case .setupUsefulWorkspace: return "Start Local Workspace" + case .packagesAndServices: return "Back to Overview" + } + } + + func progress(using controller: SpiderwebAppController) -> RecipeProgress { + switch self { + case .mountExistingSpiderweb: + return controller.savedMounts.contains(where: { $0.kind == .remote }) ? .done : .guide + case .shareThisSpiderweb: + if controller.serviceStatus?.loaded == true, + controller.authStatus?.accessPresent == true, + controller.serviceStatus?.remoteReachable != false { + return .done + } + if controller.serviceStatus?.loaded == true, controller.authStatus?.accessPresent == true { + return .ready + } + return .guide + case .provideThisMac: + if controller.pairedRemoteNode != nil { + return .done + } + if controller.serviceStatus?.loaded == true { + return .ready + } + return .guide + case .setupUsefulWorkspace: + if !controller.mountableLocalWorkspaces.isEmpty { + return .done + } + if controller.serviceStatus?.loaded == true { + return .ready + } + return .guide + case .packagesAndServices: + if controller.quickstartState?.isComplete == true, controller.quickstartCanOpenSpiderApp { + return .done + } + if controller.quickstartState?.isComplete == true { + return .ready + } + return .guide + } + } +} + +private struct SpiderwebRecipeSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var controller: SpiderwebAppController + + let recipe: SpiderwebRecipe + let primaryAction: () -> Void + let secondaryAction: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + Text(recipe.eyebrow) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + RecipeProgressBadge(progress: recipe.progress(using: controller)) + } + + Text(recipe.title) + .font(.title2.weight(.bold)) + + Text(recipe.summary) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 10) { + Text("What to do") + .font(.headline) + ForEach(Array(recipe.steps.enumerated()), id: \.offset) { index, step in + Text("\(index + 1). \(step)") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(14) + .background(Color(NSColor.textBackgroundColor), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + + if recipe == .packagesAndServices { + Text(controller.quickstartCanOpenSpiderApp + ? "SpiderApp is available, so that is the best place to continue into packages, devices, and recipes." + : "If SpiderApp is not available yet, continue from the mounted drive and come back to package setup later.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + HStack(spacing: 10) { + Button(recipe.primaryButtonTitle) { + primaryAction() + dismiss() + } + .buttonStyle(.borderedProminent) + + if let secondaryAction, let secondaryButtonTitle = recipe.secondaryButtonTitle { + Button(secondaryButtonTitle) { + secondaryAction() + dismiss() + } + .buttonStyle(.bordered) + } + + Spacer() + + Button("Close") { + dismiss() + } + .buttonStyle(.bordered) + } + } + .padding(24) + .frame(minWidth: 520, minHeight: 360) + } +} + +private struct RecipeProgressBadge: View { + let progress: RecipeProgress + + var body: some View { + Text(progress.label) + .font(.caption.weight(.semibold)) + .foregroundStyle(progress.tint) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(progress.tint.opacity(0.12), in: Capsule()) + } +} + private struct InlineBanner: View { let text: String let tint: Color @@ -768,6 +1403,71 @@ private struct StatusCard: View { } } +private struct HeroFactCard: View { + let title: String + let value: String + let detail: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(value) + .font(.headline) + .textSelection(.enabled) + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(NSColor.textBackgroundColor).opacity(0.85), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +private struct OnboardingRecipeCard: View { + let eyebrow: String + let title: String + let summary: String + let steps: [String] + let progress: RecipeProgress + let buttonTitle: String? + let action: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(eyebrow.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + RecipeProgressBadge(progress: progress) + } + Text(title) + .font(.headline) + Text(summary) + .font(.footnote) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(steps.enumerated()), id: \.offset) { index, step in + Text("\(index + 1). \(step)") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + if let buttonTitle, let action { + Button(buttonTitle, action: action) + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(NSColor.textBackgroundColor), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + private struct SettingsRow: View { let title: String let detail: String @@ -852,6 +1552,27 @@ private struct SecretValueRow: View { } } +@ViewBuilder +private func actionCard(title: String, detail: String, buttonTitle: String, action: @escaping () -> Void) -> some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.semibold)) + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Button(buttonTitle, action: action) + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(14) + .background(Color(NSColor.textBackgroundColor), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) +} + #Preview { ContentView() .environmentObject(SpiderwebAppController()) diff --git a/platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift b/platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift index 8cfc76e..5c4bc4f 100644 --- a/platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift +++ b/platform/macos/SpiderwebFSKitApp/SpiderwebAppController.swift @@ -225,6 +225,103 @@ struct SpiderwebWorkspaceSummary: Identifiable, Hashable { var isMountable: Bool { mountCount > 0 } } +enum QuickstartPreset: String, Codable, CaseIterable, Identifiable { + case justTryIt = "just_try_it" + case connectMachines = "connect_machines" + case agentLab = "agent_lab" + + var id: String { rawValue } + + var title: String { + switch self { + case .justTryIt: return "Just Try It" + case .connectMachines: return "Connect Machines" + case .agentLab: return "Agent Lab" + } + } + + var workspaceName: String { + switch self { + case .justTryIt: return "My Workspace" + case .connectMachines: return "Shared Workspace" + case .agentLab: return "Agent Lab" + } + } + + var workspaceVision: String { + switch self { + case .justTryIt: + return "A simple local workspace mounted on this Mac." + case .connectMachines: + return "A starter workspace for connecting a second machine." + case .agentLab: + return "A starter workspace for trying packages and agent workflows." + } + } + + var templateID: String { + switch self { + case .justTryIt: + return "just_try_it" + case .connectMachines, .agentLab: + return "dev" + } + } +} + +enum QuickstartStep: String, Codable, CaseIterable { + case installService = "install_service" + case installFileSystem = "install_file_system" + case enableFileSystem = "enable_file_system" + case ensureWorkspace = "ensure_workspace" + case ensureMount = "ensure_mount" + case mountDrive = "mount_drive" + case revealDrive = "reveal_drive" + case complete = "complete" + + var title: String { + switch self { + case .installService: return "Install background service" + case .installFileSystem: return "Install file system support" + case .enableFileSystem: return "Enable file system support" + case .ensureWorkspace: return "Create or reuse workspace" + case .ensureMount: return "Create or reuse drive" + case .mountDrive: return "Mount drive" + case .revealDrive: return "Reveal drive" + case .complete: return "Ready" + } + } +} + +struct QuickstartResult: Codable { + var workspaceID: String + var workspaceName: String + var mountID: String + var mountpoint: String + var createdWorkspace: Bool + var createdMount: Bool + var mountedNow: Bool + var driveAvailable: Bool? = nil + var driveIssueSummary: String? = nil +} + +struct QuickstartState: Codable { + var preset: QuickstartPreset + var currentStep: QuickstartStep + var workspaceID: String? + var workspaceName: String? + var mountID: String? + var mountpoint: String? + var lastMessage: String? + var blockedReason: String? + var updatedAt: Date + var result: QuickstartResult? + + var isComplete: Bool { + currentStep == .complete && result != nil + } +} + struct SpiderwebMountEditorDraft { var editingID: String? var name: String = "" @@ -302,13 +399,17 @@ struct SpiderwebAccessEndpoint: Identifiable, Hashable { final class SpiderwebAppController: ObservableObject { static let localServerURL = "ws://127.0.0.1:18790/" + private static let spiderAppBundleIdentifier = "com.deanocalver.spiderapp" private static let appGroupIdentifier = "group.com.deanoc.spiderweb.fskit" private static let savedMountsFilename = "saved-mounts.json" private static let pairedNodeFilename = "paired-node.json" + private static let quickstartStateFilename = "quickstart-state.json" private static let remoteMountSecretService = "com.deanoc.spiderweb.saved-mount" private static let spiderwebCredentialService = "spiderweb" private static let systemSettingsURL = URL(string: "x-apple.systempreferences:com.apple.LoginItems-Settings.extension")! private static let nativeMountActionTimeout: TimeInterval = 20 + private static let quickstartMountTimeoutRecoveryWindow: TimeInterval = 10 + private static let quickstartMountTimeoutRecoveryPollIntervalUS: useconds_t = 500_000 @Published var selectedSection: SpiderwebAppSection = .overview @Published var highlightedOnboardingPath: SpiderwebOnboardingPath = .localHost @@ -324,6 +425,7 @@ final class SpiderwebAppController: ObservableObject { @Published var activeMountpoints: Set = [] @Published var extensionRegistrationPaths: [String] = [] @Published var launchAtLoginEnabled = false + @Published var quickstartState: QuickstartState? @Published var statusMessage: String? @Published var lastError: String? @Published var isBusy = false @@ -361,6 +463,73 @@ final class SpiderwebAppController: ObservableObject { localWorkspaces.filter { $0.kind != "system_builtin" && !$0.isMountable } } + var quickstartButtonTitle: String { + if isBusy { + return "Working..." + } + if quickstartState?.isComplete == true { + return "Ready" + } + if quickstartState != nil { + return "Resume Local Workspace" + } + return "Start Local Workspace" + } + + var quickstartPrimaryButtonTitle: String { + if quickstartState?.isComplete == true, quickstartCanOpenSpiderApp { + return "Open SpiderApp" + } + return quickstartButtonTitle + } + + var quickstartStepTitle: String { + quickstartState?.currentStep.title ?? QuickstartStep.installService.title + } + + var quickstartDetail: String { + if let driveIssueSummary = quickstartState?.result?.driveIssueSummary, !driveIssueSummary.isEmpty { + return driveIssueSummary + } + if let blockedReason = quickstartState?.blockedReason, !blockedReason.isEmpty { + return blockedReason + } + if let lastMessage = quickstartState?.lastMessage, !lastMessage.isEmpty { + return lastMessage + } + return "Install Spiderweb, create a local workspace, mount it as a drive, and reveal it in Finder." + } + + var quickstartDrivePath: String? { + if let mountpoint = quickstartState?.result?.mountpoint { + return mountpoint + } + return quickstartState?.mountpoint + } + + var quickstartCanRevealDrive: Bool { + guard let mountpoint = quickstartDrivePath else { return false } + return activeMountpoints.contains(mountpoint) + } + + var quickstartCanOpenSpiderApp: Bool { + Self.findSpiderAppApplicationURL() != nil || Self.findSpiderAppExecutablePath() != nil + } + + var quickstartNextStepDetail: String { + if let driveIssueSummary = quickstartState?.result?.driveIssueSummary, !driveIssueSummary.isEmpty { + return "Open SpiderApp to keep working while the drive mount is blocked, then retry the drive from Spiderweb after macOS clears the stuck FSKit state." + } + if quickstartCanOpenSpiderApp { + return "Open SpiderApp’s workspace shell and keep Devices, Capabilities, Explore, and Settings close by." + } + return "SpiderApp is not available on this Mac yet, so the mounted drive remains the fastest next step." + } + + var quickstartNeedsSystemApproval: Bool { + quickstartState?.currentStep == .enableFileSystem && nativeStatus?.ready != true + } + var hasSetupIssue: Bool { if nativeStatus?.ready != true { return true } if highlightedOnboardingPath == .localHost { @@ -539,6 +708,13 @@ final class SpiderwebAppController: ObservableObject { self.localWorkspaces = workspaces self.launchAtLoginEnabled = launchAtLoginEnabled self.buildInfo = buildInfo + self.reconcileQuickstartState( + serviceStatus: serviceStatus, + nativeStatus: nativeStatus, + workspaces: workspaces, + mounts: mergedMounts, + activeMountpoints: activeMountpoints + ) if Self.shouldAutofillRemoteNodePublicBaseURL(current: self.remoteNodeDraft.publicBaseURL), let suggested = Self.preferredRemoteNodePublicBaseURL(from: serviceStatus) { self.remoteNodeDraft.publicBaseURL = suggested @@ -1203,15 +1379,727 @@ final class SpiderwebAppController: ObservableObject { } } + func startLocalWorkspaceQuickstart(preset: QuickstartPreset = .justTryIt) { + isBusy = true + lastError = nil + + let nextState = Self.initialQuickstartState(currentState: quickstartState, preset: preset) + + quickstartState = nextState + persistQuickstartState() + statusMessage = nextState.lastMessage + + Task.detached(priority: .userInitiated) { + var quickstart = nextState + do { + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: quickstart.currentStep, + status: "started", + detail: quickstart.lastMessage + ) + + if Self.fetchServiceStatus()?.loaded != true { + quickstart.currentStep = .installService + quickstart.blockedReason = nil + quickstart.lastMessage = "Installing Spiderweb background service..." + quickstart.updatedAt = Date() + await self.applyQuickstartProgress(quickstart) + _ = try Self.runCLI("spiderweb-config", arguments: ["config", "install-service"]) + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .installService, + status: "completed", + detail: "Background service installed" + ) + } else { + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .installService, + status: "reused", + detail: "Background service already running" + ) + } + + let nativeAfterService = Self.fetchNativeStatus() + if nativeAfterService?.registered != true { + quickstart.currentStep = .installFileSystem + quickstart.lastMessage = "Installing Spiderweb file system support..." + quickstart.blockedReason = nil + quickstart.updatedAt = Date() + await self.applyQuickstartProgress(quickstart) + _ = try Self.runCLI("spiderweb-config", arguments: ["config", "install-fs-extension"]) + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .installFileSystem, + status: "completed", + detail: "File system support installed" + ) + } else { + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .installFileSystem, + status: "reused", + detail: "File system support already installed" + ) + } + + let nativeStatus = Self.fetchNativeStatus() + if nativeStatus?.ready != true { + quickstart.currentStep = .enableFileSystem + quickstart.lastMessage = "Enable “Spiderweb file system” in System Settings, then resume." + quickstart.blockedReason = "Open System Settings -> General -> Login Items & Extensions -> File System Extensions, enable “Spiderweb file system”, then return and resume." + quickstart.updatedAt = Date() + await self.applyQuickstartProgress(quickstart, isBusy: false) + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .enableFileSystem, + status: "blocked", + detail: quickstart.blockedReason + ) + return + } + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .enableFileSystem, + status: "completed", + detail: "File system support is ready" + ) + + guard let auth = Self.fetchAuthStatus(revealTokens: true), let accessToken = auth.accessToken else { + throw SpiderwebAppError.message("Spiderweb auth token is unavailable after service setup.") + } + + let savedMountsBefore = Self.loadSavedMounts() + let workspacesBefore = Self.fetchLocalWorkspaces(using: auth) + + quickstart.currentStep = .ensureWorkspace + quickstart.lastMessage = "Creating or reusing a local workspace..." + quickstart.blockedReason = nil + quickstart.updatedAt = Date() + await self.applyQuickstartProgress(quickstart) + + let workspace = try Self.ensureQuickstartWorkspace( + state: &quickstart, + preset: preset, + accessToken: accessToken, + existingWorkspaces: workspacesBefore + ) + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .ensureWorkspace, + status: workspace.created ? "completed" : "reused", + detail: workspace.summary.id + ) + + quickstart.currentStep = .ensureMount + quickstart.lastMessage = "Creating or reusing a saved drive..." + quickstart.mountpoint = workspace.mountpoint + quickstart.updatedAt = Date() + await self.applyQuickstartProgress(quickstart) + + let mount = Self.ensureQuickstartSavedMount( + state: &quickstart, + workspace: workspace.summary, + mountpoint: workspace.mountpoint, + existingMounts: savedMountsBefore + ) + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .ensureMount, + status: mount.created ? "completed" : "reused", + detail: mount.savedMount.mountpoint + ) + + let activeMountpoints = Self.fetchActiveMountpoints() + var mountedNow: Bool + var driveIssueSummary: String? = nil + if activeMountpoints.contains(mount.savedMount.mountpoint) { + mountedNow = false + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .mountDrive, + status: "reused", + detail: "Drive already mounted" + ) + } else { + quickstart.currentStep = .mountDrive + quickstart.lastMessage = "Mounting your local drive..." + quickstart.updatedAt = Date() + await self.applyQuickstartProgress(quickstart) + do { + mountedNow = try Self.mountSavedMountForQuickstart(mount.savedMount, authStatus: auth) + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .mountDrive, + status: mountedNow ? "completed" : "reused", + detail: mountedNow ? mount.savedMount.mountpoint : "Drive was already mounted while mounting" + ) + } catch { + let errorDescription = Self.describe(error: error) + let refreshedActiveMountpoints = Self.fetchActiveMountpoints() + if Self.shouldCompleteQuickstartWithoutMountedDrive( + errorMessage: errorDescription, + mountpoint: mount.savedMount.mountpoint, + activeMountpoints: refreshedActiveMountpoints + ) { + mountedNow = false + driveIssueSummary = Self.quickstartMountBlockedSummary(mountpoint: mount.savedMount.mountpoint) + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .mountDrive, + status: "blocked", + detail: errorDescription + ) + } else { + throw error + } + } + } + + if driveIssueSummary == nil { + quickstart.currentStep = .revealDrive + quickstart.lastMessage = "Revealing your local drive in Finder..." + quickstart.updatedAt = Date() + await self.applyQuickstartProgress(quickstart) + await MainActor.run { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: mount.savedMount.mountpoint)]) + } + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .revealDrive, + status: "completed", + detail: mount.savedMount.mountpoint + ) + } + + quickstart.currentStep = .complete + quickstart.result = QuickstartResult( + workspaceID: workspace.summary.id, + workspaceName: workspace.summary.name, + mountID: mount.savedMount.id, + mountpoint: mount.savedMount.mountpoint, + createdWorkspace: workspace.created, + createdMount: mount.created, + mountedNow: mountedNow, + driveAvailable: driveIssueSummary == nil, + driveIssueSummary: driveIssueSummary + ) + quickstart.lastMessage = driveIssueSummary == nil + ? "Workspace ready at \(mount.savedMount.mountpoint)" + : "Workspace ready. Native drive mount is blocked on this Mac." + quickstart.blockedReason = nil + quickstart.updatedAt = Date() + + let refreshedMounts = Self.loadSavedMounts() + let pairedNode = Self.loadPairedRemoteNode() + let serviceStatus = Self.fetchServiceStatus() + let refreshedNativeStatus = Self.fetchNativeStatus() + let refreshedAuthStatus = Self.fetchAuthStatus(revealTokens: true) + let remoteNodeStatus = Self.fetchRemoteNodeStatus() + let refreshedActiveMountpoints = Self.fetchActiveMountpoints() + let extensionRegistrationPaths = Self.fetchExtensionRegistrationPaths() + let refreshedWorkspaces = Self.fetchLocalWorkspaces(using: refreshedAuthStatus) + let mergedMounts = refreshedMounts.map { mount in + var next = mount + next.lastMountState = refreshedActiveMountpoints.contains(mount.mountpoint) ? .mounted : .idle + return next + } + let completedState = quickstart + + await MainActor.run { + self.savedMounts = mergedMounts + self.pairedRemoteNode = pairedNode + self.serviceStatus = serviceStatus + self.nativeStatus = refreshedNativeStatus + self.authStatus = refreshedAuthStatus + self.remoteNodeStatus = remoteNodeStatus + self.activeMountpoints = refreshedActiveMountpoints + self.extensionRegistrationPaths = extensionRegistrationPaths + self.localWorkspaces = refreshedWorkspaces + self.quickstartState = completedState + self.persistQuickstartState() + self.isBusy = false + self.statusMessage = completedState.lastMessage + self.selectedSection = .overview + } + try Self.logQuickstartMilestone( + preset: quickstart.preset, + step: .complete, + status: "completed", + detail: driveIssueSummary == nil ? mount.savedMount.mountpoint : "workspace ready without mounted drive" + ) + } catch { + quickstart.lastMessage = nil + quickstart.blockedReason = nil + quickstart.updatedAt = Date() + let errorDescription = Self.describe(error: error) + await self.applyQuickstartFailure(quickstart, errorDescription: errorDescription) + try? Self.logQuickstartMilestone( + preset: quickstart.preset, + step: quickstart.currentStep, + status: "failed", + detail: Self.describe(error: error) + ) + } + } + } + + func revealQuickstartDrive() { + guard let mountpoint = quickstartDrivePath else { return } + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: mountpoint)]) + statusMessage = "Revealed \(mountpoint)" + } + + @MainActor + func openSpiderApp() { + lastError = nil + + if let appURL = Self.findSpiderAppApplicationURL() { + statusMessage = "Opening SpiderApp..." + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + let applyOpenResult: @MainActor (String?) -> Void = { [weak self] errorDescription in + guard let self else { return } + if let errorDescription { + self.lastError = errorDescription + } else { + self.statusMessage = "Opened SpiderApp" + } + } + NSWorkspace.shared.openApplication(at: appURL, configuration: configuration) { _, error in + let errorDescription = error.map { Self.describe(error: $0) } + Task { @MainActor in + applyOpenResult(errorDescription) + } + } + return + } + + if let executablePath = Self.findSpiderAppExecutablePath() { + statusMessage = "Opening SpiderApp..." + Task.detached(priority: .userInitiated) { + do { + try Self.launchDetachedProcess(executableURL: URL(fileURLWithPath: executablePath), arguments: []) + await MainActor.run { + self.statusMessage = "Opened SpiderApp" + } + } catch { + await MainActor.run { + self.lastError = Self.describe(error: error) + } + } + } + return + } + + lastError = Self.spiderAppInstallHint() + } + + @MainActor + private func applyQuickstartProgress(_ state: QuickstartState, isBusy: Bool? = nil) { + quickstartState = state + persistQuickstartState() + statusMessage = state.lastMessage + if let isBusy { + self.isBusy = isBusy + } + } + + @MainActor + private func applyQuickstartFailure(_ state: QuickstartState, errorDescription: String) { + quickstartState = state + persistQuickstartState() + isBusy = false + lastError = errorDescription + } + + private func reconcileQuickstartState( + serviceStatus: SpiderwebServiceStatusSnapshot?, + nativeStatus: SpiderwebNativeStatusSnapshot?, + workspaces: [SpiderwebWorkspaceSummary], + mounts: [SpiderwebSavedMount], + activeMountpoints: Set + ) { + guard let quickstartState else { return } + if let reconciled = Self.reconciledQuickstartState( + from: quickstartState, + serviceStatus: serviceStatus, + nativeStatus: nativeStatus, + workspaces: workspaces, + mounts: mounts, + activeMountpoints: activeMountpoints + ) { + self.quickstartState = reconciled + persistQuickstartState() + } + } + + private static func logQuickstartMilestone( + preset: QuickstartPreset, + step: QuickstartStep, + status: String, + detail: String? + ) throws { + let detailText = detail ?? "" + NSLog("[SpiderwebQuickstart] preset=%@ step=%@ status=%@ detail=%@", preset.rawValue, step.rawValue, status, detailText) + } + + static func initialQuickstartState( + currentState: QuickstartState?, + preset: QuickstartPreset, + now: Date = Date() + ) -> QuickstartState { + var nextState = currentState ?? makeQuickstartState( + for: preset, + message: "Starting \(preset.title.lowercased())...", + now: now + ) + + if nextState.isComplete { + return makeQuickstartState( + for: preset, + message: "Restarting \(preset.title.lowercased())...", + now: now + ) + } + if nextState.preset != preset { + return makeQuickstartState( + for: preset, + message: "Switching to \(preset.title.lowercased())...", + now: now + ) + } + + nextState.preset = preset + if nextState.workspaceName?.isEmpty != false { + nextState.workspaceName = preset.workspaceName + } + if nextState.mountpoint?.isEmpty != false { + nextState.mountpoint = quickstartMountpoint(for: nextState.workspaceName ?? preset.workspaceName) + } + nextState.lastMessage = "Continuing \(preset.title.lowercased())..." + nextState.blockedReason = nil + nextState.updatedAt = now + return nextState + } + + static func reconciledQuickstartState( + from state: QuickstartState, + serviceStatus: SpiderwebServiceStatusSnapshot?, + nativeStatus: SpiderwebNativeStatusSnapshot?, + workspaces: [SpiderwebWorkspaceSummary], + mounts: [SpiderwebSavedMount], + activeMountpoints: Set, + now: Date = Date() + ) -> QuickstartState? { + var quickstartState = state + var changed = false + + if quickstartState.workspaceName?.isEmpty != false { + quickstartState.workspaceName = quickstartState.preset.workspaceName + changed = true + } + if quickstartState.mountpoint?.isEmpty != false { + quickstartState.mountpoint = quickstartMountpoint(for: quickstartState.workspaceName ?? quickstartState.preset.workspaceName) + changed = true + } + + switch quickstartState.currentStep { + case .installService where serviceStatus?.loaded == true: + quickstartState.currentStep = .installFileSystem + quickstartState.blockedReason = nil + quickstartState.lastMessage = "Background service is ready." + changed = true + case .installFileSystem where nativeStatus?.registered == true: + quickstartState.currentStep = nativeStatus?.ready == true ? .ensureWorkspace : .enableFileSystem + quickstartState.blockedReason = nil + quickstartState.lastMessage = nativeStatus?.ready == true ? "File system support is ready." : quickstartState.lastMessage + changed = true + case .enableFileSystem where nativeStatus?.ready == true: + quickstartState.currentStep = .ensureWorkspace + quickstartState.blockedReason = nil + quickstartState.lastMessage = "File system support is enabled. Resume to finish setup." + changed = true + case .ensureWorkspace: + if let workspace = quickstartWorkspaceCandidate(state: quickstartState, workspaces: workspaces) { + quickstartState.workspaceID = workspace.id + quickstartState.workspaceName = workspace.name + quickstartState.mountpoint = quickstartMountpoint(for: workspace.name) + quickstartState.currentStep = .ensureMount + quickstartState.lastMessage = "Workspace ready. Resume to create or reuse a drive." + changed = true + } + case .ensureMount: + if let workspace = quickstartWorkspaceCandidate(state: quickstartState, workspaces: workspaces), + let mount = quickstartMountCandidate(state: quickstartState, workspace: workspace, mounts: mounts) + { + quickstartState.workspaceID = workspace.id + quickstartState.workspaceName = workspace.name + quickstartState.mountID = mount.id + quickstartState.mountpoint = mount.mountpoint + quickstartState.currentStep = activeMountpoints.contains(mount.mountpoint) ? .revealDrive : .mountDrive + quickstartState.lastMessage = activeMountpoints.contains(mount.mountpoint) + ? "Drive is mounted. Resume to reveal it in Finder." + : "Drive is saved. Resume to mount it." + changed = true + } + case .mountDrive: + if let mountpoint = quickstartState.mountpoint, activeMountpoints.contains(mountpoint) { + quickstartState.currentStep = .revealDrive + quickstartState.lastMessage = "Drive is mounted. Resume to reveal it in Finder." + changed = true + } + case .complete: + if let mountpoint = quickstartState.result?.mountpoint { + quickstartState.lastMessage = activeMountpoints.contains(mountpoint) + ? "Workspace ready at \(mountpoint)" + : "Workspace ready. Mount again from the saved drive list." + } + default: + break + } + + guard changed else { return nil } + quickstartState.updatedAt = now + return quickstartState + } + + static func shouldTreatQuickstartMountFailureAsSatisfied( + errorMessage: String, + mountpoint: String, + activeMountpoints: Set + ) -> Bool { + guard activeMountpoints.contains(mountpoint) else { return false } + let normalized = errorMessage.lowercased() + return normalized.contains("already mounted") + || normalized.contains("a file with the same name already exists") + || normalized.contains("resource busy") + || normalized.contains("native mount timed out after") + } + + static func shouldCompleteQuickstartWithoutMountedDrive( + errorMessage: String, + mountpoint: String, + activeMountpoints: Set + ) -> Bool { + guard !activeMountpoints.contains(mountpoint) else { return false } + return errorMessage.lowercased().contains("native mount timed out after") + } + + static func quickstartMountBlockedSummary(mountpoint: String) -> String { + "Workspace setup finished, but macOS did not attach the Spiderweb drive at \(mountpoint). This Mac is stuck in Apple's FSKit mount path. Open SpiderApp to keep working, then retry the drive later. Rebooting often clears the stuck FSKit state." + } + + private static func shouldAttemptQuickstartMountTimeoutRecovery(errorMessage: String) -> Bool { + errorMessage.lowercased().contains("native mount timed out after") + } + + private static func waitForQuickstartMountActivation(_ mountpoint: String) -> Bool { + let deadline = Date().addingTimeInterval(quickstartMountTimeoutRecoveryWindow) + while Date() < deadline { + if fetchActiveMountpoints().contains(mountpoint) { + return true + } + usleep(quickstartMountTimeoutRecoveryPollIntervalUS) + } + return fetchActiveMountpoints().contains(mountpoint) + } + + private static func makeQuickstartState(for preset: QuickstartPreset, message: String, now: Date = Date()) -> QuickstartState { + QuickstartState( + preset: preset, + currentStep: .installService, + workspaceID: nil, + workspaceName: preset.workspaceName, + mountID: nil, + mountpoint: quickstartMountpoint(for: preset.workspaceName), + lastMessage: message, + blockedReason: nil, + updatedAt: now, + result: nil + ) + } + + static func quickstartMountpoint(for workspaceName: String) -> String { + let component = sanitizedMountComponent(workspaceName) + return "\(NSHomeDirectory())/Spiderweb/\(component.isEmpty ? "workspace" : component)" + } + + static func quickstartWorkspaceCandidate( + state: QuickstartState, + workspaces: [SpiderwebWorkspaceSummary] + ) -> SpiderwebWorkspaceSummary? { + if let workspaceID = state.workspaceID, + let workspace = workspaces.first(where: { $0.id == workspaceID && $0.kind != "system_builtin" }) { + return workspace + } + if let workspaceName = state.workspaceName, + let workspace = workspaces.first(where: { $0.name == workspaceName && $0.kind != "system_builtin" }) { + return workspace + } + return nil + } + + static func quickstartMountCandidate( + state: QuickstartState, + workspace: SpiderwebWorkspaceSummary, + mounts: [SpiderwebSavedMount] + ) -> SpiderwebSavedMount? { + if let mountID = state.mountID, + let mount = mounts.first(where: { $0.id == mountID }) { + return mount + } + if let mount = mounts.first(where: { $0.kind == .local && $0.workspaceID == workspace.id }) { + return mount + } + if let mountpoint = state.mountpoint, + let mount = mounts.first(where: { $0.kind == .local && $0.mountpoint == mountpoint }) { + return mount + } + return nil + } + + private static func ensureQuickstartWorkspace( + state: inout QuickstartState, + preset: QuickstartPreset, + accessToken: String, + existingWorkspaces: [SpiderwebWorkspaceSummary] + ) throws -> (summary: SpiderwebWorkspaceSummary, created: Bool, mountpoint: String) { + if let existing = quickstartWorkspaceCandidate(state: state, workspaces: existingWorkspaces) { + state.workspaceID = existing.id + state.workspaceName = existing.name + let mountpoint = quickstartMountpoint(for: existing.name) + state.mountpoint = mountpoint + return (existing, false, mountpoint) + } + + let payload = try jsonString([ + "name": preset.workspaceName, + "vision": preset.workspaceVision, + "template_id": preset.templateID, + "activate": false, + ]) + let result = try runCLI( + "spiderweb-control", + arguments: ["--url", localServerURL, "--auth-token", accessToken, "workspace_up", payload] + ) + let response = try extractControlPayloadObject(from: result.stdout) + let workspaceID = (response["workspace_id"] as? String) ?? + (response["project_id"] as? String) ?? + ((response["workspace"] as? [String: Any])?["workspace_id"] as? String) ?? + ((response["workspace"] as? [String: Any])?["project_id"] as? String) + + let refreshedAuth = fetchAuthStatus(revealTokens: true) + let refreshedWorkspaces = fetchLocalWorkspaces(using: refreshedAuth) + let summary = if let workspaceID, let workspace = refreshedWorkspaces.first(where: { $0.id == workspaceID }) { + workspace + } else if let workspace = refreshedWorkspaces.first(where: { $0.name == preset.workspaceName && $0.kind != "system_builtin" }) { + workspace + } else { + throw SpiderwebAppError.message("Spiderweb created a workspace, but it could not be loaded back into the app.") + } + + if !summary.isMountable { + throw SpiderwebAppError.message("Spiderweb created the workspace, but it is not mountable yet. Refresh the local service and try again.") + } + + state.workspaceID = summary.id + state.workspaceName = summary.name + let mountpoint = quickstartMountpoint(for: summary.name) + state.mountpoint = mountpoint + return (summary, true, mountpoint) + } + + private static func ensureQuickstartSavedMount( + state: inout QuickstartState, + workspace: SpiderwebWorkspaceSummary, + mountpoint: String, + existingMounts: [SpiderwebSavedMount] + ) -> (savedMount: SpiderwebSavedMount, created: Bool) { + if let existing = quickstartMountCandidate(state: state, workspace: workspace, mounts: existingMounts) { + state.mountID = existing.id + state.mountpoint = existing.mountpoint + return (existing, false) + } + + var next = SpiderwebSavedMount.makeDraft(kind: .local, homeDirectory: NSHomeDirectory()) + next.name = workspace.name + next.kind = .local + next.serverURL = localServerURL + next.workspaceID = workspace.id + next.authSource = .localRuntime + next.mountpoint = mountpoint + next.updatedAt = Date() + next.lastError = nil + next.lastMountState = .idle + + var refreshedMounts = existingMounts + refreshedMounts.append(next) + persistSavedMounts(refreshedMounts) + + state.mountID = next.id + state.mountpoint = next.mountpoint + return (next, true) + } + + private static func mountSavedMountForQuickstart( + _ mount: SpiderwebSavedMount, + authStatus: SpiderwebAuthStatusSnapshot? + ) throws -> Bool { + guard mount.kind == .local else { + throw SpiderwebAppError.message("Quickstart only supports local drives.") + } + guard let accessToken = authStatus?.accessToken else { + throw SpiderwebAppError.message("Spiderweb auth token is unavailable.") + } + do { + _ = try runCLI( + "spiderweb-fs-mount", + arguments: [ + "--workspace-url", mount.serverURL, + "--mount-backend", "native", + "--workspace-id", mount.workspaceID, + "mount", + mount.mountpoint, + ], + environment: ["SPIDERWEB_AUTH_TOKEN": accessToken], + timeout: nativeMountActionTimeout, + timeoutBehavior: .terminateProcessTree + ) + return true + } catch { + let errorMessage = describe(error: error) + let activeMountpoints = fetchActiveMountpoints() + if shouldTreatQuickstartMountFailureAsSatisfied( + errorMessage: errorMessage, + mountpoint: mount.mountpoint, + activeMountpoints: activeMountpoints + ) { + return false + } + if shouldAttemptQuickstartMountTimeoutRecovery(errorMessage: errorMessage), + waitForQuickstartMountActivation(mount.mountpoint) { + return false + } + throw error + } + } + private func loadPersistedState() { savedMounts = Self.loadSavedMounts() pairedRemoteNode = Self.loadPairedRemoteNode() + quickstartState = Self.loadQuickstartState() } private func persistSavedMounts() { Self.persistSavedMounts(savedMounts) } + private func persistQuickstartState() { + Self.persistQuickstartState(quickstartState) + } + private static func relativeTimestampLabel() -> String { RelativeDateTimeFormatter().localizedString(for: Date(), relativeTo: Date()) } @@ -1374,6 +2262,15 @@ final class SpiderwebAppController: ObservableObject { return base.appendingPathComponent(pairedNodeFilename) } + private static func deriveQuickstartStateURL() -> URL { + let base = appGroupContainerURL() ?? URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Spiderweb", isDirectory: true) + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + return base.appendingPathComponent(quickstartStateFilename) + } + private static func remoteNodeSecretAccount(for nodeID: String) -> String { "remote_node_secret:\(nodeID)" } @@ -1408,6 +2305,25 @@ final class SpiderwebAppController: ObservableObject { try? FileManager.default.removeItem(at: derivePairedNodeURL()) } + private static func loadQuickstartState() -> QuickstartState? { + let url = deriveQuickstartStateURL() + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(QuickstartState.self, from: data) + } + + private static func persistQuickstartState(_ quickstartState: QuickstartState?) { + let url = deriveQuickstartStateURL() + guard let quickstartState else { + try? FileManager.default.removeItem(at: url) + return + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(quickstartState) else { return } + try? data.write(to: url, options: .atomic) + } + private static func fetchBuildInfo() -> SpiderwebBuildInfo { if let url = Bundle.main.url(forResource: "build-info", withExtension: "json"), let data = try? Data(contentsOf: url), @@ -1512,6 +2428,79 @@ final class SpiderwebAppController: ObservableObject { return "ws://127.0.0.1:\(port)" } + private static func spiderAppInstallHint() -> String { + if let packageScript = spiderAppSourceCheckoutCandidates().first(where: { + $0.lastPathComponent == "package-macos-app.sh" && FileManager.default.isExecutableFile(atPath: $0.path) + }) { + return "SpiderApp is not available yet. Build it with \(packageScript.path), then try again." + } + return "SpiderApp is not installed on this Mac yet. Install SpiderApp.app or make `spider-gui` available, then try again." + } + + private static func findSpiderAppApplicationURL() -> URL? { + if let resolved = NSWorkspace.shared.urlForApplication(withBundleIdentifier: spiderAppBundleIdentifier) { + return resolved + } + + let candidates = [ + URL(fileURLWithPath: "/Applications/SpiderApp.app"), + URL(fileURLWithPath: "\(NSHomeDirectory())/Applications/SpiderApp.app"), + ] + spiderAppSourceCheckoutCandidates().filter { $0.pathExtension == "app" } + + for candidate in candidates { + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + } + return nil + } + + private static func findSpiderAppExecutablePath() -> String? { + let resolved = resolveExecutable(named: "spider-gui") + if resolved != "spider-gui", FileManager.default.isExecutableFile(atPath: resolved) { + return resolved + } + + for candidate in spiderAppSourceCheckoutCandidates() where candidate.pathExtension != "app" { + if FileManager.default.isExecutableFile(atPath: candidate.path) { + return candidate.path + } + } + return nil + } + + private static func spiderAppSourceCheckoutCandidates() -> [URL] { + var candidates: [URL] = [] + var seen: Set = [] + var current = Bundle.main.bundleURL.resolvingSymlinksInPath() + + while true { + if current.lastPathComponent == "Spiderweb" { + let spiderAppRoot = current.deletingLastPathComponent().appendingPathComponent("SpiderApp", isDirectory: true) + let appCandidate = spiderAppRoot.appendingPathComponent("zig-out/SpiderApp.app") + if seen.insert(appCandidate.path).inserted { + candidates.append(appCandidate) + } + let binaryCandidate = spiderAppRoot.appendingPathComponent("zig-out/bin/spider-gui") + if seen.insert(binaryCandidate.path).inserted { + candidates.append(binaryCandidate) + } + let packageScript = spiderAppRoot.appendingPathComponent("scripts/package-macos-app.sh") + if seen.insert(packageScript.path).inserted { + candidates.append(packageScript) + } + } + + let parent = current.deletingLastPathComponent() + if parent.path == current.path { + break + } + current = parent + } + + return candidates + } + private static func installFileSystemStatusMessage( nativeStatus: SpiderwebNativeStatusSnapshot?, extensionRegistrationPaths: [String] diff --git a/platform/macos/scripts/package-spiderweb-macos-release.sh b/platform/macos/scripts/package-spiderweb-macos-release.sh index dc73fe0..ce21eb0 100755 --- a/platform/macos/scripts/package-spiderweb-macos-release.sh +++ b/platform/macos/scripts/package-spiderweb-macos-release.sh @@ -379,6 +379,9 @@ write_export_options "$export_options_plist" "$app_profile_name" "$extension_pro echo "==> Building Spiderweb CLI binaries for host architecture ($HOST_ARCH)" build_host_zig_binaries "$work_root/zig-host" "$payload_root" +echo "==> Running macOS quickstart regression" +bash "$MACOS_DIR/scripts/quickstart-regression.sh" + echo "==> Archiving Spiderweb.app" xcodebuild \ -project "$PROJECT_PATH" \ diff --git a/platform/macos/scripts/quickstart-regression.sh b/platform/macos/scripts/quickstart-regression.sh new file mode 100755 index 0000000..439fc03 --- /dev/null +++ b/platform/macos/scripts/quickstart-regression.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MACOS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_DIR="$MACOS_DIR/SpiderwebFSKitApp" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cp "$APP_DIR/SpiderwebAppController.swift" "$TMPDIR/SpiderwebAppController.swift" +cp "$APP_DIR/SpiderwebSetupModel.swift" "$TMPDIR/SpiderwebSetupModel.swift" + +python3 - <<'PY' "$TMPDIR/SpiderwebAppController.swift" +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +text = path.read_text() +marker = "\n#Preview {" +idx = text.find(marker) +if idx != -1: + text = text[:idx] + "\n" +path.write_text(text) +PY + +cat > "$TMPDIR/QuickstartRegression.swift" <<'SWIFT' +import Foundation +import AppKit +import SwiftUI + +@discardableResult +func expect(_ condition: @autoclosure () -> Bool, _ message: String) -> Bool { + if !condition() { + fputs("FAIL: \(message)\n", stderr) + Foundation.exit(1) + } + return true +} + +func makeWorkspace( + id: String, + name: String, + kind: String = "normal", + mountCount: Int = 1 +) -> SpiderwebWorkspaceSummary { + SpiderwebWorkspaceSummary( + id: id, + name: name, + status: "active", + kind: kind, + mountCount: mountCount + ) +} + +func makeMount( + id: String, + name: String, + workspaceID: String, + mountpoint: String +) -> SpiderwebSavedMount { + SpiderwebSavedMount( + id: id, + name: name, + kind: .local, + serverURL: SpiderwebAppController.localServerURL, + workspaceID: workspaceID, + authSource: .localRuntime, + mountpoint: mountpoint, + createdAt: Date(timeIntervalSinceReferenceDate: 10), + updatedAt: Date(timeIntervalSinceReferenceDate: 10), + lastError: nil, + lastMountState: .idle + ) +} + +func makeServiceStatus() -> SpiderwebServiceStatusSnapshot { + SpiderwebServiceStatusSnapshot( + manager: "launchd", + unitPath: "/tmp/spiderweb.plist", + installed: true, + loaded: true, + bind: "127.0.0.1", + port: 18790, + remoteReachable: false + ) +} + +func makeNativeStatus(ready: Bool = true) -> SpiderwebNativeStatusSnapshot { + SpiderwebNativeStatusSnapshot( + registered: true, + moduleEnabled: ready, + ready: ready, + filesystemBundlePresent: true, + runtimeReady: ready, + notes: nil + ) +} + +@main +struct QuickstartRegression { + static func main() throws { + let now = Date(timeIntervalSinceReferenceDate: 1234) + + let switched = SpiderwebAppController.initialQuickstartState( + currentState: QuickstartState( + preset: .justTryIt, + currentStep: .ensureMount, + workspaceID: "ws-old", + workspaceName: "My Workspace", + mountID: "mount-old", + mountpoint: "/tmp/old", + lastMessage: "old", + blockedReason: "blocked", + updatedAt: Date(timeIntervalSinceReferenceDate: 1), + result: nil + ), + preset: .agentLab, + now: now + ) + expect(switched.preset == .agentLab, "preset switching should update the preset") + expect(switched.currentStep == .installService, "preset switching should restart from install_service") + expect(switched.workspaceID == nil && switched.mountID == nil, "preset switching should clear previous workspace and mount ids") + expect(switched.workspaceName == "Agent Lab", "preset switching should reset the workspace name") + expect(switched.mountpoint == SpiderwebAppController.quickstartMountpoint(for: "Agent Lab"), "preset switching should reset the mountpoint") + expect(switched.lastMessage == "Switching to agent lab...", "preset switching should use the switching status message") + + let resumed = SpiderwebAppController.initialQuickstartState( + currentState: QuickstartState( + preset: .justTryIt, + currentStep: .ensureWorkspace, + workspaceID: nil, + workspaceName: "My Workspace", + mountID: nil, + mountpoint: nil, + lastMessage: "blocked", + blockedReason: "approval needed", + updatedAt: Date(timeIntervalSinceReferenceDate: 2), + result: nil + ), + preset: .justTryIt, + now: now + ) + expect(resumed.currentStep == .ensureWorkspace, "same-preset resume should keep the current step") + expect(resumed.blockedReason == nil, "same-preset resume should clear the blocked reason") + expect(resumed.mountpoint == SpiderwebAppController.quickstartMountpoint(for: "My Workspace"), "same-preset resume should fill a missing mountpoint") + expect(resumed.lastMessage == "Continuing just try it...", "same-preset resume should use the continuing status message") + + let encoder = JSONEncoder() + let persisted = QuickstartState( + preset: .justTryIt, + currentStep: .enableFileSystem, + workspaceID: nil, + workspaceName: "My Workspace", + mountID: nil, + mountpoint: SpiderwebAppController.quickstartMountpoint(for: "My Workspace"), + lastMessage: "Enable Spiderweb file system in System Settings, then resume.", + blockedReason: "Open System Settings, enable Spiderweb file system, then return and resume.", + updatedAt: now, + result: nil + ) + let roundTrip = try JSONDecoder().decode(QuickstartState.self, from: encoder.encode(persisted)) + let reconciled = SpiderwebAppController.reconciledQuickstartState( + from: roundTrip, + serviceStatus: makeServiceStatus(), + nativeStatus: makeNativeStatus(), + workspaces: [], + mounts: [], + activeMountpoints: [], + now: now + ) + expect(reconciled?.currentStep == .ensureWorkspace, "a persisted blocked approval state should resume into ensure_workspace once the filesystem is ready") + expect(reconciled?.blockedReason == nil, "resume reconciliation should clear the blocked reason") + expect(reconciled?.workspaceName == "My Workspace", "resume reconciliation should preserve the workspace name") + + let unrelatedResume = SpiderwebAppController.reconciledQuickstartState( + from: QuickstartState( + preset: .justTryIt, + currentStep: .ensureWorkspace, + workspaceID: nil, + workspaceName: "My Workspace", + mountID: nil, + mountpoint: SpiderwebAppController.quickstartMountpoint(for: "My Workspace"), + lastMessage: "Workspace pending", + blockedReason: nil, + updatedAt: now, + result: nil + ), + serviceStatus: makeServiceStatus(), + nativeStatus: makeNativeStatus(), + workspaces: [makeWorkspace(id: "ws-9", name: "Another Test")], + mounts: [], + activeMountpoints: [], + now: now + ) + expect(unrelatedResume == nil, "relaunch reconciliation should not adopt an unrelated workspace") + + let mountResume = SpiderwebAppController.reconciledQuickstartState( + from: QuickstartState( + preset: .justTryIt, + currentStep: .ensureMount, + workspaceID: nil, + workspaceName: "My Workspace", + mountID: nil, + mountpoint: SpiderwebAppController.quickstartMountpoint(for: "My Workspace"), + lastMessage: "Drive pending", + blockedReason: nil, + updatedAt: now, + result: nil + ), + serviceStatus: makeServiceStatus(), + nativeStatus: makeNativeStatus(), + workspaces: [makeWorkspace(id: "ws-12", name: "My Workspace")], + mounts: [makeMount(id: "mount-12", name: "My Workspace", workspaceID: "ws-12", mountpoint: SpiderwebAppController.quickstartMountpoint(for: "My Workspace"))], + activeMountpoints: [], + now: now + ) + expect(mountResume?.currentStep == .mountDrive, "resume reconciliation should move into mount_drive when the matching saved drive exists but is not mounted") + expect(mountResume?.mountID == "mount-12", "resume reconciliation should adopt the matching saved drive") + + expect( + SpiderwebAppController.shouldTreatQuickstartMountFailureAsSatisfied( + errorMessage: "The file couldn’t be saved because a file with the same name already exists.", + mountpoint: "/tmp/workspace", + activeMountpoints: ["/tmp/workspace"] + ), + "already-mounted native mount errors should be treated as satisfied when the mountpoint is active" + ) + expect( + !SpiderwebAppController.shouldTreatQuickstartMountFailureAsSatisfied( + errorMessage: "Authentication failed.", + mountpoint: "/tmp/workspace", + activeMountpoints: ["/tmp/workspace"] + ), + "non-mount-collision errors should still fail" + ) + expect( + !SpiderwebAppController.shouldTreatQuickstartMountFailureAsSatisfied( + errorMessage: "The file couldn’t be saved because a file with the same name already exists.", + mountpoint: "/tmp/workspace", + activeMountpoints: [] + ), + "collision text without an active mountpoint should still fail" + ) + expect( + SpiderwebAppController.shouldTreatQuickstartMountFailureAsSatisfied( + errorMessage: "Native mount timed out after 20 seconds.", + mountpoint: "/tmp/workspace", + activeMountpoints: ["/tmp/workspace"] + ), + "timeout text with an active mountpoint should be treated as satisfied" + ) + expect( + SpiderwebAppController.shouldCompleteQuickstartWithoutMountedDrive( + errorMessage: "Native mount timed out after 20 seconds.", + mountpoint: "/tmp/workspace", + activeMountpoints: [] + ), + "timeout text without an active mountpoint should degrade into workspace-ready fallback" + ) + expect( + !SpiderwebAppController.shouldCompleteQuickstartWithoutMountedDrive( + errorMessage: "Authentication failed.", + mountpoint: "/tmp/workspace", + activeMountpoints: [] + ), + "non-timeout errors should still fail the quickstart" + ) + + print("quickstart regression checks passed") + } +} +SWIFT + +swiftc \ + -o "$TMPDIR/quickstart-regression" \ + "$TMPDIR/QuickstartRegression.swift" \ + "$TMPDIR/SpiderwebAppController.swift" \ + "$TMPDIR/SpiderwebSetupModel.swift" + +"$TMPDIR/quickstart-regression" diff --git a/src/acheron/control_plane.zig b/src/acheron/control_plane.zig index ab0219c..759764b 100644 --- a/src/acheron/control_plane.zig +++ b/src/acheron/control_plane.zig @@ -392,12 +392,25 @@ const core_workspace_bind_specs = [_]WorkspaceTemplateBindSpec{ .{ .bind_path = "/.spiderweb/venoms/search_code", .venom_id = "search_code", .host_role = .node }, }; +const quickstart_workspace_bind_specs = [_]WorkspaceTemplateBindSpec{ + core_workspace_bind_specs[0], + core_workspace_bind_specs[1], + core_workspace_bind_specs[2], + core_workspace_bind_specs[3], + core_workspace_bind_specs[4], +}; + const builtin_workspace_templates = [_]WorkspaceTemplateSpec{ .{ .id = default_project_template_id, .description = "Minimal external-agent workspace with canonical control and venom bindings under /.spiderweb.", .bind_specs = core_workspace_bind_specs[0..], }, + .{ + .id = "just_try_it", + .description = "Small first-run workspace with packages, runtimes, and terminal access for the initial local drive experience.", + .bind_specs = quickstart_workspace_bind_specs[0..], + }, .{ .id = "dev", .description = "Development workspace with canonical control and venom bindings under /.spiderweb.", @@ -10611,6 +10624,7 @@ test "acheron_control_plane: workspace template catalog lists dev template and r const listed = try plane.listWorkspaceTemplates(); defer allocator.free(listed); try std.testing.expect(std.mem.indexOf(u8, listed, "\"template_id\":\"minimum\"") != null); + try std.testing.expect(std.mem.indexOf(u8, listed, "\"template_id\":\"just_try_it\"") != null); try std.testing.expect(std.mem.indexOf(u8, listed, "\"template_id\":\"dev\"") != null); const fetched = try plane.getWorkspaceTemplate("{\"template_id\":\"dev\"}"); @@ -10629,6 +10643,27 @@ test "acheron_control_plane: workspace template catalog lists dev template and r try std.testing.expectError(ControlPlaneError.TemplateNotFound, plane.getWorkspaceTemplate("{\"template_id\":\"unknown\"}")); } +test "acheron_control_plane: just_try_it template seeds minimal local quickstart binds" { + const allocator = std.testing.allocator; + var plane = ControlPlane.init(allocator); + defer plane.deinit(); + + const provider_node_id = try seedManagedWorkspaceTemplateProviders(allocator, &plane); + defer allocator.free(provider_node_id); + const project_json = try plane.createWorkspace("{\"name\":\"Quickstart\",\"vision\":\"Quickstart\",\"template_id\":\"just_try_it\"}"); + defer allocator.free(project_json); + try std.testing.expect(std.mem.indexOf(u8, project_json, "\"template_id\":\"just_try_it\"") != null); + try std.testing.expect(std.mem.indexOf(u8, project_json, "\"bind_path\":\"/.spiderweb/control/workspace/mounts\"") != null); + try std.testing.expect(std.mem.indexOf(u8, project_json, "\"bind_path\":\"/.spiderweb/control/workspace/home\"") != null); + try std.testing.expect(std.mem.indexOf(u8, project_json, "\"bind_path\":\"/.spiderweb/control/packages\"") != null); + try std.testing.expect(std.mem.indexOf(u8, project_json, "\"bind_path\":\"/.spiderweb/control/runtimes\"") != null); + try std.testing.expect(std.mem.indexOf(u8, project_json, "\"bind_path\":\"/.spiderweb/venoms/terminal\"") != null); + try std.testing.expect(std.mem.indexOf(u8, project_json, "\"bind_path\":\"/.spiderweb/venoms/git\"") == null); + try std.testing.expect(std.mem.indexOf(u8, project_json, "\"bind_path\":\"/.spiderweb/venoms/search_code\"") == null); + try std.testing.expect(std.mem.indexOf(u8, project_json, "\"bind_path\":\"/.spiderweb/venoms/computer\"") == null); + try std.testing.expect(std.mem.indexOf(u8, project_json, "\"bind_path\":\"/.spiderweb/venoms/browser\"") == null); +} + test "acheron_control_plane: builtin host project seeds mounts control bind" { const allocator = std.testing.allocator; var plane = ControlPlane.init(allocator); diff --git a/src/first_run.zig b/src/first_run.zig index 1731006..42c7bec 100644 --- a/src/first_run.zig +++ b/src/first_run.zig @@ -66,7 +66,7 @@ pub fn runFirstRun(allocator: std.mem.Allocator, args: []const []const u8) !void try std.fs.File.stdout().writeAll(" Worker model: external filesystem agents\n"); try std.fs.File.stdout().writeAll("\nHost flow:\n"); try std.fs.File.stdout().writeAll(" 1. Start Spiderweb: spiderweb\n"); - try std.fs.File.stdout().writeAll(" 2. Create a mountable workspace: spiderweb-control workspace_up '{\"name\":\"Demo\",\"vision\":\"Mounted workspace\",\"template_id\":\"dev\",\"activate\":false}'\n"); + try std.fs.File.stdout().writeAll(" 2. Create a mountable workspace: spiderweb-control workspace_up '{\"name\":\"Demo\",\"vision\":\"Mounted workspace\",\"template_id\":\"just_try_it\",\"activate\":false}'\n"); if (builtin.os.tag == .macos) { try std.fs.File.stdout().writeAll(" 3. Open the Spiderweb macOS app and choose either:\n"); try std.fs.File.stdout().writeAll(" - Run Spiderweb on this Mac\n"); diff --git a/src/server.zig b/src/server.zig index f800e18..b70de5c 100644 --- a/src/server.zig +++ b/src/server.zig @@ -5029,6 +5029,7 @@ test "server: workspace template control ops expose dev catalog entries" { ); defer allocator.free(listed); try std.testing.expect(std.mem.indexOf(u8, listed, "\"template_id\":\"minimum\"") != null); + try std.testing.expect(std.mem.indexOf(u8, listed, "\"template_id\":\"just_try_it\"") != null); try std.testing.expect(std.mem.indexOf(u8, listed, "\"template_id\":\"dev\"") != null); try std.testing.expect(std.mem.indexOf(u8, listed, "\"template_id\":\"github\"") == null);