exo: open source mac app and build process
3
.gitignore
vendored
@@ -10,7 +10,10 @@ digest.txt
|
|||||||
|
|
||||||
# xcode / macos
|
# xcode / macos
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
|
*.xcuserdata
|
||||||
|
*.xcuserdatad/
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
app/EXO/build/
|
||||||
|
|
||||||
|
|
||||||
# rust
|
# rust
|
||||||
|
|||||||
602
app/EXO/EXO.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
7
app/EXO/EXO.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
114
app/EXO/EXO.xcodeproj/xcshareddata/xcschemes/EXO.xcscheme
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/1024-mac.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/128-mac.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/16-mac.png
Normal file
|
After Width: | Height: | Size: 324 B |
BIN
app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac 1.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/256-mac.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac 1.png
Normal file
|
After Width: | Height: | Size: 758 B |
BIN
app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/32-mac.png
Normal file
|
After Width: | Height: | Size: 758 B |
BIN
app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac 1.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/512-mac.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/64-mac.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
68
app/EXO/EXO/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/EXO/EXO/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
app/EXO/EXO/Assets.xcassets/menubar-icon.imageset/exo-logo-hq-square-transparent-bg.png
vendored
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
494
app/EXO/EXO/ContentView.swift
Normal 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 (en0–en7):")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
if statuses.isEmpty {
|
||||||
|
Text(" Unknown")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(statuses, id: \.interfaceName) { status in
|
||||||
|
let ipText = status.ipAddress ?? "No IP"
|
||||||
|
Text(" \(status.interfaceName): \(ipText)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(status.ipAddress == nil ? .red : .green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var debugSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text("Debug Info")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
collapseButton(isExpanded: $showDebugInfo)
|
||||||
|
}
|
||||||
|
.animation(nil, value: showDebugInfo)
|
||||||
|
if showDebugInfo {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Version: \(buildTag)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("Commit: \(buildCommit)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(thunderboltStatusText)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(thunderboltStatusColor)
|
||||||
|
interfaceIpList
|
||||||
|
sendBugReportButton
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: showDebugInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sendBugReportButton: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await sendBugReport()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if bugReportInFlight {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.6)
|
||||||
|
}
|
||||||
|
Text("Send Bug Report")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.accentColor.opacity(0.12))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(bugReportInFlight)
|
||||||
|
|
||||||
|
if let message = bugReportMessage {
|
||||||
|
Text(message)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var processToggleBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
switch controller.status {
|
||||||
|
case .running, .starting:
|
||||||
|
return true
|
||||||
|
case .stopped, .failed:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: { isOn in
|
||||||
|
if isOn {
|
||||||
|
stateService.resetTransientState()
|
||||||
|
stateService.startPolling()
|
||||||
|
controller.cancelPendingLaunch()
|
||||||
|
controller.launchIfNeeded()
|
||||||
|
} else {
|
||||||
|
stateService.stopPolling()
|
||||||
|
controller.stop()
|
||||||
|
stateService.resetTransientState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindingForNode(_ node: NodeViewModel) -> Binding<NodeViewModel?> {
|
||||||
|
Binding<NodeViewModel?>(
|
||||||
|
get: {
|
||||||
|
focusedNode?.id == node.id ? focusedNode : nil
|
||||||
|
},
|
||||||
|
set: { newValue in
|
||||||
|
if newValue == nil {
|
||||||
|
focusedNode = nil
|
||||||
|
} else {
|
||||||
|
focusedNode = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendBugReport() async {
|
||||||
|
bugReportInFlight = true
|
||||||
|
bugReportMessage = "Collecting logs..."
|
||||||
|
let service = BugReportService()
|
||||||
|
do {
|
||||||
|
let outcome = try await service.sendReport(isManual: true)
|
||||||
|
bugReportMessage = outcome.message
|
||||||
|
} catch {
|
||||||
|
bugReportMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
bugReportInFlight = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buildTag: String {
|
||||||
|
Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buildCommit: String {
|
||||||
|
Bundle.main.infoDictionary?["EXOBuildCommit"] as? String ?? "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct HoverButton: View {
|
||||||
|
let title: String
|
||||||
|
let tint: Color
|
||||||
|
let trailingSystemImage: String?
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
@State private var isHovering = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
Spacer()
|
||||||
|
if let systemName = trailingSystemImage {
|
||||||
|
Image(systemName: systemName)
|
||||||
|
.imageScale(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(
|
||||||
|
isHovering
|
||||||
|
? Color.accentColor.opacity(0.1)
|
||||||
|
: Color.clear
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(tint)
|
||||||
|
.onHover { isHovering = $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
12
app/EXO/EXO/EXO.entitlements
Normal 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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
232
app/EXO/EXO/ExoProcessController.swift
Normal 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
@@ -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>
|
||||||
369
app/EXO/EXO/Models/ClusterState.swift
Normal 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] { [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
539
app/EXO/EXO/Services/BugReportService.swift
Normal 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: "Ep5gIlUZ1o8ssTLQwmyy34yPGfTPEYQ4evE8NdPE",
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
145
app/EXO/EXO/Services/ClusterStateService.swift
Normal 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?
|
||||||
|
}
|
||||||
187
app/EXO/EXO/Services/NetworkSetupHelper.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
app/EXO/EXO/Services/NetworkStatusService.swift
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
246
app/EXO/EXO/ViewModels/InstanceViewModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
121
app/EXO/EXO/ViewModels/NodeViewModel.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
332
app/EXO/EXO/Views/InstanceRowView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
35
app/EXO/EXO/Views/NodeDetailView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
31
app/EXO/EXO/Views/NodeRowView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
172
app/EXO/EXO/Views/TopologyMiniView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
17
app/EXO/EXOTests/EXOTests.swift
Normal 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.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
43
app/EXO/EXOUITests/EXOUITests.swift
Normal 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 it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testExample() throws {
|
||||||
|
// UI tests must launch the application that they test.
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLaunchPerformance() throws {
|
||||||
|
if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
|
||||||
|
// This measures how long it takes to launch your application.
|
||||||
|
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||||
|
XCUIApplication().launch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/EXO/EXOUITests/EXOUITestsLaunchTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||