exo: open source mac app and build process

This commit is contained in:
Jake Hillion
2025-12-18 16:36:09 +00:00
parent 74bae3ba6d
commit 379744fe5c
41 changed files with 4319 additions and 0 deletions

3
.gitignore vendored
View File

@@ -10,7 +10,10 @@ digest.txt
# xcode / macos # xcode / macos
*.xcuserstate *.xcuserstate
*.xcuserdata
*.xcuserdatad/
**/.DS_Store **/.DS_Store
app/EXO/build/
# rust # rust

View File

@@ -0,0 +1,602 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
E0140D402ED1F909001F3171 /* exo in Resources */ = {isa = PBXBuildFile; fileRef = E0140D3F2ED1F909001F3171 /* exo */; };
E0A1B1002F5A000100000003 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E0A1B1002F5A000100000002 /* Sparkle */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
E0140D212ED1F79B001F3171 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = E0140D072ED1F79A001F3171 /* Project object */;
proxyType = 1;
remoteGlobalIDString = E0140D0E2ED1F79A001F3171;
remoteInfo = EXO;
};
E0140D2B2ED1F79B001F3171 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = E0140D072ED1F79A001F3171 /* Project object */;
proxyType = 1;
remoteGlobalIDString = E0140D0E2ED1F79A001F3171;
remoteInfo = EXO;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
E0140D0F2ED1F79A001F3171 /* EXO.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EXO.app; sourceTree = BUILT_PRODUCTS_DIR; };
E0140D202ED1F79B001F3171 /* EXOTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EXOTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
E0140D2A2ED1F79B001F3171 /* EXOUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EXOUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
E0140D3F2ED1F909001F3171 /* exo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = exo; path = ../../dist/exo; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
E0140D112ED1F79A001F3171 /* EXO */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = EXO;
sourceTree = "<group>";
};
E0140D232ED1F79B001F3171 /* EXOTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = EXOTests;
sourceTree = "<group>";
};
E0140D2D2ED1F79B001F3171 /* EXOUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = EXOUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
E0140D0C2ED1F79A001F3171 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E0A1B1002F5A000100000003 /* Sparkle in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
E0140D1D2ED1F79B001F3171 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
E0140D272ED1F79B001F3171 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
E0140D062ED1F79A001F3171 = {
isa = PBXGroup;
children = (
E0140D3F2ED1F909001F3171 /* exo */,
E0140D112ED1F79A001F3171 /* EXO */,
E0140D232ED1F79B001F3171 /* EXOTests */,
E0140D2D2ED1F79B001F3171 /* EXOUITests */,
E0140D102ED1F79A001F3171 /* Products */,
);
sourceTree = "<group>";
};
E0140D102ED1F79A001F3171 /* Products */ = {
isa = PBXGroup;
children = (
E0140D0F2ED1F79A001F3171 /* EXO.app */,
E0140D202ED1F79B001F3171 /* EXOTests.xctest */,
E0140D2A2ED1F79B001F3171 /* EXOUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
E0140D0E2ED1F79A001F3171 /* EXO */ = {
isa = PBXNativeTarget;
buildConfigurationList = E0140D342ED1F79B001F3171 /* Build configuration list for PBXNativeTarget "EXO" */;
buildPhases = (
E0140D0B2ED1F79A001F3171 /* Sources */,
E0140D0C2ED1F79A001F3171 /* Frameworks */,
E0140D0D2ED1F79A001F3171 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
E0140D112ED1F79A001F3171 /* EXO */,
);
name = EXO;
packageProductDependencies = (
E0A1B1002F5A000100000002 /* Sparkle */,
);
productName = EXO;
productReference = E0140D0F2ED1F79A001F3171 /* EXO.app */;
productType = "com.apple.product-type.application";
};
E0140D1F2ED1F79B001F3171 /* EXOTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = E0140D372ED1F79B001F3171 /* Build configuration list for PBXNativeTarget "EXOTests" */;
buildPhases = (
E0140D1C2ED1F79B001F3171 /* Sources */,
E0140D1D2ED1F79B001F3171 /* Frameworks */,
E0140D1E2ED1F79B001F3171 /* Resources */,
);
buildRules = (
);
dependencies = (
E0140D222ED1F79B001F3171 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
E0140D232ED1F79B001F3171 /* EXOTests */,
);
name = EXOTests;
packageProductDependencies = (
);
productName = EXOTests;
productReference = E0140D202ED1F79B001F3171 /* EXOTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
E0140D292ED1F79B001F3171 /* EXOUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = E0140D3A2ED1F79B001F3171 /* Build configuration list for PBXNativeTarget "EXOUITests" */;
buildPhases = (
E0140D262ED1F79B001F3171 /* Sources */,
E0140D272ED1F79B001F3171 /* Frameworks */,
E0140D282ED1F79B001F3171 /* Resources */,
);
buildRules = (
);
dependencies = (
E0140D2C2ED1F79B001F3171 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
E0140D2D2ED1F79B001F3171 /* EXOUITests */,
);
name = EXOUITests;
packageProductDependencies = (
);
productName = EXOUITests;
productReference = E0140D2A2ED1F79B001F3171 /* EXOUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
E0140D072ED1F79A001F3171 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1610;
LastUpgradeCheck = 1610;
TargetAttributes = {
E0140D0E2ED1F79A001F3171 = {
CreatedOnToolsVersion = 16.1;
};
E0140D1F2ED1F79B001F3171 = {
CreatedOnToolsVersion = 16.1;
TestTargetID = E0140D0E2ED1F79A001F3171;
};
E0140D292ED1F79B001F3171 = {
CreatedOnToolsVersion = 16.1;
TestTargetID = E0140D0E2ED1F79A001F3171;
};
};
};
buildConfigurationList = E0140D0A2ED1F79A001F3171 /* Build configuration list for PBXProject "EXO" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = E0140D062ED1F79A001F3171;
minimizedProjectReferenceProxies = 1;
packageReferences = (
E0A1B1002F5A000100000001 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = E0140D102ED1F79A001F3171 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
E0140D0E2ED1F79A001F3171 /* EXO */,
E0140D1F2ED1F79B001F3171 /* EXOTests */,
E0140D292ED1F79B001F3171 /* EXOUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
E0140D0D2ED1F79A001F3171 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
E0140D402ED1F909001F3171 /* exo in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
E0140D1E2ED1F79B001F3171 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
E0140D282ED1F79B001F3171 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
E0140D0B2ED1F79A001F3171 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
E0140D1C2ED1F79B001F3171 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
E0140D262ED1F79B001F3171 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
E0140D222ED1F79B001F3171 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = E0140D0E2ED1F79A001F3171 /* EXO */;
targetProxy = E0140D212ED1F79B001F3171 /* PBXContainerItemProxy */;
};
E0140D2C2ED1F79B001F3171 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = E0140D0E2ED1F79A001F3171 /* EXO */;
targetProxy = E0140D2B2ED1F79B001F3171 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
E0140D322ED1F79B001F3171 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
E0140D332ED1F79B001F3171 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.1;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
E0140D352ED1F79B001F3171 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = EXO/EXO.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"EXO/Preview Content\"";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = EXO/Info.plist;
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_EXOBuildCommit = "$(EXO_BUILD_COMMIT)";
INFOPLIST_KEY_EXOBuildTag = "$(EXO_BUILD_TAG)";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "EXO needs to run a signed network setup script with administrator privileges.";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_SUEnableAutomaticChecks = YES;
INFOPLIST_KEY_SUFeedURL = "$(SPARKLE_FEED_URL)";
INFOPLIST_KEY_SUPublicEDKey = "$(SPARKLE_ED25519_PUBLIC)";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = exolabs.EXO;
PRODUCT_NAME = "$(TARGET_NAME)";
EXO_BUILD_COMMIT = local;
EXO_BUILD_TAG = dev;
SPARKLE_ED25519_PUBLIC = "";
SPARKLE_FEED_URL = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
E0140D362ED1F79B001F3171 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = EXO/EXO.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"EXO/Preview Content\"";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = EXO/Info.plist;
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_EXOBuildCommit = "$(EXO_BUILD_COMMIT)";
INFOPLIST_KEY_EXOBuildTag = "$(EXO_BUILD_TAG)";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "EXO needs to run a signed network setup script with administrator privileges.";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_SUEnableAutomaticChecks = YES;
INFOPLIST_KEY_SUFeedURL = "$(SPARKLE_FEED_URL)";
INFOPLIST_KEY_SUPublicEDKey = "$(SPARKLE_ED25519_PUBLIC)";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = exolabs.EXO;
PRODUCT_NAME = "$(TARGET_NAME)";
EXO_BUILD_COMMIT = local;
EXO_BUILD_TAG = dev;
SPARKLE_ED25519_PUBLIC = "";
SPARKLE_FEED_URL = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
E0140D382ED1F79B001F3171 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = exolabs.EXOTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EXO.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EXO";
};
name = Debug;
};
E0140D392ED1F79B001F3171 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = exolabs.EXOTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EXO.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EXO";
};
name = Release;
};
E0140D3B2ED1F79B001F3171 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = exolabs.EXOUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TEST_TARGET_NAME = EXO;
};
name = Debug;
};
E0140D3C2ED1F79B001F3171 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = exolabs.EXOUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TEST_TARGET_NAME = EXO;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
E0140D0A2ED1F79A001F3171 /* Build configuration list for PBXProject "EXO" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E0140D322ED1F79B001F3171 /* Debug */,
E0140D332ED1F79B001F3171 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
E0140D342ED1F79B001F3171 /* Build configuration list for PBXNativeTarget "EXO" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E0140D352ED1F79B001F3171 /* Debug */,
E0140D362ED1F79B001F3171 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
E0140D372ED1F79B001F3171 /* Build configuration list for PBXNativeTarget "EXOTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E0140D382ED1F79B001F3171 /* Debug */,
E0140D392ED1F79B001F3171 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
E0140D3A2ED1F79B001F3171 /* Build configuration list for PBXNativeTarget "EXOUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E0140D3B2ED1F79B001F3171 /* Debug */,
E0140D3C2ED1F79B001F3171 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
E0A1B1002F5A000100000001 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.8.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
E0A1B1002F5A000100000002 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = E0A1B1002F5A000100000001 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = E0140D072ED1F79A001F3171 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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
}

