← back to Exo
Better environment variables in MacOS app (#1901)
3eead80238f6face220614f6c07eed827de168c3 · 2026-04-15 21:14:52 +0100 · rltakashige
## Motivation
Closes #1858
## Changes
<!-- Describe what you changed in detail -->
## Why It Works
<!-- Explain why your approach solves the problem -->
## Test Plan
### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->
### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
Files touched
M app/EXO/EXO/ExoProcessController.swiftM app/EXO/EXO/Views/SettingsView.swiftM app/EXO/EXO/Views/SettingsWindowController.swiftM src/exo/download/download_utils.py
Diff
commit 3eead80238f6face220614f6c07eed827de168c3
Author: rltakashige <rl.takashige@gmail.com>
Date: Wed Apr 15 21:14:52 2026 +0100
Better environment variables in MacOS app (#1901)
## Motivation
Closes #1858
## Changes
<!-- Describe what you changed in detail -->
## Why It Works
<!-- Explain why your approach solves the problem -->
## Test Plan
### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->
### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
---
app/EXO/EXO/ExoProcessController.swift | 46 ++++++++++++-
app/EXO/EXO/Views/SettingsView.swift | 88 +++++++++++++++++++++---
app/EXO/EXO/Views/SettingsWindowController.swift | 2 +-
src/exo/download/download_utils.py | 17 +++--
4 files changed, 133 insertions(+), 20 deletions(-)
diff --git a/app/EXO/EXO/ExoProcessController.swift b/app/EXO/EXO/ExoProcessController.swift
index bf866660..ff756379 100644
--- a/app/EXO/EXO/ExoProcessController.swift
+++ b/app/EXO/EXO/ExoProcessController.swift
@@ -9,11 +9,14 @@ private let enableImageModelsKey = "EXOEnableImageModels"
private let offlineModeKey = "EXOOfflineMode"
private let fastSynchEnabledKey = "EXOFastSynchEnabled"
private let onboardingCompletedKey = "EXOOnboardingCompleted"
+private let defaultModelsDirKey = "EXODefaultModelsDir"
+private let additionalModelsDirsKey = "EXOAdditionalModelsDirs"
+private let readOnlyModelsDirsKey = "EXOReadOnlyModelsDirs"
private let customEnvironmentVariablesKey = "EXOCustomEnvironmentVariables"
/// A user-defined environment variable that is injected into the exo child
-/// process at launch. Used to pass arbitrary key/value settings to exo
-/// without having to add first-class UI for each one.
+/// process at launch. Used as an escape hatch for env vars that don't have
+/// first-class typed UI in Settings.
struct CustomEnvironmentVariable: Codable, Identifiable, Equatable {
var id: UUID
var key: String
@@ -106,6 +109,30 @@ final class ExoProcessController: ObservableObject {
UserDefaults.standard.set(fastSynchEnabled, forKey: fastSynchEnabledKey)
}
}
+ @Published var defaultModelsDir: String = {
+ return UserDefaults.standard.string(forKey: defaultModelsDirKey) ?? ""
+ }()
+ {
+ didSet {
+ UserDefaults.standard.set(defaultModelsDir, forKey: defaultModelsDirKey)
+ }
+ }
+ @Published var additionalModelsDirs: String = {
+ return UserDefaults.standard.string(forKey: additionalModelsDirsKey) ?? ""
+ }()
+ {
+ didSet {
+ UserDefaults.standard.set(additionalModelsDirs, forKey: additionalModelsDirsKey)
+ }
+ }
+ @Published var readOnlyModelsDirs: String = {
+ return UserDefaults.standard.string(forKey: readOnlyModelsDirsKey) ?? ""
+ }()
+ {
+ didSet {
+ UserDefaults.standard.set(readOnlyModelsDirs, forKey: readOnlyModelsDirsKey)
+ }
+ }
@Published var customEnvironmentVariables: [CustomEnvironmentVariable] = {
guard
let data = UserDefaults.standard.data(forKey: customEnvironmentVariablesKey),
@@ -364,8 +391,21 @@ final class ExoProcessController: ObservableObject {
environment["PATH"] = paths.joined(separator: ":")
+ let trimmedDefaultModelsDir = defaultModelsDir.trimmingCharacters(in: .whitespaces)
+ if !trimmedDefaultModelsDir.isEmpty {
+ environment["EXO_DEFAULT_MODELS_DIR"] = trimmedDefaultModelsDir
+ }
+ let trimmedAdditionalModelsDirs = additionalModelsDirs.trimmingCharacters(in: .whitespaces)
+ if !trimmedAdditionalModelsDirs.isEmpty {
+ environment["EXO_MODELS_DIRS"] = trimmedAdditionalModelsDirs
+ }
+ let trimmedReadOnlyModelsDirs = readOnlyModelsDirs.trimmingCharacters(in: .whitespaces)
+ if !trimmedReadOnlyModelsDirs.isEmpty {
+ environment["EXO_MODELS_READ_ONLY_DIRS"] = trimmedReadOnlyModelsDirs
+ }
+
// Apply user-defined arbitrary environment variables last so that
- // power users can override any of the built-in keys above when
+ // power users can override any of the typed fields above when
// necessary. Empty keys are ignored.
for variable in customEnvironmentVariables {
let trimmedKey = variable.key.trimmingCharacters(in: .whitespaces)
diff --git a/app/EXO/EXO/Views/SettingsView.swift b/app/EXO/EXO/Views/SettingsView.swift
index 5aa98a50..52184e30 100644
--- a/app/EXO/EXO/Views/SettingsView.swift
+++ b/app/EXO/EXO/Views/SettingsView.swift
@@ -16,6 +16,9 @@ struct SettingsView: View {
@State private var pendingEnableImageModels = false
@State private var pendingOfflineMode = false
@State private var pendingFastSynchEnabled = false
+ @State private var pendingDefaultModelsDir: String = ""
+ @State private var pendingAdditionalModelsDirs: String = ""
+ @State private var pendingReadOnlyModelsDirs: String = ""
@State private var pendingCustomEnvironmentVariables: [CustomEnvironmentVariable] = []
@State private var needsRestart = false
@State private var bugReportInFlight = false
@@ -45,7 +48,7 @@ struct SettingsView: View {
Label("About", systemImage: "info.circle")
}
}
- .frame(width: 450, height: 400)
+ .frame(width: 640, height: 560)
.onAppear {
pendingNamespace = controller.customNamespace
pendingHFToken = controller.hfToken
@@ -53,6 +56,9 @@ struct SettingsView: View {
pendingEnableImageModels = controller.enableImageModels
pendingOfflineMode = controller.offlineMode
pendingFastSynchEnabled = controller.fastSynchEnabled
+ pendingDefaultModelsDir = controller.defaultModelsDir
+ pendingAdditionalModelsDirs = controller.additionalModelsDirs
+ pendingReadOnlyModelsDirs = controller.readOnlyModelsDirs
pendingCustomEnvironmentVariables = controller.customEnvironmentVariables
needsRestart = false
}
@@ -64,9 +70,9 @@ struct SettingsView: View {
Form {
Section {
LabeledContent("Cluster Namespace") {
- TextField("default", text: $pendingNamespace)
+ TextField("", text: $pendingNamespace, prompt: Text("default"))
.textFieldStyle(.roundedBorder)
- .frame(width: 200)
+ .frame(width: 260)
}
Text("Nodes with the same namespace form a cluster. Leave empty for default.")
.font(.caption)
@@ -75,9 +81,9 @@ struct SettingsView: View {
Section {
LabeledContent("HuggingFace Token") {
- SecureField("optional", text: $pendingHFToken)
+ SecureField("", text: $pendingHFToken, prompt: Text("optional"))
.textFieldStyle(.roundedBorder)
- .frame(width: 200)
+ .frame(width: 260)
}
Text("Required for gated models. Get yours at huggingface.co/settings/tokens")
.font(.caption)
@@ -86,9 +92,9 @@ struct SettingsView: View {
Section {
LabeledContent("HuggingFace Endpoint") {
- TextField("default", text: $pendingHFEndpoint)
+ TextField("", text: $pendingHFEndpoint, prompt: Text("default"))
.textFieldStyle(.roundedBorder)
- .frame(width: 200)
+ .frame(width: 260)
}
Text("Defaults to huggingface.co. Use a mirror (e.g. hf-mirror.com) for China.")
.font(.caption)
@@ -222,10 +228,57 @@ struct SettingsView: View {
private var environmentTab: some View {
Form {
- Section("Custom Environment Variables") {
- Text("Passed to the exo process at launch. Override built-in defaults here.")
+ Section("Models Directories") {
+ LabeledContent("Default Models Directory") {
+ TextField(
+ "",
+ text: $pendingDefaultModelsDir,
+ prompt: Text("~/.exo/models")
+ )
+ .textFieldStyle(.roundedBorder)
+ .font(.system(.body, design: .monospaced))
+ .frame(width: 260)
+ }
+ Text("Sets EXO_DEFAULT_MODELS_DIR. Where models are downloaded.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ LabeledContent("Additional Directories") {
+ TextField(
+ "",
+ text: $pendingAdditionalModelsDirs,
+ prompt: Text("optional, colon-separated")
+ )
+ .textFieldStyle(.roundedBorder)
+ .font(.system(.body, design: .monospaced))
+ .frame(width: 260)
+ }
+ Text("Sets EXO_MODELS_DIRS. Extra writable model directories.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ LabeledContent("Read-Only Directories") {
+ TextField(
+ "",
+ text: $pendingReadOnlyModelsDirs,
+ prompt: Text("optional, colon-separated")
+ )
+ .textFieldStyle(.roundedBorder)
+ .font(.system(.body, design: .monospaced))
+ .frame(width: 260)
+ }
+ Text("Sets EXO_MODELS_READ_ONLY_DIRS. Never written to.")
.font(.caption)
.foregroundColor(.secondary)
+ }
+
+ Section("Custom Environment Variables") {
+ Text(
+ "Escape hatch for env vars that don't have typed fields above. "
+ + "Values here override the typed fields on conflict."
+ )
+ .font(.caption)
+ .foregroundColor(.secondary)
if pendingCustomEnvironmentVariables.isEmpty {
Text("No custom variables.")
@@ -580,7 +633,10 @@ struct SettingsView: View {
}
private var hasEnvironmentChanges: Bool {
- pendingCustomEnvironmentVariables != controller.customEnvironmentVariables
+ pendingDefaultModelsDir != controller.defaultModelsDir
+ || pendingAdditionalModelsDirs != controller.additionalModelsDirs
+ || pendingReadOnlyModelsDirs != controller.readOnlyModelsDirs
+ || pendingCustomEnvironmentVariables != controller.customEnvironmentVariables
}
private func applyGeneralSettings() {
@@ -602,6 +658,17 @@ struct SettingsView: View {
}
private func applyEnvironmentSettings() {
+ controller.defaultModelsDir = pendingDefaultModelsDir.trimmingCharacters(
+ in: .whitespaces)
+ controller.additionalModelsDirs = pendingAdditionalModelsDirs.trimmingCharacters(
+ in: .whitespaces)
+ controller.readOnlyModelsDirs = pendingReadOnlyModelsDirs.trimmingCharacters(
+ in: .whitespaces)
+
+ pendingDefaultModelsDir = controller.defaultModelsDir
+ pendingAdditionalModelsDirs = controller.additionalModelsDirs
+ pendingReadOnlyModelsDirs = controller.readOnlyModelsDirs
+
// Trim whitespace from keys and drop empty ones so that the stored
// form matches what is actually injected into the child process and
// hasEnvironmentChanges doesn't show a stale diff after save.
@@ -629,6 +696,7 @@ struct SettingsView: View {
pendingCustomEnvironmentVariables = sanitized
controller.customEnvironmentVariables = sanitized
+
restartIfRunning()
}
diff --git a/app/EXO/EXO/Views/SettingsWindowController.swift b/app/EXO/EXO/Views/SettingsWindowController.swift
index 98517f92..9ede2a4c 100644
--- a/app/EXO/EXO/Views/SettingsWindowController.swift
+++ b/app/EXO/EXO/Views/SettingsWindowController.swift
@@ -30,7 +30,7 @@ final class SettingsWindowController: ObservableObject {
let hostingView = NSHostingView(rootView: settingsView)
let newWindow = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 450, height: 400),
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 560),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
diff --git a/src/exo/download/download_utils.py b/src/exo/download/download_utils.py
index 818efca3..de4b92e0 100644
--- a/src/exo/download/download_utils.py
+++ b/src/exo/download/download_utils.py
@@ -824,13 +824,18 @@ async def download_shard(
if "/" in f.path or not f.path.endswith(".safetensors")
]
- # Pick a writable directory with enough free space
+ # Pick a writable directory with enough free space.
total_size = sum(f.size or 0 for f in filtered_file_list)
- models_dir = (
- select_download_dir(total_size) if not skip_download else EXO_DEFAULT_MODELS_DIR
- )
- target_dir = models_dir / model_id.normalize()
- if not skip_download:
+ if skip_download:
+ existing = resolve_existing_model(model_id)
+ target_dir = (
+ existing
+ if existing is not None
+ else EXO_DEFAULT_MODELS_DIR / model_id.normalize()
+ )
+ else:
+ models_dir = select_download_dir(total_size)
+ target_dir = models_dir / model_id.normalize()
await aios.makedirs(target_dir, exist_ok=True)
file_progress: dict[str, RepoFileDownloadProgress] = {}
← 87329c80 Add usage stats to tool calls and handle multiple tool calls
·
back to Exo
·
Allow copying on dashboard even on HTTP (#1902) 058bb082 →