diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..1ad06f76 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/build-macos-app.yml b/.github/workflows/build-macos-app.yml new file mode 100644 index 00000000..b9f01998 --- /dev/null +++ b/.github/workflows/build-macos-app.yml @@ -0,0 +1,198 @@ +name: Build and Release Exo macOS App + +on: + push: + tags: + - 'v*' # Trigger only on version tags + branches: + - main # Also build on main branch for testing + - app-staging # Add app-staging for testing + pull_request: + branches: + - main # Test builds on PRs + +jobs: + build-exov2-macos: + runs-on: macos-15 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Rust (nightly) + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: nightly + components: rustfmt, clippy + default: true + + - name: Set Rust toolchain override + run: | + rustup default nightly + cd rust && rustup override set nightly + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Install Just + run: | + brew install just + + - name: Install UV + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Setup Python Environment + run: | + uv python install + uv sync --locked --all-extras + + - name: Build Rust Components + env: + RUSTFLAGS: "-A unused-imports -A dead-code -A unreachable-code" + run: | + just build-all + + - name: Install Python Bindings + run: | + uv pip install dist/exo_pyo3_bindings-*.whl + + - name: Verify Python Environment + run: | + uv run python -c "import exo_pyo3_bindings; print('Python bindings installed successfully')" + uv run python -c "import master.main; print('Master module available')" + uv run python -c "import worker.main; print('Worker module available')" + + - name: Prepare Code Signing Keychain + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + PROVISIONING_PROFILE: ${{ secrets.PROVISIONING_PROFILE }} + run: | + security create-keychain -p "$MACOS_CERTIFICATE_PASSWORD" exov2.keychain + security default-keychain -s exov2.keychain + security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" exov2.keychain + + echo "$MACOS_CERTIFICATE" | base64 --decode > /tmp/exov2-certificate.p12 + security import /tmp/exov2-certificate.p12 -k exov2.keychain -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + rm /tmp/exov2-certificate.p12 + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CERTIFICATE_PASSWORD" exov2.keychain + + PROFILES_HOME="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" + mkdir -p "$PROFILES_HOME" + PROFILE_PATH="$(mktemp "$PROFILES_HOME"/EXOV2_PP.provisionprofile)" + echo "$PROVISIONING_PROFILE" | base64 --decode > "$PROFILE_PATH" + + - name: Build Exo Swift App + env: + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + run: | + cd app/exov2 + sudo xcode-select -s /Applications/Xcode.app/Contents/Developer + + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == refs/tags/v* ]]; then + # Release build with code signing + security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" exov2.keychain + SIGNING_IDENTITY=$(security find-identity -v -p codesigning | awk -F '"' '{print $2}') + + xcodebuild clean build \ + -project exov2.xcodeproj \ + -scheme exov2 \ + -configuration Release \ + -derivedDataPath build \ + CODE_SIGNING_IDENTITY="$SIGNING_IDENTITY" \ + PROVISIONING_PROFILE_SPECIFIER="Exo Provisioning Profile" \ + CODE_SIGN_INJECT_BASE_ENTITLEMENTS=YES \ + OTHER_CODE_SIGN_FLAGS="--timestamp" + else + # Debug build without code signing for testing + xcodebuild clean build \ + -project exov2.xcodeproj \ + -scheme exov2 \ + -configuration Debug \ + -derivedDataPath build \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO + fi + + mv build/Build/Products/*/exov2.app ../../ + + - name: Sign and Create DMG (Release only) + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + env: + APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} + APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} + APPLE_NOTARIZATION_TEAM: ${{ secrets.APPLE_NOTARIZATION_TEAM }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + run: | + security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" exov2.keychain + SIGNING_IDENTITY=$(security find-identity -v -p codesigning | awk -F '"' '{print $2}') + + # Sign the app + /usr/bin/codesign --deep --force --timestamp --options runtime \ + --sign "$SIGNING_IDENTITY" exov2.app + + # Verify the signing + codesign -dvv exov2.app + + # Create DMG + mkdir -p tmp/dmg-contents + cp -r ./exov2.app tmp/dmg-contents/ + ln -s /Applications tmp/dmg-contents/Applications + VERSION=$(git describe --tags --abbrev=0 | sed 's/^v//') + + # Create and sign DMG + hdiutil create -volname "Exo" -srcfolder tmp/dmg-contents -ov -format UDZO exov2-${VERSION}.dmg + /usr/bin/codesign --deep --force --timestamp --options runtime \ + --sign "$SIGNING_IDENTITY" exov2-${VERSION}.dmg + + # Setup notarization credentials (optional - comment out if no notarization secrets) + if [[ -n "$APPLE_NOTARIZATION_USERNAME" ]]; then + xcrun notarytool store-credentials notary_pass \ + --apple-id "$APPLE_NOTARIZATION_USERNAME" \ + --password "$APPLE_NOTARIZATION_PASSWORD" \ + --team-id "$APPLE_NOTARIZATION_TEAM" + + # Submit for notarization + xcrun notarytool submit --wait \ + --team-id "$APPLE_NOTARIZATION_TEAM" \ + --keychain-profile notary_pass \ + exov2-${VERSION}.dmg + + # Staple the notarization + xcrun stapler staple exov2-${VERSION}.dmg + fi + + - name: Create DMG (Debug builds) + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/v') + run: | + mkdir -p tmp/dmg-contents + cp -r ./exov2.app tmp/dmg-contents/ + ln -s /Applications tmp/dmg-contents/Applications + VERSION=$(git rev-parse --short HEAD) + + hdiutil create -volname "Exo Debug" -srcfolder tmp/dmg-contents -ov -format UDZO exov2-debug-${VERSION}.dmg + + - name: Cleanup Keychain + if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + run: | + security default-keychain -s login.keychain + security delete-keychain exov2.keychain + + - name: Upload DMG file + uses: actions/upload-artifact@v4 + with: + name: exov2-dmg + path: exov2*.dmg + + - name: Upload App Bundle + uses: actions/upload-artifact@v4 + with: + name: exov2-app + path: exov2.app/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index b3f86bdf..930ec3e1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ networking/target/ networking/topology/target/ build/ +*.xcuserstate \ No newline at end of file diff --git a/app/exov2/.DS_Store b/app/exov2/.DS_Store new file mode 100644 index 00000000..31bdd033 Binary files /dev/null and b/app/exov2/.DS_Store differ diff --git a/app/exov2/exov2.xcodeproj/project.pbxproj b/app/exov2/exov2.xcodeproj/project.pbxproj new file mode 100644 index 00000000..a4e54fad --- /dev/null +++ b/app/exov2/exov2.xcodeproj/project.pbxproj @@ -0,0 +1,548 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + E07D64CC2E36127F009BFB4D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E07D64B22E36127E009BFB4D /* Project object */; + proxyType = 1; + remoteGlobalIDString = E07D64B92E36127E009BFB4D; + remoteInfo = exov2; + }; + E07D64D62E36127F009BFB4D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E07D64B22E36127E009BFB4D /* Project object */; + proxyType = 1; + remoteGlobalIDString = E07D64B92E36127E009BFB4D; + remoteInfo = exov2; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + E07D64BA2E36127E009BFB4D /* exov2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = exov2.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E07D64CB2E36127F009BFB4D /* exov2Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = exov2Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + E07D64D52E36127F009BFB4D /* exov2UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = exov2UITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + E07D64BC2E36127E009BFB4D /* exov2 */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = exov2; + sourceTree = ""; + }; + E07D64CE2E36127F009BFB4D /* exov2Tests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = exov2Tests; + sourceTree = ""; + }; + E07D64D82E36127F009BFB4D /* exov2UITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = exov2UITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + E07D64B72E36127E009BFB4D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E07D64C82E36127F009BFB4D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E07D64D22E36127F009BFB4D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + E07D64B12E36127E009BFB4D = { + isa = PBXGroup; + children = ( + E07D64BC2E36127E009BFB4D /* exov2 */, + E07D64CE2E36127F009BFB4D /* exov2Tests */, + E07D64D82E36127F009BFB4D /* exov2UITests */, + E07D64BB2E36127E009BFB4D /* Products */, + ); + sourceTree = ""; + }; + E07D64BB2E36127E009BFB4D /* Products */ = { + isa = PBXGroup; + children = ( + E07D64BA2E36127E009BFB4D /* exov2.app */, + E07D64CB2E36127F009BFB4D /* exov2Tests.xctest */, + E07D64D52E36127F009BFB4D /* exov2UITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E07D64B92E36127E009BFB4D /* exov2 */ = { + isa = PBXNativeTarget; + buildConfigurationList = E07D64DF2E36127F009BFB4D /* Build configuration list for PBXNativeTarget "exov2" */; + buildPhases = ( + E07D64B62E36127E009BFB4D /* Sources */, + E07D64B72E36127E009BFB4D /* Frameworks */, + E07D64B82E36127E009BFB4D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + E07D64BC2E36127E009BFB4D /* exov2 */, + ); + name = exov2; + packageProductDependencies = ( + ); + productName = exov2; + productReference = E07D64BA2E36127E009BFB4D /* exov2.app */; + productType = "com.apple.product-type.application"; + }; + E07D64CA2E36127F009BFB4D /* exov2Tests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E07D64E22E36127F009BFB4D /* Build configuration list for PBXNativeTarget "exov2Tests" */; + buildPhases = ( + E07D64C72E36127F009BFB4D /* Sources */, + E07D64C82E36127F009BFB4D /* Frameworks */, + E07D64C92E36127F009BFB4D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E07D64CD2E36127F009BFB4D /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E07D64CE2E36127F009BFB4D /* exov2Tests */, + ); + name = exov2Tests; + packageProductDependencies = ( + ); + productName = exov2Tests; + productReference = E07D64CB2E36127F009BFB4D /* exov2Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + E07D64D42E36127F009BFB4D /* exov2UITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E07D64E52E36127F009BFB4D /* Build configuration list for PBXNativeTarget "exov2UITests" */; + buildPhases = ( + E07D64D12E36127F009BFB4D /* Sources */, + E07D64D22E36127F009BFB4D /* Frameworks */, + E07D64D32E36127F009BFB4D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E07D64D72E36127F009BFB4D /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E07D64D82E36127F009BFB4D /* exov2UITests */, + ); + name = exov2UITests; + packageProductDependencies = ( + ); + productName = exov2UITests; + productReference = E07D64D52E36127F009BFB4D /* exov2UITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E07D64B22E36127E009BFB4D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1610; + LastUpgradeCheck = 1610; + TargetAttributes = { + E07D64B92E36127E009BFB4D = { + CreatedOnToolsVersion = 16.1; + }; + E07D64CA2E36127F009BFB4D = { + CreatedOnToolsVersion = 16.1; + TestTargetID = E07D64B92E36127E009BFB4D; + }; + E07D64D42E36127F009BFB4D = { + CreatedOnToolsVersion = 16.1; + TestTargetID = E07D64B92E36127E009BFB4D; + }; + }; + }; + buildConfigurationList = E07D64B52E36127E009BFB4D /* Build configuration list for PBXProject "exov2" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = E07D64B12E36127E009BFB4D; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = E07D64BB2E36127E009BFB4D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E07D64B92E36127E009BFB4D /* exov2 */, + E07D64CA2E36127F009BFB4D /* exov2Tests */, + E07D64D42E36127F009BFB4D /* exov2UITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E07D64B82E36127E009BFB4D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E07D64C92E36127F009BFB4D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E07D64D32E36127F009BFB4D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E07D64B62E36127E009BFB4D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E07D64C72E36127F009BFB4D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E07D64D12E36127F009BFB4D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + E07D64CD2E36127F009BFB4D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E07D64B92E36127E009BFB4D /* exov2 */; + targetProxy = E07D64CC2E36127F009BFB4D /* PBXContainerItemProxy */; + }; + E07D64D72E36127F009BFB4D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E07D64B92E36127E009BFB4D /* exov2 */; + targetProxy = E07D64D62E36127F009BFB4D /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + E07D64DD2E36127F009BFB4D /* 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; + }; + E07D64DE2E36127F009BFB4D /* 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; + }; + E07D64E02E36127F009BFB4D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = exov2/exov2.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"exov2/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = exolabs.exov2; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + E07D64E12E36127F009BFB4D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = exov2/exov2.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"exov2/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = exolabs.exov2; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + E07D64E32E36127F009BFB4D /* 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.exov2Tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/exov2.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/exov2"; + }; + name = Debug; + }; + E07D64E42E36127F009BFB4D /* 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.exov2Tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/exov2.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/exov2"; + }; + name = Release; + }; + E07D64E62E36127F009BFB4D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = exolabs.exov2UITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = exov2; + }; + name = Debug; + }; + E07D64E72E36127F009BFB4D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = exolabs.exov2UITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = exov2; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E07D64B52E36127E009BFB4D /* Build configuration list for PBXProject "exov2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E07D64DD2E36127F009BFB4D /* Debug */, + E07D64DE2E36127F009BFB4D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E07D64DF2E36127F009BFB4D /* Build configuration list for PBXNativeTarget "exov2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E07D64E02E36127F009BFB4D /* Debug */, + E07D64E12E36127F009BFB4D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E07D64E22E36127F009BFB4D /* Build configuration list for PBXNativeTarget "exov2Tests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E07D64E32E36127F009BFB4D /* Debug */, + E07D64E42E36127F009BFB4D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E07D64E52E36127F009BFB4D /* Build configuration list for PBXNativeTarget "exov2UITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E07D64E62E36127F009BFB4D /* Debug */, + E07D64E72E36127F009BFB4D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = E07D64B22E36127E009BFB4D /* Project object */; +} diff --git a/app/exov2/exov2.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/exov2/exov2.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/app/exov2/exov2.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/exov2/exov2.xcodeproj/project.xcworkspace/xcuserdata/samikhan.xcuserdatad/UserInterfaceState.xcuserstate b/app/exov2/exov2.xcodeproj/project.xcworkspace/xcuserdata/samikhan.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 00000000..92fe6a19 Binary files /dev/null and b/app/exov2/exov2.xcodeproj/project.xcworkspace/xcuserdata/samikhan.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/app/exov2/exov2.xcodeproj/xcshareddata/xcschemes/exov2.xcscheme b/app/exov2/exov2.xcodeproj/xcshareddata/xcschemes/exov2.xcscheme new file mode 100644 index 00000000..b6afb5a7 --- /dev/null +++ b/app/exov2/exov2.xcodeproj/xcshareddata/xcschemes/exov2.xcscheme @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/exov2/exov2.xcodeproj/xcuserdata/samikhan.xcuserdatad/xcschemes/xcschememanagement.plist b/app/exov2/exov2.xcodeproj/xcuserdata/samikhan.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 00000000..f9edf8e6 --- /dev/null +++ b/app/exov2/exov2.xcodeproj/xcuserdata/samikhan.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,32 @@ + + + + + SchemeUserState + + exov2.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + E07D64B92E36127E009BFB4D + + primary + + + E07D64CA2E36127F009BFB4D + + primary + + + E07D64D42E36127F009BFB4D + + primary + + + + + diff --git a/app/exov2/exov2/Preview Content/Preview Assets.xcassets/Contents.json b/app/exov2/exov2/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/app/exov2/exov2/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/exov2/exov2/ProcessManager.swift b/app/exov2/exov2/ProcessManager.swift new file mode 100644 index 00000000..81c5275a --- /dev/null +++ b/app/exov2/exov2/ProcessManager.swift @@ -0,0 +1,377 @@ +import Foundation +import OSLog +import SwiftUI +import AppKit +import ServiceManagement + +extension NSApplication { + func addTerminationHandler(_ handler: @escaping () -> Void) { + NSApp.setActivationPolicy(.accessory) + NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, + object: nil, + queue: .main) { _ in + handler() + } + } +} + +class ProcessManager: ObservableObject { + @Published var masterProcess: Process? + @Published var workerProcess: Process? + @Published var masterStatus: String = "Stopped" + @Published var workerStatus: String = "Stopped" + @Published var isLoginItemEnabled: Bool = false + @Published var isMasterMode: Bool = false // Default to replica mode (false) + + private var masterStdout: Pipe? + private var workerStdout: Pipe? + private let logger = Logger(subsystem: "exolabs.exov2", category: "ProcessManager") + + // Add file handle properties to track them + private var masterFileHandle: FileHandle? + private var workerFileHandle: FileHandle? + + private let loginService = SMAppService.mainApp + + // Find uv executable in common installation paths + private var uvPath: String? { + let commonPaths = [ + "/usr/local/bin/uv", + "/opt/homebrew/bin/uv", + "/usr/bin/uv", + "/bin/uv", + "/Users/\(NSUserName())/.cargo/bin/uv", + "/Users/\(NSUserName())/.local/bin/uv" + ] + + for path in commonPaths { + if FileManager.default.fileExists(atPath: path) { + return path + } + } + + // Try using 'which uv' command as fallback + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/which") + process.arguments = ["uv"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + + if process.terminationStatus == 0 { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + if let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty { + return path + } + } + } catch { + logger.error("Failed to run 'which uv': \(error.localizedDescription)") + } + + return nil + } + + // Project root path - assuming the app bundle is in the project directory + private var projectPath: URL? { + // Get the app bundle path and navigate to the project root + // This assumes the app is built/run from within the project directory + guard let bundlePath = Bundle.main.bundleURL.path as String? else { return nil } + + // Navigate up from the app bundle to find the project root + // Look for pyproject.toml to identify the project root + var currentPath = URL(fileURLWithPath: bundlePath) + while currentPath.pathComponents.count > 1 { + let pyprojectPath = currentPath.appendingPathComponent("pyproject.toml") + if FileManager.default.fileExists(atPath: pyprojectPath.path) { + return currentPath + } + currentPath = currentPath.deletingLastPathComponent() + } + + // Fallback: try to find project in common development locations + let homeDir = FileManager.default.homeDirectoryForCurrentUser + let commonPaths = [ + "exo", + "Projects/exo", + "Documents/exo", + "Desktop/exo" + ] + + for path in commonPaths { + let projectDir = homeDir.appendingPathComponent(path) + let pyprojectPath = projectDir.appendingPathComponent("pyproject.toml") + if FileManager.default.fileExists(atPath: pyprojectPath.path) { + return projectDir + } + } + + return nil + } + + init() { + // Add termination handler + NSApplication.shared.addTerminationHandler { [weak self] in + self?.stopAll() + } + + // Check if login item is enabled + isLoginItemEnabled = (loginService.status == .enabled) + + // Start processes automatically + startMaster() + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.startWorker() + } + } + + private func handleProcessOutput(_ pipe: Pipe, processName: String) -> FileHandle { + let fileHandle = pipe.fileHandleForReading + fileHandle.readabilityHandler = { [weak self] handle in + guard let data = try? handle.read(upToCount: 1024), + let output = String(data: data, encoding: .utf8) else { + return + } + + DispatchQueue.main.async { + self?.logger.info("\(processName) output: \(output)") + print("[\(processName)] \(output)") + } + } + return fileHandle + } + + private func cleanupProcess(process: Process?, fileHandle: FileHandle?, pipe: Pipe?) { + // Remove readability handler + fileHandle?.readabilityHandler = nil + + // Close file handles + try? fileHandle?.close() + try? pipe?.fileHandleForReading.close() + try? pipe?.fileHandleForWriting.close() + + // Terminate process if still running + if process?.isRunning == true { + process?.terminate() + } + } + + func startMaster() { + guard let projectPath = self.projectPath else { + masterStatus = "Error: Project directory not found" + logger.error("Could not find project directory with pyproject.toml") + return + } + + guard let uvPath = self.uvPath else { + masterStatus = "Error: uv not found" + logger.error("Could not find uv executable in common paths") + return + } + + // Cleanup any existing process + cleanupProcess(process: masterProcess, fileHandle: masterFileHandle, pipe: masterStdout) + + masterProcess = Process() + masterStdout = Pipe() + + // Use uv to run the master module + masterProcess?.executableURL = URL(fileURLWithPath: uvPath) + masterProcess?.arguments = ["run", "python", "-m", "master.main"] + masterProcess?.standardOutput = masterStdout + masterProcess?.standardError = masterStdout + + // Set up environment + var env = ProcessInfo.processInfo.environment + env["PYTHONUNBUFFERED"] = "1" + env["PYTHONPATH"] = projectPath.path + + // Set replica mode if not in master mode + if !self.isMasterMode { + env["EXO_RUN_AS_REPLICA"] = "1" + } + + masterProcess?.environment = env + + // Set working directory to project root + masterProcess?.currentDirectoryURL = projectPath + + // Store the file handle + masterFileHandle = handleProcessOutput(masterStdout!, processName: "Master") + + do { + logger.info("Starting master process with \(uvPath) run python -m master.main at \(projectPath.path)") + try masterProcess?.run() + masterStatus = "Running" + + masterProcess?.terminationHandler = { [weak self] process in + DispatchQueue.main.async { + let status = "Stopped (exit: \(process.terminationStatus))" + self?.masterStatus = status + self?.logger.error("Master process terminated: \(status)") + // Cleanup on termination + self?.cleanupProcess(process: self?.masterProcess, + fileHandle: self?.masterFileHandle, + pipe: self?.masterStdout) + } + } + } catch { + masterStatus = "Error: \(error.localizedDescription)" + logger.error("Failed to start master: \(error.localizedDescription)") + cleanupProcess(process: masterProcess, fileHandle: masterFileHandle, pipe: masterStdout) + } + } + + func startWorker() { + guard let projectPath = self.projectPath else { + workerStatus = "Error: Project directory not found" + logger.error("Could not find project directory with pyproject.toml") + return + } + + guard let uvPath = self.uvPath else { + workerStatus = "Error: uv not found" + logger.error("Could not find uv executable in common paths") + return + } + + // Cleanup any existing process + cleanupProcess(process: workerProcess, fileHandle: workerFileHandle, pipe: workerStdout) + + workerProcess = Process() + workerStdout = Pipe() + + // Use uv to run the worker module + workerProcess?.executableURL = URL(fileURLWithPath: uvPath) + workerProcess?.arguments = ["run", "python", "-m", "worker.main"] + workerProcess?.standardOutput = workerStdout + workerProcess?.standardError = workerStdout + + // Set up environment + var env = ProcessInfo.processInfo.environment + env["PYTHONUNBUFFERED"] = "1" + env["PYTHONPATH"] = projectPath.path + workerProcess?.environment = env + + // Set working directory to project root + workerProcess?.currentDirectoryURL = projectPath + + // Store the file handle + workerFileHandle = handleProcessOutput(workerStdout!, processName: "Worker") + + do { + logger.info("Starting worker process with \(uvPath) run python -m worker.main at \(projectPath.path)") + try workerProcess?.run() + workerStatus = "Running" + + workerProcess?.terminationHandler = { [weak self] process in + DispatchQueue.main.async { + let status = "Stopped (exit: \(process.terminationStatus))" + self?.workerStatus = status + self?.logger.error("Worker process terminated: \(status)") + // Cleanup on termination + self?.cleanupProcess(process: self?.workerProcess, + fileHandle: self?.workerFileHandle, + pipe: self?.workerStdout) + } + } + } catch { + workerStatus = "Error: \(error.localizedDescription)" + logger.error("Failed to start worker: \(error.localizedDescription)") + cleanupProcess(process: workerProcess, fileHandle: workerFileHandle, pipe: workerStdout) + } + } + + func stopAll() { + logger.info("Stopping all processes") + + // Clean up master process + cleanupProcess(process: masterProcess, fileHandle: masterFileHandle, pipe: masterStdout) + masterProcess = nil + masterStdout = nil + masterFileHandle = nil + masterStatus = "Stopped" + + // Clean up worker process + cleanupProcess(process: workerProcess, fileHandle: workerFileHandle, pipe: workerStdout) + workerProcess = nil + workerStdout = nil + workerFileHandle = nil + workerStatus = "Stopped" + } + + func checkBinaries() -> Bool { + guard let projectPath = self.projectPath else { + logger.error("Could not find project directory") + return false + } + + guard let uvPath = self.uvPath else { + logger.error("Could not find uv executable") + return false + } + + let fileManager = FileManager.default + let pyprojectPath = projectPath.appendingPathComponent("pyproject.toml").path + let masterPath = projectPath.appendingPathComponent("master/main.py").path + let workerPath = projectPath.appendingPathComponent("worker/main.py").path + + let uvExists = fileManager.fileExists(atPath: uvPath) + let pyprojectExists = fileManager.fileExists(atPath: pyprojectPath) + let masterExists = fileManager.fileExists(atPath: masterPath) + let workerExists = fileManager.fileExists(atPath: workerPath) + + if !uvExists { + logger.error("uv not found at \(uvPath)") + } + if !pyprojectExists { + logger.error("pyproject.toml not found at \(pyprojectPath)") + } + if !masterExists { + logger.error("master/main.py not found at \(masterPath)") + } + if !workerExists { + logger.error("worker/main.py not found at \(workerPath)") + } + + return uvExists && pyprojectExists && masterExists && workerExists + } + + func toggleLoginItem() { + do { + if isLoginItemEnabled { + try loginService.unregister() + } else { + try loginService.register() + } + isLoginItemEnabled = (loginService.status == .enabled) + } catch { + logger.error("Failed to toggle login item: \(error.localizedDescription)") + } + } + + func toggleMasterMode() { + isMasterMode.toggle() + logger.info("Toggling master mode to: \(self.isMasterMode ? "Master" : "Replica")") + + // Restart master process with new mode + if masterProcess?.isRunning == true { + // Clean up current master process + cleanupProcess(process: masterProcess, fileHandle: masterFileHandle, pipe: masterStdout) + masterProcess = nil + masterStdout = nil + masterFileHandle = nil + masterStatus = "Stopped" + + // Start master with new mode after a brief delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.startMaster() + } + } + } +} \ No newline at end of file diff --git a/app/exov2/exov2/exov2.entitlements b/app/exov2/exov2/exov2.entitlements new file mode 100644 index 00000000..9b5d06d4 --- /dev/null +++ b/app/exov2/exov2/exov2.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.automation.apple-events + + + diff --git a/app/exov2/exov2/exov2App.swift b/app/exov2/exov2/exov2App.swift new file mode 100644 index 00000000..2a6910b6 --- /dev/null +++ b/app/exov2/exov2/exov2App.swift @@ -0,0 +1,115 @@ +// +// exov2App.swift +// exov2 +// +// Created by Sami Khan on 2025-07-27. +// + +import SwiftUI +import AppKit +import Foundation +import OSLog +import ServiceManagement + +@main +struct exov2App: App { + @StateObject private var processManager = ProcessManager() + + private func resizedMenuBarIcon(named: String, size: CGFloat = 18.0) -> NSImage? { + guard let original = NSImage(named: named) else { + print("Failed to load image named: \(named)") + return nil + } + + let resized = NSImage(size: NSSize(width: size, height: size), flipped: false) { rect in + NSGraphicsContext.current?.imageInterpolation = .high + original.draw(in: rect) + return true + } + + resized.isTemplate = false + resized.size = NSSize(width: size, height: size) + return resized + } + + var body: some Scene { + MenuBarExtra { + MenuBarView(processManager: processManager) + } label: { + if let resizedImage = resizedMenuBarIcon(named: "menubar-icon") { + Image(nsImage: resizedImage) + .opacity(processManager.masterStatus == "Running" ? 1.0 : 0.5) + } + } + .menuBarExtraStyle(.window) + } +} + +struct MenuBarView: View { + @ObservedObject var processManager: ProcessManager + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + StatusSection(processManager: processManager) + + Divider() + + Toggle("Launch at Login", isOn: Binding( + get: { processManager.isLoginItemEnabled }, + set: { _ in processManager.toggleLoginItem() } + )) + .padding(.horizontal) + + Toggle("Is Master?", isOn: Binding( + get: { processManager.isMasterMode }, + set: { _ in processManager.toggleMasterMode() } + )) + .padding(.horizontal) + + Divider() + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + } + .padding() + .frame(width: 250) + .onAppear { + if !processManager.checkBinaries() { + showEnvironmentError() + } + } + } + + private func showEnvironmentError() { + let alert = NSAlert() + alert.messageText = "Python Environment Error" + alert.informativeText = "Could not find the required Python environment, uv, or project files. Please ensure uv is installed and the project directory is accessible." + alert.alertStyle = .critical + alert.addButton(withTitle: "OK") + alert.runModal() + NSApplication.shared.terminate(nil) + } +} + +struct StatusSection: View { + @ObservedObject var processManager: ProcessManager + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Master:") + .bold() + Text(processManager.masterStatus) + .foregroundColor(processManager.masterStatus == "Running" ? .green : .red) + } + + HStack { + Text("Worker:") + .bold() + Text(processManager.workerStatus) + .foregroundColor(processManager.workerStatus == "Running" ? .green : .red) + } + } + } +} diff --git a/app/exov2/exov2Tests/exov2Tests.swift b/app/exov2/exov2Tests/exov2Tests.swift new file mode 100644 index 00000000..dd137fbd --- /dev/null +++ b/app/exov2/exov2Tests/exov2Tests.swift @@ -0,0 +1,17 @@ +// +// exov2Tests.swift +// exov2Tests +// +// Created by Sami Khan on 2025-07-27. +// + +import Testing +@testable import exov2 + +struct exov2Tests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/app/exov2/exov2UITests/exov2UITests.swift b/app/exov2/exov2UITests/exov2UITests.swift new file mode 100644 index 00000000..db1586a9 --- /dev/null +++ b/app/exov2/exov2UITests/exov2UITests.swift @@ -0,0 +1,43 @@ +// +// exov2UITests.swift +// exov2UITests +// +// Created by Sami Khan on 2025-07-27. +// + +import XCTest + +final class exov2UITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} diff --git a/app/exov2/exov2UITests/exov2UITestsLaunchTests.swift b/app/exov2/exov2UITests/exov2UITestsLaunchTests.swift new file mode 100644 index 00000000..928b4443 --- /dev/null +++ b/app/exov2/exov2UITests/exov2UITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// exov2UITestsLaunchTests.swift +// exov2UITests +// +// Created by Sami Khan on 2025-07-27. +// + +import XCTest + +final class exov2UITestsLaunchTests: 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) + } +}