View File

@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1610"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E0140D0E2ED1F79A001F3171"
BuildableName = "EXO.app"
BlueprintName = "EXO"
ReferencedContainer = "container:EXO.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E0140D1F2ED1F79B001F3171"
BuildableName = "EXOTests.xctest"
BlueprintName = "EXOTests"
ReferencedContainer = "container:EXO.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E0140D292ED1F79B001F3171"
BuildableName = "EXOUITests.xctest"
BlueprintName = "EXOUITests"
ReferencedContainer = "container:EXO.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E0140D0E2ED1F79A001F3171"
BuildableName = "EXO.app"
BlueprintName = "EXO"
ReferencedContainer = "container:EXO.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "EXO_BUG_AWS_ACCESS_KEY_ID"
value = "AKIAYEKP5EMXTOBYDGHX"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "EXO_BUG_AWS_SECRET_ACCESS_KEY"
value = "Ep5gIlUZ1o8ssTLQwmyy34yPGfTPEYQ4evE8NdPE"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E0140D0E2ED1F79A001F3171"
BuildableName = "EXO.app"
BlueprintName = "EXO"
ReferencedContainer = "container:EXO.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>EXO.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>E0140D0E2ED1F79A001F3171</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>E0140D1F2ED1F79B001F3171</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>E0140D292ED1F79B001F3171</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,494 @@
//
// ContentView.swift
// EXO
//
// Created by Sami Khan on 2025-11-22.
//
import AppKit
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var controller: ExoProcessController
@EnvironmentObject private var stateService: ClusterStateService
@EnvironmentObject private var networkStatusService: NetworkStatusService
@EnvironmentObject private var updater: SparkleUpdater
@State private var focusedNode: NodeViewModel?
@State private var deletingInstanceIDs: Set<String> = []
@State private var showAllNodes = false
@State private var showAllInstances = false
@State private var showDebugInfo = false
@State private var bugReportInFlight = false
@State private var bugReportMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
statusSection
if shouldShowClusterDetails {
Divider()
overviewSection
topologySection
nodeSection
}
if shouldShowInstances {
instanceSection
}
Spacer(minLength: 0)
controlButtons
}
.animation(.easeInOut(duration: 0.3), value: shouldShowClusterDetails)
.animation(.easeInOut(duration: 0.3), value: shouldShowInstances)
.padding()
.frame(width: 340)
.onAppear {
Task {
await networkStatusService.refresh()
}
}
}
private var topologySection: some View {
Group {
if let topology = stateService.latestSnapshot?.topologyViewModel(), !topology.nodes.isEmpty {
TopologyMiniView(topology: topology)
}
}
}
private var statusSection: some View {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text("EXO")
.font(.headline)
Text(controller.status.displayText)
.font(.caption)
.foregroundColor(.secondary)
if let detail = statusDetailText {
Text(detail)
.font(.caption2)
.foregroundColor(.secondary)
}
}
Spacer()
Toggle("", isOn: processToggleBinding)
.toggleStyle(.switch)
.labelsHidden()
}
}
private var overviewSection: some View {
Group {
if let snapshot = stateService.latestSnapshot {
let overview = snapshot.overview()
VStack(alignment: .leading, spacing: 4) {
HStack {
VStack(alignment: .leading) {
Text("\(overview.usedRam, specifier: "%.0f") / \(overview.totalRam, specifier: "%.0f") GB")
.font(.headline)
Text("Memory")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .leading) {
Text("\(overview.nodeCount)")
.font(.headline)
Text("Nodes")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .leading) {
Text("\(overview.instanceCount)")
.font(.headline)
Text("Instances")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
} else {
Text("Connecting to EXO…")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private var nodeSection: some View {
Group {
if let nodes = stateService.latestSnapshot?.nodeViewModels(), !nodes.isEmpty {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Nodes")
.font(.caption)
.foregroundColor(.secondary)
Text("(\(nodes.count))")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
collapseButton(isExpanded: $showAllNodes)
}
.animation(nil, value: showAllNodes)
if showAllNodes {
VStack(alignment: .leading, spacing: 8) {
ForEach(nodes) { node in
NodeRowView(node: node)
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(.regularMaterial.opacity(0.6))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.25), value: showAllNodes)
}
}
}
private var instanceSection: some View {
Group {
if let instances = stateService.latestSnapshot?.instanceViewModels() {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Instances")
.font(.caption)
.foregroundColor(.secondary)
Text("(\(instances.count))")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
if !instances.isEmpty {
collapseButton(isExpanded: $showAllInstances)
}
}
.animation(nil, value: showAllInstances)
if showAllInstances, !instances.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ForEach(instances) { instance in
InstanceRowView(instance: instance)
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(.regularMaterial.opacity(0.6))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.25), value: showAllInstances)
}
}
}
private var controlButtons: some View {
VStack(alignment: .leading, spacing: 0) {
if controller.status != .stopped {
dashboardButton
Divider()
.padding(.vertical, 8)
} else {
Divider()
.padding(.vertical, 4)
}
controlButton(title: "Check for Updates") {
updater.checkForUpdates()
}
.padding(.bottom, 8)
debugSection
.padding(.bottom, 8)
controlButton(title: "Quit", tint: .secondary) {
controller.stop()
NSApplication.shared.terminate(nil)
}
}
}
private func controlButton(title: String, tint: Color = .primary, action: @escaping () -> Void) -> some View {
HoverButton(title: title, tint: tint, trailingSystemImage: nil, action: action)
}
private var dashboardButton: some View {
Button {
guard let url = URL(string: "http://localhost:8000/") else { return }
NSWorkspace.shared.open(url)
} label: {
HStack {
Image(systemName: "arrow.up.right.square")
.imageScale(.small)
Text("Dashboard")
.fontWeight(.medium)
Spacer()
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(red: 1.0, green: 0.87, blue: 0.0).opacity(0.2))
)
}
.buttonStyle(.plain)
.padding(.bottom, 4)
}
private func collapseButton(isExpanded: Binding<Bool>) -> some View {
Button {
isExpanded.wrappedValue.toggle()
} label: {
Label(isExpanded.wrappedValue ? "Hide" : "Show All", systemImage: isExpanded.wrappedValue ? "chevron.up" : "chevron.down")
.labelStyle(.titleAndIcon)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
.font(.caption2)
}
private func instancesToDisplay(_ instances: [InstanceViewModel]) -> [InstanceViewModel] {
if showAllInstances {
return instances
}
return []
}
private var shouldShowClusterDetails: Bool {
controller.status != .stopped
}
private var shouldShowInstances: Bool {
controller.status != .stopped
}
private var statusDetailText: String? {
switch controller.status {
case .failed(let message):
return message
case .stopped:
if let countdown = controller.launchCountdownSeconds {
return "Launching in \(countdown)s"
}
return nil
default:
if let countdown = controller.launchCountdownSeconds {
return "Launching in \(countdown)s"
}
if let lastError = controller.lastError {
return lastError
}
if let message = stateService.lastActionMessage {
return message
}
return nil
}
}
private var thunderboltStatusText: String {
switch networkStatusService.status.thunderboltBridgeState {
case .some(.disabled):
return "Thunderbolt Bridge: Disabled"
case .some(.deleted):
return "Thunderbolt Bridge: Deleted"
case .some(.enabled):
return "Thunderbolt Bridge: Enabled"
case nil:
return "Thunderbolt Bridge: Unknown"
}
}
private var thunderboltStatusColor: Color {
switch networkStatusService.status.thunderboltBridgeState {
case .some(.disabled), .some(.deleted):
return .green
case .some(.enabled):
return .red
case nil:
return .secondary
}
}
private var interfaceIpList: some View {
let statuses = networkStatusService.status.interfaceStatuses
return VStack(alignment: .leading, spacing: 1) {
Text("Interfaces (en0en7):")
.font(.caption2)
.foregroundColor(.secondary)
if statuses.isEmpty {
Text(" Unknown")
.font(.caption2)
.foregroundColor(.secondary)
} else {
ForEach(statuses, id: \.interfaceName) { status in
let ipText = status.ipAddress ?? "No IP"
Text(" \(status.interfaceName): \(ipText)")
.font(.caption2)
.foregroundColor(status.ipAddress == nil ? .red : .green)
}
}
}
}
private var debugSection: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Debug Info")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
collapseButton(isExpanded: $showDebugInfo)
}
.animation(nil, value: showDebugInfo)
if showDebugInfo {
VStack(alignment: .leading, spacing: 4) {
Text("Version: \(buildTag)")
.font(.caption2)
.foregroundColor(.secondary)
Text("Commit: \(buildCommit)")
.font(.caption2)
.foregroundColor(.secondary)
Text(thunderboltStatusText)
.font(.caption2)
.foregroundColor(thunderboltStatusColor)
interfaceIpList
sendBugReportButton
.padding(.top, 6)
}
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.25), value: showDebugInfo)
}
private var sendBugReportButton: some View {
VStack(alignment: .leading, spacing: 4) {
Button {
Task {
await sendBugReport()
}
} label: {
HStack {
if bugReportInFlight {
ProgressView()
.scaleEffect(0.6)
}
Text("Send Bug Report")
.font(.caption)
.fontWeight(.semibold)
Spacer()
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.accentColor.opacity(0.12))
)
}
.buttonStyle(.plain)
.disabled(bugReportInFlight)
if let message = bugReportMessage {
Text(message)
.font(.caption2)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private var processToggleBinding: Binding<Bool> {
Binding(
get: {
switch controller.status {
case .running, .starting:
return true
case .stopped, .failed:
return false
}
},
set: { isOn in
if isOn {
stateService.resetTransientState()
stateService.startPolling()
controller.cancelPendingLaunch()
controller.launchIfNeeded()
} else {
stateService.stopPolling()
controller.stop()
stateService.resetTransientState()
}
}
)
}
private func bindingForNode(_ node: NodeViewModel) -> Binding<NodeViewModel?> {
Binding<NodeViewModel?>(
get: {
focusedNode?.id == node.id ? focusedNode : nil
},
set: { newValue in
if newValue == nil {
focusedNode = nil
} else {
focusedNode = newValue
}
}
)
}
private func sendBugReport() async {
bugReportInFlight = true
bugReportMessage = "Collecting logs..."
let service = BugReportService()
do {
let outcome = try await service.sendReport(isManual: true)
bugReportMessage = outcome.message
} catch {
bugReportMessage = error.localizedDescription
}
bugReportInFlight = false
}
private var buildTag: String {
Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown"
}
private var buildCommit: String {
Bundle.main.infoDictionary?["EXOBuildCommit"] as? String ?? "unknown"
}
}
private struct HoverButton: View {
let title: String
let tint: Color
let trailingSystemImage: String?
let action: () -> Void
@State private var isHovering = false
var body: some View {
Button(action: action) {
HStack {
Text(title)
Spacer()
if let systemName = trailingSystemImage {
Image(systemName: systemName)
.imageScale(.small)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(
isHovering
? Color.accentColor.opacity(0.1)
: Color.clear
)
)
}
.buttonStyle(.plain)
.foregroundColor(tint)
.onHover { isHovering = $0 }
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>

240
app/EXO/EXO/EXOApp.swift Normal file
View File

@@ -0,0 +1,240 @@
//
// EXOApp.swift
// EXO
//
// Created by Sami Khan on 2025-11-22.
//
import AppKit
import CoreImage
import CoreImage.CIFilterBuiltins
import Sparkle
import SwiftUI
import ServiceManagement
import UserNotifications
import os.log
@main
struct EXOApp: App {
@StateObject private var controller: ExoProcessController
@StateObject private var stateService: ClusterStateService
@StateObject private var networkStatusService: NetworkStatusService
@StateObject private var updater: SparkleUpdater
private let terminationObserver: TerminationObserver
private let ciContext = CIContext(options: nil)
init() {
let controller = ExoProcessController()
let updater = SparkleUpdater(processController: controller)
terminationObserver = TerminationObserver {
Task { @MainActor in
controller.cancelPendingLaunch()
controller.stop()
}
}
_controller = StateObject(wrappedValue: controller)
let service = ClusterStateService()
_stateService = StateObject(wrappedValue: service)
let networkStatus = NetworkStatusService()
_networkStatusService = StateObject(wrappedValue: networkStatus)
_updater = StateObject(wrappedValue: updater)
enableLaunchAtLoginIfNeeded()
NetworkSetupHelper.ensureLaunchDaemonInstalled()
controller.scheduleLaunch(after: 15)
service.startPolling()
networkStatus.startPolling()
}
var body: some Scene {
MenuBarExtra {
ContentView()
.environmentObject(controller)
.environmentObject(stateService)
.environmentObject(networkStatusService)
.environmentObject(updater)
} label: {
menuBarIcon
}
.menuBarExtraStyle(.window)
}
private var menuBarIcon: some View {
let baseImage = resizedMenuBarIcon(named: "menubar-icon", size: 26)
let iconImage: NSImage
if controller.status == .stopped, let grey = greyscale(image: baseImage) {
iconImage = grey
} else {
iconImage = baseImage ?? NSImage(named: "menubar-icon") ?? NSImage()
}
return Image(nsImage: iconImage)
.accessibilityLabel("EXO")
}
private func resizedMenuBarIcon(named: String, size: CGFloat) -> NSImage? {
guard let original = NSImage(named: named) else {
print("Failed to load image named: \(named)")
return nil
}
let targetSize = NSSize(width: size, height: size)
let resized = NSImage(size: targetSize)
resized.lockFocus()
defer { resized.unlockFocus() }
NSGraphicsContext.current?.imageInterpolation = .high
original.draw(
in: NSRect(origin: .zero, size: targetSize),
from: NSRect(origin: .zero, size: original.size),
operation: .copy,
fraction: 1.0
)
return resized
}
private func greyscale(image: NSImage?) -> NSImage? {
guard
let image,
let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
let cgImage = bitmap.cgImage
else {
return nil
}
let ciImage = CIImage(cgImage: cgImage)
let filter = CIFilter.colorControls()
filter.inputImage = ciImage
filter.saturation = 0
filter.brightness = -0.2
filter.contrast = 0.9
guard let output = filter.outputImage,
let rendered = ciContext.createCGImage(output, from: output.extent)
else {
return nil
}
return NSImage(cgImage: rendered, size: image.size)
}
private func enableLaunchAtLoginIfNeeded() {
guard SMAppService.mainApp.status != .enabled else { return }
do {
try SMAppService.mainApp.register()
} catch {
Logger().error("Failed to register EXO for launch at login: \(error.localizedDescription)")
}
}
}
final class SparkleUpdater: NSObject, ObservableObject {
private let controller: SPUStandardUpdaterController
private let delegateProxy: ExoUpdaterDelegate
private let notificationDelegate = ExoNotificationDelegate()
private var periodicCheckTask: Task<Void, Never>?
init(processController: ExoProcessController) {
let proxy = ExoUpdaterDelegate(processController: processController)
delegateProxy = proxy
controller = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: proxy,
userDriverDelegate: nil
)
super.init()
let center = UNUserNotificationCenter.current()
center.delegate = notificationDelegate
center.requestAuthorization(options: [.alert, .sound]) { _, _ in }
controller.updater.automaticallyChecksForUpdates = true
controller.updater.automaticallyDownloadsUpdates = false
controller.updater.updateCheckInterval = 900 // 15 minutes
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak controller] in
controller?.updater.checkForUpdatesInBackground()
}
let updater = controller.updater
let intervalSeconds = max(60.0, controller.updater.updateCheckInterval)
let intervalNanos = UInt64(intervalSeconds * 1_000_000_000)
periodicCheckTask = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: intervalNanos)
await MainActor.run {
updater.checkForUpdatesInBackground()
}
}
}
}
deinit {
periodicCheckTask?.cancel()
}
@MainActor
func checkForUpdates() {
controller.checkForUpdates(nil)
}
}
private final class ExoUpdaterDelegate: NSObject, SPUUpdaterDelegate {
private weak var processController: ExoProcessController?
init(processController: ExoProcessController) {
self.processController = processController
}
nonisolated func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
showNotification(
title: "Update available",
body: "EXO \(item.displayVersionString) is ready to install."
)
}
nonisolated func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
Task { @MainActor in
guard let controller = self.processController else { return }
controller.cancelPendingLaunch()
controller.stop()
}
}
private func showNotification(title: String, body: String) {
let center = UNUserNotificationCenter.current()
let content = UNMutableNotificationContent()
content.title = title
content.body = body
let request = UNNotificationRequest(
identifier: "exo-update-\(UUID().uuidString)",
content: content,
trigger: nil
)
center.add(request, withCompletionHandler: nil)
}
}
private final class ExoNotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .list, .sound])
}
}
@MainActor
private final class TerminationObserver {
private var token: NSObjectProtocol?
init(onTerminate: @escaping () -> Void) {
token = NotificationCenter.default.addObserver(
forName: NSApplication.willTerminateNotification,
object: nil,
queue: .main
) { _ in
onTerminate()
}
}
deinit {
if let token {
NotificationCenter.default.removeObserver(token)
}
}
}

