← back to Exo
exo: open source mac app and build process
379744fe5c2ca7c847768d87b3bdb0f0988ae554 · 2025-12-18 16:36:09 +0000 · Jake Hillion
Files touched
M .gitignoreA app/EXO/EXO.xcodeproj/project.pbxprojA app/EXO/EXO.xcodeproj/project.xcworkspace/contents.xcworkspacedataA app/EXO/EXO.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolvedA app/EXO/EXO.xcodeproj/xcshareddata/xcschemes/EXO.xcschemeA app/EXO/EXO.xcodeproj/xcuserdata/samikhan.xcuserdatad/xcschemes/xcschememanagement.plistA app/EXO/EXO/Assets.xcassets/AccentColor.colorset/Contents.jsonA app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/1024-mac.pngA app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/128-mac.pngA app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/16-mac.pngA app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac 1.pngA app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac.pngA app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac 1.pngA app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac.pngA app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac 1.pngA app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac.pngA app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/64-mac.pngA app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/Contents.jsonA app/EXO/EXO/Assets.xcassets/Contents.jsonA app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/Contents.jsonA app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/exo-logo-hq-square-transparent-bg.pngA app/EXO/EXO/ContentView.swiftA app/EXO/EXO/EXO.entitlementsA app/EXO/EXO/EXOApp.swiftA app/EXO/EXO/ExoProcessController.swiftA app/EXO/EXO/Info.plistA app/EXO/EXO/Models/ClusterState.swiftA app/EXO/EXO/Preview Content/Preview Assets.xcassets/Contents.jsonA app/EXO/EXO/Services/BugReportService.swiftA app/EXO/EXO/Services/ClusterStateService.swiftA app/EXO/EXO/Services/NetworkSetupHelper.swiftA app/EXO/EXO/Services/NetworkStatusService.swiftA app/EXO/EXO/ViewModels/InstanceViewModel.swiftA app/EXO/EXO/ViewModels/NodeViewModel.swiftA app/EXO/EXO/Views/InstanceRowView.swiftA app/EXO/EXO/Views/NodeDetailView.swiftA app/EXO/EXO/Views/NodeRowView.swiftA app/EXO/EXO/Views/TopologyMiniView.swiftA app/EXO/EXOTests/EXOTests.swiftA app/EXO/EXOUITests/EXOUITests.swiftA app/EXO/EXOUITests/EXOUITestsLaunchTests.swift
Diff
commit 379744fe5c2ca7c847768d87b3bdb0f0988ae554
Author: Jake Hillion <jake@hillion.co.uk>
Date: Thu Dec 18 16:36:09 2025 +0000
exo: open source mac app and build process
---
.gitignore | 3 +
app/EXO/EXO.xcodeproj/project.pbxproj | 602 +++++++++++++++++++++
.../project.xcworkspace/contents.xcworkspacedata | 7 +
.../xcshareddata/swiftpm/Package.resolved | 15 +
.../xcshareddata/xcschemes/EXO.xcscheme | 114 ++++
.../xcschemes/xcschememanagement.plist | 32 ++
.../AccentColor.colorset/Contents.json | 11 +
.../AppIcon.appiconset/1024-mac.png | Bin 0 -> 15062 bytes
.../Assets.xcassets/AppIcon.appiconset/128-mac.png | Bin 0 -> 2462 bytes
.../Assets.xcassets/AppIcon.appiconset/16-mac.png | Bin 0 -> 324 bytes
.../AppIcon.appiconset/256-mac 1.png | Bin 0 -> 4522 bytes
.../Assets.xcassets/AppIcon.appiconset/256-mac.png | Bin 0 -> 4522 bytes
.../AppIcon.appiconset/32-mac 1.png | Bin 0 -> 758 bytes
.../Assets.xcassets/AppIcon.appiconset/32-mac.png | Bin 0 -> 758 bytes
.../AppIcon.appiconset/512-mac 1.png | Bin 0 -> 8624 bytes
.../Assets.xcassets/AppIcon.appiconset/512-mac.png | Bin 0 -> 8624 bytes
.../Assets.xcassets/AppIcon.appiconset/64-mac.png | Bin 0 -> 1326 bytes
.../AppIcon.appiconset/Contents.json | 68 +++
app/EXO/EXO/Assets.xcassets/Contents.json | 6 +
.../menubar-icon.imageset/Contents.json | 21 +
.../exo-logo-hq-square-transparent-bg.png | Bin 0 -> 4619 bytes
app/EXO/EXO/ContentView.swift | 494 +++++++++++++++++
app/EXO/EXO/EXO.entitlements | 12 +
app/EXO/EXO/EXOApp.swift | 240 ++++++++
app/EXO/EXO/ExoProcessController.swift | 232 ++++++++
app/EXO/EXO/Info.plist | 12 +
app/EXO/EXO/Models/ClusterState.swift | 369 +++++++++++++
.../Preview Assets.xcassets/Contents.json | 6 +
app/EXO/EXO/Services/BugReportService.swift | 539 ++++++++++++++++++
app/EXO/EXO/Services/ClusterStateService.swift | 145 +++++
app/EXO/EXO/Services/NetworkSetupHelper.swift | 187 +++++++
app/EXO/EXO/Services/NetworkStatusService.swift | 174 ++++++
app/EXO/EXO/ViewModels/InstanceViewModel.swift | 246 +++++++++
app/EXO/EXO/ViewModels/NodeViewModel.swift | 121 +++++
app/EXO/EXO/Views/InstanceRowView.swift | 332 ++++++++++++
app/EXO/EXO/Views/NodeDetailView.swift | 35 ++
app/EXO/EXO/Views/NodeRowView.swift | 31 ++
app/EXO/EXO/Views/TopologyMiniView.swift | 172 ++++++
app/EXO/EXOTests/EXOTests.swift | 17 +
app/EXO/EXOUITests/EXOUITests.swift | 43 ++
app/EXO/EXOUITests/EXOUITestsLaunchTests.swift | 33 ++
41 files changed, 4319 insertions(+)
diff --git a/.gitignore b/.gitignore
index befc8b3b..ec6faec1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,10 @@ digest.txt
# xcode / macos
*.xcuserstate
+*.xcuserdata
+*.xcuserdatad/
**/.DS_Store
+app/EXO/build/
# rust
diff --git a/app/EXO/EXO.xcodeproj/project.pbxproj b/app/EXO/EXO.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..47553174
--- /dev/null
+++ b/app/EXO/EXO.xcodeproj/project.pbxproj
@@ -0,0 +1,602 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ E0140D402ED1F909001F3171 /* exo in Resources */ = {isa = PBXBuildFile; fileRef = E0140D3F2ED1F909001F3171 /* exo */; };
+ E0A1B1002F5A000100000003 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E0A1B1002F5A000100000002 /* Sparkle */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ E0140D212ED1F79B001F3171 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = E0140D072ED1F79A001F3171 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = E0140D0E2ED1F79A001F3171;
+ remoteInfo = EXO;
+ };
+ E0140D2B2ED1F79B001F3171 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = E0140D072ED1F79A001F3171 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = E0140D0E2ED1F79A001F3171;
+ remoteInfo = EXO;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+ E0140D0F2ED1F79A001F3171 /* EXO.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EXO.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ E0140D202ED1F79B001F3171 /* EXOTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EXOTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ E0140D2A2ED1F79B001F3171 /* EXOUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EXOUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ E0140D3F2ED1F909001F3171 /* exo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = exo; path = ../../dist/exo; sourceTree = "<group>"; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ E0140D112ED1F79A001F3171 /* EXO */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = EXO;
+ sourceTree = "<group>";
+ };
+ E0140D232ED1F79B001F3171 /* EXOTests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = EXOTests;
+ sourceTree = "<group>";
+ };
+ E0140D2D2ED1F79B001F3171 /* EXOUITests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = EXOUITests;
+ sourceTree = "<group>";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ E0140D0C2ED1F79A001F3171 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ E0A1B1002F5A000100000003 /* Sparkle in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E0140D1D2ED1F79B001F3171 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E0140D272ED1F79B001F3171 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ E0140D062ED1F79A001F3171 = {
+ isa = PBXGroup;
+ children = (
+ E0140D3F2ED1F909001F3171 /* exo */,
+ E0140D112ED1F79A001F3171 /* EXO */,
+ E0140D232ED1F79B001F3171 /* EXOTests */,
+ E0140D2D2ED1F79B001F3171 /* EXOUITests */,
+ E0140D102ED1F79A001F3171 /* Products */,
+ );
+ sourceTree = "<group>";
+ };
+ E0140D102ED1F79A001F3171 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ E0140D0F2ED1F79A001F3171 /* EXO.app */,
+ E0140D202ED1F79B001F3171 /* EXOTests.xctest */,
+ E0140D2A2ED1F79B001F3171 /* EXOUITests.xctest */,
+ );
+ name = Products;
+ sourceTree = "<group>";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ E0140D0E2ED1F79A001F3171 /* EXO */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = E0140D342ED1F79B001F3171 /* Build configuration list for PBXNativeTarget "EXO" */;
+ buildPhases = (
+ E0140D0B2ED1F79A001F3171 /* Sources */,
+ E0140D0C2ED1F79A001F3171 /* Frameworks */,
+ E0140D0D2ED1F79A001F3171 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ E0140D112ED1F79A001F3171 /* EXO */,
+ );
+ name = EXO;
+ packageProductDependencies = (
+ E0A1B1002F5A000100000002 /* Sparkle */,
+ );
+ productName = EXO;
+ productReference = E0140D0F2ED1F79A001F3171 /* EXO.app */;
+ productType = "com.apple.product-type.application";
+ };
+ E0140D1F2ED1F79B001F3171 /* EXOTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = E0140D372ED1F79B001F3171 /* Build configuration list for PBXNativeTarget "EXOTests" */;
+ buildPhases = (
+ E0140D1C2ED1F79B001F3171 /* Sources */,
+ E0140D1D2ED1F79B001F3171 /* Frameworks */,
+ E0140D1E2ED1F79B001F3171 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ E0140D222ED1F79B001F3171 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ E0140D232ED1F79B001F3171 /* EXOTests */,
+ );
+ name = EXOTests;
+ packageProductDependencies = (
+ );
+ productName = EXOTests;
+ productReference = E0140D202ED1F79B001F3171 /* EXOTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ E0140D292ED1F79B001F3171 /* EXOUITests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = E0140D3A2ED1F79B001F3171 /* Build configuration list for PBXNativeTarget "EXOUITests" */;
+ buildPhases = (
+ E0140D262ED1F79B001F3171 /* Sources */,
+ E0140D272ED1F79B001F3171 /* Frameworks */,
+ E0140D282ED1F79B001F3171 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ E0140D2C2ED1F79B001F3171 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ E0140D2D2ED1F79B001F3171 /* EXOUITests */,
+ );
+ name = EXOUITests;
+ packageProductDependencies = (
+ );
+ productName = EXOUITests;
+ productReference = E0140D2A2ED1F79B001F3171 /* EXOUITests.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ E0140D072ED1F79A001F3171 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1610;
+ LastUpgradeCheck = 1610;
+ TargetAttributes = {
+ E0140D0E2ED1F79A001F3171 = {
+ CreatedOnToolsVersion = 16.1;
+ };
+ E0140D1F2ED1F79B001F3171 = {
+ CreatedOnToolsVersion = 16.1;
+ TestTargetID = E0140D0E2ED1F79A001F3171;
+ };
+ E0140D292ED1F79B001F3171 = {
+ CreatedOnToolsVersion = 16.1;
+ TestTargetID = E0140D0E2ED1F79A001F3171;
+ };
+ };
+ };
+ buildConfigurationList = E0140D0A2ED1F79A001F3171 /* Build configuration list for PBXProject "EXO" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = E0140D062ED1F79A001F3171;
+ minimizedProjectReferenceProxies = 1;
+ packageReferences = (
+ E0A1B1002F5A000100000001 /* XCRemoteSwiftPackageReference "Sparkle" */,
+ );
+ preferredProjectObjectVersion = 77;
+ productRefGroup = E0140D102ED1F79A001F3171 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ E0140D0E2ED1F79A001F3171 /* EXO */,
+ E0140D1F2ED1F79B001F3171 /* EXOTests */,
+ E0140D292ED1F79B001F3171 /* EXOUITests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ E0140D0D2ED1F79A001F3171 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ E0140D402ED1F909001F3171 /* exo in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E0140D1E2ED1F79B001F3171 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E0140D282ED1F79B001F3171 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ E0140D0B2ED1F79A001F3171 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E0140D1C2ED1F79B001F3171 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E0140D262ED1F79B001F3171 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ E0140D222ED1F79B001F3171 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = E0140D0E2ED1F79A001F3171 /* EXO */;
+ targetProxy = E0140D212ED1F79B001F3171 /* PBXContainerItemProxy */;
+ };
+ E0140D2C2ED1F79B001F3171 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = E0140D0E2ED1F79A001F3171 /* EXO */;
+ targetProxy = E0140D2B2ED1F79B001F3171 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ E0140D322ED1F79B001F3171 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 15.1;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ E0140D332ED1F79B001F3171 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 15.1;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ };
+ name = Release;
+ };
+ E0140D352ED1F79B001F3171 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = EXO/EXO.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "\"EXO/Preview Content\"";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = EXO/Info.plist;
+ INFOPLIST_KEY_LSUIElement = YES;
+ INFOPLIST_KEY_EXOBuildCommit = "$(EXO_BUILD_COMMIT)";
+ INFOPLIST_KEY_EXOBuildTag = "$(EXO_BUILD_TAG)";
+ INFOPLIST_KEY_NSAppleEventsUsageDescription = "EXO needs to run a signed network setup script with administrator privileges.";
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ INFOPLIST_KEY_SUEnableAutomaticChecks = YES;
+ INFOPLIST_KEY_SUFeedURL = "$(SPARKLE_FEED_URL)";
+ INFOPLIST_KEY_SUPublicEDKey = "$(SPARKLE_ED25519_PUBLIC)";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0.1;
+ PRODUCT_BUNDLE_IDENTIFIER = exolabs.EXO;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ EXO_BUILD_COMMIT = local;
+ EXO_BUILD_TAG = dev;
+ SPARKLE_ED25519_PUBLIC = "";
+ SPARKLE_FEED_URL = "";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ E0140D362ED1F79B001F3171 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = EXO/EXO.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "\"EXO/Preview Content\"";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = EXO/Info.plist;
+ INFOPLIST_KEY_LSUIElement = YES;
+ INFOPLIST_KEY_EXOBuildCommit = "$(EXO_BUILD_COMMIT)";
+ INFOPLIST_KEY_EXOBuildTag = "$(EXO_BUILD_TAG)";
+ INFOPLIST_KEY_NSAppleEventsUsageDescription = "EXO needs to run a signed network setup script with administrator privileges.";
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ INFOPLIST_KEY_SUEnableAutomaticChecks = YES;
+ INFOPLIST_KEY_SUFeedURL = "$(SPARKLE_FEED_URL)";
+ INFOPLIST_KEY_SUPublicEDKey = "$(SPARKLE_ED25519_PUBLIC)";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0.1;
+ PRODUCT_BUNDLE_IDENTIFIER = exolabs.EXO;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ EXO_BUILD_COMMIT = local;
+ EXO_BUILD_TAG = dev;
+ SPARKLE_ED25519_PUBLIC = "";
+ SPARKLE_FEED_URL = "";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ E0140D382ED1F79B001F3171 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 15.1;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = exolabs.EXOTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EXO.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EXO";
+ };
+ name = Debug;
+ };
+ E0140D392ED1F79B001F3171 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 15.1;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = exolabs.EXOTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EXO.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EXO";
+ };
+ name = Release;
+ };
+ E0140D3B2ED1F79B001F3171 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = exolabs.EXOUITests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TEST_TARGET_NAME = EXO;
+ };
+ name = Debug;
+ };
+ E0140D3C2ED1F79B001F3171 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = exolabs.EXOUITests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TEST_TARGET_NAME = EXO;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ E0140D0A2ED1F79A001F3171 /* Build configuration list for PBXProject "EXO" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ E0140D322ED1F79B001F3171 /* Debug */,
+ E0140D332ED1F79B001F3171 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ E0140D342ED1F79B001F3171 /* Build configuration list for PBXNativeTarget "EXO" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ E0140D352ED1F79B001F3171 /* Debug */,
+ E0140D362ED1F79B001F3171 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ E0140D372ED1F79B001F3171 /* Build configuration list for PBXNativeTarget "EXOTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ E0140D382ED1F79B001F3171 /* Debug */,
+ E0140D392ED1F79B001F3171 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ E0140D3A2ED1F79B001F3171 /* Build configuration list for PBXNativeTarget "EXOUITests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ E0140D3B2ED1F79B001F3171 /* Debug */,
+ E0140D3C2ED1F79B001F3171 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+ E0A1B1002F5A000100000001 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 2.8.1;
+ };
+ };
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ E0A1B1002F5A000100000002 /* Sparkle */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = E0A1B1002F5A000100000001 /* XCRemoteSwiftPackageReference "Sparkle" */;
+ productName = Sparkle;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = E0140D072ED1F79A001F3171 /* Project object */;
+}
diff --git a/app/EXO/EXO.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/EXO/EXO.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..919434a6
--- /dev/null
+++ b/app/EXO/EXO.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+ version = "1.0">
+ <FileRef
+ location = "self:">
+ </FileRef>
+</Workspace>
diff --git a/app/EXO/EXO.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/app/EXO/EXO.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 00000000..351bd259
--- /dev/null
+++ b/app/EXO/EXO.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,15 @@
+{
+ "originHash" : "5751fcbe53b64441ed73aceb16987d6b3fc3ebc666cb9ec2de1f6a2d441f2515",
+ "pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle.git",
+ "state" : {
+ "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6",
+ "version" : "2.8.1"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/app/EXO/EXO.xcodeproj/xcshareddata/xcschemes/EXO.xcscheme b/app/EXO/EXO.xcodeproj/xcshareddata/xcschemes/EXO.xcscheme
new file mode 100644
index 00000000..167f16ea
--- /dev/null
+++ b/app/EXO/EXO.xcodeproj/xcshareddata/xcschemes/EXO.xcscheme
@@ -0,0 +1,114 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+ LastUpgradeVersion = "1610"
+ version = "1.7">
+ <BuildAction
+ parallelizeBuildables = "YES"
+ buildImplicitDependencies = "YES"
+ buildArchitectures = "Automatic">
+ <BuildActionEntries>
+ <BuildActionEntry
+ buildForTesting = "YES"
+ buildForRunning = "YES"
+ buildForProfiling = "YES"
+ buildForArchiving = "YES"
+ buildForAnalyzing = "YES">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "E0140D0E2ED1F79A001F3171"
+ BuildableName = "EXO.app"
+ BlueprintName = "EXO"
+ ReferencedContainer = "container:EXO.xcodeproj">
+ </BuildableReference>
+ </BuildActionEntry>
+ </BuildActionEntries>
+ </BuildAction>
+ <TestAction
+ buildConfiguration = "Debug"
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+ shouldUseLaunchSchemeArgsEnv = "YES"
+ shouldAutocreateTestPlan = "YES">
+ <Testables>
+ <TestableReference
+ skipped = "NO"
+ parallelizable = "YES">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "E0140D1F2ED1F79B001F3171"
+ BuildableName = "EXOTests.xctest"
+ BlueprintName = "EXOTests"
+ ReferencedContainer = "container:EXO.xcodeproj">
+ </BuildableReference>
+ </TestableReference>
+ <TestableReference
+ skipped = "NO"
+ parallelizable = "YES">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "E0140D292ED1F79B001F3171"
+ BuildableName = "EXOUITests.xctest"
+ BlueprintName = "EXOUITests"
+ ReferencedContainer = "container:EXO.xcodeproj">
+ </BuildableReference>
+ </TestableReference>
+ </Testables>
+ </TestAction>
+ <LaunchAction
+ buildConfiguration = "Debug"
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+ launchStyle = "0"
+ useCustomWorkingDirectory = "NO"
+ ignoresPersistentStateOnLaunch = "NO"
+ debugDocumentVersioning = "YES"
+ debugServiceExtension = "internal"
+ allowLocationSimulation = "YES">
+ <BuildableProductRunnable
+ runnableDebuggingMode = "0">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "E0140D0E2ED1F79A001F3171"
+ BuildableName = "EXO.app"
+ BlueprintName = "EXO"
+ ReferencedContainer = "container:EXO.xcodeproj">
+ </BuildableReference>
+ </BuildableProductRunnable>
+ <EnvironmentVariables>
+ <EnvironmentVariable
+ key = "EXO_BUG_AWS_ACCESS_KEY_ID"
+ value = "AKIAYEKP5EMXTOBYDGHX"
+ isEnabled = "YES">
+ </EnvironmentVariable>
+ <EnvironmentVariable
+ key = "EXO_BUG_AWS_SECRET_ACCESS_KEY"
+ value = "Ep5gIlUZ1o8ssTLQwmyy34yPGfTPEYQ4evE8NdPE"
+ isEnabled = "YES">
+ </EnvironmentVariable>
+ </EnvironmentVariables>
+ </LaunchAction>
+ <ProfileAction
+ buildConfiguration = "Release"
+ shouldUseLaunchSchemeArgsEnv = "YES"
+ savedToolIdentifier = ""
+ useCustomWorkingDirectory = "NO"
+ debugDocumentVersioning = "YES">
+ <BuildableProductRunnable
+ runnableDebuggingMode = "0">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "E0140D0E2ED1F79A001F3171"
+ BuildableName = "EXO.app"
+ BlueprintName = "EXO"
+ ReferencedContainer = "container:EXO.xcodeproj">
+ </BuildableReference>
+ </BuildableProductRunnable>
+ </ProfileAction>
+ <AnalyzeAction
+ buildConfiguration = "Debug">
+ </AnalyzeAction>
+ <ArchiveAction
+ buildConfiguration = "Release"
+ revealArchiveInOrganizer = "YES">
+ </ArchiveAction>
+</Scheme>
diff --git a/app/EXO/EXO.xcodeproj/xcuserdata/samikhan.xcuserdatad/xcschemes/xcschememanagement.plist b/app/EXO/EXO.xcodeproj/xcuserdata/samikhan.xcuserdatad/xcschemes/xcschememanagement.plist
new file mode 100644
index 00000000..9dd86925
--- /dev/null
+++ b/app/EXO/EXO.xcodeproj/xcuserdata/samikhan.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>SchemeUserState</key>
+ <dict>
+ <key>EXO.xcscheme_^#shared#^_</key>
+ <dict>
+ <key>orderHint</key>
+ <integer>0</integer>
+ </dict>
+ </dict>
+ <key>SuppressBuildableAutocreation</key>
+ <dict>
+ <key>E0140D0E2ED1F79A001F3171</key>
+ <dict>
+ <key>primary</key>
+ <true/>
+ </dict>
+ <key>E0140D1F2ED1F79B001F3171</key>
+ <dict>
+ <key>primary</key>
+ <true/>
+ </dict>
+ <key>E0140D292ED1F79B001F3171</key>
+ <dict>
+ <key>primary</key>
+ <true/>
+ </dict>
+ </dict>
+</dict>
+</plist>
diff --git a/app/EXO/EXO/Assets.xcassets/AccentColor.colorset/Contents.json b/app/EXO/EXO/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 00000000..eb878970
--- /dev/null
+++ b/app/EXO/EXO/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/1024-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/1024-mac.png
new file mode 100644
index 00000000..a78c7bf2
Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/1024-mac.png differ
diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/128-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/128-mac.png
new file mode 100644
index 00000000..500fab16
Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/128-mac.png differ
diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/16-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/16-mac.png
new file mode 100644
index 00000000..0310fb30
Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/16-mac.png differ
diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac 1.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac 1.png
new file mode 100644
index 00000000..7380a155
Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac 1.png differ
diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac.png
new file mode 100644
index 00000000..7380a155
Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac.png differ
diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac 1.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac 1.png
new file mode 100644
index 00000000..2c2cc206
Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac 1.png differ
diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac.png
new file mode 100644
index 00000000..2c2cc206
Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac.png differ
diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac 1.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac 1.png
new file mode 100644
index 00000000..84e5ca77
Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac 1.png differ
diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac.png
new file mode 100644
index 00000000..84e5ca77
Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac.png differ
diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/64-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/64-mac.png
new file mode 100644
index 00000000..210db023
Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/64-mac.png differ
diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..0373f0e8
--- /dev/null
+++ b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images" : [
+ {
+ "filename" : "16-mac.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "32-mac.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "32-mac 1.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "32x32"
+ },
+ {
+ "filename" : "64-mac.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "32x32"
+ },
+ {
+ "filename" : "128-mac.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "128x128"
+ },
+ {
+ "filename" : "256-mac 1.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "128x128"
+ },
+ {
+ "filename" : "256-mac.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "256x256"
+ },
+ {
+ "filename" : "512-mac.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "256x256"
+ },
+ {
+ "filename" : "512-mac 1.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "512x512"
+ },
+ {
+ "filename" : "1024-mac.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "512x512"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/app/EXO/EXO/Assets.xcassets/Contents.json b/app/EXO/EXO/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/app/EXO/EXO/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/Contents.json b/app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/Contents.json
new file mode 100644
index 00000000..96f76627
--- /dev/null
+++ b/app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "exo-logo-hq-square-transparent-bg.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/exo-logo-hq-square-transparent-bg.png b/app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/exo-logo-hq-square-transparent-bg.png
new file mode 100644
index 00000000..5b463dc7
Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/exo-logo-hq-square-transparent-bg.png differ
diff --git a/app/EXO/EXO/ContentView.swift b/app/EXO/EXO/ContentView.swift
new file mode 100644
index 00000000..774b2b09
--- /dev/null
+++ b/app/EXO/EXO/ContentView.swift
@@ -0,0 +1,494 @@
+//
+// ContentView.swift
+// EXO
+//
+// Created by Sami Khan on 2025-11-22.
+//
+
+import AppKit
+import SwiftUI
+
+struct ContentView: View {
+ @EnvironmentObject private var controller: ExoProcessController
+ @EnvironmentObject private var stateService: ClusterStateService
+ @EnvironmentObject private var networkStatusService: NetworkStatusService
+ @EnvironmentObject private var updater: SparkleUpdater
+ @State private var focusedNode: NodeViewModel?
+ @State private var deletingInstanceIDs: Set<String> = []
+ @State private var showAllNodes = false
+ @State private var showAllInstances = false
+ @State private var showDebugInfo = false
+ @State private var bugReportInFlight = false
+ @State private var bugReportMessage: String?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ statusSection
+ if shouldShowClusterDetails {
+ Divider()
+ overviewSection
+ topologySection
+ nodeSection
+ }
+ if shouldShowInstances {
+ instanceSection
+ }
+ Spacer(minLength: 0)
+ controlButtons
+ }
+ .animation(.easeInOut(duration: 0.3), value: shouldShowClusterDetails)
+ .animation(.easeInOut(duration: 0.3), value: shouldShowInstances)
+ .padding()
+ .frame(width: 340)
+ .onAppear {
+ Task {
+ await networkStatusService.refresh()
+ }
+ }
+ }
+
+ private var topologySection: some View {
+ Group {
+ if let topology = stateService.latestSnapshot?.topologyViewModel(), !topology.nodes.isEmpty {
+ TopologyMiniView(topology: topology)
+ }
+ }
+ }
+
+ private var statusSection: some View {
+ HStack(spacing: 8) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("EXO")
+ .font(.headline)
+ Text(controller.status.displayText)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ if let detail = statusDetailText {
+ Text(detail)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+ }
+ Spacer()
+ Toggle("", isOn: processToggleBinding)
+ .toggleStyle(.switch)
+ .labelsHidden()
+ }
+ }
+
+ private var overviewSection: some View {
+ Group {
+ if let snapshot = stateService.latestSnapshot {
+ let overview = snapshot.overview()
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ VStack(alignment: .leading) {
+ Text("\(overview.usedRam, specifier: "%.0f") / \(overview.totalRam, specifier: "%.0f") GB")
+ .font(.headline)
+ Text("Memory")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ VStack(alignment: .leading) {
+ Text("\(overview.nodeCount)")
+ .font(.headline)
+ Text("Nodes")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ VStack(alignment: .leading) {
+ Text("\(overview.instanceCount)")
+ .font(.headline)
+ Text("Instances")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ } else {
+ Text("Connecting to EXO…")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+
+ private var nodeSection: some View {
+ Group {
+ if let nodes = stateService.latestSnapshot?.nodeViewModels(), !nodes.isEmpty {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text("Nodes")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Text("(\(nodes.count))")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ collapseButton(isExpanded: $showAllNodes)
+ }
+ .animation(nil, value: showAllNodes)
+ if showAllNodes {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(nodes) { node in
+ NodeRowView(node: node)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 4)
+ .background(.regularMaterial.opacity(0.6))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ }
+ }
+ .transition(.opacity)
+ }
+ }
+ .animation(.easeInOut(duration: 0.25), value: showAllNodes)
+ }
+ }
+ }
+
+ private var instanceSection: some View {
+ Group {
+ if let instances = stateService.latestSnapshot?.instanceViewModels() {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text("Instances")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Text("(\(instances.count))")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ if !instances.isEmpty {
+ collapseButton(isExpanded: $showAllInstances)
+ }
+ }
+ .animation(nil, value: showAllInstances)
+ if showAllInstances, !instances.isEmpty {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(instances) { instance in
+ InstanceRowView(instance: instance)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 4)
+ .background(.regularMaterial.opacity(0.6))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ }
+ }
+ .transition(.opacity)
+ }
+ }
+ .animation(.easeInOut(duration: 0.25), value: showAllInstances)
+ }
+ }
+ }
+
+ private var controlButtons: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ if controller.status != .stopped {
+ dashboardButton
+ Divider()
+ .padding(.vertical, 8)
+ } else {
+ Divider()
+ .padding(.vertical, 4)
+ }
+ controlButton(title: "Check for Updates") {
+ updater.checkForUpdates()
+ }
+ .padding(.bottom, 8)
+ debugSection
+ .padding(.bottom, 8)
+ controlButton(title: "Quit", tint: .secondary) {
+ controller.stop()
+ NSApplication.shared.terminate(nil)
+ }
+ }
+ }
+
+ private func controlButton(title: String, tint: Color = .primary, action: @escaping () -> Void) -> some View {
+ HoverButton(title: title, tint: tint, trailingSystemImage: nil, action: action)
+ }
+
+ private var dashboardButton: some View {
+ Button {
+ guard let url = URL(string: "http://localhost:8000/") else { return }
+ NSWorkspace.shared.open(url)
+ } label: {
+ HStack {
+ Image(systemName: "arrow.up.right.square")
+ .imageScale(.small)
+ Text("Dashboard")
+ .fontWeight(.medium)
+ Spacer()
+ }
+ .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)
+ }
+
+ private func collapseButton(isExpanded: Binding<Bool>) -> some View {
+ Button {
+ isExpanded.wrappedValue.toggle()
+ } label: {
+ Label(isExpanded.wrappedValue ? "Hide" : "Show All", systemImage: isExpanded.wrappedValue ? "chevron.up" : "chevron.down")
+ .labelStyle(.titleAndIcon)
+ .contentTransition(.symbolEffect(.replace))
+ }
+ .buttonStyle(.plain)
+ .font(.caption2)
+ }
+ private func instancesToDisplay(_ instances: [InstanceViewModel]) -> [InstanceViewModel] {
+ if showAllInstances {
+ return instances
+ }
+ return []
+ }
+
+ private var shouldShowClusterDetails: Bool {
+ controller.status != .stopped
+ }
+
+ private var shouldShowInstances: Bool {
+ controller.status != .stopped
+ }
+
+ private var statusDetailText: String? {
+ switch controller.status {
+ case .failed(let message):
+ return message
+ case .stopped:
+ if let countdown = controller.launchCountdownSeconds {
+ return "Launching in \(countdown)s"
+ }
+ return nil
+ default:
+ if let countdown = controller.launchCountdownSeconds {
+ return "Launching in \(countdown)s"
+ }
+ if let lastError = controller.lastError {
+ return lastError
+ }
+ if let message = stateService.lastActionMessage {
+ return message
+ }
+ return nil
+ }
+ }
+
+ private var thunderboltStatusText: String {
+ switch networkStatusService.status.thunderboltBridgeState {
+ case .some(.disabled):
+ return "Thunderbolt Bridge: Disabled"
+ case .some(.deleted):
+ return "Thunderbolt Bridge: Deleted"
+ case .some(.enabled):
+ return "Thunderbolt Bridge: Enabled"
+ case nil:
+ return "Thunderbolt Bridge: Unknown"
+ }
+ }
+
+ private var thunderboltStatusColor: Color {
+ switch networkStatusService.status.thunderboltBridgeState {
+ case .some(.disabled), .some(.deleted):
+ return .green
+ case .some(.enabled):
+ return .red
+ case nil:
+ return .secondary
+ }
+ }
+
+ private var interfaceIpList: some View {
+ let statuses = networkStatusService.status.interfaceStatuses
+ return VStack(alignment: .leading, spacing: 1) {
+ Text("Interfaces (en0–en7):")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ if statuses.isEmpty {
+ Text(" Unknown")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ } else {
+ ForEach(statuses, id: \.interfaceName) { status in
+ let ipText = status.ipAddress ?? "No IP"
+ Text(" \(status.interfaceName): \(ipText)")
+ .font(.caption2)
+ .foregroundColor(status.ipAddress == nil ? .red : .green)
+ }
+ }
+ }
+ }
+
+ private var debugSection: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ Text("Debug Info")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ collapseButton(isExpanded: $showDebugInfo)
+ }
+ .animation(nil, value: showDebugInfo)
+ if showDebugInfo {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Version: \(buildTag)")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ Text("Commit: \(buildCommit)")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ Text(thunderboltStatusText)
+ .font(.caption2)
+ .foregroundColor(thunderboltStatusColor)
+ interfaceIpList
+ sendBugReportButton
+ .padding(.top, 6)
+ }
+ .transition(.opacity)
+ }
+ }
+ .animation(.easeInOut(duration: 0.25), value: showDebugInfo)
+ }
+
+ private var sendBugReportButton: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Button {
+ Task {
+ await sendBugReport()
+ }
+ } label: {
+ HStack {
+ if bugReportInFlight {
+ ProgressView()
+ .scaleEffect(0.6)
+ }
+ Text("Send Bug Report")
+ .font(.caption)
+ .fontWeight(.semibold)
+ Spacer()
+ }
+ .padding(.vertical, 6)
+ .padding(.horizontal, 8)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color.accentColor.opacity(0.12))
+ )
+ }
+ .buttonStyle(.plain)
+ .disabled(bugReportInFlight)
+
+ if let message = bugReportMessage {
+ Text(message)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+
+ private var processToggleBinding: Binding<Bool> {
+ Binding(
+ get: {
+ switch controller.status {
+ case .running, .starting:
+ return true
+ case .stopped, .failed:
+ return false
+ }
+ },
+ set: { isOn in
+ if isOn {
+ stateService.resetTransientState()
+ stateService.startPolling()
+ controller.cancelPendingLaunch()
+ controller.launchIfNeeded()
+ } else {
+ stateService.stopPolling()
+ controller.stop()
+ stateService.resetTransientState()
+ }
+ }
+ )
+ }
+
+ private func bindingForNode(_ node: NodeViewModel) -> Binding<NodeViewModel?> {
+ Binding<NodeViewModel?>(
+ get: {
+ focusedNode?.id == node.id ? focusedNode : nil
+ },
+ set: { newValue in
+ if newValue == nil {
+ focusedNode = nil
+ } else {
+ focusedNode = newValue
+ }
+ }
+ )
+ }
+
+ private func sendBugReport() async {
+ bugReportInFlight = true
+ bugReportMessage = "Collecting logs..."
+ let service = BugReportService()
+ do {
+ let outcome = try await service.sendReport(isManual: true)
+ bugReportMessage = outcome.message
+ } catch {
+ bugReportMessage = error.localizedDescription
+ }
+ bugReportInFlight = false
+ }
+
+ private var buildTag: String {
+ Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown"
+ }
+
+ private var buildCommit: String {
+ Bundle.main.infoDictionary?["EXOBuildCommit"] as? String ?? "unknown"
+ }
+}
+
+private struct HoverButton: View {
+ let title: String
+ let tint: Color
+ let trailingSystemImage: String?
+ let action: () -> Void
+
+ @State private var isHovering = false
+
+ var body: some View {
+ Button(action: action) {
+ HStack {
+ Text(title)
+ Spacer()
+ if let systemName = trailingSystemImage {
+ Image(systemName: systemName)
+ .imageScale(.small)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 6)
+ .padding(.horizontal, 8)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(
+ isHovering
+ ? Color.accentColor.opacity(0.1)
+ : Color.clear
+ )
+ )
+ }
+ .buttonStyle(.plain)
+ .foregroundColor(tint)
+ .onHover { isHovering = $0 }
+ }
+}
+
diff --git a/app/EXO/EXO/EXO.entitlements b/app/EXO/EXO/EXO.entitlements
new file mode 100644
index 00000000..36a5baff
--- /dev/null
+++ b/app/EXO/EXO/EXO.entitlements
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>com.apple.security.app-sandbox</key>
+ <false/>
+ <key>com.apple.security.automation.apple-events</key>
+ <true/>
+ <key>com.apple.security.files.user-selected.read-only</key>
+ <true/>
+</dict>
+</plist>
diff --git a/app/EXO/EXO/EXOApp.swift b/app/EXO/EXO/EXOApp.swift
new file mode 100644
index 00000000..da6dd31b
--- /dev/null
+++ b/app/EXO/EXO/EXOApp.swift
@@ -0,0 +1,240 @@
+//
+// EXOApp.swift
+// EXO
+//
+// Created by Sami Khan on 2025-11-22.
+//
+
+import AppKit
+import CoreImage
+import CoreImage.CIFilterBuiltins
+import Sparkle
+import SwiftUI
+import ServiceManagement
+import UserNotifications
+import os.log
+
+@main
+struct EXOApp: App {
+ @StateObject private var controller: ExoProcessController
+ @StateObject private var stateService: ClusterStateService
+ @StateObject private var networkStatusService: NetworkStatusService
+ @StateObject private var updater: SparkleUpdater
+ private let terminationObserver: TerminationObserver
+ private let ciContext = CIContext(options: nil)
+
+ init() {
+ let controller = ExoProcessController()
+ let updater = SparkleUpdater(processController: controller)
+ terminationObserver = TerminationObserver {
+ Task { @MainActor in
+ controller.cancelPendingLaunch()
+ controller.stop()
+ }
+ }
+ _controller = StateObject(wrappedValue: controller)
+ let service = ClusterStateService()
+ _stateService = StateObject(wrappedValue: service)
+ let networkStatus = NetworkStatusService()
+ _networkStatusService = StateObject(wrappedValue: networkStatus)
+ _updater = StateObject(wrappedValue: updater)
+ enableLaunchAtLoginIfNeeded()
+ NetworkSetupHelper.ensureLaunchDaemonInstalled()
+ controller.scheduleLaunch(after: 15)
+ service.startPolling()
+ networkStatus.startPolling()
+ }
+
+ var body: some Scene {
+ MenuBarExtra {
+ ContentView()
+ .environmentObject(controller)
+ .environmentObject(stateService)
+ .environmentObject(networkStatusService)
+ .environmentObject(updater)
+ } label: {
+ menuBarIcon
+ }
+ .menuBarExtraStyle(.window)
+ }
+
+ private var menuBarIcon: some View {
+ let baseImage = resizedMenuBarIcon(named: "menubar-icon", size: 26)
+ let iconImage: NSImage
+ if controller.status == .stopped, let grey = greyscale(image: baseImage) {
+ iconImage = grey
+ } else {
+ iconImage = baseImage ?? NSImage(named: "menubar-icon") ?? NSImage()
+ }
+ return Image(nsImage: iconImage)
+ .accessibilityLabel("EXO")
+ }
+
+ private func resizedMenuBarIcon(named: String, size: CGFloat) -> NSImage? {
+ guard let original = NSImage(named: named) else {
+ print("Failed to load image named: \(named)")
+ return nil
+ }
+ let targetSize = NSSize(width: size, height: size)
+ let resized = NSImage(size: targetSize)
+ resized.lockFocus()
+ defer { resized.unlockFocus() }
+ NSGraphicsContext.current?.imageInterpolation = .high
+ original.draw(
+ in: NSRect(origin: .zero, size: targetSize),
+ from: NSRect(origin: .zero, size: original.size),
+ operation: .copy,
+ fraction: 1.0
+ )
+ return resized
+ }
+
+ private func greyscale(image: NSImage?) -> NSImage? {
+ guard
+ let image,
+ let tiff = image.tiffRepresentation,
+ let bitmap = NSBitmapImageRep(data: tiff),
+ let cgImage = bitmap.cgImage
+ else {
+ return nil
+ }
+
+ let ciImage = CIImage(cgImage: cgImage)
+ let filter = CIFilter.colorControls()
+ filter.inputImage = ciImage
+ filter.saturation = 0
+ filter.brightness = -0.2
+ filter.contrast = 0.9
+
+ guard let output = filter.outputImage,
+ let rendered = ciContext.createCGImage(output, from: output.extent)
+ else {
+ return nil
+ }
+
+ return NSImage(cgImage: rendered, size: image.size)
+ }
+
+ private func enableLaunchAtLoginIfNeeded() {
+ guard SMAppService.mainApp.status != .enabled else { return }
+ do {
+ try SMAppService.mainApp.register()
+ } catch {
+ Logger().error("Failed to register EXO for launch at login: \(error.localizedDescription)")
+ }
+ }
+}
+
+final class SparkleUpdater: NSObject, ObservableObject {
+ private let controller: SPUStandardUpdaterController
+ private let delegateProxy: ExoUpdaterDelegate
+ private let notificationDelegate = ExoNotificationDelegate()
+ private var periodicCheckTask: Task<Void, Never>?
+
+ init(processController: ExoProcessController) {
+ let proxy = ExoUpdaterDelegate(processController: processController)
+ delegateProxy = proxy
+ controller = SPUStandardUpdaterController(
+ startingUpdater: true,
+ updaterDelegate: proxy,
+ userDriverDelegate: nil
+ )
+ super.init()
+ let center = UNUserNotificationCenter.current()
+ center.delegate = notificationDelegate
+ center.requestAuthorization(options: [.alert, .sound]) { _, _ in }
+ controller.updater.automaticallyChecksForUpdates = true
+ controller.updater.automaticallyDownloadsUpdates = false
+ controller.updater.updateCheckInterval = 900 // 15 minutes
+ DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak controller] in
+ controller?.updater.checkForUpdatesInBackground()
+ }
+ let updater = controller.updater
+ let intervalSeconds = max(60.0, controller.updater.updateCheckInterval)
+ let intervalNanos = UInt64(intervalSeconds * 1_000_000_000)
+ periodicCheckTask = Task {
+ while !Task.isCancelled {
+ try? await Task.sleep(nanoseconds: intervalNanos)
+ await MainActor.run {
+ updater.checkForUpdatesInBackground()
+ }
+ }
+ }
+ }
+
+ deinit {
+ periodicCheckTask?.cancel()
+ }
+
+ @MainActor
+ func checkForUpdates() {
+ controller.checkForUpdates(nil)
+ }
+}
+
+private final class ExoUpdaterDelegate: NSObject, SPUUpdaterDelegate {
+ private weak var processController: ExoProcessController?
+
+ init(processController: ExoProcessController) {
+ self.processController = processController
+ }
+
+ nonisolated func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
+ showNotification(
+ title: "Update available",
+ body: "EXO \(item.displayVersionString) is ready to install."
+ )
+ }
+
+ nonisolated func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
+ Task { @MainActor in
+ guard let controller = self.processController else { return }
+ controller.cancelPendingLaunch()
+ controller.stop()
+ }
+ }
+
+ private func showNotification(title: String, body: String) {
+ let center = UNUserNotificationCenter.current()
+ let content = UNMutableNotificationContent()
+ content.title = title
+ content.body = body
+ let request = UNNotificationRequest(
+ identifier: "exo-update-\(UUID().uuidString)",
+ content: content,
+ trigger: nil
+ )
+ center.add(request, withCompletionHandler: nil)
+ }
+}
+
+private final class ExoNotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+ ) {
+ completionHandler([.banner, .list, .sound])
+ }
+}
+
+@MainActor
+private final class TerminationObserver {
+ private var token: NSObjectProtocol?
+
+ init(onTerminate: @escaping () -> Void) {
+ token = NotificationCenter.default.addObserver(
+ forName: NSApplication.willTerminateNotification,
+ object: nil,
+ queue: .main
+ ) { _ in
+ onTerminate()
+ }
+ }
+
+ deinit {
+ if let token {
+ NotificationCenter.default.removeObserver(token)
+ }
+ }
+}
diff --git a/app/EXO/EXO/ExoProcessController.swift b/app/EXO/EXO/ExoProcessController.swift
new file mode 100644
index 00000000..ad25e5a5
--- /dev/null
+++ b/app/EXO/EXO/ExoProcessController.swift
@@ -0,0 +1,232 @@
+import AppKit
+import Combine
+import Foundation
+
+@MainActor
+final class ExoProcessController: ObservableObject {
+ enum Status: Equatable {
+ case stopped
+ case starting
+ case running
+ case failed(message: String)
+
+ var displayText: String {
+ switch self {
+ case .stopped:
+ return "Stopped"
+ case .starting:
+ return "Starting…"
+ case .running:
+ return "Running"
+ case .failed:
+ return "Failed"
+ }
+ }
+ }
+
+ @Published private(set) var status: Status = .stopped
+ @Published private(set) var lastError: String?
+ @Published private(set) var launchCountdownSeconds: Int?
+
+ private var process: Process?
+ private var runtimeDirectoryURL: URL?
+ private var pendingLaunchTask: Task<Void, Never>?
+
+ func launchIfNeeded() {
+ guard process?.isRunning != true else { return }
+ launch()
+ }
+
+ func launch() {
+ do {
+ guard process?.isRunning != true else { return }
+ cancelPendingLaunch()
+ status = .starting
+ lastError = nil
+ let runtimeURL = try resolveRuntimeDirectory()
+ runtimeDirectoryURL = runtimeURL
+
+ let executableURL = runtimeURL.appendingPathComponent("exo")
+
+ let child = Process()
+ child.executableURL = executableURL
+ child.currentDirectoryURL = runtimeURL
+ child.environment = makeEnvironment(for: runtimeURL)
+
+ child.standardOutput = FileHandle.nullDevice
+ child.standardError = FileHandle.nullDevice
+
+ child.terminationHandler = { [weak self] proc in
+ Task { @MainActor in
+ guard let self else { return }
+ self.process = nil
+ switch self.status {
+ case .stopped:
+ break
+ case .failed:
+ break
+ default:
+ self.status = .failed(
+ message: "Exited with code \(proc.terminationStatus)"
+ )
+ self.lastError = "Process exited with code \(proc.terminationStatus)"
+ }
+ }
+ }
+
+ try child.run()
+ process = child
+ status = .running
+ } catch {
+ process = nil
+ status = .failed(message: "Launch error")
+ lastError = error.localizedDescription
+ }
+ }
+
+ func stop() {
+ guard let process else {
+ status = .stopped
+ return
+ }
+ process.terminationHandler = nil
+ if process.isRunning {
+ process.terminate()
+ }
+ self.process = nil
+ status = .stopped
+ }
+
+ func restart() {
+ stop()
+ launch()
+ }
+
+ func scheduleLaunch(after seconds: TimeInterval) {
+ cancelPendingLaunch()
+ let start = max(1, Int(ceil(seconds)))
+ pendingLaunchTask = Task { [weak self] in
+ guard let self else { return }
+ await MainActor.run {
+ self.launchCountdownSeconds = start
+ }
+ var remaining = start
+ while remaining > 0 {
+ try? await Task.sleep(nanoseconds: 1_000_000_000)
+ remaining -= 1
+ if Task.isCancelled { return }
+ await MainActor.run {
+ if remaining > 0 {
+ self.launchCountdownSeconds = remaining
+ } else {
+ self.launchCountdownSeconds = nil
+ self.launchIfNeeded()
+ }
+ }
+ }
+ }
+ }
+
+ func cancelPendingLaunch() {
+ pendingLaunchTask?.cancel()
+ pendingLaunchTask = nil
+ launchCountdownSeconds = nil
+ }
+
+ func revealRuntimeDirectory() {
+ guard let runtimeDirectoryURL else { return }
+ NSWorkspace.shared.activateFileViewerSelecting([runtimeDirectoryURL])
+ }
+
+ func statusTintColor() -> NSColor {
+ switch status {
+ case .running:
+ return .systemGreen
+ case .starting:
+ return .systemYellow
+ case .failed:
+ return .systemRed
+ case .stopped:
+ return .systemGray
+ }
+ }
+
+ private func resolveRuntimeDirectory() throws -> URL {
+ let fileManager = FileManager.default
+
+ if let override = ProcessInfo.processInfo.environment["EXO_RUNTIME_DIR"] {
+ let url = URL(fileURLWithPath: override).standardizedFileURL
+ if fileManager.fileExists(atPath: url.path) {
+ return url
+ }
+ }
+
+ if let resourceRoot = Bundle.main.resourceURL {
+ let bundled = resourceRoot.appendingPathComponent("exo", isDirectory: true)
+ if fileManager.fileExists(atPath: bundled.path) {
+ return bundled
+ }
+ }
+
+ let repoCandidate = URL(fileURLWithPath: fileManager.currentDirectoryPath)
+ .appendingPathComponent("dist/exo", isDirectory: true)
+ if fileManager.fileExists(atPath: repoCandidate.path) {
+ return repoCandidate
+ }
+
+ throw RuntimeError("Unable to locate the packaged EXO runtime.")
+ }
+
+ private func makeEnvironment(for runtimeURL: URL) -> [String: String] {
+ var environment = ProcessInfo.processInfo.environment
+ environment["EXO_RUNTIME_DIR"] = runtimeURL.path
+ environment["EXO_LIBP2P_NAMESPACE"] = buildTag()
+
+ var paths: [String] = []
+ if let existing = environment["PATH"], !existing.isEmpty {
+ paths = existing.split(separator: ":").map(String.init)
+ }
+
+ let required = [
+ runtimeURL.path,
+ runtimeURL.appendingPathComponent("_internal").path,
+ "/opt/homebrew/bin",
+ "/usr/local/bin",
+ "/usr/bin",
+ "/bin",
+ "/usr/sbin",
+ "/sbin",
+ ]
+
+ for entry in required.reversed() {
+ if !paths.contains(entry) {
+ paths.insert(entry, at: 0)
+ }
+ }
+
+ environment["PATH"] = paths.joined(separator: ":")
+ return environment
+ }
+
+ private func buildTag() -> String {
+ if let tag = Bundle.main.infoDictionary?["EXOBuildTag"] as? String, !tag.isEmpty {
+ return tag
+ }
+ if let short = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, !short.isEmpty {
+ return short
+ }
+ return "dev"
+ }
+}
+
+struct RuntimeError: LocalizedError {
+ let message: String
+
+ init(_ message: String) {
+ self.message = message
+ }
+
+ var errorDescription: String? {
+ message
+ }
+}
diff --git a/app/EXO/EXO/Info.plist b/app/EXO/EXO/Info.plist
new file mode 100644
index 00000000..5a7e8cbf
--- /dev/null
+++ b/app/EXO/EXO/Info.plist
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>SUFeedURL</key>
+ <string>https://assets.exolabs.net/appcast.xml</string>
+ <key>EXOBuildTag</key>
+ <string>$(EXO_BUILD_TAG)</string>
+ <key>EXOBuildCommit</key>
+ <string>$(EXO_BUILD_COMMIT)</string>
+</dict>
+</plist>
diff --git a/app/EXO/EXO/Models/ClusterState.swift b/app/EXO/EXO/Models/ClusterState.swift
new file mode 100644
index 00000000..8f750e7e
--- /dev/null
+++ b/app/EXO/EXO/Models/ClusterState.swift
@@ -0,0 +1,369 @@
+import Foundation
+
+// MARK: - API payloads
+
+struct ClusterState: Decodable {
+ let instances: [String: ClusterInstance]
+ let runners: [String: RunnerStatusSummary]
+ let nodeProfiles: [String: NodeProfile]
+ let tasks: [String: ClusterTask]
+ let topology: Topology?
+ let downloads: [String: [NodeDownloadStatus]]
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let rawInstances = try container.decode([String: TaggedInstance].self, forKey: .instances)
+ self.instances = rawInstances.mapValues(\.instance)
+ self.runners = try container.decode([String: RunnerStatusSummary].self, forKey: .runners)
+ self.nodeProfiles = try container.decode([String: NodeProfile].self, forKey: .nodeProfiles)
+ let rawTasks = try container.decodeIfPresent([String: TaggedTask].self, forKey: .tasks) ?? [:]
+ self.tasks = rawTasks.compactMapValues(\.task)
+ self.topology = try container.decodeIfPresent(Topology.self, forKey: .topology)
+ let rawDownloads = try container.decodeIfPresent([String: [TaggedNodeDownload]].self, forKey: .downloads) ?? [:]
+ self.downloads = rawDownloads.mapValues { $0.compactMap(\.status) }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case instances
+ case runners
+ case nodeProfiles
+ case topology
+ case tasks
+ case downloads
+ }
+}
+
+private struct TaggedInstance: Decodable {
+ let instance: ClusterInstance
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let payloads = try container.decode([String: ClusterInstancePayload].self)
+ guard let entry = payloads.first else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Empty instance payload")
+ )
+ }
+ self.instance = ClusterInstance(
+ instanceId: entry.value.instanceId,
+ shardAssignments: entry.value.shardAssignments,
+ sharding: entry.key
+ )
+ }
+}
+
+private struct ClusterInstancePayload: Decodable {
+ let instanceId: String?
+ let shardAssignments: ShardAssignments
+}
+
+struct ClusterInstance {
+ let instanceId: String?
+ let shardAssignments: ShardAssignments
+ let sharding: String
+}
+
+struct ShardAssignments: Decodable {
+ let modelId: String
+ let nodeToRunner: [String: String]
+}
+
+struct RunnerStatusSummary: Decodable {
+ let status: String
+ let errorMessage: String?
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let payloads = try container.decode([String: RunnerStatusDetail].self)
+ guard let entry = payloads.first else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Empty runner status payload")
+ )
+ }
+ self.status = entry.key
+ self.errorMessage = entry.value.errorMessage
+ }
+}
+
+struct RunnerStatusDetail: Decodable {
+ let errorMessage: String?
+}
+
+struct NodeProfile: Decodable {
+ let modelId: String?
+ let chipId: String?
+ let friendlyName: String?
+ let memory: MemoryInfo?
+ let system: SystemInfo?
+}
+
+struct MemoryInfo: Decodable {
+ let ramTotal: MemoryValue?
+ let ramAvailable: MemoryValue?
+}
+
+struct MemoryValue: Decodable {
+ let inBytes: Int64?
+}
+
+struct SystemInfo: Decodable {
+ let gpuUsage: Double?
+ let temp: Double?
+ let sysPower: Double?
+ let pcpuUsage: Double?
+ let ecpuUsage: Double?
+}
+
+struct Topology: Decodable {
+ let nodes: [TopologyNode]
+ let connections: [TopologyConnection]?
+}
+
+struct TopologyNode: Decodable {
+ let nodeId: String
+ let nodeProfile: NodeProfile
+}
+
+struct TopologyConnection: Decodable {
+ let localNodeId: String
+ let sendBackNodeId: String
+}
+
+// MARK: - Downloads
+
+private struct TaggedNodeDownload: Decodable {
+ let status: NodeDownloadStatus?
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let payloads = try container.decode([String: NodeDownloadPayload].self)
+ guard let entry = payloads.first else {
+ status = nil
+ return
+ }
+ status = NodeDownloadStatus(statusKey: entry.key, payload: entry.value)
+ }
+}
+
+struct NodeDownloadPayload: Decodable {
+ let nodeId: String?
+ let downloadProgress: DownloadProgress?
+}
+
+struct NodeDownloadStatus {
+ let nodeId: String
+ let progress: DownloadProgress?
+
+ init?(statusKey: String, payload: NodeDownloadPayload) {
+ guard let nodeId = payload.nodeId else { return nil }
+ self.nodeId = nodeId
+ self.progress = statusKey == "DownloadOngoing" ? payload.downloadProgress : nil
+ }
+}
+
+struct DownloadProgress: Decodable {
+ let totalBytes: ByteValue
+ let downloadedBytes: ByteValue
+ let speed: Double?
+ let etaMs: Int64?
+ let completedFiles: Int?
+ let totalFiles: Int?
+ let files: [String: FileDownloadProgress]?
+}
+
+struct ByteValue: Decodable {
+ let inBytes: Int64
+}
+
+struct FileDownloadProgress: Decodable {
+ let totalBytes: ByteValue
+ let downloadedBytes: ByteValue
+ let speed: Double?
+ let etaMs: Int64?
+}
+
+// MARK: - Tasks
+
+struct ClusterTask {
+ enum Kind {
+ case chatCompletion
+ }
+
+ let id: String
+ let status: TaskStatus
+ let instanceId: String?
+ let kind: Kind
+ let modelName: String?
+ let promptPreview: String?
+ let errorMessage: String?
+ let parameters: ChatCompletionTaskParameters?
+
+ var sortPriority: Int {
+ switch status {
+ case .running:
+ return 0
+ case .pending:
+ return 1
+ case .complete:
+ return 2
+ case .failed:
+ return 3
+ case .unknown:
+ return 4
+ }
+ }
+}
+
+private struct TaggedTask: Decodable {
+ let task: ClusterTask?
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let payloads = try container.decode([String: ClusterTaskPayload].self)
+ guard let entry = payloads.first else {
+ task = nil
+ return
+ }
+ task = ClusterTask(kindKey: entry.key, payload: entry.value)
+ }
+}
+
+struct ClusterTaskPayload: Decodable {
+ let taskId: String?
+ let taskStatus: TaskStatus?
+ let instanceId: String?
+ let commandId: String?
+ let taskParams: ChatCompletionTaskParameters?
+ let errorType: String?
+ let errorMessage: String?
+}
+
+struct ChatCompletionTaskParameters: Decodable, Equatable {
+ let model: String?
+ let messages: [ChatCompletionMessage]?
+ let maxTokens: Int?
+ let stream: Bool?
+ let temperature: Double?
+ let topP: Double?
+
+ private enum CodingKeys: String, CodingKey {
+ case model
+ case messages
+ case maxTokens
+ case stream
+ case temperature
+ case topP
+ }
+
+ func promptPreview() -> String? {
+ guard let messages else { return nil }
+ if let userMessage = messages.last(where: { $0.role?.lowercased() == "user" && ($0.content?.isEmpty == false) }) {
+ return userMessage.content
+ }
+ return messages.last?.content
+ }
+
+}
+
+struct ChatCompletionMessage: Decodable, Equatable {
+ let role: String?
+ let content: String?
+}
+
+extension ClusterTask {
+ init?(kindKey: String, payload: ClusterTaskPayload) {
+ guard let id = payload.taskId else { return nil }
+ let status = payload.taskStatus ?? .unknown
+ switch kindKey {
+ case "ChatCompletion":
+ self.init(
+ id: id,
+ status: status,
+ instanceId: payload.instanceId,
+ kind: .chatCompletion,
+ modelName: payload.taskParams?.model,
+ promptPreview: payload.taskParams?.promptPreview(),
+ errorMessage: payload.errorMessage,
+ parameters: payload.taskParams
+ )
+ default:
+ return nil
+ }
+ }
+}
+
+enum TaskStatus: String, Decodable {
+ case pending = "Pending"
+ case running = "Running"
+ case complete = "Complete"
+ case failed = "Failed"
+ case unknown
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let value = try container.decode(String.self)
+ self = TaskStatus(rawValue: value) ?? .unknown
+ }
+
+ var displayLabel: String {
+ switch self {
+ case .pending, .running, .complete, .failed:
+ return rawValue
+ case .unknown:
+ return "Unknown"
+ }
+ }
+}
+
+// MARK: - Derived summaries
+
+struct ClusterOverview {
+ let totalRam: Double
+ let usedRam: Double
+ let nodeCount: Int
+ let instanceCount: Int
+}
+
+struct NodeSummary: Identifiable {
+ let id: String
+ let friendlyName: String
+ let model: String
+ let usedRamGB: Double
+ let totalRamGB: Double
+ let gpuUsagePercent: Double
+ let temperatureCelsius: Double
+}
+
+struct InstanceSummary: Identifiable {
+ let id: String
+ let modelId: String
+ let nodeCount: Int
+ let statusText: String
+}
+
+extension ClusterState {
+ func overview() -> ClusterOverview {
+ var total: Double = 0
+ var available: Double = 0
+ for profile in nodeProfiles.values {
+ if let totalBytes = profile.memory?.ramTotal?.inBytes {
+ total += Double(totalBytes)
+ }
+ if let availableBytes = profile.memory?.ramAvailable?.inBytes {
+ available += Double(availableBytes)
+ }
+ }
+ let totalGB = total / 1_073_741_824.0
+ let usedGB = max(total - available, 0) / 1_073_741_824.0
+ return ClusterOverview(
+ totalRam: totalGB,
+ usedRam: usedGB,
+ nodeCount: nodeProfiles.count,
+ instanceCount: instances.count
+ )
+ }
+
+ func availableModels() -> [ModelOption] { [] }
+}
+
+
diff --git a/app/EXO/EXO/Preview Content/Preview Assets.xcassets/Contents.json b/app/EXO/EXO/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/app/EXO/EXO/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/app/EXO/EXO/Services/BugReportService.swift b/app/EXO/EXO/Services/BugReportService.swift
new file mode 100644
index 00000000..99f55e57
--- /dev/null
+++ b/app/EXO/EXO/Services/BugReportService.swift
@@ -0,0 +1,539 @@
+import CryptoKit
+import Foundation
+
+struct BugReportOutcome: Equatable {
+ let success: Bool
+ let message: String
+}
+
+enum BugReportError: LocalizedError {
+ case missingCredentials
+ case invalidEndpoint
+ case uploadFailed(String)
+ case collectFailed(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ return "Bug report upload credentials are not set."
+ case .invalidEndpoint:
+ return "Bug report endpoint is invalid."
+ case .uploadFailed(let message):
+ return "Bug report upload failed: \(message)"
+ case .collectFailed(let message):
+ return "Bug report collection failed: \(message)"
+ }
+ }
+}
+
+struct BugReportService {
+ struct AWSConfig {
+ let accessKey: String
+ let secretKey: String
+ let region: String
+ let bucket: String
+ }
+
+ func sendReport(
+ baseURL: URL = URL(string: "http://127.0.0.1:8000")!,
+ now: Date = Date(),
+ isManual: Bool = false
+ ) async throws -> BugReportOutcome {
+ let credentials = try loadCredentials()
+ let timestamp = ISO8601DateFormatter().string(from: now)
+ let prefix = "reports/\(timestamp)/"
+
+ let logData = readLog()
+ let ifconfigText = try await captureIfconfig()
+ let hostName = Host.current().localizedName ?? "unknown"
+ let debugInfo = readDebugInfo()
+
+ async let stateResult = fetch(url: baseURL.appendingPathComponent("state"))
+ async let eventsResult = fetch(url: baseURL.appendingPathComponent("events"))
+
+ let stateData = try await stateResult
+ let eventsData = try await eventsResult
+
+ let reportJSON = makeReportJson(
+ timestamp: timestamp,
+ hostName: hostName,
+ ifconfig: ifconfigText,
+ debugInfo: debugInfo,
+ isManual: isManual
+ )
+
+ let uploads: [(path: String, data: Data?)] = [
+ ("\(prefix)exo.log", logData),
+ ("\(prefix)state.json", stateData),
+ ("\(prefix)events.json", eventsData),
+ ("\(prefix)report.json", reportJSON)
+ ]
+
+ let uploader = try S3Uploader(config: credentials)
+ for item in uploads {
+ guard let data = item.data else { continue }
+ try await uploader.upload(
+ objectPath: item.path,
+ body: data
+ )
+ }
+
+ return BugReportOutcome(success: true, message: "Bug Report sent. Thank you for helping to improve EXO 1.0.")
+ }
+
+ private func loadCredentials() throws -> AWSConfig {
+ // These credentials are write-only and necessary to receive bug reports from users
+ return AWSConfig(
+ accessKey: "AKIAYEKP5EMXTOBYDGHX",
+ secretKey: "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",
+ region: "us-east-1",
+ bucket: "exo-bug-reports"
+ )
+ }
+
+ private func readLog() -> Data? {
+ let logURL = URL(fileURLWithPath: NSHomeDirectory())
+ .appendingPathComponent(".exo")
+ .appendingPathComponent("exo.log")
+ return try? Data(contentsOf: logURL)
+ }
+
+ private func captureIfconfig() async throws -> String {
+ let result = runCommand(["/sbin/ifconfig"])
+ guard result.exitCode == 0 else {
+ throw BugReportError.collectFailed(result.error.isEmpty ? "ifconfig failed" : result.error)
+ }
+ return result.output
+ }
+
+ private func readDebugInfo() -> DebugInfo {
+ DebugInfo(
+ thunderboltBridgeDisabled: readThunderboltBridgeDisabled(),
+ interfaces: readInterfaces()
+ )
+ }
+
+ private func readThunderboltBridgeDisabled() -> Bool? {
+ let result = runCommand(["/usr/sbin/networksetup", "-getnetworkserviceenabled", "Thunderbolt Bridge"])
+ guard result.exitCode == 0 else { return nil }
+ let output = result.output.lowercased()
+ if output.contains("enabled") {
+ return false
+ }
+ if output.contains("disabled") {
+ return true
+ }
+ return nil
+ }
+
+ private func readInterfaces() -> [DebugInfo.InterfaceStatus] {
+ (0...7).map { "en\($0)" }.map { iface in
+ let result = runCommand(["/sbin/ifconfig", iface])
+ guard result.exitCode == 0 else {
+ return DebugInfo.InterfaceStatus(name: iface, ip: nil)
+ }
+ let ip = firstInet(from: result.output)
+ return DebugInfo.InterfaceStatus(name: iface, ip: ip)
+ }
+ }
+
+ private func firstInet(from ifconfigOutput: String) -> String? {
+ for line in ifconfigOutput.split(separator: "\n") {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard trimmed.hasPrefix("inet ") else { continue }
+ let parts = trimmed.split(separator: " ")
+ if parts.count >= 2 {
+ let candidate = String(parts[1])
+ if candidate != "127.0.0.1" {
+ return candidate
+ }
+ }
+ }
+ return nil
+ }
+
+ private func fetch(url: URL) async throws -> Data? {
+ var request = URLRequest(url: url)
+ request.timeoutInterval = 5
+ do {
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+ return nil
+ }
+ return data
+ } catch {
+ return nil
+ }
+ }
+
+ private func makeReportJson(
+ timestamp: String,
+ hostName: String,
+ ifconfig: String,
+ debugInfo: DebugInfo,
+ isManual: Bool
+ ) -> Data? {
+ let system = readSystemMetadata()
+ let exo = readExoMetadata()
+ let payload: [String: Any] = [
+ "timestamp": timestamp,
+ "host": hostName,
+ "ifconfig": ifconfig,
+ "debug": debugInfo.toDictionary(),
+ "system": system,
+ "exo_version": exo.version as Any,
+ "exo_commit": exo.commit as Any,
+ "report_type": isManual ? "manual" : "automated"
+ ]
+ return try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted])
+ }
+
+ private func readSystemMetadata() -> [String: Any] {
+ let hostname = safeRunCommand(["/bin/hostname"])
+ let computerName = safeRunCommand(["/usr/sbin/scutil", "--get", "ComputerName"])
+ let localHostName = safeRunCommand(["/usr/sbin/scutil", "--get", "LocalHostName"])
+ let hostNameCommand = safeRunCommand(["/usr/sbin/scutil", "--get", "HostName"])
+ let hardwareModel = safeRunCommand(["/usr/sbin/sysctl", "-n", "hw.model"])
+ let hardwareProfile = safeRunCommand(["/usr/sbin/system_profiler", "SPHardwareDataType"])
+ let hardwareUUID = hardwareProfile.flatMap(extractHardwareUUID)
+
+ let osVersion = safeRunCommand(["/usr/bin/sw_vers", "-productVersion"])
+ let osBuild = safeRunCommand(["/usr/bin/sw_vers", "-buildVersion"])
+ let kernel = safeRunCommand(["/usr/bin/uname", "-srv"])
+ let arch = safeRunCommand(["/usr/bin/uname", "-m"])
+
+ let routeInfo = safeRunCommand(["/sbin/route", "-n", "get", "default"])
+ let defaultInterface = routeInfo.flatMap(parseDefaultInterface)
+ let defaultIP = defaultInterface.flatMap { iface in
+ safeRunCommand(["/usr/sbin/ipconfig", "getifaddr", iface])
+ }
+ let defaultMac = defaultInterface.flatMap { iface in
+ safeRunCommand(["/sbin/ifconfig", iface]).flatMap(parseEtherAddress)
+ }
+
+ let user = safeRunCommand(["/usr/bin/whoami"])
+ let consoleUser = safeRunCommand(["/usr/bin/stat", "-f%Su", "/dev/console"])
+ let uptime = safeRunCommand(["/usr/bin/uptime"])
+ let diskRoot = safeRunCommand(["/bin/sh", "-c", "/bin/df -h / | awk 'NR==2 {print $1, $2, $3, $4, $5}'"])
+
+ let interfacesList = safeRunCommand(["/usr/sbin/ipconfig", "getiflist"])
+ let interfacesAndIPs = interfacesList?
+ .split(whereSeparator: { $0 == " " || $0 == "\n" })
+ .compactMap { iface -> [String: Any]? in
+ let name = String(iface)
+ guard let ip = safeRunCommand(["/usr/sbin/ipconfig", "getifaddr", name]) else {
+ return nil
+ }
+ return ["name": name, "ip": ip]
+ } ?? []
+
+ let wifiSSID: String?
+ let airportPath = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport"
+ if FileManager.default.isExecutableFile(atPath: airportPath) {
+ wifiSSID = safeRunCommand([airportPath, "-I"]).flatMap(parseWifiSSID)
+ } else {
+ wifiSSID = nil
+ }
+
+ return [
+ "hostname": hostname as Any,
+ "computer_name": computerName as Any,
+ "local_hostname": localHostName as Any,
+ "host_name": hostNameCommand as Any,
+ "hardware_model": hardwareModel as Any,
+ "hardware_profile": hardwareProfile as Any,
+ "hardware_uuid": hardwareUUID as Any,
+ "os_version": osVersion as Any,
+ "os_build": osBuild as Any,
+ "kernel": kernel as Any,
+ "arch": arch as Any,
+ "default_interface": defaultInterface as Any,
+ "default_ip": defaultIP as Any,
+ "default_mac": defaultMac as Any,
+ "user": user as Any,
+ "console_user": consoleUser as Any,
+ "uptime": uptime as Any,
+ "disk_root": diskRoot as Any,
+ "interfaces_and_ips": interfacesAndIPs,
+ "ipconfig_getiflist": interfacesList as Any,
+ "wifi_ssid": wifiSSID as Any
+ ]
+ }
+
+ private func readExoMetadata(bundle: Bundle = .main) -> (version: String?, commit: String?) {
+ let info = bundle.infoDictionary ?? [:]
+ let tag = info["EXOBuildTag"] as? String
+ let short = info["CFBundleShortVersionString"] as? String
+ let version = [tag, short]
+ .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .first { !$0.isEmpty }
+ let commit = (info["EXOBuildCommit"] as? String)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let normalizedCommit = (commit?.isEmpty == true) ? nil : commit
+ return (version: version, commit: normalizedCommit)
+ }
+
+ private func safeRunCommand(_ arguments: [String]) -> String? {
+ let result = runCommand(arguments)
+ guard result.exitCode == 0 else { return nil }
+ let trimmed = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private func extractHardwareUUID(from hardwareProfile: String) -> String? {
+ hardwareProfile
+ .split(separator: "\n")
+ .first { $0.contains("Hardware UUID") }?
+ .split(separator: ":")
+ .dropFirst()
+ .joined(separator: ":")
+ .trimmingCharacters(in: .whitespaces)
+ }
+
+ private func parseDefaultInterface(from routeOutput: String) -> String? {
+ for line in routeOutput.split(separator: "\n") {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.hasPrefix("interface: ") {
+ return trimmed.replacingOccurrences(of: "interface: ", with: "")
+ }
+ }
+ return nil
+ }
+
+ private func parseEtherAddress(from ifconfigOutput: String) -> String? {
+ for line in ifconfigOutput.split(separator: "\n") {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.hasPrefix("ether ") {
+ return trimmed.replacingOccurrences(of: "ether ", with: "")
+ }
+ }
+ return nil
+ }
+
+ private func parseWifiSSID(from airportOutput: String) -> String? {
+ for line in airportOutput.split(separator: "\n") {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.hasPrefix("SSID:") {
+ return trimmed.replacingOccurrences(of: "SSID:", with: "").trimmingCharacters(in: .whitespaces)
+ }
+ }
+ return nil
+ }
+
+ private func runCommand(_ arguments: [String]) -> CommandResult {
+ let process = Process()
+ process.launchPath = arguments.first
+ process.arguments = Array(arguments.dropFirst())
+
+ let stdout = Pipe()
+ let stderr = Pipe()
+ process.standardOutput = stdout
+ process.standardError = stderr
+
+ do {
+ try process.run()
+ } catch {
+ return CommandResult(exitCode: -1, output: "", error: error.localizedDescription)
+ }
+ process.waitUntilExit()
+
+ let outputData = stdout.fileHandleForReading.readDataToEndOfFile()
+ let errorData = stderr.fileHandleForReading.readDataToEndOfFile()
+
+ return CommandResult(
+ exitCode: process.terminationStatus,
+ output: String(decoding: outputData, as: UTF8.self),
+ error: String(decoding: errorData, as: UTF8.self)
+ )
+ }
+}
+
+private struct DebugInfo {
+ let thunderboltBridgeDisabled: Bool?
+ let interfaces: [InterfaceStatus]
+
+ struct InterfaceStatus {
+ let name: String
+ let ip: String?
+
+ func toDictionary() -> [String: Any] {
+ [
+ "name": name,
+ "ip": ip as Any
+ ]
+ }
+ }
+
+ func toDictionary() -> [String: Any] {
+ [
+ "thunderbolt_bridge_disabled": thunderboltBridgeDisabled as Any,
+ "interfaces": interfaces.map { $0.toDictionary() }
+ ]
+ }
+}
+
+private struct CommandResult {
+ let exitCode: Int32
+ let output: String
+ let error: String
+}
+
+private struct S3Uploader {
+ let config: BugReportService.AWSConfig
+
+ init(config: BugReportService.AWSConfig) throws {
+ self.config = config
+ }
+
+ func upload(objectPath: String, body: Data) async throws {
+ let host = "\(config.bucket).s3.amazonaws.com"
+ guard let url = URL(string: "https://\(host)/\(objectPath)") else {
+ throw BugReportError.invalidEndpoint
+ }
+
+ let now = Date()
+ let amzDate = awsTimestamp(now)
+ let dateStamp = dateStamp(now)
+ let payloadHash = sha256Hex(body)
+
+ let headers = [
+ "host": host,
+ "x-amz-content-sha256": payloadHash,
+ "x-amz-date": amzDate
+ ]
+
+ let canonicalRequest = buildCanonicalRequest(
+ method: "PUT",
+ url: url,
+ headers: headers,
+ payloadHash: payloadHash
+ )
+
+ let stringToSign = buildStringToSign(
+ amzDate: amzDate,
+ dateStamp: dateStamp,
+ canonicalRequestHash: sha256Hex(canonicalRequest.data(using: .utf8) ?? Data())
+ )
+
+ let signingKey = deriveKey(secret: config.secretKey, dateStamp: dateStamp, region: config.region, service: "s3")
+ let signature = hmacHex(key: signingKey, data: Data(stringToSign.utf8))
+
+ let signedHeaders = "host;x-amz-content-sha256;x-amz-date"
+ let authorization = """
+AWS4-HMAC-SHA256 Credential=\(config.accessKey)/\(dateStamp)/\(config.region)/s3/aws4_request, SignedHeaders=\(signedHeaders), Signature=\(signature)
+"""
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "PUT"
+ request.httpBody = body
+ request.setValue(headers["x-amz-content-sha256"], forHTTPHeaderField: "x-amz-content-sha256")
+ request.setValue(headers["x-amz-date"], forHTTPHeaderField: "x-amz-date")
+ request.setValue(host, forHTTPHeaderField: "Host")
+ request.setValue(authorization, forHTTPHeaderField: "Authorization")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+ let statusText = (response as? HTTPURLResponse)?.statusCode ?? -1
+ _ = data // ignore response body for UX
+ throw BugReportError.uploadFailed("HTTP status \(statusText)")
+ }
+ }
+
+ private func buildCanonicalRequest(
+ method: String,
+ url: URL,
+ headers: [String: String],
+ payloadHash: String
+ ) -> String {
+ let canonicalURI = encodePath(url.path)
+ let canonicalQuery = url.query ?? ""
+ let sortedHeaders = headers.sorted { $0.key < $1.key }
+ let canonicalHeaders = sortedHeaders
+ .map { "\($0.key.lowercased()):\($0.value)\n" }
+ .joined()
+ let signedHeaders = sortedHeaders.map { $0.key.lowercased() }.joined(separator: ";")
+
+ return [
+ method,
+ canonicalURI,
+ canonicalQuery,
+ canonicalHeaders,
+ signedHeaders,
+ payloadHash
+ ].joined(separator: "\n")
+ }
+
+ private func encodePath(_ path: String) -> String {
+ return path
+ .split(separator: "/")
+ .map { segment in
+ segment.addingPercentEncoding(withAllowedCharacters: Self.rfc3986) ?? String(segment)
+ }
+ .joined(separator: "/")
+ .prependSlashIfNeeded()
+ }
+
+ private func buildStringToSign(
+ amzDate: String,
+ dateStamp: String,
+ canonicalRequestHash: String
+ ) -> String {
+ """
+AWS4-HMAC-SHA256
+\(amzDate)
+\(dateStamp)/\(config.region)/s3/aws4_request
+\(canonicalRequestHash)
+"""
+ }
+
+ private func deriveKey(secret: String, dateStamp: String, region: String, service: String) -> Data {
+ let kDate = hmac(key: Data(("AWS4" + secret).utf8), data: Data(dateStamp.utf8))
+ let kRegion = hmac(key: kDate, data: Data(region.utf8))
+ let kService = hmac(key: kRegion, data: Data(service.utf8))
+ return hmac(key: kService, data: Data("aws4_request".utf8))
+ }
+
+ private func hmac(key: Data, data: Data) -> Data {
+ let keySym = SymmetricKey(data: key)
+ let mac = HMAC<SHA256>.authenticationCode(for: data, using: keySym)
+ return Data(mac)
+ }
+
+ private func hmacHex(key: Data, data: Data) -> String {
+ hmac(key: key, data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private func sha256Hex(_ data: Data) -> String {
+ let digest = SHA256.hash(data: data)
+ return digest.compactMap { String(format: "%02x", $0) }.joined()
+ }
+
+ private func awsTimestamp(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
+ formatter.timeZone = TimeZone(abbreviation: "UTC")
+ return formatter.string(from: date)
+ }
+
+ private func dateStamp(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyyMMdd"
+ formatter.timeZone = TimeZone(abbreviation: "UTC")
+ return formatter.string(from: date)
+ }
+
+ private static let rfc3986: CharacterSet = {
+ var set = CharacterSet.alphanumerics
+ set.insert(charactersIn: "-._~")
+ return set
+ }()
+}
+
+private extension String {
+ func prependSlashIfNeeded() -> String {
+ if hasPrefix("/") {
+ return self
+ }
+ return "/" + self
+ }
+}
diff --git a/app/EXO/EXO/Services/ClusterStateService.swift b/app/EXO/EXO/Services/ClusterStateService.swift
new file mode 100644
index 00000000..c6a35f92
--- /dev/null
+++ b/app/EXO/EXO/Services/ClusterStateService.swift
@@ -0,0 +1,145 @@
+import Combine
+import Foundation
+
+@MainActor
+final class ClusterStateService: ObservableObject {
+ @Published private(set) var latestSnapshot: ClusterState?
+ @Published private(set) var lastError: String?
+ @Published private(set) var lastActionMessage: String?
+ @Published private(set) var modelOptions: [ModelOption] = []
+
+ private var timer: Timer?
+ private let decoder: JSONDecoder
+ private let session: URLSession
+ private let baseURL: URL
+ private let endpoint: URL
+
+ init(
+ baseURL: URL = URL(string: "http://127.0.0.1:8000")!,
+ session: URLSession = .shared
+ ) {
+ self.baseURL = baseURL
+ self.endpoint = baseURL.appendingPathComponent("state")
+ self.session = session
+ let decoder = JSONDecoder()
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
+ self.decoder = decoder
+ }
+
+ func startPolling(interval: TimeInterval = 0.5) {
+ stopPolling()
+ Task {
+ await fetchModels()
+ await fetchSnapshot()
+ }
+ timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
+ Task { await self?.fetchSnapshot() }
+ }
+ }
+
+ func stopPolling() {
+ timer?.invalidate()
+ timer = nil
+ }
+
+ func resetTransientState() {
+ latestSnapshot = nil
+ lastError = nil
+ lastActionMessage = nil
+ }
+
+ private func fetchSnapshot() async {
+ do {
+ var request = URLRequest(url: endpoint)
+ request.cachePolicy = .reloadIgnoringLocalCacheData
+ let (data, response) = try await session.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw URLError(.badServerResponse)
+ }
+ guard (200..<300).contains(httpResponse.statusCode) else {
+ throw URLError(.badServerResponse)
+ }
+ let snapshot = try decoder.decode(ClusterState.self, from: data)
+ latestSnapshot = snapshot
+ if modelOptions.isEmpty {
+ Task { await fetchModels() }
+ }
+ lastError = nil
+ } catch {
+ lastError = error.localizedDescription
+ }
+ }
+
+ func deleteInstance(_ id: String) async {
+ do {
+ var request = URLRequest(url: baseURL.appendingPathComponent("instance/\(id)"))
+ request.httpMethod = "DELETE"
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ let (_, response) = try await session.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw URLError(.badServerResponse)
+ }
+ guard (200..<300).contains(httpResponse.statusCode) else {
+ throw URLError(.badServerResponse)
+ }
+ lastActionMessage = "Instance deleted"
+ await fetchSnapshot()
+ } catch {
+ lastError = "Failed to delete instance: \(error.localizedDescription)"
+ }
+ }
+
+ func launchInstance(modelId: String, sharding: String, instanceMeta: String, minNodes: Int) async {
+ do {
+ var request = URLRequest(url: baseURL.appendingPathComponent("instance"))
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ let payload: [String: Any] = [
+ "model_id": modelId,
+ "sharding": sharding,
+ "instance_meta": instanceMeta,
+ "min_nodes": minNodes
+ ]
+ request.httpBody = try JSONSerialization.data(withJSONObject: payload, options: [])
+ let (_, response) = try await session.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw URLError(.badServerResponse)
+ }
+ guard (200..<300).contains(httpResponse.statusCode) else {
+ throw URLError(.badServerResponse)
+ }
+ lastActionMessage = "Instance launched"
+ await fetchSnapshot()
+ } catch {
+ lastError = "Failed to launch instance: \(error.localizedDescription)"
+ }
+ }
+
+ func fetchModels() async {
+ do {
+ let url = baseURL.appendingPathComponent("models")
+ let (data, response) = try await session.data(from: url)
+ guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else {
+ throw URLError(.badServerResponse)
+ }
+ let list = try decoder.decode(ModelListResponse.self, from: data)
+ modelOptions = list.data.map { ModelOption(id: $0.id, displayName: $0.name ?? $0.id) }
+ } catch {
+ lastError = "Failed to load models: \(error.localizedDescription)"
+ }
+ }
+}
+
+struct ModelOption: Identifiable {
+ let id: String
+ let displayName: String
+}
+
+struct ModelListResponse: Decodable {
+ let data: [ModelListModel]
+}
+
+struct ModelListModel: Decodable {
+ let id: String
+ let name: String?
+}
diff --git a/app/EXO/EXO/Services/NetworkSetupHelper.swift b/app/EXO/EXO/Services/NetworkSetupHelper.swift
new file mode 100644
index 00000000..f62d44b1
--- /dev/null
+++ b/app/EXO/EXO/Services/NetworkSetupHelper.swift
@@ -0,0 +1,187 @@
+import AppKit
+import Foundation
+import os.log
+
+enum NetworkSetupHelper {
+ private static let logger = Logger(subsystem: "io.exo.EXO", category: "NetworkSetup")
+ private static let daemonLabel = "io.exo.networksetup"
+ private static let scriptDestination = "/Library/Application Support/EXO/disable_bridge_enable_dhcp.sh"
+ private static let plistDestination = "/Library/LaunchDaemons/io.exo.networksetup.plist"
+ private static let requiredStartInterval: Int = 1791
+
+ private static let setupScript = """
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+PREFS="/Library/Preferences/SystemConfiguration/preferences.plist"
+
+# Remove bridge0 interface
+ifconfig bridge0 &>/dev/null && {
+ ifconfig bridge0 | grep -q 'member' && {
+ ifconfig bridge0 | awk '/member/ {print $2}' | xargs -n1 ifconfig bridge0 deletem 2>/dev/null || true
+ }
+ ifconfig bridge0 destroy 2>/dev/null || true
+}
+
+# Remove Thunderbolt Bridge from VirtualNetworkInterfaces in preferences.plist
+/usr/libexec/PlistBuddy -c "Delete :VirtualNetworkInterfaces:Bridge:bridge0" "$PREFS" 2>/dev/null || true
+
+networksetup -listlocations | grep -q exo || {
+ networksetup -createlocation exo
+}
+
+networksetup -switchtolocation exo
+networksetup -listallhardwareports \\
+ | awk -F': ' '/Hardware Port: / {print $2}' \\
+ | while IFS=":" read -r name; do
+ case "$name" in
+ "Ethernet Adapter"*)
+ ;;
+ "Thunderbolt Bridge")
+ ;;
+ "Thunderbolt "*)
+ networksetup -listallnetworkservices \\
+ | grep -q "EXO $name" \\
+ || networksetup -createnetworkservice "EXO $name" "$name" 2>/dev/null \\
+ || continue
+ networksetup -setdhcp "EXO $name"
+ ;;
+ *)
+ networksetup -listallnetworkservices \\
+ | grep -q "$name" \\
+ || networksetup -createnetworkservice "$name" "$name" 2>/dev/null \\
+ || continue
+ ;;
+ esac
+ done
+
+networksetup -listnetworkservices | grep -q "Thunderbolt Bridge" && {
+ networksetup -setnetworkserviceenabled "Thunderbolt Bridge" off
+} || true
+"""
+
+ static func ensureLaunchDaemonInstalled() {
+ Task.detached {
+ do {
+ if daemonAlreadyInstalled() {
+ return
+ }
+ try await installLaunchDaemon()
+ logger.info("Network setup launch daemon installed and started")
+ } catch {
+ logger.error("Network setup launch daemon failed: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+ }
+
+ private static func daemonAlreadyInstalled() -> Bool {
+ let manager = FileManager.default
+ let scriptExists = manager.fileExists(atPath: scriptDestination)
+ let plistExists = manager.fileExists(atPath: plistDestination)
+ guard scriptExists, plistExists else { return false }
+ guard
+ let data = try? Data(contentsOf: URL(fileURLWithPath: plistDestination)),
+ let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any]
+ else {
+ return false
+ }
+ guard
+ let interval = plist["StartInterval"] as? Int,
+ interval == requiredStartInterval
+ else {
+ return false
+ }
+ if let programArgs = plist["ProgramArguments"] as? [String], programArgs.contains(scriptDestination) == false {
+ return false
+ }
+ return true
+ }
+
+ private static func installLaunchDaemon() async throws {
+ let installerScript = makeInstallerScript()
+ try runShellAsAdmin(installerScript)
+ }
+
+ private static func makeInstallerScript() -> String {
+ """
+set -euo pipefail
+
+LABEL="\(daemonLabel)"
+SCRIPT_DEST="\(scriptDestination)"
+PLIST_DEST="\(plistDestination)"
+
+mkdir -p "$(dirname "$SCRIPT_DEST")"
+
+cat > "$SCRIPT_DEST" <<'EOF_SCRIPT'
+\(setupScript)
+EOF_SCRIPT
+chmod 755 "$SCRIPT_DEST"
+
+cat > "$PLIST_DEST" <<'EOF_PLIST'
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>\(daemonLabel)</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/bin/bash</string>
+ <string>\(scriptDestination)</string>
+ </array>
+ <key>StartInterval</key>
+ <integer>\(requiredStartInterval)</integer>
+ <key>RunAtLoad</key>
+ <true/>
+ <key>StandardOutPath</key>
+ <string>/var/log/\(daemonLabel).log</string>
+ <key>StandardErrorPath</key>
+ <string>/var/log/\(daemonLabel).err.log</string>
+</dict>
+</plist>
+EOF_PLIST
+
+launchctl bootout system/"$LABEL" >/dev/null 2>&1 || true
+launchctl bootstrap system "$PLIST_DEST"
+launchctl enable system/"$LABEL"
+launchctl kickstart -k system/"$LABEL"
+"""
+ }
+
+ private static func runShellAsAdmin(_ script: String) throws {
+ let escapedScript = script
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+
+ let appleScriptSource = """
+do shell script "\(escapedScript)" with administrator privileges
+"""
+
+ guard let appleScript = NSAppleScript(source: appleScriptSource) else {
+ throw NetworkSetupError.scriptCreationFailed
+ }
+
+ var errorInfo: NSDictionary?
+ appleScript.executeAndReturnError(&errorInfo)
+
+ if let errorInfo {
+ let message = errorInfo[NSAppleScript.errorMessage] as? String ?? "Unknown error"
+ throw NetworkSetupError.executionFailed(message)
+ }
+ }
+}
+
+enum NetworkSetupError: LocalizedError {
+ case scriptCreationFailed
+ case executionFailed(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .scriptCreationFailed:
+ return "Failed to create AppleScript for network setup"
+ case .executionFailed(let message):
+ return "Network setup script failed: \(message)"
+ }
+ }
+}
diff --git a/app/EXO/EXO/Services/NetworkStatusService.swift b/app/EXO/EXO/Services/NetworkStatusService.swift
new file mode 100644
index 00000000..16e09667
--- /dev/null
+++ b/app/EXO/EXO/Services/NetworkStatusService.swift
@@ -0,0 +1,174 @@
+import AppKit
+import Foundation
+
+@MainActor
+final class NetworkStatusService: ObservableObject {
+ @Published private(set) var status: NetworkStatus = .empty
+ private var timer: Timer?
+
+ func refresh() async {
+ let fetched = await Task.detached(priority: .background) {
+ NetworkStatusFetcher().fetch()
+ }.value
+ status = fetched
+ }
+
+ func startPolling(interval: TimeInterval = 30) {
+ timer?.invalidate()
+ timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
+ guard let self else { return }
+ Task { await self.refresh() }
+ }
+ if let timer {
+ RunLoop.main.add(timer, forMode: .common)
+ }
+ Task { await refresh() }
+ }
+
+ func stopPolling() {
+ timer?.invalidate()
+ timer = nil
+ }
+}
+
+struct NetworkStatus: Equatable {
+ let thunderboltBridgeState: ThunderboltState?
+ let bridgeInactive: Bool?
+ let interfaceStatuses: [InterfaceIpStatus]
+
+ static let empty = NetworkStatus(
+ thunderboltBridgeState: nil,
+ bridgeInactive: nil,
+ interfaceStatuses: []
+ )
+}
+
+struct InterfaceIpStatus: Equatable {
+ let interfaceName: String
+ let ipAddress: String?
+}
+
+enum ThunderboltState: Equatable {
+ case enabled
+ case disabled
+ case deleted
+}
+
+private struct NetworkStatusFetcher {
+ func fetch() -> NetworkStatus {
+ NetworkStatus(
+ thunderboltBridgeState: readThunderboltBridgeState(),
+ bridgeInactive: readBridgeInactive(),
+ interfaceStatuses: readInterfaceStatuses()
+ )
+ }
+
+ private func readThunderboltBridgeState() -> ThunderboltState? {
+ let result = runCommand(["networksetup", "-getnetworkserviceenabled", "Thunderbolt Bridge"])
+ guard result.exitCode == 0 else {
+ let lower = result.output.lowercased() + result.error.lowercased()
+ if lower.contains("not a recognized network service") {
+ return .deleted
+ }
+ return nil
+ }
+ let output = result.output.lowercased()
+ if output.contains("enabled") {
+ return .enabled
+ }
+ if output.contains("disabled") {
+ return .disabled
+ }
+ return nil
+ }
+
+ private func readBridgeInactive() -> Bool? {
+ let result = runCommand(["ifconfig", "bridge0"])
+ guard result.exitCode == 0 else { return nil }
+ guard let statusLine = result.output
+ .components(separatedBy: .newlines)
+ .first(where: { $0.contains("status:") })?
+ .lowercased()
+ else {
+ return nil
+ }
+ if statusLine.contains("inactive") {
+ return true
+ }
+ if statusLine.contains("active") {
+ return false
+ }
+ return nil
+ }
+
+ private func readInterfaceStatuses() -> [InterfaceIpStatus] {
+ (0...7).map { "en\($0)" }.map(readInterfaceStatus)
+ }
+
+ private func readInterfaceStatus(for interface: String) -> InterfaceIpStatus {
+ let result = runCommand(["ifconfig", interface])
+ guard result.exitCode == 0 else {
+ return InterfaceIpStatus(
+ interfaceName: interface,
+ ipAddress: nil
+ )
+ }
+
+ let output = result.output
+ let ip = firstInet(from: output)
+
+ return InterfaceIpStatus(
+ interfaceName: interface,
+ ipAddress: ip
+ )
+ }
+
+ private func firstInet(from ifconfigOutput: String) -> String? {
+ for line in ifconfigOutput.split(separator: "\n") {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard trimmed.hasPrefix("inet ") else { continue }
+ let parts = trimmed.split(separator: " ")
+ if parts.count >= 2 {
+ let candidate = String(parts[1])
+ if candidate != "127.0.0.1" {
+ return candidate
+ }
+ }
+ }
+ return nil
+ }
+
+ private struct CommandResult {
+ let exitCode: Int32
+ let output: String
+ let error: String
+ }
+
+ private func runCommand(_ arguments: [String]) -> CommandResult {
+ let process = Process()
+ process.launchPath = "/usr/bin/env"
+ process.arguments = arguments
+
+ let stdout = Pipe()
+ let stderr = Pipe()
+ process.standardOutput = stdout
+ process.standardError = stderr
+
+ do {
+ try process.run()
+ } catch {
+ return CommandResult(exitCode: -1, output: "", error: error.localizedDescription)
+ }
+ process.waitUntilExit()
+
+ let outputData = stdout.fileHandleForReading.readDataToEndOfFile()
+ let errorData = stderr.fileHandleForReading.readDataToEndOfFile()
+
+ return CommandResult(
+ exitCode: process.terminationStatus,
+ output: String(decoding: outputData, as: UTF8.self),
+ error: String(decoding: errorData, as: UTF8.self)
+ )
+ }
+}
+
diff --git a/app/EXO/EXO/ViewModels/InstanceViewModel.swift b/app/EXO/EXO/ViewModels/InstanceViewModel.swift
new file mode 100644
index 00000000..c472a162
--- /dev/null
+++ b/app/EXO/EXO/ViewModels/InstanceViewModel.swift
@@ -0,0 +1,246 @@
+import Foundation
+
+struct DownloadProgressViewModel: Equatable {
+ let downloadedBytes: Int64
+ let totalBytes: Int64
+ let speedBytesPerSecond: Double
+ let etaSeconds: Double?
+ let completedFiles: Int
+ let totalFiles: Int
+
+ var fractionCompleted: Double {
+ guard totalBytes > 0 else { return 0 }
+ return Double(downloadedBytes) / Double(totalBytes)
+ }
+
+ var percentCompleted: Double {
+ fractionCompleted * 100
+ }
+
+ var formattedProgress: String {
+ let downloaded = formatBytes(downloadedBytes)
+ let total = formatBytes(totalBytes)
+ let percent = String(format: "%.1f", percentCompleted)
+ return "\(downloaded)/\(total) (\(percent)%)"
+ }
+
+ var formattedSpeed: String {
+ "\(formatBytes(Int64(speedBytesPerSecond)))/s"
+ }
+
+ var formattedETA: String? {
+ guard let eta = etaSeconds, eta > 0 else { return nil }
+ let minutes = Int(eta) / 60
+ let seconds = Int(eta) % 60
+ if minutes > 0 {
+ return "ETA \(minutes)m \(seconds)s"
+ }
+ return "ETA \(seconds)s"
+ }
+
+ private func formatBytes(_ bytes: Int64) -> String {
+ let gb = Double(bytes) / 1_073_741_824.0
+ let mb = Double(bytes) / 1_048_576.0
+ if gb >= 1.0 {
+ return String(format: "%.2f GB", gb)
+ }
+ return String(format: "%.0f MB", mb)
+ }
+}
+
+struct InstanceViewModel: Identifiable, Equatable {
+ enum State {
+ case downloading
+ case warmingUp
+ case running
+ case ready
+ case waiting
+ case failed
+ case idle
+ case unknown
+
+ var label: String {
+ switch self {
+ case .downloading: return "Downloading"
+ case .warmingUp: return "Warming Up"
+ case .running: return "Running"
+ case .ready: return "Ready"
+ case .waiting: return "Waiting"
+ case .failed: return "Failed"
+ case .idle: return "Idle"
+ case .unknown: return "Unknown"
+ }
+ }
+ }
+
+ let id: String
+ let modelName: String
+ let sharding: String?
+ let nodeNames: [String]
+ let state: State
+ let chatTasks: [InstanceTaskViewModel]
+ let downloadProgress: DownloadProgressViewModel?
+
+ var nodeSummary: String {
+ guard !nodeNames.isEmpty else { return "0 nodes" }
+ if nodeNames.count == 1 {
+ return nodeNames[0]
+ }
+ if nodeNames.count == 2 {
+ return nodeNames.joined(separator: ", ")
+ }
+ let others = nodeNames.count - 1
+ return "\(nodeNames.first ?? "") +\(others)"
+ }
+}
+
+extension ClusterState {
+ func instanceViewModels() -> [InstanceViewModel] {
+ let chatTasksByInstance = Dictionary(
+ grouping: tasks.values.filter { $0.kind == .chatCompletion && $0.instanceId != nil },
+ by: { $0.instanceId! }
+ )
+
+ return instances.map { entry in
+ let instance = entry.value
+ let modelName = instance.shardAssignments.modelId
+ let nodeToRunner = instance.shardAssignments.nodeToRunner
+ let nodeIds = Array(nodeToRunner.keys)
+ let runnerIds = Array(nodeToRunner.values)
+ let nodeNames = nodeIds.compactMap { nodeProfiles[$0]?.friendlyName ?? nodeProfiles[$0]?.modelId ?? $0 }
+ let statuses = runnerIds.compactMap { runners[$0]?.status.lowercased() }
+ let downloadProgress = aggregateDownloadProgress(for: nodeIds)
+ let state = InstanceViewModel.State(statuses: statuses, hasActiveDownload: downloadProgress != nil)
+ let chatTasks = (chatTasksByInstance[entry.key] ?? [])
+ .sorted(by: { $0.sortPriority < $1.sortPriority })
+ .map { InstanceTaskViewModel(task: $0) }
+ return InstanceViewModel(
+ id: entry.key,
+ modelName: modelName,
+ sharding: InstanceViewModel.friendlyShardingName(for: instance.sharding),
+ nodeNames: nodeNames,
+ state: state,
+ chatTasks: chatTasks,
+ downloadProgress: downloadProgress
+ )
+ }
+ .sorted { $0.modelName < $1.modelName }
+ }
+
+ private func aggregateDownloadProgress(for nodeIds: [String]) -> DownloadProgressViewModel? {
+ var totalDownloaded: Int64 = 0
+ var totalSize: Int64 = 0
+ var totalSpeed: Double = 0
+ var maxEtaMs: Int64 = 0
+ var totalCompletedFiles = 0
+ var totalFileCount = 0
+ var hasActiveDownload = false
+
+ for nodeId in nodeIds {
+ guard let nodeDownloads = downloads[nodeId] else { continue }
+ for download in nodeDownloads {
+ guard let progress = download.progress else { continue }
+ hasActiveDownload = true
+ totalDownloaded += progress.downloadedBytes.inBytes
+ totalSize += progress.totalBytes.inBytes
+ totalSpeed += progress.speed ?? 0
+ if let eta = progress.etaMs {
+ maxEtaMs = max(maxEtaMs, eta)
+ }
+ totalCompletedFiles += progress.completedFiles ?? 0
+ totalFileCount += progress.totalFiles ?? 0
+ }
+ }
+
+ guard hasActiveDownload else { return nil }
+
+ return DownloadProgressViewModel(
+ downloadedBytes: totalDownloaded,
+ totalBytes: totalSize,
+ speedBytesPerSecond: totalSpeed,
+ etaSeconds: maxEtaMs > 0 ? Double(maxEtaMs) / 1000.0 : nil,
+ completedFiles: totalCompletedFiles,
+ totalFiles: totalFileCount
+ )
+ }
+}
+
+private extension InstanceViewModel.State {
+ init(statuses: [String], hasActiveDownload: Bool = false) {
+ if statuses.contains(where: { $0.contains("failed") }) {
+ self = .failed
+ } else if hasActiveDownload || statuses.contains(where: { $0.contains("downloading") }) {
+ self = .downloading
+ } else if statuses.contains(where: { $0.contains("warming") }) {
+ self = .warmingUp
+ } else if statuses.contains(where: { $0.contains("running") }) {
+ self = .running
+ } else if statuses.contains(where: { $0.contains("ready") || $0.contains("loaded") }) {
+ self = .ready
+ } else if statuses.contains(where: { $0.contains("waiting") }) {
+ self = .waiting
+ } else if statuses.isEmpty {
+ self = .idle
+ } else {
+ self = .unknown
+ }
+ }
+}
+
+extension InstanceViewModel {
+ static func friendlyShardingName(for raw: String?) -> String? {
+ guard let raw else { return nil }
+ switch raw.lowercased() {
+ case "mlxringinstance", "mlxring":
+ return "MLX Ring"
+ case "mlxibvinstance", "mlxibv":
+ return "MLX RDMA"
+ default:
+ return raw
+ }
+ }
+}
+
+struct InstanceTaskViewModel: Identifiable, Equatable {
+ enum Kind {
+ case chatCompletion
+ }
+
+ let id: String
+ let kind: Kind
+ let status: TaskStatus
+ let modelName: String?
+ let promptPreview: String?
+ let errorMessage: String?
+ let subtitle: String?
+ let parameters: ChatCompletionTaskParameters?
+
+ var title: String {
+ switch kind {
+ case .chatCompletion:
+ return "Chat Completion"
+ }
+ }
+
+ var detailText: String? {
+ if let errorMessage, !errorMessage.isEmpty {
+ return errorMessage
+ }
+ return promptPreview
+ }
+
+}
+
+extension InstanceTaskViewModel {
+ init(task: ClusterTask) {
+ self.id = task.id
+ self.kind = .chatCompletion
+ self.status = task.status
+ self.modelName = task.modelName
+ self.promptPreview = task.promptPreview
+ self.errorMessage = task.errorMessage
+ self.subtitle = task.modelName
+ self.parameters = task.parameters
+ }
+}
+
diff --git a/app/EXO/EXO/ViewModels/NodeViewModel.swift b/app/EXO/EXO/ViewModels/NodeViewModel.swift
new file mode 100644
index 00000000..684f4487
--- /dev/null
+++ b/app/EXO/EXO/ViewModels/NodeViewModel.swift
@@ -0,0 +1,121 @@
+import Foundation
+
+struct NodeViewModel: Identifiable, Equatable {
+ let id: String
+ let friendlyName: String
+ let model: String
+ let usedRamGB: Double
+ let totalRamGB: Double
+ let gpuUsagePercent: Double
+ let cpuUsagePercent: Double
+ let temperatureCelsius: Double
+ let systenPowerWatts: Double
+
+ var memoryProgress: Double {
+ guard totalRamGB > 0 else { return 0 }
+ return min(max(usedRamGB / totalRamGB, 0), 1)
+ }
+
+ var memoryLabel: String {
+ String(format: "%.1f / %.1f GB", usedRamGB, totalRamGB)
+ }
+
+ var temperatureLabel: String {
+ String(format: "%.0f°C", temperatureCelsius)
+ }
+
+ var powerLabel: String {
+ systenPowerWatts > 0 ? String(format: "%.0fW", systenPowerWatts) : "—"
+ }
+
+ var cpuUsageLabel: String {
+ String(format: "%.0f%%", cpuUsagePercent)
+ }
+
+ var gpuUsageLabel: String {
+ String(format: "%.0f%%", gpuUsagePercent)
+ }
+
+ var deviceIconName: String {
+ let lower = model.lowercased()
+ if lower.contains("studio") {
+ return "macstudio"
+ }
+ if lower.contains("mini") {
+ return "macmini"
+ }
+ return "macbook"
+ }
+}
+
+extension ClusterState {
+ func nodeViewModels() -> [NodeViewModel] {
+ nodeProfiles.map { entry in
+ let profile = entry.value
+ let friendly = profile.friendlyName ?? profile.modelId ?? entry.key
+ let model = profile.modelId ?? "Unknown"
+ let totalBytes = Double(profile.memory?.ramTotal?.inBytes ?? 0)
+ let availableBytes = Double(profile.memory?.ramAvailable?.inBytes ?? 0)
+ let usedBytes = max(totalBytes - availableBytes, 0)
+ return NodeViewModel(
+ id: entry.key,
+ friendlyName: friendly,
+ model: model,
+ usedRamGB: usedBytes / 1_073_741_824.0,
+ totalRamGB: totalBytes / 1_073_741_824.0,
+ gpuUsagePercent: (profile.system?.gpuUsage ?? 0) * 100,
+ cpuUsagePercent: (profile.system?.pcpuUsage ?? 0) * 100,
+ temperatureCelsius: profile.system?.temp ?? 0,
+ systenPowerWatts: profile.system?.sysPower ?? 0
+ )
+ }
+ .sorted { $0.friendlyName < $1.friendlyName }
+ }
+}
+
+struct TopologyEdgeViewModel: Hashable {
+ let sourceId: String
+ let targetId: String
+}
+
+struct TopologyViewModel {
+ let nodes: [NodeViewModel]
+ let edges: [TopologyEdgeViewModel]
+ let currentNodeId: String?
+}
+
+extension ClusterState {
+ func topologyViewModel() -> TopologyViewModel? {
+ let topologyNodeIds = Set(topology?.nodes.map(\.nodeId) ?? [])
+ let allNodes = nodeViewModels().filter { topologyNodeIds.isEmpty || topologyNodeIds.contains($0.id) }
+ guard !allNodes.isEmpty else { return nil }
+
+ let nodesById = Dictionary(uniqueKeysWithValues: allNodes.map { ($0.id, $0) })
+ var orderedNodes: [NodeViewModel] = []
+ if let topologyNodes = topology?.nodes {
+ for topoNode in topologyNodes {
+ if let viewModel = nodesById[topoNode.nodeId] {
+ orderedNodes.append(viewModel)
+ }
+ }
+ let seenIds = Set(orderedNodes.map(\.id))
+ let remaining = allNodes.filter { !seenIds.contains($0.id) }
+ orderedNodes.append(contentsOf: remaining)
+ } else {
+ orderedNodes = allNodes
+ }
+
+ let nodeIds = Set(orderedNodes.map(\.id))
+ let edgesArray: [TopologyEdgeViewModel] = topology?.connections?.compactMap { connection in
+ guard nodeIds.contains(connection.localNodeId), nodeIds.contains(connection.sendBackNodeId) else { return nil }
+ return TopologyEdgeViewModel(sourceId: connection.localNodeId, targetId: connection.sendBackNodeId)
+ } ?? []
+ let edges = Set(edgesArray)
+
+ let topologyRootId = topology?.nodes.first?.nodeId
+ let currentId = orderedNodes.first(where: { $0.id == topologyRootId })?.id ?? orderedNodes.first?.id
+
+ return TopologyViewModel(nodes: orderedNodes, edges: Array(edges), currentNodeId: currentId)
+ }
+}
+
diff --git a/app/EXO/EXO/Views/InstanceRowView.swift b/app/EXO/EXO/Views/InstanceRowView.swift
new file mode 100644
index 00000000..92217525
--- /dev/null
+++ b/app/EXO/EXO/Views/InstanceRowView.swift
@@ -0,0 +1,332 @@
+import SwiftUI
+
+struct InstanceRowView: View {
+ let instance: InstanceViewModel
+ @State private var animatedTaskIDs: Set<String> = []
+ @State private var infoTask: InstanceTaskViewModel?
+ @State private var showChatTasks = true
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 8) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(instance.modelName)
+ .font(.subheadline)
+ Text(instance.nodeSummary)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ if let progress = instance.downloadProgress {
+ downloadStatusView(progress: progress)
+ } else {
+ statusChip(label: instance.state.label.uppercased(), color: statusColor)
+ }
+ }
+ if let progress = instance.downloadProgress {
+ GeometryReader { geometry in
+ HStack {
+ Spacer()
+ downloadProgressBar(progress: progress)
+ .frame(width: geometry.size.width * 0.5)
+ }
+ }
+ .frame(height: 4)
+ .padding(.top, -8)
+ .padding(.bottom, 2)
+ HStack(spacing: 8) {
+ Text(instance.sharding ?? "")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ Spacer()
+ downloadSpeedView(progress: progress)
+ }
+ } else {
+ Text(instance.sharding ?? "")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+ if !instance.chatTasks.isEmpty {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text("Chat Tasks")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ Text("(\(instance.chatTasks.count))")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ Spacer()
+ collapseButton(isExpanded: $showChatTasks)
+ }
+ .animation(nil, value: showChatTasks)
+ if showChatTasks {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(instance.chatTasks) { task in
+ taskRow(for: task, parentModelName: instance.modelName)
+ }
+ }
+ .transition(.opacity)
+ }
+ }
+ .padding(.top, 4)
+ .animation(.easeInOut(duration: 0.25), value: showChatTasks)
+ }
+ }
+ .padding(.vertical, 6)
+ }
+
+ private var statusColor: Color {
+ switch instance.state {
+ case .downloading: return .blue
+ case .warmingUp: return .orange
+ case .running: return .green
+ case .ready: return .teal
+ case .waiting, .idle: return .gray
+ case .failed: return .red
+ case .unknown: return .secondary
+ }
+ }
+
+ @ViewBuilder
+ private func taskRow(for task: InstanceTaskViewModel, parentModelName: String) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .top, spacing: 8) {
+ taskStatusIcon(for: task)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Chat")
+ .font(.caption)
+ .fontWeight(.semibold)
+ if let subtitle = task.subtitle,
+ subtitle.caseInsensitiveCompare(parentModelName) != .orderedSame {
+ Text(subtitle)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+ if let prompt = task.promptPreview, !prompt.isEmpty {
+ Text("⊙ \(prompt)")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ .lineLimit(2)
+ }
+ if task.status == .failed, let error = task.errorMessage, !error.isEmpty {
+ Text(error)
+ .font(.caption2)
+ .foregroundColor(.red)
+ .lineLimit(3)
+ }
+ }
+ Spacer(minLength: 6)
+ Button {
+ infoTask = task
+ } label: {
+ Image(systemName: "info.circle")
+ .imageScale(.small)
+ }
+ .buttonStyle(.plain)
+ .popover(
+ item: Binding<InstanceTaskViewModel?>(
+ get: { infoTask?.id == task.id ? infoTask : nil },
+ set: { newValue in
+ if newValue == nil {
+ infoTask = nil
+ } else {
+ infoTask = newValue
+ }
+ }
+ ),
+ attachmentAnchor: .rect(.bounds),
+ arrowEdge: .top
+ ) { _ in
+ TaskDetailView(task: task)
+ .padding()
+ .frame(width: 240)
+ }
+ }
+ }
+ }
+
+ private func taskStatusIcon(for task: InstanceTaskViewModel) -> some View {
+ let icon: String
+ let color: Color
+ let animation: Animation?
+
+ switch task.status {
+ case .running:
+ icon = "arrow.triangle.2.circlepath"
+ color = .blue
+ animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)
+ case .pending:
+ icon = "circle.dashed"
+ color = .secondary
+ animation = nil
+ case .failed:
+ icon = "exclamationmark.triangle.fill"
+ color = .red
+ animation = nil
+ case .complete:
+ icon = "checkmark.circle.fill"
+ color = .green
+ animation = nil
+ case .unknown:
+ icon = "questionmark.circle"
+ color = .secondary
+ animation = nil
+ }
+
+ let image = Image(systemName: icon)
+ .imageScale(.small)
+ .foregroundColor(color)
+
+ if let animation {
+ return AnyView(
+ image
+ .rotationEffect(.degrees(animatedTaskIDs.contains(task.id) ? 360 : 0))
+ .onAppear {
+ if !animatedTaskIDs.contains(task.id) {
+ animatedTaskIDs.insert(task.id)
+ }
+ }
+ .animation(animation, value: animatedTaskIDs)
+ )
+ }
+
+ return AnyView(image)
+ }
+
+ private func statusChip(label: String, color: Color) -> some View {
+ Text(label)
+ .font(.caption2)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(color.opacity(0.15))
+ .foregroundColor(color)
+ .clipShape(Capsule())
+ }
+
+ private func downloadStatusView(progress: DownloadProgressViewModel) -> some View {
+ VStack(alignment: .trailing, spacing: 4) {
+ statusChip(label: "DOWNLOADING", color: .blue)
+ Text(progress.formattedProgress)
+ .foregroundColor(.primary)
+ }
+ .font(.caption2)
+ }
+
+ private func downloadSpeedView(progress: DownloadProgressViewModel) -> some View {
+ HStack(spacing: 4) {
+ Text(progress.formattedSpeed)
+ if let eta = progress.formattedETA {
+ Text("·")
+ Text(eta)
+ }
+ }
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+
+ private func downloadProgressBar(progress: DownloadProgressViewModel) -> some View {
+ ProgressView(value: progress.fractionCompleted)
+ .progressViewStyle(.linear)
+ .tint(.blue)
+ }
+
+ private func collapseButton(isExpanded: Binding<Bool>) -> some View {
+ Button {
+ isExpanded.wrappedValue.toggle()
+ } label: {
+ Label(isExpanded.wrappedValue ? "Hide" : "Show", systemImage: isExpanded.wrappedValue ? "chevron.up" : "chevron.down")
+ .labelStyle(.titleAndIcon)
+ .contentTransition(.symbolEffect(.replace))
+ }
+ .buttonStyle(.plain)
+ .font(.caption2)
+ }
+
+ private struct TaskDetailView: View, Identifiable {
+ let task: InstanceTaskViewModel
+ var id: String { task.id }
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 12) {
+ parameterSection
+ messageSection
+ if let error = task.errorMessage, !error.isEmpty {
+ detailRow(
+ icon: "exclamationmark.triangle.fill",
+ title: "Error",
+ value: error,
+ tint: .red
+ )
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var parameterSection: some View {
+ if let params = task.parameters {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Parameters")
+ .font(.subheadline)
+ if let temperature = params.temperature {
+ detailRow(title: "Temperature", value: String(format: "%.1f", temperature))
+ }
+ if let maxTokens = params.maxTokens {
+ detailRow(title: "Max Tokens", value: "\(maxTokens)")
+ }
+ if let stream = params.stream {
+ detailRow(title: "Stream", value: stream ? "On" : "Off")
+ }
+ if let topP = params.topP {
+ detailRow(title: "Top P", value: String(format: "%.2f", topP))
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var messageSection: some View {
+ if let messages = task.parameters?.messages, !messages.isEmpty {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Messages")
+ .font(.subheadline)
+ ForEach(Array(messages.enumerated()), id: \.offset) { _, message in
+ VStack(alignment: .leading, spacing: 2) {
+ Text(message.role?.capitalized ?? "Message")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ if let content = message.content, !content.isEmpty {
+ Text(content)
+ .font(.caption2)
+ .foregroundColor(.primary)
+ }
+ }
+ .padding(8)
+ .background(Color.secondary.opacity(0.08))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func detailRow(icon: String? = nil, title: String, value: String, tint: Color = .secondary) -> some View {
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ if let icon {
+ Image(systemName: icon)
+ .imageScale(.small)
+ .foregroundColor(tint)
+ }
+ Text(title)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ Text(value)
+ .font(.caption2)
+ .foregroundColor(.primary)
+ }
+ }
+ }
+}
+
diff --git a/app/EXO/EXO/Views/NodeDetailView.swift b/app/EXO/EXO/Views/NodeDetailView.swift
new file mode 100644
index 00000000..5a38a65b
--- /dev/null
+++ b/app/EXO/EXO/Views/NodeDetailView.swift
@@ -0,0 +1,35 @@
+import SwiftUI
+
+struct NodeDetailView: View {
+ let node: NodeViewModel
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(node.friendlyName)
+ .font(.headline)
+ Text(node.model)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Divider()
+ metricRow(label: "Memory", value: node.memoryLabel)
+ ProgressView(value: node.memoryProgress)
+ metricRow(label: "CPU Usage", value: node.cpuUsageLabel)
+ metricRow(label: "GPU Usage", value: node.gpuUsageLabel)
+ metricRow(label: "Temperature", value: node.temperatureLabel)
+ metricRow(label: "Power", value: node.powerLabel)
+ }
+ .padding()
+ }
+
+ private func metricRow(label: String, value: String) -> some View {
+ HStack {
+ Text(label)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ Text(value)
+ .font(.subheadline)
+ }
+ }
+}
+
diff --git a/app/EXO/EXO/Views/NodeRowView.swift b/app/EXO/EXO/Views/NodeRowView.swift
new file mode 100644
index 00000000..730e9ad4
--- /dev/null
+++ b/app/EXO/EXO/Views/NodeRowView.swift
@@ -0,0 +1,31 @@
+import SwiftUI
+
+struct NodeRowView: View {
+ let node: NodeViewModel
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ VStack(alignment: .leading) {
+ Text(node.friendlyName)
+ .font(.subheadline)
+ Text(node.memoryLabel)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ VStack(alignment: .trailing) {
+ Text("\(node.gpuUsagePercent, specifier: "%.0f")% GPU")
+ .font(.caption)
+ Text(node.temperatureLabel)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+ }
+ ProgressView(value: node.memoryProgress)
+ .progressViewStyle(.linear)
+ }
+ .padding(.vertical, 4)
+ }
+}
+
diff --git a/app/EXO/EXO/Views/TopologyMiniView.swift b/app/EXO/EXO/Views/TopologyMiniView.swift
new file mode 100644
index 00000000..eccab35b
--- /dev/null
+++ b/app/EXO/EXO/Views/TopologyMiniView.swift
@@ -0,0 +1,172 @@
+import SwiftUI
+
+struct TopologyMiniView: View {
+ let topology: TopologyViewModel
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Topology")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ GeometryReader { geo in
+ ZStack {
+ connectionLines(in: geo.size)
+ let positions = positionedNodes(in: geo.size)
+ ForEach(Array(positions.enumerated()), id: \.element.node.id) { _, positioned in
+ NodeGlyphView(node: positioned.node, isCurrent: positioned.isCurrent)
+ .position(positioned.point)
+ }
+ }
+ }
+ .frame(height: heightForNodes())
+ }
+ }
+
+ private func positionedNodes(in size: CGSize) -> [PositionedNode] {
+ let nodes = orderedNodesForLayout()
+ guard !nodes.isEmpty else { return [] }
+ var result: [PositionedNode] = []
+ let glyphHeight: CGFloat = 70
+ let rootPoint = CGPoint(x: size.width / 2, y: glyphHeight / 2 + 10)
+ result.append(
+ PositionedNode(
+ node: nodes[0],
+ point: rootPoint,
+ isCurrent: nodes[0].id == topology.currentNodeId
+ )
+ )
+ guard nodes.count > 1 else { return result }
+ let childCount = nodes.count - 1
+ // Larger radius to reduce overlap when several nodes exist
+ let minDimension = min(size.width, size.height)
+ let radius = max(120, minDimension * 0.42)
+ let startAngle = Double.pi * 0.75
+ let endAngle = Double.pi * 0.25
+ let step = childCount == 1 ? 0 : (startAngle - endAngle) / Double(childCount - 1)
+ for (index, node) in nodes.dropFirst().enumerated() {
+ let angle = startAngle - step * Double(index)
+ let x = size.width / 2 + radius * CGFloat(cos(angle))
+ let y = rootPoint.y + radius * CGFloat(sin(angle)) + glyphHeight / 2
+ result.append(
+ PositionedNode(
+ node: node,
+ point: CGPoint(x: x, y: y),
+ isCurrent: node.id == topology.currentNodeId
+ )
+ )
+ }
+ return result
+ }
+
+ private func orderedNodesForLayout() -> [NodeViewModel] {
+ guard let currentId = topology.currentNodeId else {
+ return topology.nodes
+ }
+ guard let currentIndex = topology.nodes.firstIndex(where: { $0.id == currentId }) else {
+ return topology.nodes
+ }
+ if currentIndex == 0 {
+ return topology.nodes
+ }
+ var reordered = topology.nodes
+ let current = reordered.remove(at: currentIndex)
+ reordered.insert(current, at: 0)
+ return reordered
+ }
+
+ private func connectionLines(in size: CGSize) -> some View {
+ let positions = positionedNodes(in: size)
+ let positionById = Dictionary(uniqueKeysWithValues: positions.map { ($0.node.id, $0.point) })
+ return Canvas { context, _ in
+ guard !topology.edges.isEmpty else { return }
+ let nodeRadius: CGFloat = 32
+ let arrowLength: CGFloat = 10
+ let arrowSpread: CGFloat = .pi / 7
+ for edge in topology.edges {
+ guard let start = positionById[edge.sourceId], let end = positionById[edge.targetId] else { continue }
+ let dx = end.x - start.x
+ let dy = end.y - start.y
+ let distance = max(CGFloat(hypot(dx, dy)), 1)
+ let ux = dx / distance
+ let uy = dy / distance
+ let adjustedStart = CGPoint(x: start.x + ux * nodeRadius, y: start.y + uy * nodeRadius)
+ let adjustedEnd = CGPoint(x: end.x - ux * nodeRadius, y: end.y - uy * nodeRadius)
+
+ var linePath = Path()
+ linePath.move(to: adjustedStart)
+ linePath.addLine(to: adjustedEnd)
+ context.stroke(
+ linePath,
+ with: .color(.secondary.opacity(0.3)),
+ style: StrokeStyle(lineWidth: 1, dash: [4, 4])
+ )
+
+ let angle = atan2(uy, ux)
+ let tip = adjustedEnd
+ let leftWing = CGPoint(
+ x: tip.x - arrowLength * cos(angle - arrowSpread),
+ y: tip.y - arrowLength * sin(angle - arrowSpread)
+ )
+ let rightWing = CGPoint(
+ x: tip.x - arrowLength * cos(angle + arrowSpread),
+ y: tip.y - arrowLength * sin(angle + arrowSpread)
+ )
+ var arrowPath = Path()
+ arrowPath.move(to: tip)
+ arrowPath.addLine(to: leftWing)
+ arrowPath.move(to: tip)
+ arrowPath.addLine(to: rightWing)
+ context.stroke(
+ arrowPath,
+ with: .color(.secondary.opacity(0.5)),
+ style: StrokeStyle(lineWidth: 1)
+ )
+ }
+ }
+ }
+
+ private func heightForNodes() -> CGFloat {
+ switch topology.nodes.count {
+ case 0...1:
+ return 130
+ case 2...3:
+ return 200
+ default:
+ return 240
+ }
+ }
+
+ private struct PositionedNode {
+ let node: NodeViewModel
+ let point: CGPoint
+ let isCurrent: Bool
+ }
+}
+
+private struct NodeGlyphView: View {
+ let node: NodeViewModel
+ let isCurrent: Bool
+
+ var body: some View {
+ VStack(spacing: 2) {
+ Image(systemName: node.deviceIconName)
+ .font(.subheadline)
+ Text(node.friendlyName)
+ .font(.caption2)
+ .lineLimit(1)
+ .foregroundColor(isCurrent ? Color(nsColor: .systemBlue) : .primary)
+ Text(node.memoryLabel)
+ .font(.caption2)
+ HStack(spacing: 3) {
+ Text(node.gpuUsageLabel)
+ Text(node.temperatureLabel)
+ }
+ .foregroundColor(.secondary)
+ .font(.caption2)
+ }
+ .padding(.vertical, 3)
+ .frame(width: 95)
+ }
+}
+
+
diff --git a/app/EXO/EXOTests/EXOTests.swift b/app/EXO/EXOTests/EXOTests.swift
new file mode 100644
index 00000000..d8595d48
--- /dev/null
+++ b/app/EXO/EXOTests/EXOTests.swift
@@ -0,0 +1,17 @@
+//
+// EXOTests.swift
+// EXOTests
+//
+// Created by Sami Khan on 2025-11-22.
+//
+
+import Testing
+@testable import EXO
+
+struct EXOTests {
+
+ @Test func example() async throws {
+ // Write your test here and use APIs like `#expect(...)` to check expected conditions.
+ }
+
+}
diff --git a/app/EXO/EXOUITests/EXOUITests.swift b/app/EXO/EXOUITests/EXOUITests.swift
new file mode 100644
index 00000000..6293a292
--- /dev/null
+++ b/app/EXO/EXOUITests/EXOUITests.swift
@@ -0,0 +1,43 @@
+//
+// EXOUITests.swift
+// EXOUITests
+//
+// Created by Sami Khan on 2025-11-22.
+//
+
+import XCTest
+
+final class EXOUITests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+
+ // In UI tests it is usually best to stop immediately when a failure occurs.
+ continueAfterFailure = false
+
+ // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ @MainActor
+ func testExample() throws {
+ // UI tests must launch the application that they test.
+ let app = XCUIApplication()
+ app.launch()
+
+ // Use XCTAssert and related functions to verify your tests produce the correct results.
+ }
+
+ @MainActor
+ func testLaunchPerformance() throws {
+ if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
+ // This measures how long it takes to launch your application.
+ measure(metrics: [XCTApplicationLaunchMetric()]) {
+ XCUIApplication().launch()
+ }
+ }
+ }
+}
diff --git a/app/EXO/EXOUITests/EXOUITestsLaunchTests.swift b/app/EXO/EXOUITests/EXOUITestsLaunchTests.swift
new file mode 100644
index 00000000..e17d4062
--- /dev/null
+++ b/app/EXO/EXOUITests/EXOUITestsLaunchTests.swift
@@ -0,0 +1,33 @@
+//
+// EXOUITestsLaunchTests.swift
+// EXOUITests
+//
+// Created by Sami Khan on 2025-11-22.
+//
+
+import XCTest
+
+final class EXOUITestsLaunchTests: XCTestCase {
+
+ override class var runsForEachTargetApplicationUIConfiguration: Bool {
+ true
+ }
+
+ override func setUpWithError() throws {
+ continueAfterFailure = false
+ }
+
+ @MainActor
+ func testLaunch() throws {
+ let app = XCUIApplication()
+ app.launch()
+
+ // Insert steps here to perform after app launch but before taking a screenshot,
+ // such as logging into a test account or navigating somewhere in the app
+
+ let attachment = XCTAttachment(screenshot: app.screenshot())
+ attachment.name = "Launch Screen"
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+}
← 74bae3ba Update README.md
·
back to Exo
·
LICENSE: Fix company name/year 9e2bdeef →