diff --git a/app/EXO/EXO/EXOApp.swift b/app/EXO/EXO/EXOApp.swift index 9eeed041..e9ba45c5 100644 --- a/app/EXO/EXO/EXOApp.swift +++ b/app/EXO/EXO/EXOApp.swift @@ -68,6 +68,9 @@ struct EXOApp: App { .environmentObject(settingsWindowController) .onReceive(controller.$isFirstLaunchReady) { ready in if ready { + firstLaunchPopout.onComplete = { [weak controller] in + controller?.markOnboardingCompleted() + } firstLaunchPopout.show() } } diff --git a/app/EXO/EXO/ExoProcessController.swift b/app/EXO/EXO/ExoProcessController.swift index c67a77b5..2350c118 100644 --- a/app/EXO/EXO/ExoProcessController.swift +++ b/app/EXO/EXO/ExoProcessController.swift @@ -5,7 +5,7 @@ import Foundation private let customNamespaceKey = "EXOCustomNamespace" private let hfTokenKey = "EXOHFToken" private let enableImageModelsKey = "EXOEnableImageModels" -private let hasLaunchedBeforeKey = "EXOHasLaunchedBefore" +private let onboardingCompletedKey = "EXOOnboardingCompleted" @MainActor final class ExoProcessController: ObservableObject { @@ -118,9 +118,8 @@ final class ExoProcessController: ObservableObject { process = child status = .running - // Detect first-ever launch to trigger welcome popout - if !UserDefaults.standard.bool(forKey: hasLaunchedBeforeKey) { - UserDefaults.standard.set(true, forKey: hasLaunchedBeforeKey) + // Show welcome popout if onboarding was never completed + if !UserDefaults.standard.bool(forKey: onboardingCompletedKey) { isFirstLaunchReady = true } } catch { @@ -174,6 +173,17 @@ final class ExoProcessController: ObservableObject { launch() } + /// Mark onboarding as completed (user interacted with the welcome popout). + func markOnboardingCompleted() { + UserDefaults.standard.set(true, forKey: onboardingCompletedKey) + } + + /// Reset onboarding so the welcome popout appears on next launch. + func resetOnboarding() { + UserDefaults.standard.removeObject(forKey: onboardingCompletedKey) + isFirstLaunchReady = false + } + func scheduleLaunch(after seconds: TimeInterval) { cancelPendingLaunch() let start = max(1, Int(ceil(seconds))) diff --git a/app/EXO/EXO/Views/FirstLaunchPopout.swift b/app/EXO/EXO/Views/FirstLaunchPopout.swift index 65eb90ed..45042ca8 100644 --- a/app/EXO/EXO/Views/FirstLaunchPopout.swift +++ b/app/EXO/EXO/Views/FirstLaunchPopout.swift @@ -9,16 +9,22 @@ final class FirstLaunchPopout { private var countdownTask: Task? private static let dashboardURL = "http://localhost:52415/" + /// Called when the user completes onboarding (clicks Open Dashboard or dismisses). + var onComplete: (() -> Void)? + func show() { guard panel == nil else { return } let hostingView = NSHostingView( rootView: WelcomeCalloutView( + countdownDuration: 30, onDismiss: { [weak self] in + self?.onComplete?() self?.dismiss() }, onOpen: { [weak self] in self?.openDashboard() + self?.onComplete?() self?.dismiss() })) hostingView.frame = NSRect(x: 0, y: 0, width: 280, height: 100) @@ -60,15 +66,12 @@ final class FirstLaunchPopout { window.animator().alphaValue = 1 } - // Auto-open dashboard after 5s then dismiss + // Auto-open dashboard after 30s but keep popout visible until user interacts countdownTask = Task { - try? await Task.sleep(nanoseconds: 5_000_000_000) + try? await Task.sleep(nanoseconds: 30_000_000_000) if !Task.isCancelled { openDashboard() - try? await Task.sleep(nanoseconds: 800_000_000) - if !Task.isCancelled { - dismiss() - } + onComplete?() } } } @@ -99,12 +102,20 @@ final class FirstLaunchPopout { /// Minimal welcome callout — friendly pointer, not a wall of text. private struct WelcomeCalloutView: View { + let countdownDuration: Int let onDismiss: () -> Void let onOpen: () -> Void - @State private var countdown = 5 + @State private var countdown: Int @State private var timerTask: Task? @State private var appeared = false + init(countdownDuration: Int, onDismiss: @escaping () -> Void, onOpen: @escaping () -> Void) { + self.countdownDuration = countdownDuration + self.onDismiss = onDismiss + self.onOpen = onOpen + self._countdown = State(initialValue: countdownDuration) + } + var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top) { @@ -142,7 +153,7 @@ private struct WelcomeCalloutView: View { Spacer() if countdown > 0 { - Text("Opening in \(countdown)s") + Text("Auto-opens in \(countdown)s") .font(.system(.caption2, design: .default)) .foregroundColor(.secondary.opacity(0.6)) .monospacedDigit()