View File

@@ -0,0 +1,232 @@
import AppKit
import Combine
import Foundation
@MainActor
final class ExoProcessController: ObservableObject {
enum Status: Equatable {
case stopped
case starting
case running
case failed(message: String)
var displayText: String {
switch self {
case .stopped:
return "Stopped"
case .starting:
return "Starting…"
case .running:
return "Running"
case .failed:
return "Failed"
}
}
}
@Published private(set) var status: Status = .stopped
@Published private(set) var lastError: String?
@Published private(set) var launchCountdownSeconds: Int?
private var process: Process?
private var runtimeDirectoryURL: URL?
private var pendingLaunchTask: Task<Void, Never>?
func launchIfNeeded() {
guard process?.isRunning != true else { return }
launch()
}
func launch() {
do {
guard process?.isRunning != true else { return }
cancelPendingLaunch()
status = .starting
lastError = nil
let runtimeURL = try resolveRuntimeDirectory()
runtimeDirectoryURL = runtimeURL
let executableURL = runtimeURL.appendingPathComponent("exo")
let child = Process()
child.executableURL = executableURL
child.currentDirectoryURL = runtimeURL
child.environment = makeEnvironment(for: runtimeURL)
child.standardOutput = FileHandle.nullDevice
child.standardError = FileHandle.nullDevice
child.terminationHandler = { [weak self] proc in
Task { @MainActor in
guard let self else { return }
self.process = nil
switch self.status {
case .stopped:
break
case .failed:
break
default:
self.status = .failed(
message: "Exited with code \(proc.terminationStatus)"
)
self.lastError = "Process exited with code \(proc.terminationStatus)"
}
}
}
try child.run()
process = child
status = .running
} catch {
process = nil
status = .failed(message: "Launch error")
lastError = error.localizedDescription
}
}
func stop() {
guard let process else {
status = .stopped
return
}
process.terminationHandler = nil
if process.isRunning {
process.terminate()
}
self.process = nil
status = .stopped
}
func restart() {
stop()
launch()
}
func scheduleLaunch(after seconds: TimeInterval) {
cancelPendingLaunch()
let start = max(1, Int(ceil(seconds)))
pendingLaunchTask = Task { [weak self] in
guard let self else { return }
await MainActor.run {
self.launchCountdownSeconds = start
}
var remaining = start
while remaining > 0 {
try? await Task.sleep(nanoseconds: 1_000_000_000)
remaining -= 1
if Task.isCancelled { return }
await MainActor.run {
if remaining > 0 {
self.launchCountdownSeconds = remaining
} else {
self.launchCountdownSeconds = nil
self.launchIfNeeded()
}
}
}
}
}
func cancelPendingLaunch() {
pendingLaunchTask?.cancel()
pendingLaunchTask = nil
launchCountdownSeconds = nil
}
func revealRuntimeDirectory() {
guard let runtimeDirectoryURL else { return }
NSWorkspace.shared.activateFileViewerSelecting([runtimeDirectoryURL])
}
func statusTintColor() -> NSColor {
switch status {
case .running:
return .systemGreen
case .starting:
return .systemYellow
case .failed:
return .systemRed
case .stopped:
return .systemGray
}
}
private func resolveRuntimeDirectory() throws -> URL {
let fileManager = FileManager.default
if let override = ProcessInfo.processInfo.environment["EXO_RUNTIME_DIR"] {
let url = URL(fileURLWithPath: override).standardizedFileURL
if fileManager.fileExists(atPath: url.path) {
return url
}
}
if let resourceRoot = Bundle.main.resourceURL {
let bundled = resourceRoot.appendingPathComponent("exo", isDirectory: true)
if fileManager.fileExists(atPath: bundled.path) {
return bundled
}
}
let repoCandidate = URL(fileURLWithPath: fileManager.currentDirectoryPath)
.appendingPathComponent("dist/exo", isDirectory: true)
if fileManager.fileExists(atPath: repoCandidate.path) {
return repoCandidate
}
throw RuntimeError("Unable to locate the packaged EXO runtime.")
}
private func makeEnvironment(for runtimeURL: URL) -> [String: String] {
var environment = ProcessInfo.processInfo.environment
environment["EXO_RUNTIME_DIR"] = runtimeURL.path
environment["EXO_LIBP2P_NAMESPACE"] = buildTag()
var paths: [String] = []
if let existing = environment["PATH"], !existing.isEmpty {
paths = existing.split(separator: ":").map(String.init)
}
let required = [
runtimeURL.path,
runtimeURL.appendingPathComponent("_internal").path,
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
]
for entry in required.reversed() {
if !paths.contains(entry) {
paths.insert(entry, at: 0)
}
}
environment["PATH"] = paths.joined(separator: ":")
return environment
}
private func buildTag() -> String {
if let tag = Bundle.main.infoDictionary?["EXOBuildTag"] as? String, !tag.isEmpty {
return tag
}
if let short = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, !short.isEmpty {
return short
}
return "dev"
}
}
struct RuntimeError: LocalizedError {
let message: String
init(_ message: String) {
self.message = message
}
var errorDescription: String? {
message
}
}

