[object Object]

← back to Exo

exo: open source mac app and build process

379744fe5c2ca7c847768d87b3bdb0f0988ae554 · 2025-12-18 16:36:09 +0000 · Jake Hillion

Files touched

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 →