← back to Exo
add env override to macos app (#1869)
6172617b0005ff1ec009ba7f7c90f3c2a561f494 · 2026-04-10 17:39:55 +0100 · ciaranbor
## Motivation
- Let users pass/override arbitrary exo env vars from the macOS app
without a code change.
## Changes
- `ExoProcessController.swift`: `CustomEnvironmentVariable` struct +
`@Published` list persisted to `UserDefaults`, injected into the child
process after built-ins.
- `SettingsView.swift`: new **Environment** tab with add/remove rows,
trim + dedup on save, and a POSIX name validator with a warning badge.
## Why It Works
- Custom vars applied last in `makeEnvironment`, so overriding a
built-in works with no special-casing.
## Test Plan
### Manual Testing
- Set `EXO_LIBP2P_NAMESPACE` via the new UI; confirmed override in
`~/.exo/exo_log/exo.log`.
Files touched
M .gitignoreM app/EXO/EXO/ExoProcessController.swiftM app/EXO/EXO/Views/SettingsView.swift
Diff
commit 6172617b0005ff1ec009ba7f7c90f3c2a561f494
Author: ciaranbor <81697641+ciaranbor@users.noreply.github.com>
Date: Fri Apr 10 17:39:55 2026 +0100
add env override to macos app (#1869)
## Motivation
- Let users pass/override arbitrary exo env vars from the macOS app
without a code change.
## Changes
- `ExoProcessController.swift`: `CustomEnvironmentVariable` struct +
`@Published` list persisted to `UserDefaults`, injected into the child
process after built-ins.
- `SettingsView.swift`: new **Environment** tab with add/remove rows,
trim + dedup on save, and a POSIX name validator with a warning badge.
## Why It Works
- Custom vars applied last in `makeEnvironment`, so overriding a
built-in works with no special-casing.
## Test Plan
### Manual Testing
- Set `EXO_LIBP2P_NAMESPACE` via the new UI; confirmed override in
`~/.exo/exo_log/exo.log`.
---
.gitignore | 2 +
app/EXO/EXO/ExoProcessController.swift | 45 +++++++++++
app/EXO/EXO/Views/SettingsView.swift | 137 +++++++++++++++++++++++++++++++++
3 files changed, 184 insertions(+)
diff --git a/.gitignore b/.gitignore
index 3afebe2e..b162de34 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,3 +38,5 @@ bench/**/*.json
# tmp
tmp/models
+/build/exo
+/.claude/skills
diff --git a/app/EXO/EXO/ExoProcessController.swift b/app/EXO/EXO/ExoProcessController.swift
index d25236ef..bf866660 100644
--- a/app/EXO/EXO/ExoProcessController.swift
+++ b/app/EXO/EXO/ExoProcessController.swift
@@ -9,6 +9,22 @@ private let enableImageModelsKey = "EXOEnableImageModels"
private let offlineModeKey = "EXOOfflineMode"
private let fastSynchEnabledKey = "EXOFastSynchEnabled"
private let onboardingCompletedKey = "EXOOnboardingCompleted"
+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.
+struct CustomEnvironmentVariable: Codable, Identifiable, Equatable {
+ var id: UUID
+ var key: String
+ var value: String
+
+ init(id: UUID = UUID(), key: String = "", value: String = "") {
+ self.id = id
+ self.key = key
+ self.value = value
+ }
+}
@MainActor
final class ExoProcessController: ObservableObject {
@@ -90,6 +106,25 @@ final class ExoProcessController: ObservableObject {
UserDefaults.standard.set(fastSynchEnabled, forKey: fastSynchEnabledKey)
}
}
+ @Published var customEnvironmentVariables: [CustomEnvironmentVariable] = {
+ guard
+ let data = UserDefaults.standard.data(forKey: customEnvironmentVariablesKey),
+ let decoded = try? JSONDecoder().decode(
+ [CustomEnvironmentVariable].self, from: data
+ )
+ else {
+ return []
+ }
+ return decoded
+ }()
+ {
+ didSet {
+ guard let data = try? JSONEncoder().encode(customEnvironmentVariables) else {
+ return
+ }
+ UserDefaults.standard.set(data, forKey: customEnvironmentVariablesKey)
+ }
+ }
/// Fires once when EXO transitions to `.running` for the very first time (fresh install).
@Published private(set) var isFirstLaunchReady = false
@@ -328,6 +363,16 @@ final class ExoProcessController: ObservableObject {
}
environment["PATH"] = paths.joined(separator: ":")
+
+ // Apply user-defined arbitrary environment variables last so that
+ // power users can override any of the built-in keys above when
+ // necessary. Empty keys are ignored.
+ for variable in customEnvironmentVariables {
+ let trimmedKey = variable.key.trimmingCharacters(in: .whitespaces)
+ guard !trimmedKey.isEmpty else { continue }
+ environment[trimmedKey] = variable.value
+ }
+
return environment
}
diff --git a/app/EXO/EXO/Views/SettingsView.swift b/app/EXO/EXO/Views/SettingsView.swift
index 7dfc703b..5aa98a50 100644
--- a/app/EXO/EXO/Views/SettingsView.swift
+++ b/app/EXO/EXO/Views/SettingsView.swift
@@ -16,6 +16,7 @@ struct SettingsView: View {
@State private var pendingEnableImageModels = false
@State private var pendingOfflineMode = false
@State private var pendingFastSynchEnabled = false
+ @State private var pendingCustomEnvironmentVariables: [CustomEnvironmentVariable] = []
@State private var needsRestart = false
@State private var bugReportInFlight = false
@State private var bugReportMessage: String?
@@ -35,6 +36,10 @@ struct SettingsView: View {
.tabItem {
Label("Advanced", systemImage: "wrench.and.screwdriver")
}
+ environmentTab
+ .tabItem {
+ Label("Environment", systemImage: "terminal")
+ }
aboutTab
.tabItem {
Label("About", systemImage: "info.circle")
@@ -48,6 +53,7 @@ struct SettingsView: View {
pendingEnableImageModels = controller.enableImageModels
pendingOfflineMode = controller.offlineMode
pendingFastSynchEnabled = controller.fastSynchEnabled
+ pendingCustomEnvironmentVariables = controller.customEnvironmentVariables
needsRestart = false
}
}
@@ -212,6 +218,81 @@ struct SettingsView: View {
.padding()
}
+ // MARK: - Environment Tab
+
+ private var environmentTab: some View {
+ Form {
+ Section("Custom Environment Variables") {
+ Text("Passed to the exo process at launch. Override built-in defaults here.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ if pendingCustomEnvironmentVariables.isEmpty {
+ Text("No custom variables.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ } else {
+ ForEach($pendingCustomEnvironmentVariables) { $variable in
+ HStack(alignment: .center, spacing: 8) {
+ VStack(spacing: 4) {
+ TextField("key", text: $variable.key)
+ .labelsHidden()
+ .textFieldStyle(.roundedBorder)
+ .font(.system(.body, design: .monospaced))
+ TextField("value", text: $variable.value)
+ .labelsHidden()
+ .textFieldStyle(.roundedBorder)
+ .font(.system(.body, design: .monospaced))
+ }
+ VStack(spacing: 4) {
+ Button {
+ pendingCustomEnvironmentVariables.removeAll {
+ $0.id == variable.id
+ }
+ } label: {
+ Image(systemName: "minus.circle")
+ }
+ .buttonStyle(.borderless)
+ .help("Remove variable")
+ if !isValidEnvironmentVariableName(variable.key) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.orange)
+ .help(
+ "Invalid environment variable name. "
+ + "Must match [A-Za-z_][A-Za-z0-9_]*."
+ )
+ }
+ }
+ }
+ }
+ }
+
+ HStack {
+ Button {
+ pendingCustomEnvironmentVariables.append(
+ CustomEnvironmentVariable()
+ )
+ } label: {
+ Label("Add Variable", systemImage: "plus")
+ }
+ Spacer()
+ }
+ }
+
+ Section {
+ HStack {
+ Spacer()
+ Button("Save & Restart") {
+ applyEnvironmentSettings()
+ }
+ .disabled(!hasEnvironmentChanges)
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .padding()
+ }
+
// MARK: - About Tab
private var aboutTab: some View {
@@ -498,6 +579,10 @@ struct SettingsView: View {
pendingFastSynchEnabled != controller.fastSynchEnabled
}
+ private var hasEnvironmentChanges: Bool {
+ pendingCustomEnvironmentVariables != controller.customEnvironmentVariables
+ }
+
private func applyGeneralSettings() {
controller.customNamespace = pendingNamespace
controller.hfToken = pendingHFToken
@@ -516,6 +601,58 @@ struct SettingsView: View {
restartIfRunning()
}
+ private func applyEnvironmentSettings() {
+ // 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.
+ let trimmed: [CustomEnvironmentVariable] =
+ pendingCustomEnvironmentVariables.compactMap { variable in
+ let key = variable.key.trimmingCharacters(in: .whitespaces)
+ guard !key.isEmpty else { return nil }
+ return CustomEnvironmentVariable(
+ id: variable.id, key: key, value: variable.value
+ )
+ }
+
+ // De-duplicate keys, keeping the last occurrence. This matches the
+ // effective semantics of the dictionary assignment in
+ // ExoProcessController.makeEnvironment and avoids silently losing
+ // visible rows after save.
+ var seenKeys = Set<String>()
+ var deduplicatedReversed: [CustomEnvironmentVariable] = []
+ for variable in trimmed.reversed() {
+ if seenKeys.insert(variable.key).inserted {
+ deduplicatedReversed.append(variable)
+ }
+ }
+ let sanitized = Array(deduplicatedReversed.reversed())
+
+ pendingCustomEnvironmentVariables = sanitized
+ controller.customEnvironmentVariables = sanitized
+ restartIfRunning()
+ }
+
+ /// Validates a POSIX-style environment variable name:
+ /// `[A-Za-z_][A-Za-z0-9_]*`. Uses an ASCII-only charset so that
+ /// Unicode letters (e.g. `ñ`, Cyrillic) are rejected in line with what
+ /// the help tooltip advertises. Empty strings are treated as valid
+ /// here so that a freshly added blank row does not immediately look
+ /// broken; the save step filters empty keys out instead.
+ private func isValidEnvironmentVariableName(_ key: String) -> Bool {
+ if key.isEmpty { return true }
+ let headAllowed = CharacterSet(
+ charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_"
+ )
+ let tailAllowed = headAllowed.union(CharacterSet(charactersIn: "0123456789"))
+ guard let first = key.unicodeScalars.first, headAllowed.contains(first) else {
+ return false
+ }
+ for scalar in key.unicodeScalars.dropFirst() {
+ if !tailAllowed.contains(scalar) { return false }
+ }
+ return true
+ }
+
private func restartIfRunning() {
if controller.status == .running || controller.status == .starting {
controller.restart()
← 93a980a6 `just package` first builds dashboard (#1867)
·
back to Exo
·
Add Gemma 4 + VLM fixes + thinking parsing updates (#1851) 196543ce →