12
app/EXO/EXO/Info.plist Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SUFeedURL</key>
<string>https://assets.exolabs.net/appcast.xml</string>
<key>EXOBuildTag</key>
<string>$(EXO_BUILD_TAG)</string>
<key>EXOBuildCommit</key>
<string>$(EXO_BUILD_COMMIT)</string>
</dict>
</plist>

View File

@@ -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] { [] }
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,539 @@
import CryptoKit
import Foundation
struct BugReportOutcome: Equatable {
let success: Bool
let message: String
}
enum BugReportError: LocalizedError {
case missingCredentials
case invalidEndpoint
case uploadFailed(String)
case collectFailed(String)
var errorDescription: String? {
switch self {
case .missingCredentials:
return "Bug report upload credentials are not set."
case .invalidEndpoint:
return "Bug report endpoint is invalid."
case .uploadFailed(let message):
return "Bug report upload failed: \(message)"
case .collectFailed(let message):
return "Bug report collection failed: \(message)"
}
}
}
struct BugReportService {
struct AWSConfig {
let accessKey: String
let secretKey: String
let region: String
let bucket: String
}
func sendReport(
baseURL: URL = URL(string: "http://127.0.0.1:8000")!,
now: Date = Date(),
isManual: Bool = false
) async throws -> BugReportOutcome {
let credentials = try loadCredentials()
let timestamp = ISO8601DateFormatter().string(from: now)
let prefix = "reports/\(timestamp)/"
let logData = readLog()
let ifconfigText = try await captureIfconfig()
let hostName = Host.current().localizedName ?? "unknown"
let debugInfo = readDebugInfo()
async let stateResult = fetch(url: baseURL.appendingPathComponent("state"))
async let eventsResult = fetch(url: baseURL.appendingPathComponent("events"))
let stateData = try await stateResult
let eventsData = try await eventsResult
let reportJSON = makeReportJson(
timestamp: timestamp,
hostName: hostName,
ifconfig: ifconfigText,
debugInfo: debugInfo,
isManual: isManual
)
let uploads: [(path: String, data: Data?)] = [
("\(prefix)exo.log", logData),
("\(prefix)state.json", stateData),
("\(prefix)events.json", eventsData),
("\(prefix)report.json", reportJSON)
]
let uploader = try S3Uploader(config: credentials)
for item in uploads {
guard let data = item.data else { continue }
try await uploader.upload(
objectPath: item.path,
body: data
)
}
return BugReportOutcome(success: true, message: "Bug Report sent. Thank you for helping to improve EXO 1.0.")
}
private func loadCredentials() throws -> AWSConfig {
// These credentials are write-only and necessary to receive bug reports from users
return AWSConfig(
accessKey: "AKIAYEKP5EMXTOBYDGHX",
secretKey: "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",
region: "us-east-1",
bucket: "exo-bug-reports"
)
}
private func readLog() -> Data? {
let logURL = URL(fileURLWithPath: NSHomeDirectory())
.appendingPathComponent(".exo")
.appendingPathComponent("exo.log")
return try? Data(contentsOf: logURL)
}
private func captureIfconfig() async throws -> String {
let result = runCommand(["/sbin/ifconfig"])
guard result.exitCode == 0 else {
throw BugReportError.collectFailed(result.error.isEmpty ? "ifconfig failed" : result.error)
}
return result.output
}
private func readDebugInfo() -> DebugInfo {
DebugInfo(
thunderboltBridgeDisabled: readThunderboltBridgeDisabled(),
interfaces: readInterfaces()
)
}
private func readThunderboltBridgeDisabled() -> Bool? {
let result = runCommand(["/usr/sbin/networksetup", "-getnetworkserviceenabled", "Thunderbolt Bridge"])
guard result.exitCode == 0 else { return nil }
let output = result.output.lowercased()
if output.contains("enabled") {
return false
}
if output.contains("disabled") {
return true
}
return nil
}
private func readInterfaces() -> [DebugInfo.InterfaceStatus] {
(0...7).map { "en\($0)" }.map { iface in
let result = runCommand(["/sbin/ifconfig", iface])
guard result.exitCode == 0 else {
return DebugInfo.InterfaceStatus(name: iface, ip: nil)
}
let ip = firstInet(from: result.output)
return DebugInfo.InterfaceStatus(name: iface, ip: ip)
}
}
private func firstInet(from ifconfigOutput: String) -> String? {
for line in ifconfigOutput.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("inet ") else { continue }
let parts = trimmed.split(separator: " ")
if parts.count >= 2 {
let candidate = String(parts[1])
if candidate != "127.0.0.1" {
return candidate
}
}
}
return nil
}
private func fetch(url: URL) async throws -> Data? {
var request = URLRequest(url: url)
request.timeoutInterval = 5
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
return nil
}
return data
} catch {
return nil
}
}
private func makeReportJson(
timestamp: String,
hostName: String,
ifconfig: String,
debugInfo: DebugInfo,
isManual: Bool
) -> Data? {
let system = readSystemMetadata()
let exo = readExoMetadata()
let payload: [String: Any] = [
"timestamp": timestamp,
"host": hostName,
"ifconfig": ifconfig,
"debug": debugInfo.toDictionary(),
"system": system,
"exo_version": exo.version as Any,
"exo_commit": exo.commit as Any,
"report_type": isManual ? "manual" : "automated"
]
return try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted])
}
private func readSystemMetadata() -> [String: Any] {
let hostname = safeRunCommand(["/bin/hostname"])
let computerName = safeRunCommand(["/usr/sbin/scutil", "--get", "ComputerName"])
let localHostName = safeRunCommand(["/usr/sbin/scutil", "--get", "LocalHostName"])
let hostNameCommand = safeRunCommand(["/usr/sbin/scutil", "--get", "HostName"])
let hardwareModel = safeRunCommand(["/usr/sbin/sysctl", "-n", "hw.model"])
let hardwareProfile = safeRunCommand(["/usr/sbin/system_profiler", "SPHardwareDataType"])
let hardwareUUID = hardwareProfile.flatMap(extractHardwareUUID)
let osVersion = safeRunCommand(["/usr/bin/sw_vers", "-productVersion"])
let osBuild = safeRunCommand(["/usr/bin/sw_vers", "-buildVersion"])
let kernel = safeRunCommand(["/usr/bin/uname", "-srv"])
let arch = safeRunCommand(["/usr/bin/uname", "-m"])
let routeInfo = safeRunCommand(["/sbin/route", "-n", "get", "default"])
let defaultInterface = routeInfo.flatMap(parseDefaultInterface)
let defaultIP = defaultInterface.flatMap { iface in
safeRunCommand(["/usr/sbin/ipconfig", "getifaddr", iface])
}
let defaultMac = defaultInterface.flatMap { iface in
safeRunCommand(["/sbin/ifconfig", iface]).flatMap(parseEtherAddress)
}
let user = safeRunCommand(["/usr/bin/whoami"])
let consoleUser = safeRunCommand(["/usr/bin/stat", "-f%Su", "/dev/console"])
let uptime = safeRunCommand(["/usr/bin/uptime"])
let diskRoot = safeRunCommand(["/bin/sh", "-c", "/bin/df -h / | awk 'NR==2 {print $1, $2, $3, $4, $5}'"])
let interfacesList = safeRunCommand(["/usr/sbin/ipconfig", "getiflist"])
let interfacesAndIPs = interfacesList?
.split(whereSeparator: { $0 == " " || $0 == "\n" })
.compactMap { iface -> [String: Any]? in
let name = String(iface)
guard let ip = safeRunCommand(["/usr/sbin/ipconfig", "getifaddr", name]) else {
return nil
}
return ["name": name, "ip": ip]
} ?? []
let wifiSSID: String?
let airportPath = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport"
if FileManager.default.isExecutableFile(atPath: airportPath) {
wifiSSID = safeRunCommand([airportPath, "-I"]).flatMap(parseWifiSSID)
} else {
wifiSSID = nil
}
return [
"hostname": hostname as Any,
"computer_name": computerName as Any,
"local_hostname": localHostName as Any,
"host_name": hostNameCommand as Any,
"hardware_model": hardwareModel as Any,
"hardware_profile": hardwareProfile as Any,
"hardware_uuid": hardwareUUID as Any,
"os_version": osVersion as Any,
"os_build": osBuild as Any,
"kernel": kernel as Any,
"arch": arch as Any,
"default_interface": defaultInterface as Any,
"default_ip": defaultIP as Any,
"default_mac": defaultMac as Any,
"user": user as Any,
"console_user": consoleUser as Any,
"uptime": uptime as Any,
"disk_root": diskRoot as Any,
"interfaces_and_ips": interfacesAndIPs,
"ipconfig_getiflist": interfacesList as Any,
"wifi_ssid": wifiSSID as Any
]
}
private func readExoMetadata(bundle: Bundle = .main) -> (version: String?, commit: String?) {
let info = bundle.infoDictionary ?? [:]
let tag = info["EXOBuildTag"] as? String
let short = info["CFBundleShortVersionString"] as? String
let version = [tag, short]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.first { !$0.isEmpty }
let commit = (info["EXOBuildCommit"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedCommit = (commit?.isEmpty == true) ? nil : commit
return (version: version, commit: normalizedCommit)
}
private func safeRunCommand(_ arguments: [String]) -> String? {
let result = runCommand(arguments)
guard result.exitCode == 0 else { return nil }
let trimmed = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func extractHardwareUUID(from hardwareProfile: String) -> String? {
hardwareProfile
.split(separator: "\n")
.first { $0.contains("Hardware UUID") }?
.split(separator: ":")
.dropFirst()
.joined(separator: ":")
.trimmingCharacters(in: .whitespaces)
}
private func parseDefaultInterface(from routeOutput: String) -> String? {
for line in routeOutput.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("interface: ") {
return trimmed.replacingOccurrences(of: "interface: ", with: "")
}
}
return nil
}
private func parseEtherAddress(from ifconfigOutput: String) -> String? {
for line in ifconfigOutput.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("ether ") {
return trimmed.replacingOccurrences(of: "ether ", with: "")
}
}
return nil
}
private func parseWifiSSID(from airportOutput: String) -> String? {
for line in airportOutput.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("SSID:") {
return trimmed.replacingOccurrences(of: "SSID:", with: "").trimmingCharacters(in: .whitespaces)
}
}
return nil
}
private func runCommand(_ arguments: [String]) -> CommandResult {
let process = Process()
process.launchPath = arguments.first
process.arguments = Array(arguments.dropFirst())
let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
do {
try process.run()
} catch {
return CommandResult(exitCode: -1, output: "", error: error.localizedDescription)
}
process.waitUntilExit()
let outputData = stdout.fileHandleForReading.readDataToEndOfFile()
let errorData = stderr.fileHandleForReading.readDataToEndOfFile()
return CommandResult(
exitCode: process.terminationStatus,
output: String(decoding: outputData, as: UTF8.self),
error: String(decoding: errorData, as: UTF8.self)
)
}
}
private struct DebugInfo {
let thunderboltBridgeDisabled: Bool?
let interfaces: [InterfaceStatus]
struct InterfaceStatus {
let name: String
let ip: String?
func toDictionary() -> [String: Any] {
[
"name": name,
"ip": ip as Any
]
}
}
func toDictionary() -> [String: Any] {
[
"thunderbolt_bridge_disabled": thunderboltBridgeDisabled as Any,
"interfaces": interfaces.map { $0.toDictionary() }
]
}
}
private struct CommandResult {
let exitCode: Int32
let output: String
let error: String
}
private struct S3Uploader {
let config: BugReportService.AWSConfig
init(config: BugReportService.AWSConfig) throws {
self.config = config
}
func upload(objectPath: String, body: Data) async throws {
let host = "\(config.bucket).s3.amazonaws.com"
guard let url = URL(string: "https://\(host)/\(objectPath)") else {
throw BugReportError.invalidEndpoint
}
let now = Date()
let amzDate = awsTimestamp(now)
let dateStamp = dateStamp(now)
let payloadHash = sha256Hex(body)
let headers = [
"host": host,
"x-amz-content-sha256": payloadHash,
"x-amz-date": amzDate
]
let canonicalRequest = buildCanonicalRequest(
method: "PUT",
url: url,
headers: headers,
payloadHash: payloadHash
)
let stringToSign = buildStringToSign(
amzDate: amzDate,
dateStamp: dateStamp,
canonicalRequestHash: sha256Hex(canonicalRequest.data(using: .utf8) ?? Data())
)
let signingKey = deriveKey(secret: config.secretKey, dateStamp: dateStamp, region: config.region, service: "s3")
let signature = hmacHex(key: signingKey, data: Data(stringToSign.utf8))
let signedHeaders = "host;x-amz-content-sha256;x-amz-date"
let authorization = """
AWS4-HMAC-SHA256 Credential=\(config.accessKey)/\(dateStamp)/\(config.region)/s3/aws4_request, SignedHeaders=\(signedHeaders), Signature=\(signature)
"""
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.httpBody = body
request.setValue(headers["x-amz-content-sha256"], forHTTPHeaderField: "x-amz-content-sha256")
request.setValue(headers["x-amz-date"], forHTTPHeaderField: "x-amz-date")
request.setValue(host, forHTTPHeaderField: "Host")
request.setValue(authorization, forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
let statusText = (response as? HTTPURLResponse)?.statusCode ?? -1
_ = data // ignore response body for UX
throw BugReportError.uploadFailed("HTTP status \(statusText)")
}
}
private func buildCanonicalRequest(
method: String,
url: URL,
headers: [String: String],
payloadHash: String
) -> String {
let canonicalURI = encodePath(url.path)
let canonicalQuery = url.query ?? ""
let sortedHeaders = headers.sorted { $0.key < $1.key }
let canonicalHeaders = sortedHeaders
.map { "\($0.key.lowercased()):\($0.value)\n" }
.joined()
let signedHeaders = sortedHeaders.map { $0.key.lowercased() }.joined(separator: ";")
return [
method,
canonicalURI,
canonicalQuery,
canonicalHeaders,
signedHeaders,
payloadHash
].joined(separator: "\n")
}
private func encodePath(_ path: String) -> String {
return path
.split(separator: "/")
.map { segment in
segment.addingPercentEncoding(withAllowedCharacters: Self.rfc3986) ?? String(segment)
}
.joined(separator: "/")
.prependSlashIfNeeded()
}
private func buildStringToSign(
amzDate: String,
dateStamp: String,
canonicalRequestHash: String
) -> String {
"""
AWS4-HMAC-SHA256
\(amzDate)
\(dateStamp)/\(config.region)/s3/aws4_request
\(canonicalRequestHash)
"""
}
private func deriveKey(secret: String, dateStamp: String, region: String, service: String) -> Data {
let kDate = hmac(key: Data(("AWS4" + secret).utf8), data: Data(dateStamp.utf8))
let kRegion = hmac(key: kDate, data: Data(region.utf8))
let kService = hmac(key: kRegion, data: Data(service.utf8))
return hmac(key: kService, data: Data("aws4_request".utf8))
}
private func hmac(key: Data, data: Data) -> Data {
let keySym = SymmetricKey(data: key)
let mac = HMAC<SHA256>.authenticationCode(for: data, using: keySym)
return Data(mac)
}
private func hmacHex(key: Data, data: Data) -> String {
hmac(key: key, data: data).map { String(format: "%02x", $0) }.joined()
}
private func sha256Hex(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.compactMap { String(format: "%02x", $0) }.joined()
}
private func awsTimestamp(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
formatter.timeZone = TimeZone(abbreviation: "UTC")
return formatter.string(from: date)
}
private func dateStamp(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd"
formatter.timeZone = TimeZone(abbreviation: "UTC")
return formatter.string(from: date)
}
private static let rfc3986: CharacterSet = {
var set = CharacterSet.alphanumerics
set.insert(charactersIn: "-._~")
return set
}()
}
private extension String {
func prependSlashIfNeeded() -> String {
if hasPrefix("/") {
return self
}
return "/" + self
}
}

