feat: complete onboarding rewrite, native settings, DMG polish, menu bar UX
- Full-screen Apple-style onboarding wizard (Welcome → Devices → Pick Model → Downloading → Loading → Ready) replacing the broken layered approach - Native macOS Settings window (General/Model/About tabs) replacing the cramped Advanced dropdown section - First-launch floating popout with 5s countdown that auto-opens dashboard - Clean DMG installer: dark gradient, white anti-aliased arrow, no grid - Menu bar: "Web Dashboard" with link icon, Base URL copy (localhost:52415/v1) - UI renames: Sharding Strategy, Interconnect, Load Model - Helpful "no model loaded" message instead of raw error on chat submit - Reduce launch delay from 15s to 5s Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
51b2506711
commit
01400485bf
+63
-103
@@ -15,18 +15,16 @@ struct ContentView: View {
|
||||
@EnvironmentObject private var localNetworkChecker: LocalNetworkChecker
|
||||
@EnvironmentObject private var updater: SparkleUpdater
|
||||
@EnvironmentObject private var thunderboltBridgeService: ThunderboltBridgeService
|
||||
@EnvironmentObject private var settingsWindowController: SettingsWindowController
|
||||
@State private var focusedNode: NodeViewModel?
|
||||
@State private var deletingInstanceIDs: Set<String> = []
|
||||
@State private var showAllNodes = false
|
||||
@State private var showAllInstances = false
|
||||
@State private var showAdvanced = false
|
||||
@State private var showDebugInfo = false
|
||||
@State private var bugReportInFlight = false
|
||||
@State private var bugReportMessage: String?
|
||||
@State private var uninstallInProgress = false
|
||||
@State private var pendingNamespace: String = ""
|
||||
@State private var pendingHFToken: String = ""
|
||||
@State private var pendingEnableImageModels = false
|
||||
@State private var baseURLCopied = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -275,93 +273,23 @@ struct ContentView: View {
|
||||
|
||||
private var advancedSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Advanced")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
collapseButton(isExpanded: $showAdvanced)
|
||||
HoverButton(
|
||||
title: "Settings",
|
||||
tint: .primary,
|
||||
trailingSystemImage: "gear",
|
||||
small: false
|
||||
) {
|
||||
settingsWindowController.open(controller: controller, updater: updater)
|
||||
}
|
||||
.animation(nil, value: showAdvanced)
|
||||
if showAdvanced {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Cluster Namespace")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
HStack {
|
||||
TextField("optional", text: $pendingNamespace)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption2)
|
||||
.onAppear {
|
||||
pendingNamespace = controller.customNamespace
|
||||
}
|
||||
Button("Save & Restart") {
|
||||
controller.customNamespace = pendingNamespace
|
||||
if controller.status == .running || controller.status == .starting {
|
||||
controller.restart()
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.disabled(pendingNamespace == controller.customNamespace)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("HuggingFace Token")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
HStack {
|
||||
SecureField("optional", text: $pendingHFToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption2)
|
||||
.onAppear {
|
||||
pendingHFToken = controller.hfToken
|
||||
}
|
||||
Button("Save & Restart") {
|
||||
controller.hfToken = pendingHFToken
|
||||
if controller.status == .running || controller.status == .starting {
|
||||
controller.restart()
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.disabled(pendingHFToken == controller.hfToken)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
HStack {
|
||||
Toggle(
|
||||
"Enable Image Models (experimental)", isOn: $pendingEnableImageModels
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
.font(.caption2)
|
||||
.onAppear {
|
||||
pendingEnableImageModels = controller.enableImageModels
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Save & Restart") {
|
||||
controller.enableImageModels = pendingEnableImageModels
|
||||
if controller.status == .running || controller.status == .starting {
|
||||
controller.restart()
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.disabled(pendingEnableImageModels == controller.enableImageModels)
|
||||
}
|
||||
HoverButton(title: "Check for Updates", small: true) {
|
||||
updater.checkForUpdates()
|
||||
}
|
||||
debugSection
|
||||
HoverButton(title: "Uninstall", tint: .red, small: true) {
|
||||
showUninstallConfirmationAlert()
|
||||
}
|
||||
.disabled(uninstallInProgress)
|
||||
}
|
||||
.transition(.opacity)
|
||||
HoverButton(title: "Check for Updates", small: true) {
|
||||
updater.checkForUpdates()
|
||||
}
|
||||
debugSection
|
||||
HoverButton(title: "Uninstall", tint: .red, small: true) {
|
||||
showUninstallConfirmationAlert()
|
||||
}
|
||||
.disabled(uninstallInProgress)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: showAdvanced)
|
||||
}
|
||||
|
||||
private func controlButton(title: String, tint: Color = .primary, action: @escaping () -> Void)
|
||||
@@ -371,25 +299,57 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var dashboardButton: some View {
|
||||
Button {
|
||||
guard let url = URL(string: "http://localhost:52415/") else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.imageScale(.small)
|
||||
Text("Dashboard")
|
||||
.fontWeight(.medium)
|
||||
VStack(spacing: 6) {
|
||||
Button {
|
||||
guard let url = URL(string: "http://localhost:52415/") else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "globe")
|
||||
.imageScale(.small)
|
||||
Text("Web Dashboard")
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right")
|
||||
.imageScale(.small)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color(red: 1.0, green: 0.87, blue: 0.0).opacity(0.2))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Base URL for API integrations (SillyTavern, etc.)
|
||||
HStack(spacing: 6) {
|
||||
Text("Base URL:")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text("localhost:52415/v1")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString("http://localhost:52415/v1", forType: .string)
|
||||
baseURLCopied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
baseURLCopied = false
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: baseURLCopied ? "checkmark" : "doc.on.doc")
|
||||
.imageScale(.small)
|
||||
.foregroundColor(baseURLCopied ? .green : .secondary)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Copy API base URL")
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color(red: 1.0, green: 0.87, blue: 0.0).opacity(0.2))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ struct EXOApp: App {
|
||||
@StateObject private var localNetworkChecker: LocalNetworkChecker
|
||||
@StateObject private var updater: SparkleUpdater
|
||||
@StateObject private var thunderboltBridgeService: ThunderboltBridgeService
|
||||
@StateObject private var settingsWindowController: SettingsWindowController
|
||||
private let terminationObserver: TerminationObserver
|
||||
private let firstLaunchPopout = FirstLaunchPopout()
|
||||
private let ciContext = CIContext(options: nil)
|
||||
|
||||
init() {
|
||||
@@ -43,12 +45,13 @@ struct EXOApp: App {
|
||||
_updater = StateObject(wrappedValue: updater)
|
||||
let thunderboltBridge = ThunderboltBridgeService(clusterStateService: service)
|
||||
_thunderboltBridgeService = StateObject(wrappedValue: thunderboltBridge)
|
||||
_settingsWindowController = StateObject(wrappedValue: SettingsWindowController())
|
||||
enableLaunchAtLoginIfNeeded()
|
||||
// Install LaunchDaemon to disable Thunderbolt Bridge on startup (prevents network loops)
|
||||
NetworkSetupHelper.promptAndInstallIfNeeded()
|
||||
// Check local network access periodically (warning disappears when user grants permission)
|
||||
localNetwork.startPeriodicChecking(interval: 10)
|
||||
controller.scheduleLaunch(after: 15)
|
||||
controller.scheduleLaunch(after: 5)
|
||||
service.startPolling()
|
||||
networkStatus.startPolling()
|
||||
}
|
||||
@@ -62,6 +65,12 @@ struct EXOApp: App {
|
||||
.environmentObject(localNetworkChecker)
|
||||
.environmentObject(updater)
|
||||
.environmentObject(thunderboltBridgeService)
|
||||
.environmentObject(settingsWindowController)
|
||||
.onReceive(controller.$isFirstLaunchReady) { ready in
|
||||
if ready {
|
||||
firstLaunchPopout.show()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
menuBarIcon
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Foundation
|
||||
private let customNamespaceKey = "EXOCustomNamespace"
|
||||
private let hfTokenKey = "EXOHFToken"
|
||||
private let enableImageModelsKey = "EXOEnableImageModels"
|
||||
private let hasLaunchedBeforeKey = "EXOHasLaunchedBefore"
|
||||
|
||||
@MainActor
|
||||
final class ExoProcessController: ObservableObject {
|
||||
@@ -60,6 +61,9 @@ final class ExoProcessController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires once when EXO transitions to `.running` for the very first time (fresh install).
|
||||
@Published private(set) var isFirstLaunchReady = false
|
||||
|
||||
private var process: Process?
|
||||
private var runtimeDirectoryURL: URL?
|
||||
private var pendingLaunchTask: Task<Void, Never>?
|
||||
@@ -113,6 +117,12 @@ final class ExoProcessController: ObservableObject {
|
||||
try child.run()
|
||||
process = child
|
||||
status = .running
|
||||
|
||||
// Detect first-ever launch to trigger welcome popout
|
||||
if !UserDefaults.standard.bool(forKey: hasLaunchedBeforeKey) {
|
||||
UserDefaults.standard.set(true, forKey: hasLaunchedBeforeKey)
|
||||
isFirstLaunchReady = true
|
||||
}
|
||||
} catch {
|
||||
process = nil
|
||||
status = .failed(message: "Launch error")
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// A small floating panel that appears near the menu bar on first launch,
|
||||
/// showing a countdown before auto-opening the dashboard.
|
||||
/// Inspired by LlamaBarn's menu bar popout pattern.
|
||||
@MainActor
|
||||
final class FirstLaunchPopout {
|
||||
private var panel: NSPanel?
|
||||
private var countdownTask: Task<Void, Never>?
|
||||
private static let dashboardURL = "http://localhost:52415/"
|
||||
|
||||
func show() {
|
||||
guard panel == nil else { return }
|
||||
|
||||
let hostingView = NSHostingView(
|
||||
rootView: PopoutContentView(
|
||||
onDismiss: { [weak self] in
|
||||
self?.dismiss()
|
||||
},
|
||||
onOpen: { [weak self] in
|
||||
self?.openDashboard()
|
||||
}))
|
||||
hostingView.frame = NSRect(x: 0, y: 0, width: 300, height: 120)
|
||||
|
||||
let window = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 300, height: 120),
|
||||
styleMask: [.nonactivatingPanel, .hudWindow, .utilityWindow],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.contentView = hostingView
|
||||
window.isFloatingPanel = true
|
||||
window.level = .floating
|
||||
window.hasShadow = true
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .clear
|
||||
window.isMovableByWindowBackground = false
|
||||
window.hidesOnDeactivate = false
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .stationary]
|
||||
|
||||
// Position near top-right of screen (near menu bar area)
|
||||
if let screen = NSScreen.main {
|
||||
let screenFrame = screen.visibleFrame
|
||||
let x = screenFrame.maxX - window.frame.width - 16
|
||||
let y = screenFrame.maxY - 8
|
||||
window.setFrameOrigin(NSPoint(x: x, y: y))
|
||||
}
|
||||
|
||||
window.orderFrontRegardless()
|
||||
panel = window
|
||||
|
||||
// Start countdown: auto-open dashboard after 5 seconds, then dismiss
|
||||
countdownTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||
if !Task.isCancelled {
|
||||
openDashboard()
|
||||
// Give the browser a moment, then dismiss
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
if !Task.isCancelled {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
countdownTask?.cancel()
|
||||
countdownTask = nil
|
||||
panel?.close()
|
||||
panel = nil
|
||||
}
|
||||
|
||||
private func openDashboard() {
|
||||
guard let url = URL(string: Self.dashboardURL) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI content for the first-launch popout
|
||||
private struct PopoutContentView: View {
|
||||
let onDismiss: () -> Void
|
||||
let onOpen: () -> Void
|
||||
@State private var countdown = 5
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
.imageScale(.large)
|
||||
Text("EXO is ready!")
|
||||
.font(.system(.headline, design: .default))
|
||||
.fontWeight(.semibold)
|
||||
Spacer()
|
||||
Button {
|
||||
onDismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.imageScale(.small)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Text("http://localhost:52415")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
onOpen()
|
||||
onDismiss()
|
||||
} label: {
|
||||
Text("Open Dashboard")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Opening in \(countdown)s")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.onAppear {
|
||||
startCountdown()
|
||||
}
|
||||
}
|
||||
|
||||
private func startCountdown() {
|
||||
Task {
|
||||
while countdown > 0 {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
if !Task.isCancelled {
|
||||
countdown -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Native macOS Settings window following Apple HIG.
|
||||
/// Organized into General, Model, and Advanced sections.
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject private var controller: ExoProcessController
|
||||
@EnvironmentObject private var updater: SparkleUpdater
|
||||
|
||||
@State private var pendingNamespace: String = ""
|
||||
@State private var pendingHFToken: String = ""
|
||||
@State private var pendingEnableImageModels = false
|
||||
@State private var needsRestart = false
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
generalTab
|
||||
.tabItem {
|
||||
Label("General", systemImage: "gear")
|
||||
}
|
||||
modelTab
|
||||
.tabItem {
|
||||
Label("Model", systemImage: "cube")
|
||||
}
|
||||
aboutTab
|
||||
.tabItem {
|
||||
Label("About", systemImage: "info.circle")
|
||||
}
|
||||
}
|
||||
.frame(width: 450, height: 320)
|
||||
.onAppear {
|
||||
pendingNamespace = controller.customNamespace
|
||||
pendingHFToken = controller.hfToken
|
||||
pendingEnableImageModels = controller.enableImageModels
|
||||
needsRestart = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - General Tab
|
||||
|
||||
private var generalTab: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Cluster Namespace") {
|
||||
TextField("default", text: $pendingNamespace)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 200)
|
||||
}
|
||||
Text("Nodes with the same namespace form a cluster. Leave empty for default.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("HuggingFace Token") {
|
||||
SecureField("optional", text: $pendingHFToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 200)
|
||||
}
|
||||
Text("Required for gated models. Get yours at huggingface.co/settings/tokens")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Save & Restart") {
|
||||
applyGeneralSettings()
|
||||
}
|
||||
.disabled(!hasGeneralChanges)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Model Tab
|
||||
|
||||
private var modelTab: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Enable Image Models (experimental)", isOn: $pendingEnableImageModels)
|
||||
Text("Allow text-to-image and image-to-image models in the model picker.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Save & Restart") {
|
||||
applyModelSettings()
|
||||
}
|
||||
.disabled(!hasModelChanges)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - About Tab
|
||||
|
||||
private var aboutTab: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Version") {
|
||||
Text(buildTag)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
LabeledContent("Commit") {
|
||||
Text(buildCommit)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Check for Updates") {
|
||||
updater.checkForUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var hasGeneralChanges: Bool {
|
||||
pendingNamespace != controller.customNamespace || pendingHFToken != controller.hfToken
|
||||
}
|
||||
|
||||
private var hasModelChanges: Bool {
|
||||
pendingEnableImageModels != controller.enableImageModels
|
||||
}
|
||||
|
||||
private func applyGeneralSettings() {
|
||||
controller.customNamespace = pendingNamespace
|
||||
controller.hfToken = pendingHFToken
|
||||
restartIfRunning()
|
||||
}
|
||||
|
||||
private func applyModelSettings() {
|
||||
controller.enableImageModels = pendingEnableImageModels
|
||||
restartIfRunning()
|
||||
}
|
||||
|
||||
private func restartIfRunning() {
|
||||
if controller.status == .running || controller.status == .starting {
|
||||
controller.restart()
|
||||
}
|
||||
}
|
||||
|
||||
private var buildTag: String {
|
||||
Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown"
|
||||
}
|
||||
|
||||
private var buildCommit: String {
|
||||
Bundle.main.infoDictionary?["EXOBuildCommit"] as? String ?? "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Manages a standalone native macOS Settings window.
|
||||
/// Ensures only one instance exists and brings it to front on repeated opens.
|
||||
@MainActor
|
||||
final class SettingsWindowController: ObservableObject {
|
||||
private var window: NSWindow?
|
||||
|
||||
func open(controller: ExoProcessController, updater: SparkleUpdater) {
|
||||
if let existing = window, existing.isVisible {
|
||||
existing.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
let settingsView = SettingsView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(updater)
|
||||
|
||||
let hostingView = NSHostingView(rootView: settingsView)
|
||||
|
||||
let newWindow = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 450, height: 320),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
newWindow.title = "EXO Settings"
|
||||
newWindow.contentView = hostingView
|
||||
newWindow.center()
|
||||
newWindow.isReleasedWhenClosed = false
|
||||
newWindow.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
window = newWindow
|
||||
}
|
||||
}
|
||||
@@ -1805,7 +1805,7 @@ class AppStore {
|
||||
assistantMessage.id,
|
||||
(msg) => {
|
||||
msg.content =
|
||||
"Error: No model available. Please launch an instance first.";
|
||||
"No model is loaded yet. Select a model from the sidebar to get started — it will download and load automatically.";
|
||||
},
|
||||
);
|
||||
this.syncActiveMessagesIfNeeded(targetConversationId);
|
||||
@@ -2243,7 +2243,7 @@ class AppStore {
|
||||
const modelToUse = this.getModelForRequest();
|
||||
if (!modelToUse) {
|
||||
throw new Error(
|
||||
"No model selected and no running instances available. Please launch an instance first.",
|
||||
"No model is loaded yet. Select a model from the sidebar to get started — it will download and load automatically.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1580
-1082
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,10 @@
|
||||
"""Generate a DMG background image for the EXO installer.
|
||||
|
||||
Creates a 660x400 PNG with:
|
||||
- Dark gradient background matching the EXO brand
|
||||
- Right-pointing arrow between app and Applications
|
||||
- "Drag to install" instruction text
|
||||
- Clean dark gradient background (no grid)
|
||||
- Minimal right-pointing arrow between app and Applications
|
||||
- White "Drag to install" instruction text
|
||||
- Premium style inspired by Slack/Discord/VSCode DMGs
|
||||
|
||||
Usage:
|
||||
python3 generate-background.py output.png
|
||||
@@ -12,6 +13,7 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
@@ -56,32 +58,71 @@ def _lerp(a: int, b: int, t: float) -> int:
|
||||
return max(0, min(255, int(a + (b - a) * t)))
|
||||
|
||||
|
||||
def _draw_arrow(
|
||||
def _blend(
|
||||
bg: tuple[int, int, int, int], fg: tuple[int, int, int, int]
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""Alpha-blend fg over bg."""
|
||||
fa = fg[3] / 255.0
|
||||
ba = bg[3] / 255.0
|
||||
oa = fa + ba * (1 - fa)
|
||||
if oa == 0:
|
||||
return (0, 0, 0, 0)
|
||||
r = int((fg[0] * fa + bg[0] * ba * (1 - fa)) / oa)
|
||||
g = int((fg[1] * fa + bg[1] * ba * (1 - fa)) / oa)
|
||||
b = int((fg[2] * fa + bg[2] * ba * (1 - fa)) / oa)
|
||||
return (r, g, b, int(oa * 255))
|
||||
|
||||
|
||||
def _draw_smooth_arrow(
|
||||
pixels: list[list[tuple[int, int, int, int]]],
|
||||
cx: int,
|
||||
cy: int,
|
||||
color: tuple[int, int, int, int],
|
||||
color: tuple[int, int, int],
|
||||
) -> None:
|
||||
"""Draw a simple right-pointing arrow at (cx, cy)."""
|
||||
# Shaft: horizontal line
|
||||
shaft_len = 60
|
||||
shaft_thickness = 3
|
||||
for dx in range(-shaft_len, shaft_len + 1):
|
||||
for dy in range(-shaft_thickness, shaft_thickness + 1):
|
||||
y = cy + dy
|
||||
x = cx + dx
|
||||
if 0 <= y < len(pixels) and 0 <= x < len(pixels[0]):
|
||||
pixels[y][x] = color
|
||||
"""Draw a clean, minimal right-pointing arrow with anti-aliased edges."""
|
||||
height = len(pixels)
|
||||
width = len(pixels[0]) if height > 0 else 0
|
||||
|
||||
# Arrowhead: triangle pointing right
|
||||
head_size = 20
|
||||
for i in range(head_size):
|
||||
spread = int(i * 1.2)
|
||||
x = cx + shaft_len + i
|
||||
for dy in range(-spread, spread + 1):
|
||||
y = cy + dy
|
||||
if 0 <= y < len(pixels) and 0 <= x < len(pixels[0]):
|
||||
pixels[y][x] = color
|
||||
# Slim shaft
|
||||
shaft_half_len = 32
|
||||
shaft_half_thickness = 1.5
|
||||
|
||||
for x in range(cx - shaft_half_len, cx + shaft_half_len + 1):
|
||||
for y_offset_10 in range(-30, 31): # sub-pixel sampling
|
||||
y_f = cy + y_offset_10 / 10.0
|
||||
yi = int(y_f)
|
||||
dist = abs(y_f - cy)
|
||||
if dist <= shaft_half_thickness and 0 <= yi < height and 0 <= x < width:
|
||||
# Smooth edge falloff
|
||||
edge_dist = shaft_half_thickness - dist
|
||||
alpha = min(1.0, edge_dist * 2.0)
|
||||
a = int(alpha * 200)
|
||||
fg = (color[0], color[1], color[2], a)
|
||||
pixels[yi][x] = _blend(pixels[yi][x], fg)
|
||||
|
||||
# Chevron arrowhead (> shape) — clean and modern
|
||||
head_x = cx + shaft_half_len - 2
|
||||
head_size = 14
|
||||
stroke_width = 2.0
|
||||
|
||||
for i_10 in range(head_size * 10):
|
||||
t = i_10 / 10.0
|
||||
# Top arm of chevron
|
||||
px_f = head_x + t
|
||||
py_top_f = cy - t
|
||||
# Bottom arm of chevron
|
||||
py_bot_f = cy + t
|
||||
|
||||
for dy_10 in range(int(-stroke_width * 10), int(stroke_width * 10) + 1):
|
||||
for arm_py in [py_top_f, py_bot_f]:
|
||||
py = int(arm_py + dy_10 / 10.0)
|
||||
px = int(px_f)
|
||||
if 0 <= py < height and 0 <= px < width:
|
||||
dist = abs(dy_10 / 10.0)
|
||||
alpha = max(0.0, min(1.0, (stroke_width - dist) * 1.5))
|
||||
a = int(alpha * 200)
|
||||
fg = (color[0], color[1], color[2], a)
|
||||
pixels[py][px] = _blend(pixels[py][px], fg)
|
||||
|
||||
|
||||
def _draw_text_pixel(
|
||||
@@ -93,7 +134,7 @@ def _draw_text_pixel(
|
||||
scale: int = 1,
|
||||
) -> None:
|
||||
"""Draw simple pixel text. Limited to the phrase 'Drag to install'."""
|
||||
# 5x7 pixel font for uppercase + lowercase letters we need
|
||||
# 5x7 pixel font for the letters we need
|
||||
glyphs: dict[str, list[str]] = {
|
||||
"D": ["1110 ", "1 01", "1 01", "1 01", "1 01", "1 01", "1110 "],
|
||||
"r": [" ", " ", " 110 ", "1 ", "1 ", "1 ", "1 "],
|
||||
@@ -122,7 +163,7 @@ def _draw_text_pixel(
|
||||
py = y + row_idx * scale + sy
|
||||
px = cursor_x + col_idx * scale + sx
|
||||
if 0 <= py < len(pixels) and 0 <= px < len(pixels[0]):
|
||||
pixels[py][px] = color
|
||||
pixels[py][px] = _blend(pixels[py][px], color)
|
||||
cursor_x += (len(glyph[0]) + 1) * scale
|
||||
|
||||
|
||||
@@ -130,33 +171,33 @@ def generate_background(output_path: str) -> None:
|
||||
"""Generate the DMG background image."""
|
||||
width, height = 660, 400
|
||||
|
||||
# Build gradient background: dark gray to slightly darker
|
||||
top_color = (30, 30, 30) # #1e1e1e — matches exo-dark-gray
|
||||
bottom_color = (18, 18, 18) # #121212 — matches exo-black
|
||||
# Clean dark gradient — no grid, no noise
|
||||
top_color = (28, 28, 30) # macOS dark mode surface
|
||||
bottom_color = (16, 16, 18) # slightly darker at bottom
|
||||
|
||||
pixels: list[list[tuple[int, int, int, int]]] = []
|
||||
for y in range(height):
|
||||
t = y / (height - 1)
|
||||
r = _lerp(top_color[0], bottom_color[0], t)
|
||||
g = _lerp(top_color[1], bottom_color[1], t)
|
||||
b = _lerp(top_color[2], bottom_color[2], t)
|
||||
pixels.append([(r, g, b, 255)] * width)
|
||||
|
||||
# Draw subtle grid lines (matches the exo dashboard grid)
|
||||
grid_color = (40, 40, 40, 255)
|
||||
for y in range(0, height, 40):
|
||||
row: list[tuple[int, int, int, int]] = []
|
||||
for x in range(width):
|
||||
pixels[y][x] = grid_color
|
||||
for x in range(0, width, 40):
|
||||
for y in range(height):
|
||||
pixels[y][x] = grid_color
|
||||
# Radial vignette: slightly brighter in center for depth
|
||||
dx = (x - width / 2) / (width / 2)
|
||||
dy = (y - height * 0.45) / (height / 2)
|
||||
dist = math.sqrt(dx * dx + dy * dy)
|
||||
vignette = max(0.0, 1.0 - dist * 0.3)
|
||||
|
||||
# Draw the arrow in the center (between app icon at x=155 and Applications at x=505)
|
||||
arrow_color = (200, 180, 50, 255) # EXO yellow
|
||||
_draw_arrow(pixels, width // 2, 200, arrow_color)
|
||||
r = _lerp(top_color[0], bottom_color[0], t) + int(vignette * 6)
|
||||
g = _lerp(top_color[1], bottom_color[1], t) + int(vignette * 6)
|
||||
b = _lerp(top_color[2], bottom_color[2], t) + int(vignette * 6)
|
||||
row.append((min(255, r), min(255, g), min(255, b), 255))
|
||||
pixels.append(row)
|
||||
|
||||
# Draw instruction text below the arrow
|
||||
text_color = (150, 150, 150, 200)
|
||||
# Draw a clean, minimal arrow between app (x=155) and Applications (x=505)
|
||||
arrow_color = (255, 255, 255) # white — clean and visible
|
||||
_draw_smooth_arrow(pixels, width // 2, 200, arrow_color)
|
||||
|
||||
# Draw instruction text below the arrow — white for readability
|
||||
text_color = (255, 255, 255, 140) # white, semi-transparent
|
||||
_draw_text_pixel(pixels, 268, 310, "Drag to install", text_color, scale=2)
|
||||
|
||||
# Write PNG
|
||||
|
||||
Reference in New Issue
Block a user