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 00000000..a78c7bf2 Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/1024-mac.png differ diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/128-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/128-mac.png new file mode 100644 index 00000000..500fab16 Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/128-mac.png differ diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/16-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/16-mac.png new file mode 100644 index 00000000..0310fb30 Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/16-mac.png differ diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac 1.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac 1.png new file mode 100644 index 00000000..7380a155 Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac 1.png differ diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac.png new file mode 100644 index 00000000..7380a155 Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac.png differ diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac 1.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac 1.png new file mode 100644 index 00000000..2c2cc206 Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac 1.png differ diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac.png new file mode 100644 index 00000000..2c2cc206 Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac.png differ diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac 1.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac 1.png new file mode 100644 index 00000000..84e5ca77 Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac 1.png differ diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac.png new file mode 100644 index 00000000..84e5ca77 Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac.png differ diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/64-mac.png b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/64-mac.png new file mode 100644 index 00000000..210db023 Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/64-mac.png differ diff --git a/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..0373f0e8 --- /dev/null +++ b/app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "16-mac.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "32-mac.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "32-mac 1.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "64-mac.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "128-mac.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "256-mac 1.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "256-mac.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "512-mac.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "512-mac 1.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "1024-mac.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/EXO/EXO/Assets.xcassets/Contents.json b/app/EXO/EXO/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/app/EXO/EXO/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/Contents.json b/app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/Contents.json new file mode 100644 index 00000000..96f76627 --- /dev/null +++ b/app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "exo-logo-hq-square-transparent-bg.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/exo-logo-hq-square-transparent-bg.png b/app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/exo-logo-hq-square-transparent-bg.png new file mode 100644 index 00000000..5b463dc7 Binary files /dev/null and b/app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/exo-logo-hq-square-transparent-bg.png differ diff --git a/app/EXO/EXO/ContentView.swift b/app/EXO/EXO/ContentView.swift new file mode 100644 index 00000000..774b2b09 --- /dev/null +++ b/app/EXO/EXO/ContentView.swift @@ -0,0 +1,494 @@ +// +// ContentView.swift +// EXO +// +// Created by Sami Khan on 2025-11-22. +// + +import AppKit +import SwiftUI + +struct ContentView: View { + @EnvironmentObject private var controller: ExoProcessController + @EnvironmentObject private var stateService: ClusterStateService + @EnvironmentObject private var networkStatusService: NetworkStatusService + @EnvironmentObject private var updater: SparkleUpdater + @State private var focusedNode: NodeViewModel? + @State private var deletingInstanceIDs: Set = [] + @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) + } +}