View File

@@ -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?
}

View File

@@ -0,0 +1,187 @@
import AppKit
import Foundation
import os.log
enum NetworkSetupHelper {
private static let logger = Logger(subsystem: "io.exo.EXO", category: "NetworkSetup")
private static let daemonLabel = "io.exo.networksetup"
private static let scriptDestination = "/Library/Application Support/EXO/disable_bridge_enable_dhcp.sh"
private static let plistDestination = "/Library/LaunchDaemons/io.exo.networksetup.plist"
private static let requiredStartInterval: Int = 1791
private static let setupScript = """
#!/usr/bin/env bash
set -euo pipefail
PREFS="/Library/Preferences/SystemConfiguration/preferences.plist"
# Remove bridge0 interface
ifconfig bridge0 &>/dev/null && {
ifconfig bridge0 | grep -q 'member' && {
ifconfig bridge0 | awk '/member/ {print $2}' | xargs -n1 ifconfig bridge0 deletem 2>/dev/null || true
}
ifconfig bridge0 destroy 2>/dev/null || true
}
# Remove Thunderbolt Bridge from VirtualNetworkInterfaces in preferences.plist
/usr/libexec/PlistBuddy -c "Delete :VirtualNetworkInterfaces:Bridge:bridge0" "$PREFS" 2>/dev/null || true
networksetup -listlocations | grep -q exo || {
networksetup -createlocation exo
}
networksetup -switchtolocation exo
networksetup -listallhardwareports \\
| awk -F': ' '/Hardware Port: / {print $2}' \\
| while IFS=":" read -r name; do
case "$name" in
"Ethernet Adapter"*)
;;
"Thunderbolt Bridge")
;;
"Thunderbolt "*)
networksetup -listallnetworkservices \\
| grep -q "EXO $name" \\
|| networksetup -createnetworkservice "EXO $name" "$name" 2>/dev/null \\
|| continue
networksetup -setdhcp "EXO $name"
;;
*)
networksetup -listallnetworkservices \\
| grep -q "$name" \\
|| networksetup -createnetworkservice "$name" "$name" 2>/dev/null \\
|| continue
;;
esac
done
networksetup -listnetworkservices | grep -q "Thunderbolt Bridge" && {
networksetup -setnetworkserviceenabled "Thunderbolt Bridge" off
} || true
"""
static func ensureLaunchDaemonInstalled() {
Task.detached {
do {
if daemonAlreadyInstalled() {
return
}
try await installLaunchDaemon()
logger.info("Network setup launch daemon installed and started")
} catch {
logger.error("Network setup launch daemon failed: \(error.localizedDescription, privacy: .public)")
}
}
}
private static func daemonAlreadyInstalled() -> Bool {
let manager = FileManager.default
let scriptExists = manager.fileExists(atPath: scriptDestination)
let plistExists = manager.fileExists(atPath: plistDestination)
guard scriptExists, plistExists else { return false }
guard
let data = try? Data(contentsOf: URL(fileURLWithPath: plistDestination)),
let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any]
else {
return false
}
guard
let interval = plist["StartInterval"] as? Int,
interval == requiredStartInterval
else {
return false
}
if let programArgs = plist["ProgramArguments"] as? [String], programArgs.contains(scriptDestination) == false {
return false
}
return true
}
private static func installLaunchDaemon() async throws {
let installerScript = makeInstallerScript()
try runShellAsAdmin(installerScript)
}
private static func makeInstallerScript() -> String {
"""
set -euo pipefail
LABEL="\(daemonLabel)"
SCRIPT_DEST="\(scriptDestination)"
PLIST_DEST="\(plistDestination)"
mkdir -p "$(dirname "$SCRIPT_DEST")"
cat > "$SCRIPT_DEST" <<'EOF_SCRIPT'
\(setupScript)
EOF_SCRIPT
chmod 755 "$SCRIPT_DEST"
cat > "$PLIST_DEST" <<'EOF_PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>\(daemonLabel)</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>\(scriptDestination)</string>
</array>
<key>StartInterval</key>
<integer>\(requiredStartInterval)</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/\(daemonLabel).log</string>
<key>StandardErrorPath</key>
<string>/var/log/\(daemonLabel).err.log</string>
</dict>
</plist>
EOF_PLIST
launchctl bootout system/"$LABEL" >/dev/null 2>&1 || true
launchctl bootstrap system "$PLIST_DEST"
launchctl enable system/"$LABEL"
launchctl kickstart -k system/"$LABEL"
"""
}
private static func runShellAsAdmin(_ script: String) throws {
let escapedScript = script
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let appleScriptSource = """
do shell script "\(escapedScript)" with administrator privileges
"""
guard let appleScript = NSAppleScript(source: appleScriptSource) else {
throw NetworkSetupError.scriptCreationFailed
}
var errorInfo: NSDictionary?
appleScript.executeAndReturnError(&errorInfo)
if let errorInfo {
let message = errorInfo[NSAppleScript.errorMessage] as? String ?? "Unknown error"
throw NetworkSetupError.executionFailed(message)
}
}
}
enum NetworkSetupError: LocalizedError {
case scriptCreationFailed
case executionFailed(String)
var errorDescription: String? {
switch self {
case .scriptCreationFailed:
return "Failed to create AppleScript for network setup"
case .executionFailed(let message):
return "Network setup script failed: \(message)"
}
}
}

