exo: open source mac app and build process
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)
|
||||
}
|
||||
}
|
||||