From 379744fe5c2ca7c847768d87b3bdb0f0988ae554 Mon Sep 17 00:00:00 2001 From: Jake Hillion Date: Thu, 18 Dec 2025 16:36:09 +0000 Subject: [PATCH] exo: open source mac app and build process --- .gitignore | 3 + app/EXO/EXO.xcodeproj/project.pbxproj | 602 ++++++++++++++++++ .../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 .../AppIcon.appiconset/128-mac.png | Bin 0 -> 2462 bytes .../AppIcon.appiconset/16-mac.png | Bin 0 -> 324 bytes .../AppIcon.appiconset/256-mac 1.png | Bin 0 -> 4522 bytes .../AppIcon.appiconset/256-mac.png | Bin 0 -> 4522 bytes .../AppIcon.appiconset/32-mac 1.png | Bin 0 -> 758 bytes .../AppIcon.appiconset/32-mac.png | Bin 0 -> 758 bytes .../AppIcon.appiconset/512-mac 1.png | Bin 0 -> 8624 bytes .../AppIcon.appiconset/512-mac.png | Bin 0 -> 8624 bytes .../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 ++++++++++++++++ .../EXO/Services/ClusterStateService.swift | 145 +++++ app/EXO/EXO/Services/NetworkSetupHelper.swift | 187 ++++++ .../EXO/Services/NetworkStatusService.swift | 174 +++++ .../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 ++ .../EXOUITests/EXOUITestsLaunchTests.swift | 33 + 41 files changed, 4319 insertions(+) create mode 100644 app/EXO/EXO.xcodeproj/project.pbxproj create mode 100644 app/EXO/EXO.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 app/EXO/EXO.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 app/EXO/EXO.xcodeproj/xcshareddata/xcschemes/EXO.xcscheme create mode 100644 app/EXO/EXO.xcodeproj/xcuserdata/samikhan.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 app/EXO/EXO/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/1024-mac.png create mode 100644 app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/128-mac.png create mode 100644 app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/16-mac.png create mode 100644 app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac 1.png create mode 100644 app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac.png create mode 100644 app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac 1.png create mode 100644 app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac.png create mode 100644 app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac 1.png create mode 100644 app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac.png create mode 100644 app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/64-mac.png create mode 100644 app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 app/EXO/EXO/Assets.xcassets/Contents.json create mode 100644 app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/Contents.json create mode 100644 app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/exo-logo-hq-square-transparent-bg.png create mode 100644 app/EXO/EXO/ContentView.swift create mode 100644 app/EXO/EXO/EXO.entitlements create mode 100644 app/EXO/EXO/EXOApp.swift create mode 100644 app/EXO/EXO/ExoProcessController.swift create mode 100644 app/EXO/EXO/Info.plist create mode 100644 app/EXO/EXO/Models/ClusterState.swift create mode 100644 app/EXO/EXO/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 app/EXO/EXO/Services/BugReportService.swift create mode 100644 app/EXO/EXO/Services/ClusterStateService.swift create mode 100644 app/EXO/EXO/Services/NetworkSetupHelper.swift create mode 100644 app/EXO/EXO/Services/NetworkStatusService.swift create mode 100644 app/EXO/EXO/ViewModels/InstanceViewModel.swift create mode 100644 app/EXO/EXO/ViewModels/NodeViewModel.swift create mode 100644 app/EXO/EXO/Views/InstanceRowView.swift create mode 100644 app/EXO/EXO/Views/NodeDetailView.swift create mode 100644 app/EXO/EXO/Views/NodeRowView.swift create mode 100644 app/EXO/EXO/Views/TopologyMiniView.swift create mode 100644 app/EXO/EXOTests/EXOTests.swift create mode 100644 app/EXO/EXOUITests/EXOUITests.swift create mode 100644 app/EXO/EXOUITests/EXOUITestsLaunchTests.swift 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + E0140D112ED1F79A001F3171 /* EXO */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = EXO; + sourceTree = ""; + }; + E0140D232ED1F79B001F3171 /* EXOTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = EXOTests; + sourceTree = ""; + }; + E0140D2D2ED1F79B001F3171 /* EXOUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = EXOUITests; + sourceTree = ""; + }; +/* 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 = ""; + }; + E0140D102ED1F79A001F3171 /* Products */ = { + isa = PBXGroup; + children = ( + E0140D0F2ED1F79A001F3171 /* EXO.app */, + E0140D202ED1F79B001F3171 /* EXOTests.xctest */, + E0140D2A2ED1F79B001F3171 /* EXOUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 @@ + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + SchemeUserState + + EXO.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + E0140D0E2ED1F79A001F3171 + + primary + + + E0140D1F2ED1F79B001F3171 + + primary + + + E0140D292ED1F79B001F3171 + + primary + + + + + 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 0000000000000000000000000000000000000000..a78c7bf2ebb5fa59f70537ea3b82e75f150353a2 GIT binary patch literal 15062 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..500fab16c697de618f8b862bccc4a5f8698640c3 GIT binary patch literal 2462 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 ca*~5=YuAdU$yeVTOVj<_U-Vs7)qJ6ad{5s>l W4q}|?@#L;PVtM?oH+q6xfB*nEd52E` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7380a155c2b91f2b2bb2ad0f9d5e3a617516fb65 GIT binary patch literal 4522 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%0ABbKuPztmWhjHo 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 jbKfik*H5q1uKyO5F9-){-N=!} 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%0ABbKuPztmWhjHo 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 literal 0 HcmV?d00001 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 = [] + @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) -> 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 { + 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 { + Binding( + 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 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.automation.apple-events + + com.apple.security.files.user-selected.read-only + + + 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? + + 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? + + 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 @@ + + + + + SUFeedURL + https://assets.exolabs.net/appcast.xml + EXOBuildTag + $(EXO_BUILD_TAG) + EXOBuildCommit + $(EXO_BUILD_COMMIT) + + 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.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' + + + + + Label + \(daemonLabel) + ProgramArguments + + /bin/bash + \(scriptDestination) + + StartInterval + \(requiredStartInterval) + RunAtLoad + + StandardOutPath + /var/log/\(daemonLabel).log + StandardErrorPath + /var/log/\(daemonLabel).err.log + + +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 = [] + @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( + 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) -> 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) + } +}