View File

@@ -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)
)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,332 @@
import SwiftUI
struct InstanceRowView: View {
let instance: InstanceViewModel
@State private var animatedTaskIDs: Set<String> = []
@State private var infoTask: InstanceTaskViewModel?
@State private var showChatTasks = true
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(instance.modelName)
.font(.subheadline)
Text(instance.nodeSummary)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if let progress = instance.downloadProgress {
downloadStatusView(progress: progress)
} else {
statusChip(label: instance.state.label.uppercased(), color: statusColor)
}
}
if let progress = instance.downloadProgress {
GeometryReader { geometry in
HStack {
Spacer()
downloadProgressBar(progress: progress)
.frame(width: geometry.size.width * 0.5)
}
}
.frame(height: 4)
.padding(.top, -8)
.padding(.bottom, 2)
HStack(spacing: 8) {
Text(instance.sharding ?? "")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
downloadSpeedView(progress: progress)
}
} else {
Text(instance.sharding ?? "")
.font(.caption2)
.foregroundColor(.secondary)
}
if !instance.chatTasks.isEmpty {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Chat Tasks")
.font(.caption2)
.foregroundColor(.secondary)
Text("(\(instance.chatTasks.count))")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
collapseButton(isExpanded: $showChatTasks)
}
.animation(nil, value: showChatTasks)
if showChatTasks {
VStack(alignment: .leading, spacing: 8) {
ForEach(instance.chatTasks) { task in
taskRow(for: task, parentModelName: instance.modelName)
}
}
.transition(.opacity)
}
}
.padding(.top, 4)
.animation(.easeInOut(duration: 0.25), value: showChatTasks)
}
}
.padding(.vertical, 6)
}
private var statusColor: Color {
switch instance.state {
case .downloading: return .blue
case .warmingUp: return .orange
case .running: return .green
case .ready: return .teal
case .waiting, .idle: return .gray
case .failed: return .red
case .unknown: return .secondary
}
}
@ViewBuilder
private func taskRow(for task: InstanceTaskViewModel, parentModelName: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .top, spacing: 8) {
taskStatusIcon(for: task)
VStack(alignment: .leading, spacing: 2) {
Text("Chat")
.font(.caption)
.fontWeight(.semibold)
if let subtitle = task.subtitle,
subtitle.caseInsensitiveCompare(parentModelName) != .orderedSame {
Text(subtitle)
.font(.caption2)
.foregroundColor(.secondary)
}
if let prompt = task.promptPreview, !prompt.isEmpty {
Text("\(prompt)")
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(2)
}
if task.status == .failed, let error = task.errorMessage, !error.isEmpty {
Text(error)
.font(.caption2)
.foregroundColor(.red)
.lineLimit(3)
}
}
Spacer(minLength: 6)
Button {
infoTask = task
} label: {
Image(systemName: "info.circle")
.imageScale(.small)
}
.buttonStyle(.plain)
.popover(
item: Binding<InstanceTaskViewModel?>(
get: { infoTask?.id == task.id ? infoTask : nil },
set: { newValue in
if newValue == nil {
infoTask = nil
} else {
infoTask = newValue
}
}
),
attachmentAnchor: .rect(.bounds),
arrowEdge: .top
) { _ in
TaskDetailView(task: task)
.padding()
.frame(width: 240)
}
}
}
}
private func taskStatusIcon(for task: InstanceTaskViewModel) -> some View {
let icon: String
let color: Color
let animation: Animation?
switch task.status {
case .running:
icon = "arrow.triangle.2.circlepath"
color = .blue
animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)
case .pending:
icon = "circle.dashed"
color = .secondary
animation = nil
case .failed:
icon = "exclamationmark.triangle.fill"
color = .red
animation = nil
case .complete:
icon = "checkmark.circle.fill"
color = .green
animation = nil
case .unknown:
icon = "questionmark.circle"
color = .secondary
animation = nil
}
let image = Image(systemName: icon)
.imageScale(.small)
.foregroundColor(color)
if let animation {
return AnyView(
image
.rotationEffect(.degrees(animatedTaskIDs.contains(task.id) ? 360 : 0))
.onAppear {
if !animatedTaskIDs.contains(task.id) {
animatedTaskIDs.insert(task.id)
}
}
.animation(animation, value: animatedTaskIDs)
)
}
return AnyView(image)
}
private func statusChip(label: String, color: Color) -> some View {
Text(label)
.font(.caption2)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.15))
.foregroundColor(color)
.clipShape(Capsule())
}
private func downloadStatusView(progress: DownloadProgressViewModel) -> some View {
VStack(alignment: .trailing, spacing: 4) {
statusChip(label: "DOWNLOADING", color: .blue)
Text(progress.formattedProgress)
.foregroundColor(.primary)
}
.font(.caption2)
}
private func downloadSpeedView(progress: DownloadProgressViewModel) -> some View {
HStack(spacing: 4) {
Text(progress.formattedSpeed)
if let eta = progress.formattedETA {
Text("·")
Text(eta)
}
}
.font(.caption2)
.foregroundColor(.secondary)
}
private func downloadProgressBar(progress: DownloadProgressViewModel) -> some View {
ProgressView(value: progress.fractionCompleted)
.progressViewStyle(.linear)
.tint(.blue)
}
private func collapseButton(isExpanded: Binding<Bool>) -> some View {
Button {
isExpanded.wrappedValue.toggle()
} label: {
Label(isExpanded.wrappedValue ? "Hide" : "Show", systemImage: isExpanded.wrappedValue ? "chevron.up" : "chevron.down")
.labelStyle(.titleAndIcon)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
.font(.caption2)
}
private struct TaskDetailView: View, Identifiable {
let task: InstanceTaskViewModel
var id: String { task.id }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
parameterSection
messageSection
if let error = task.errorMessage, !error.isEmpty {
detailRow(
icon: "exclamationmark.triangle.fill",
title: "Error",
value: error,
tint: .red
)
}
}
}
}
@ViewBuilder
private var parameterSection: some View {
if let params = task.parameters {
VStack(alignment: .leading, spacing: 6) {
Text("Parameters")
.font(.subheadline)
if let temperature = params.temperature {
detailRow(title: "Temperature", value: String(format: "%.1f", temperature))
}
if let maxTokens = params.maxTokens {
detailRow(title: "Max Tokens", value: "\(maxTokens)")
}
if let stream = params.stream {
detailRow(title: "Stream", value: stream ? "On" : "Off")
}
if let topP = params.topP {
detailRow(title: "Top P", value: String(format: "%.2f", topP))
}
}
}
}
@ViewBuilder
private var messageSection: some View {
if let messages = task.parameters?.messages, !messages.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("Messages")
.font(.subheadline)
ForEach(Array(messages.enumerated()), id: \.offset) { _, message in
VStack(alignment: .leading, spacing: 2) {
Text(message.role?.capitalized ?? "Message")
.font(.caption)
.foregroundColor(.secondary)
if let content = message.content, !content.isEmpty {
Text(content)
.font(.caption2)
.foregroundColor(.primary)
}
}
.padding(8)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
}
@ViewBuilder
private func detailRow(icon: String? = nil, title: String, value: String, tint: Color = .secondary) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
if let icon {
Image(systemName: icon)
.imageScale(.small)
.foregroundColor(tint)
}
Text(title)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(value)
.font(.caption2)
.foregroundColor(.primary)
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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.
}
}

View File

@@ -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 its 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()
}
}
}
}

View File

@@ -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)
}
}