diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0ec1ab6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + branches: ["main", "agent/**"] + pull_request: + +permissions: + contents: read + +jobs: + build-and-unit-test: + name: Build and unit test (${{ matrix.runner }}) + strategy: + fail-fast: false + matrix: + include: + - runner: macos-26 + arch: arm64 + - runner: macos-26-intel + arch: x86_64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + - name: Toolchain + run: xcodebuild -version + - name: Build + run: | + set -o pipefail + xcodebuild \ + -project LaunchdTOC.xcodeproj \ + -scheme LaunchdTOC \ + -configuration Debug \ + -destination "platform=macOS,arch=${{ matrix.arch }}" \ + CODE_SIGNING_ALLOWED=NO \ + build + - name: Unit tests + run: | + set -o pipefail + xcodebuild \ + -project LaunchdTOC.xcodeproj \ + -scheme LaunchdTOC \ + -configuration Debug \ + -destination "platform=macOS,arch=${{ matrix.arch }}" \ + CODE_SIGNING_ALLOWED=NO \ + -only-testing:LaunchdTOCTests \ + test + + analyze-and-verify: + name: Analyze and verify universal product + runs-on: macos-26 + steps: + - uses: actions/checkout@v5 + - name: Static analysis + run: | + xcodebuild \ + -project LaunchdTOC.xcodeproj \ + -scheme LaunchdTOC \ + -configuration Debug \ + -destination "platform=macOS" \ + CODE_SIGNING_ALLOWED=NO \ + analyze + - name: Universal Release build + run: | + xcodebuild \ + -project LaunchdTOC.xcodeproj \ + -scheme LaunchdTOC \ + -configuration Release \ + -derivedDataPath build/DerivedData \ + -destination "generic/platform=macOS" \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=NO \ + build + - name: Verify architectures and entitlements + run: | + app_path="build/DerivedData/Build/Products/Release/Launchd TOC.app" + binary_path="$app_path/Contents/MacOS/Launchd TOC" + architectures="$(lipo -archs "$binary_path")" + [[ "$architectures" == *"arm64"* && "$architectures" == *"x86_64"* ]] + entitlements="$(codesign -d --entitlements - "$app_path" 2>&1 || true)" + [[ "$entitlements" != *"com.apple.security.app-sandbox"* ]] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8277d91 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,142 @@ +name: Notarized Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + runs-on: macos-26 + steps: + - uses: actions/checkout@v5 + + - name: Verify tag and project version + env: + TAG_NAME: ${{ github.ref_name }} + run: | + tag_version="${TAG_NAME#v}" + tag_core="${tag_version%%-*}" + project_version="$(xcodebuild -project LaunchdTOC.xcodeproj -scheme LaunchdTOC -showBuildSettings | awk '/MARKETING_VERSION/ { print $3; exit }')" + [[ "$tag_core" == "$project_version" ]] + echo "VERSION=$tag_version" >> "$GITHUB_ENV" + + - name: Create ephemeral signing keychain + env: + CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + certificate_path="$RUNNER_TEMP/developer-id.p12" + keychain_path="$RUNNER_TEMP/launchd-toc.keychain-db" + printf '%s' "$CERTIFICATE_BASE64" | base64 --decode > "$certificate_path" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" + security import "$certificate_path" \ + -P "$CERTIFICATE_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$keychain_path" + security list-keychain -d user -s "$keychain_path" + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$KEYCHAIN_PASSWORD" \ + "$keychain_path" + echo "SIGNING_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV" + + - name: Archive universal signed application + run: | + xcodebuild archive \ + -project LaunchdTOC.xcodeproj \ + -scheme LaunchdTOC \ + -configuration Release \ + -archivePath build/LaunchdTOC.xcarchive \ + -destination "generic/platform=macOS" \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM=N9U29A4T8J \ + CODE_SIGN_IDENTITY="Developer ID Application" + + - name: Verify signed product + run: | + app_path="build/LaunchdTOC.xcarchive/Products/Applications/Launchd TOC.app" + scripts/verify_app.sh "$app_path" + codesign -dvvv "$app_path" + + - name: Package DMG + run: | + app_path="build/LaunchdTOC.xcarchive/Products/Applications/Launchd TOC.app" + scripts/package_dmg.sh "$app_path" "$VERSION" build/release + codesign --force \ + --sign "Developer ID Application" \ + --timestamp \ + "build/release/Launchd-TOC-${VERSION}-universal.dmg" + + - name: Notarize and staple + env: + NOTARY_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY_BASE64 }} + NOTARY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + run: | + key_path="$RUNNER_TEMP/AuthKey_${NOTARY_KEY_ID}.p8" + printf '%s' "$NOTARY_KEY_BASE64" | base64 --decode > "$key_path" + echo "NOTARY_KEY_PATH=$key_path" >> "$GITHUB_ENV" + dmg_path="build/release/Launchd-TOC-${VERSION}-universal.dmg" + xcrun notarytool submit "$dmg_path" \ + --key "$key_path" \ + --key-id "$NOTARY_KEY_ID" \ + --issuer "$NOTARY_ISSUER_ID" \ + --wait + xcrun stapler staple "$dmg_path" + xcrun stapler validate "$dmg_path" + + - name: Gatekeeper and clean-install smoke checks + run: | + dmg_path="build/release/Launchd-TOC-${VERSION}-universal.dmg" + spctl --assess --type open --context context:primary-signature --verbose=2 "$dmg_path" + mount_point="$(mktemp -d "$RUNNER_TEMP/launchd-toc-mount.XXXXXX")" + hdiutil attach "$dmg_path" -nobrowse -readonly -mountpoint "$mount_point" + installed_path="$RUNNER_TEMP/Clean Install/Launchd TOC.app" + mkdir -p "$(dirname "$installed_path")" + ditto "$mount_point/Launchd TOC.app" "$installed_path" + hdiutil detach "$mount_point" + spctl --assess --type execute --verbose=2 "$installed_path" + scripts/verify_app.sh "$installed_path" + + - name: Generate checksum + run: | + cd build/release + shasum -a 256 "Launchd-TOC-${VERSION}-universal.dmg" \ + > "Launchd-TOC-${VERSION}-universal.dmg.sha256" + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.ref_name }} + run: | + prerelease_args=() + if [[ "$TAG_NAME" == *"-"* ]]; then + prerelease_args+=(--prerelease) + fi + gh release create "$TAG_NAME" \ + "build/release/Launchd-TOC-${VERSION}-universal.dmg" \ + "build/release/Launchd-TOC-${VERSION}-universal.dmg.sha256" \ + --generate-notes \ + --verify-tag \ + "${prerelease_args[@]}" + + - name: Remove signing material + if: always() + run: | + if [[ -n "${SIGNING_KEYCHAIN:-}" ]]; then + security delete-keychain "$SIGNING_KEYCHAIN" || true + fi + if [[ -n "${NOTARY_KEY_PATH:-}" ]]; then + rm -f "$NOTARY_KEY_PATH" + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2eb3f8a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,19 @@ +# Contributing + +Thank you for helping improve Launchd TOC. + +## Before opening a pull request + +1. Keep the project native Swift/SwiftUI with no third-party runtime dependencies. +2. Preserve the user-only mutation boundary and fixed-executable process model. +3. Add or update tests for every filesystem, parsing, or launchctl behavior change. +4. Run the unit and UI suites on macOS 26 with Xcode 26.3 or later. +5. Treat warnings as errors and retain Swift 6 strict concurrency. +6. Do not add analytics, automatic network requests, administrator access, or an undocumented launchd option. +7. Do not copy code, assets, UI text, or icons from `azu/launchd-ui` or other applications. + +Use clear commits and explain user-visible safety implications in the pull request. + +## Rights + +This repository does not include an open-source license. Copyright © 2026 Thierry Charbonnel. All rights reserved. A contribution does not change that status unless the copyright holder agrees separately in writing. diff --git a/LaunchdTOC.xcodeproj/project.pbxproj b/LaunchdTOC.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e177597 --- /dev/null +++ b/LaunchdTOC.xcodeproj/project.pbxproj @@ -0,0 +1,681 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0267BF3672358E12B45CCE88 /* LaunchdTOCApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFD2A07470E5DB3895238AF /* LaunchdTOCApp.swift */; }; + 0E67C80EDAB838EE60275621 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D04BAEDBBD3DF94279A2569 /* IntegrationTests.swift */; }; + 1CD20BDCA56F56343788E50A /* LaunchdModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13B0AC7D2F8B83766A5D76 /* LaunchdModels.swift */; }; + 23284BBAEFA846DE89446D88 /* LaunchctlClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E8DA431F29D19CB2A444139 /* LaunchctlClient.swift */; }; + 24FAADEB6E9F8B5C70CA0109 /* PlistRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7D15D4DCFA594E6E3E4AEC /* PlistRepositoryTests.swift */; }; + 390538BAF48E81D25C86240F /* JobPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5877D59AA6B741E00DAC05D /* JobPresentation.swift */; }; + 41C1BCB15A8879172FEDE17F /* JobPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312E4914D3D68CDDF644A333 /* JobPresentationTests.swift */; }; + 4387C423EA35E0FF38E60E71 /* JobStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D38BAB57BAFE3886D475789E /* JobStore.swift */; }; + 59AAF48ED8E2DC023D684D8D /* AgentEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1EDA258ACBEDAA1DF0B632 /* AgentEditorView.swift */; }; + 5B520218C68189DF84F22F6B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E39988112218583585A7B96E /* SettingsView.swift */; }; + 64240924378CF923A3DB43CD /* LaunchdTOCUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A691916693CC5309B545BD9 /* LaunchdTOCUITests.swift */; }; + 64614DC240A36872F60C23F4 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B600523384EE9C149E53D32 /* AboutView.swift */; }; + 6D42BFF54DCF9CB8CB8EECDF /* JobDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F5DAFF0A57DF27B9140E559 /* JobDetailView.swift */; }; + 709C37585833FAEFA5719918 /* DockTileBadgeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CCB9B41C4718BD08BE7A37B /* DockTileBadgeController.swift */; }; + 7159D01A8E115D5563CAFF62 /* PlistRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00E07A37E83F6AB21EBE936 /* PlistRepository.swift */; }; + 73E7DCD0BBBFEC438172F4AC /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBA1B41E277B9A464BF61DC /* RootView.swift */; }; + 85A6BE37B33AE292FA21C26E /* TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981759836406404F167C3834 /* TestSupport.swift */; }; + 8E2A8F1DA15BFE23C6DF7D6D /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 062528AFF600AA4F40675A93 /* SidebarView.swift */; }; + AA4C5E9C3A46DFF72A9DF28D /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 06481317BA01DDA78E584B84 /* Fixtures */; }; + ACC680C45A46F4F268C44540 /* SchedulePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08673C1915FD031CF7D4C2C5 /* SchedulePreview.swift */; }; + B30A833AAA6F836B29C3D53B /* ScheduleAndUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4815154A8A6C12212D11AA7B /* ScheduleAndUpdateTests.swift */; }; + B8490F45676337F03941C20F /* LogTailer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60B92BEFFF2234A7EE937CF7 /* LogTailer.swift */; }; + BDCE8EE6896E766FC6BF36F4 /* CommandRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AD7719261E0372EA78A5E87 /* CommandRunner.swift */; }; + DADF9B2121E35CDED6C012AB /* DockTileBadgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C156E7F5013EF30A2B1DEE1 /* DockTileBadgeTests.swift */; }; + E6F2594189450D2C96F04DC3 /* JobTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D7063D4C6C34BCBAE1C9D15 /* JobTableView.swift */; }; + ECC153F7BC3F935668EF199E /* LaunchctlClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F6F43F36283F4AE35C4359 /* LaunchctlClientTests.swift */; }; + F8102D74CAF3E8F76967EC31 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 62DA3EBDA9986ADB1B137C48 /* Assets.xcassets */; }; + FDFCA118A5C877A086372F62 /* UpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4825A84A756E5F37A9F8C750 /* UpdateChecker.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 3F5E3DDB851866609A1BAE68 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3660CB12EFD3F93AD9DB8742 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B0D8B5729F6DD5EB71DFB4AA; + remoteInfo = LaunchdTOC; + }; + BB016B95698E0E53DBA93557 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3660CB12EFD3F93AD9DB8742 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B0D8B5729F6DD5EB71DFB4AA; + remoteInfo = LaunchdTOC; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 062528AFF600AA4F40675A93 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; + 06481317BA01DDA78E584B84 /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Fixtures; path = LaunchdTOCTests/Fixtures; sourceTree = SOURCE_ROOT; }; + 08673C1915FD031CF7D4C2C5 /* SchedulePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchedulePreview.swift; sourceTree = ""; }; + 1292A3A7F6490AFF8746D1D3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 156A08B02A6A5A13F754DB0B /* LaunchdTOC.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LaunchdTOC.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1D1EDA258ACBEDAA1DF0B632 /* AgentEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentEditorView.swift; sourceTree = ""; }; + 1FBA1B41E277B9A464BF61DC /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + 2AD7719261E0372EA78A5E87 /* CommandRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunner.swift; sourceTree = ""; }; + 2B600523384EE9C149E53D32 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + 312E4914D3D68CDDF644A333 /* JobPresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobPresentationTests.swift; sourceTree = ""; }; + 3A691916693CC5309B545BD9 /* LaunchdTOCUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchdTOCUITests.swift; sourceTree = ""; }; + 3C156E7F5013EF30A2B1DEE1 /* DockTileBadgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockTileBadgeTests.swift; sourceTree = ""; }; + 4815154A8A6C12212D11AA7B /* ScheduleAndUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleAndUpdateTests.swift; sourceTree = ""; }; + 4825A84A756E5F37A9F8C750 /* UpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateChecker.swift; sourceTree = ""; }; + 4B7D15D4DCFA594E6E3E4AEC /* PlistRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlistRepositoryTests.swift; sourceTree = ""; }; + 4FFD2A07470E5DB3895238AF /* LaunchdTOCApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchdTOCApp.swift; sourceTree = ""; }; + 5E8DA431F29D19CB2A444139 /* LaunchctlClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchctlClient.swift; sourceTree = ""; }; + 5F5DAFF0A57DF27B9140E559 /* JobDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobDetailView.swift; sourceTree = ""; }; + 60B92BEFFF2234A7EE937CF7 /* LogTailer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogTailer.swift; sourceTree = ""; }; + 62DA3EBDA9986ADB1B137C48 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 63F6F43F36283F4AE35C4359 /* LaunchctlClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchctlClientTests.swift; sourceTree = ""; }; + 6D7063D4C6C34BCBAE1C9D15 /* JobTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobTableView.swift; sourceTree = ""; }; + 942154B019AED0201DC4E88E /* LaunchdTOCUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LaunchdTOCUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 981759836406404F167C3834 /* TestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSupport.swift; sourceTree = ""; }; + 9CCB9B41C4718BD08BE7A37B /* DockTileBadgeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockTileBadgeController.swift; sourceTree = ""; }; + 9D04BAEDBBD3DF94279A2569 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = ""; }; + A00E07A37E83F6AB21EBE936 /* PlistRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlistRepository.swift; sourceTree = ""; }; + A63A79E793703EF8AFA7BBAB /* LaunchdTOCTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LaunchdTOCTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D38BAB57BAFE3886D475789E /* JobStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobStore.swift; sourceTree = ""; }; + DB13B0AC7D2F8B83766A5D76 /* LaunchdModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchdModels.swift; sourceTree = ""; }; + E39988112218583585A7B96E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + E5877D59AA6B741E00DAC05D /* JobPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobPresentation.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 0BAA60ED707D81F05D438F40 /* Models */ = { + isa = PBXGroup; + children = ( + E5877D59AA6B741E00DAC05D /* JobPresentation.swift */, + DB13B0AC7D2F8B83766A5D76 /* LaunchdModels.swift */, + ); + path = Models; + sourceTree = ""; + }; + 2F52E7A69DA89358042E2CAA /* LaunchdTOC */ = { + isa = PBXGroup; + children = ( + 3FB8B591B003F77BA31CE342 /* App */, + 0BAA60ED707D81F05D438F40 /* Models */, + AECD5C3A378DAB8BA2883D79 /* Resources */, + A2A142A331A3E5D0AAF480A2 /* Services */, + D6B1025800E2F62D98150543 /* Store */, + 90D9B6647E578858DA0E6046 /* Views */, + ); + path = LaunchdTOC; + sourceTree = ""; + }; + 3FB8B591B003F77BA31CE342 /* App */ = { + isa = PBXGroup; + children = ( + 9CCB9B41C4718BD08BE7A37B /* DockTileBadgeController.swift */, + 4FFD2A07470E5DB3895238AF /* LaunchdTOCApp.swift */, + ); + path = App; + sourceTree = ""; + }; + 7CB8461B7A8048183C21F25E = { + isa = PBXGroup; + children = ( + 2F52E7A69DA89358042E2CAA /* LaunchdTOC */, + B4D5227FD4D3244C3A1C60E4 /* LaunchdTOCTests */, + 8066E3C8FF70A12AC58495F8 /* LaunchdTOCUITests */, + C6059142D72D4BD20AB91022 /* Products */, + ); + sourceTree = ""; + }; + 8066E3C8FF70A12AC58495F8 /* LaunchdTOCUITests */ = { + isa = PBXGroup; + children = ( + 3A691916693CC5309B545BD9 /* LaunchdTOCUITests.swift */, + ); + path = LaunchdTOCUITests; + sourceTree = ""; + }; + 90D9B6647E578858DA0E6046 /* Views */ = { + isa = PBXGroup; + children = ( + 2B600523384EE9C149E53D32 /* AboutView.swift */, + 1D1EDA258ACBEDAA1DF0B632 /* AgentEditorView.swift */, + 5F5DAFF0A57DF27B9140E559 /* JobDetailView.swift */, + 6D7063D4C6C34BCBAE1C9D15 /* JobTableView.swift */, + 1FBA1B41E277B9A464BF61DC /* RootView.swift */, + E39988112218583585A7B96E /* SettingsView.swift */, + 062528AFF600AA4F40675A93 /* SidebarView.swift */, + ); + path = Views; + sourceTree = ""; + }; + A2A142A331A3E5D0AAF480A2 /* Services */ = { + isa = PBXGroup; + children = ( + 2AD7719261E0372EA78A5E87 /* CommandRunner.swift */, + 5E8DA431F29D19CB2A444139 /* LaunchctlClient.swift */, + 60B92BEFFF2234A7EE937CF7 /* LogTailer.swift */, + A00E07A37E83F6AB21EBE936 /* PlistRepository.swift */, + 08673C1915FD031CF7D4C2C5 /* SchedulePreview.swift */, + 4825A84A756E5F37A9F8C750 /* UpdateChecker.swift */, + ); + path = Services; + sourceTree = ""; + }; + AECD5C3A378DAB8BA2883D79 /* Resources */ = { + isa = PBXGroup; + children = ( + 62DA3EBDA9986ADB1B137C48 /* Assets.xcassets */, + 1292A3A7F6490AFF8746D1D3 /* Info.plist */, + ); + path = Resources; + sourceTree = ""; + }; + B4D5227FD4D3244C3A1C60E4 /* LaunchdTOCTests */ = { + isa = PBXGroup; + children = ( + 3C156E7F5013EF30A2B1DEE1 /* DockTileBadgeTests.swift */, + 06481317BA01DDA78E584B84 /* Fixtures */, + 9D04BAEDBBD3DF94279A2569 /* IntegrationTests.swift */, + 312E4914D3D68CDDF644A333 /* JobPresentationTests.swift */, + 63F6F43F36283F4AE35C4359 /* LaunchctlClientTests.swift */, + 4B7D15D4DCFA594E6E3E4AEC /* PlistRepositoryTests.swift */, + 4815154A8A6C12212D11AA7B /* ScheduleAndUpdateTests.swift */, + 981759836406404F167C3834 /* TestSupport.swift */, + ); + path = LaunchdTOCTests; + sourceTree = ""; + }; + C6059142D72D4BD20AB91022 /* Products */ = { + isa = PBXGroup; + children = ( + 156A08B02A6A5A13F754DB0B /* LaunchdTOC.app */, + A63A79E793703EF8AFA7BBAB /* LaunchdTOCTests.xctest */, + 942154B019AED0201DC4E88E /* LaunchdTOCUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + D6B1025800E2F62D98150543 /* Store */ = { + isa = PBXGroup; + children = ( + D38BAB57BAFE3886D475789E /* JobStore.swift */, + ); + path = Store; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1DA24961996A644EE6FB021C /* LaunchdTOCTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 47AB21917DCAB8610433879A /* Build configuration list for PBXNativeTarget "LaunchdTOCTests" */; + buildPhases = ( + D4352EDCFB1AE6C08FD191F1 /* Sources */, + B094CE89E69926923FF43D77 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 38F63AADE7389CA7EBECE591 /* PBXTargetDependency */, + ); + name = LaunchdTOCTests; + packageProductDependencies = ( + ); + productName = LaunchdTOCTests; + productReference = A63A79E793703EF8AFA7BBAB /* LaunchdTOCTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 60CCFAF7DBED9C5FE9E11306 /* LaunchdTOCUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 10A3AC6B8FCC57C1CC8B9B97 /* Build configuration list for PBXNativeTarget "LaunchdTOCUITests" */; + buildPhases = ( + 5A589B3649F2123890A34710 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 43A47C07D5F54C3D6CC0D78E /* PBXTargetDependency */, + ); + name = LaunchdTOCUITests; + packageProductDependencies = ( + ); + productName = LaunchdTOCUITests; + productReference = 942154B019AED0201DC4E88E /* LaunchdTOCUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + B0D8B5729F6DD5EB71DFB4AA /* LaunchdTOC */ = { + isa = PBXNativeTarget; + buildConfigurationList = 81B251F2E56D98AC71DDA4A2 /* Build configuration list for PBXNativeTarget "LaunchdTOC" */; + buildPhases = ( + 15D8A1E43AC9ADBFB1267E57 /* Sources */, + 94DAA4821294285E12D823A9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LaunchdTOC; + packageProductDependencies = ( + ); + productName = LaunchdTOC; + productReference = 156A08B02A6A5A13F754DB0B /* LaunchdTOC.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 3660CB12EFD3F93AD9DB8742 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 2630; + TargetAttributes = { + 1DA24961996A644EE6FB021C = { + DevelopmentTeam = N9U29A4T8J; + }; + 60CCFAF7DBED9C5FE9E11306 = { + DevelopmentTeam = N9U29A4T8J; + TestTargetID = B0D8B5729F6DD5EB71DFB4AA; + }; + B0D8B5729F6DD5EB71DFB4AA = { + DevelopmentTeam = N9U29A4T8J; + }; + }; + }; + buildConfigurationList = 91149544656755F27A271FDA /* Build configuration list for PBXProject "LaunchdTOC" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 7CB8461B7A8048183C21F25E; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = C6059142D72D4BD20AB91022 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B0D8B5729F6DD5EB71DFB4AA /* LaunchdTOC */, + 1DA24961996A644EE6FB021C /* LaunchdTOCTests */, + 60CCFAF7DBED9C5FE9E11306 /* LaunchdTOCUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 94DAA4821294285E12D823A9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F8102D74CAF3E8F76967EC31 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B094CE89E69926923FF43D77 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA4C5E9C3A46DFF72A9DF28D /* Fixtures in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 15D8A1E43AC9ADBFB1267E57 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 64614DC240A36872F60C23F4 /* AboutView.swift in Sources */, + 59AAF48ED8E2DC023D684D8D /* AgentEditorView.swift in Sources */, + BDCE8EE6896E766FC6BF36F4 /* CommandRunner.swift in Sources */, + 709C37585833FAEFA5719918 /* DockTileBadgeController.swift in Sources */, + 6D42BFF54DCF9CB8CB8EECDF /* JobDetailView.swift in Sources */, + 390538BAF48E81D25C86240F /* JobPresentation.swift in Sources */, + 4387C423EA35E0FF38E60E71 /* JobStore.swift in Sources */, + E6F2594189450D2C96F04DC3 /* JobTableView.swift in Sources */, + 23284BBAEFA846DE89446D88 /* LaunchctlClient.swift in Sources */, + 1CD20BDCA56F56343788E50A /* LaunchdModels.swift in Sources */, + 0267BF3672358E12B45CCE88 /* LaunchdTOCApp.swift in Sources */, + B8490F45676337F03941C20F /* LogTailer.swift in Sources */, + 7159D01A8E115D5563CAFF62 /* PlistRepository.swift in Sources */, + 73E7DCD0BBBFEC438172F4AC /* RootView.swift in Sources */, + ACC680C45A46F4F268C44540 /* SchedulePreview.swift in Sources */, + 5B520218C68189DF84F22F6B /* SettingsView.swift in Sources */, + 8E2A8F1DA15BFE23C6DF7D6D /* SidebarView.swift in Sources */, + FDFCA118A5C877A086372F62 /* UpdateChecker.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A589B3649F2123890A34710 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 64240924378CF923A3DB43CD /* LaunchdTOCUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D4352EDCFB1AE6C08FD191F1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DADF9B2121E35CDED6C012AB /* DockTileBadgeTests.swift in Sources */, + 0E67C80EDAB838EE60275621 /* IntegrationTests.swift in Sources */, + 41C1BCB15A8879172FEDE17F /* JobPresentationTests.swift in Sources */, + ECC153F7BC3F935668EF199E /* LaunchctlClientTests.swift in Sources */, + 24FAADEB6E9F8B5C70CA0109 /* PlistRepositoryTests.swift in Sources */, + B30A833AAA6F836B29C3D53B /* ScheduleAndUpdateTests.swift in Sources */, + 85A6BE37B33AE292FA21C26E /* TestSupport.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 38F63AADE7389CA7EBECE591 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B0D8B5729F6DD5EB71DFB4AA /* LaunchdTOC */; + targetProxy = BB016B95698E0E53DBA93557 /* PBXContainerItemProxy */; + }; + 43A47C07D5F54C3D6CC0D78E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B0D8B5729F6DD5EB71DFB4AA /* LaunchdTOC */; + targetProxy = 3F5E3DDB851866609A1BAE68 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 0080125139164DF9180D9C4A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = "$(ARCHS_STANDARD)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + DEVELOPMENT_TEAM = N9U29A4T8J; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_TREAT_WARNINGS_AS_ERRORS = 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; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = NO; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 596C170E9429C5E912C83CB8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = "$(ARCHS_STANDARD)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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"; + DEVELOPMENT_TEAM = N9U29A4T8J; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_TREAT_WARNINGS_AS_ERRORS = 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; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = NO; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + 9C0FE421F2567F802E52F27B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = LaunchdTOC/Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.litsquare.launchdtoc; + PRODUCT_NAME = "Launchd TOC"; + SDKROOT = macosx; + }; + name = Release; + }; + C1C942DA57821CD15E52EA36 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.litsquare.launchdtoc.tests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Launchd TOC.app/Contents/MacOS/Launchd TOC"; + }; + name = Debug; + }; + CBBBC8152C6C1DCDF31F5B3A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = LaunchdTOC/Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.litsquare.launchdtoc; + PRODUCT_NAME = "Launchd TOC"; + SDKROOT = macosx; + }; + name = Debug; + }; + E908DBE0C6AAAEB30F60DB8A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.litsquare.launchdtoc.tests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Launchd TOC.app/Contents/MacOS/Launchd TOC"; + }; + name = Release; + }; + F816971822FB0952974074F1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.litsquare.launchdtoc.uitests; + SDKROOT = macosx; + TEST_TARGET_NAME = LaunchdTOC; + }; + name = Debug; + }; + FAEC6060721184413AEF3C9E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.litsquare.launchdtoc.uitests; + SDKROOT = macosx; + TEST_TARGET_NAME = LaunchdTOC; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 10A3AC6B8FCC57C1CC8B9B97 /* Build configuration list for PBXNativeTarget "LaunchdTOCUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F816971822FB0952974074F1 /* Debug */, + FAEC6060721184413AEF3C9E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 47AB21917DCAB8610433879A /* Build configuration list for PBXNativeTarget "LaunchdTOCTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C1C942DA57821CD15E52EA36 /* Debug */, + E908DBE0C6AAAEB30F60DB8A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 81B251F2E56D98AC71DDA4A2 /* Build configuration list for PBXNativeTarget "LaunchdTOC" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CBBBC8152C6C1DCDF31F5B3A /* Debug */, + 9C0FE421F2567F802E52F27B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 91149544656755F27A271FDA /* Build configuration list for PBXProject "LaunchdTOC" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0080125139164DF9180D9C4A /* Debug */, + 596C170E9429C5E912C83CB8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 3660CB12EFD3F93AD9DB8742 /* Project object */; +} diff --git a/LaunchdTOC.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/LaunchdTOC.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/LaunchdTOC.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/LaunchdTOC.xcodeproj/xcshareddata/xcschemes/LaunchdTOC.xcscheme b/LaunchdTOC.xcodeproj/xcshareddata/xcschemes/LaunchdTOC.xcscheme new file mode 100644 index 0000000..492ddcf --- /dev/null +++ b/LaunchdTOC.xcodeproj/xcshareddata/xcschemes/LaunchdTOC.xcscheme @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LaunchdTOC/App/DockTileBadgeController.swift b/LaunchdTOC/App/DockTileBadgeController.swift new file mode 100644 index 0000000..631de42 --- /dev/null +++ b/LaunchdTOC/App/DockTileBadgeController.swift @@ -0,0 +1,26 @@ +import AppKit + +@MainActor +protocol DockTileBadgeDisplaying: AnyObject { + var badgeLabel: String? { get set } +} + +extension NSDockTile: DockTileBadgeDisplaying {} + +@MainActor +struct DockTileBadgeController { + private let dockTile: any DockTileBadgeDisplaying + + init(dockTile: any DockTileBadgeDisplaying = NSApplication.shared.dockTile) { + self.dockTile = dockTile + } + + func update(runningJobCount: Int) { + dockTile.badgeLabel = Self.badgeLabel(for: runningJobCount) + } + + private static func badgeLabel(for runningJobCount: Int) -> String? { + guard runningJobCount > 0 else { return nil } + return runningJobCount > 99 ? "99+" : String(runningJobCount) + } +} diff --git a/LaunchdTOC/App/LaunchdTOCApp.swift b/LaunchdTOC/App/LaunchdTOCApp.swift new file mode 100644 index 0000000..215f9dc --- /dev/null +++ b/LaunchdTOC/App/LaunchdTOCApp.swift @@ -0,0 +1,370 @@ +import SwiftUI + +@main +struct LaunchdTOCApp: App { + @State private var store: JobStore + + init() { + let store: JobStore + #if DEBUG + let environment = ProcessInfo.processInfo.environment + let arguments = ProcessInfo.processInfo.arguments + let isReadmeCapture = environment["LAUNCHD_TOC_README_CAPTURE"] == "1" + || arguments.contains("-READMECapture") + let isUITesting = environment["LAUNCHD_TOC_UI_TESTING"] == "1" + || arguments.contains("-UITesting") + if isReadmeCapture || isUITesting { + let suiteName = isReadmeCapture + ? "com.litsquare.launchdtoc.readme-capture" + : "com.litsquare.launchdtoc.ui-testing" + store = Self.syntheticStore(defaultsSuiteName: suiteName) + } else { + store = JobStore() + } + #else + store = JobStore() + #endif + + _store = State(initialValue: store) + } + + var body: some Scene { + WindowGroup(id: "main") { + RootView(store: store) + .frame(minWidth: 980, minHeight: 640) + .preferredColorScheme(isReadmeCapture ? .light : nil) + .onAppear { + store.setRunningJobCountObserver { runningJobCount in + DockTileBadgeController().update(runningJobCount: runningJobCount) + } + } + } + .defaultSize(width: 1_180, height: 760) + .windowResizability(.contentMinSize) + .commands { + LaunchdTOCCommands(store: store) + } + + Settings { + SettingsView(store: store) + .frame(width: 520) + } + } + + private var isReadmeCapture: Bool { + #if DEBUG + ProcessInfo.processInfo.environment["LAUNCHD_TOC_README_CAPTURE"] == "1" + || ProcessInfo.processInfo.arguments.contains("-READMECapture") + #else + false + #endif + } + + #if DEBUG + private static func syntheticStore(defaultsSuiteName: String) -> JobStore { + let defaults = UserDefaults(suiteName: defaultsSuiteName)! + defaults.removePersistentDomain(forName: defaultsSuiteName) + + let root = FileManager.default.temporaryDirectory.appending( + path: "launchd-toc-synthetic", + directoryHint: .isDirectory + ) + let locations = LaunchdLocations( + userAgents: root.appending(path: "Library/LaunchAgents", directoryHint: .isDirectory), + globalAgents: root.appending(path: "Library/GlobalAgents", directoryHint: .isDirectory), + globalDaemons: root.appending(path: "Library/Daemons", directoryHint: .isDirectory), + appleAgents: root.appending(path: "System/Agents", directoryHint: .isDirectory), + appleDaemons: root.appending(path: "System/Daemons", directoryHint: .isDirectory), + applicationSupport: root.appending(path: "Application Support", directoryHint: .isDirectory) + ) + + return JobStore( + repository: PlistRepository(locations: locations), + launchctl: SyntheticLaunchctl(), + defaults: defaults, + initialJobs: demoJobs, + inventoryRefreshEnabled: false + ) + } + + private static var demoJobs: [LaunchdJob] { + let photoBackupConfiguration = JobConfiguration( + label: "com.example.backup.photos", + programArguments: [ + "/usr/bin/python3", + "/Users/demo/Applications/Photo Backup/photo_backup.py", + "sync", + "--library", + "/Users/demo/Pictures" + ], + runAtLoad: true, + keepAlive: true, + throttleInterval: 10, + processType: .background, + workingDirectory: "/Users/demo/Documents/Photo Backup", + environment: [ + "BACKUP_PROFILE": "demo", + "LOG_LEVEL": "info" + ], + standardOutPath: "/Users/demo/Library/Logs/Photo Backup/backup.log", + standardErrorPath: "/Users/demo/Library/Logs/Photo Backup/backup-error.log" + ) + let photoBackup = LaunchdJob( + plistURL: demoPlistURL( + label: photoBackupConfiguration.label, + source: .userAgent + ), + source: .userAgent, + configuration: photoBackupConfiguration, + runtimeState: .running(pid: 4_242), + scheduleSummary: "At load", + lastRun: Date(timeIntervalSince1970: 1_785_241_800) + ) + + let dailyReportConfiguration = JobConfiguration( + label: "com.example.reports.daily", + program: "/usr/bin/shortcuts", + programArguments: [ + "/usr/bin/shortcuts", + "run", + "Create Daily Report" + ], + runAtLoad: false, + keepAlive: false, + startInterval: 86_400, + workingDirectory: "/Users/demo/Documents/Reports", + standardOutPath: "/Users/demo/Library/Logs/Daily Report/report.log" + ) + let dailyReport = LaunchdJob( + plistURL: demoPlistURL( + label: dailyReportConfiguration.label, + source: .userAgent + ), + source: .userAgent, + configuration: dailyReportConfiguration, + runtimeState: .waiting, + scheduleSummary: "Every day", + lastRun: Date(timeIntervalSince1970: 1_785_236_400) + ) + + let cleanupConfiguration = JobConfiguration( + label: "com.example.downloads.cleanup", + program: "/bin/zsh", + programArguments: [ + "/bin/zsh", + "/Users/demo/Scripts/cleanup-downloads.sh" + ], + keepAlive: false, + startInterval: 3_600, + workingDirectory: "/Users/demo/Downloads" + ) + let cleanup = LaunchdJob( + plistURL: demoPlistURL( + label: cleanupConfiguration.label, + source: .userAgent + ), + source: .userAgent, + configuration: cleanupConfiguration, + runtimeState: .disabled, + scheduleSummary: "Every hour", + lastRun: Date(timeIntervalSince1970: 1_785_232_800) + ) + + let menuHelperConfiguration = JobConfiguration( + label: "com.example.menu.weather", + program: "/Applications/Example Weather.app/Contents/MacOS/Example Weather", + runAtLoad: true, + keepAlive: false + ) + let menuHelper = LaunchdJob( + plistURL: demoPlistURL( + label: menuHelperConfiguration.label, + source: .globalAgent + ), + source: .globalAgent, + configuration: menuHelperConfiguration, + runtimeState: .unloaded, + scheduleSummary: "At load" + ) + + let digestConfiguration = JobConfiguration( + label: "com.example.notifications.digest", + program: "/usr/bin/shortcuts", + programArguments: [ + "/usr/bin/shortcuts", + "run", + "Send Weekly Digest" + ], + calendarSchedules: [ + CalendarSchedule(minute: 0, hour: 9, weekday: 1) + ] + ) + let digest = LaunchdJob( + plistURL: demoPlistURL( + label: digestConfiguration.label, + source: .globalAgent + ), + source: .globalAgent, + configuration: digestConfiguration, + runtimeState: .waiting, + scheduleSummary: "Monday at 09:00", + lastRun: Date(timeIntervalSince1970: 1_784_731_600) + ) + + let maintenanceConfiguration = JobConfiguration( + label: "com.example.cache.maintenance", + program: "/usr/local/bin/example-maintenance", + keepAlive: false, + processType: .background + ) + let maintenance = LaunchdJob( + plistURL: demoPlistURL( + label: maintenanceConfiguration.label, + source: .daemon + ), + source: .daemon, + configuration: maintenanceConfiguration, + runtimeState: .failed(exitCode: 1), + scheduleSummary: "Not scheduled", + lastRun: Date(timeIntervalSince1970: 1_785_239_100) + ) + + let searchIndexConfiguration = JobConfiguration( + label: "com.example.search.index", + program: "/usr/local/bin/example-indexer", + runAtLoad: true, + keepAlive: true, + throttleInterval: 30, + processType: .adaptive + ) + let searchIndex = LaunchdJob( + plistURL: demoPlistURL( + label: searchIndexConfiguration.label, + source: .daemon + ), + source: .daemon, + configuration: searchIndexConfiguration, + runtimeState: .running(pid: 8_120), + scheduleSummary: "At load", + lastRun: Date(timeIntervalSince1970: 1_785_241_200) + ) + + return [ + photoBackup, + dailyReport, + cleanup, + menuHelper, + digest, + maintenance, + searchIndex + ] + } + + private static func demoPlistURL(label: String, source: JobSource) -> URL { + switch source { + case .userAgent: + URL(filePath: "/Users/demo/Library/LaunchAgents/\(label).plist") + case .globalAgent: + URL(filePath: "/Library/LaunchAgents/\(label).plist") + case .daemon: + URL(filePath: "/Library/LaunchDaemons/\(label).plist") + case .appleAgent: + URL(filePath: "/System/Library/LaunchAgents/\(label).plist") + case .appleDaemon: + URL(filePath: "/System/Library/LaunchDaemons/\(label).plist") + } + } + #endif +} + +#if DEBUG +private actor SyntheticLaunchctl: LaunchctlRunning { + func runtimeState(for job: LaunchdJob) async -> JobRuntimeState { + job.runtimeState + } + + func disabledLabels(in domain: String) async -> Set { + [] + } + + func perform(_ action: LaunchctlAction, on job: LaunchdJob) async throws {} +} +#endif + +@MainActor +private struct LaunchdTOCCommands: Commands { + @Environment(\.openWindow) private var openWindow + + let store: JobStore + + var body: some Commands { + CommandGroup(replacing: .appInfo) { + Button("About Launchd TOC") { + store.showsAbout = true + } + } + + CommandGroup(replacing: .newItem) { + #if DEBUG + Button("New Window") { + openWindow(id: "main") + } + .keyboardShortcut("n", modifiers: [.command, .shift]) + + Divider() + #endif + + Button("New Agent…") { + store.presentNewAgent() + } + .keyboardShortcut("n", modifiers: .command) + } + + CommandMenu("Job") { + Button("Load or Unload") { + store.loadOrUnloadSelected() + } + .keyboardShortcut("l", modifiers: [.command, .shift]) + + Button("Run Now") { + store.runSelected() + } + .keyboardShortcut(.return, modifiers: .command) + + Button("Restart") { + store.restartSelected() + } + .keyboardShortcut("r", modifiers: [.command, .option]) + + Divider() + + Button("Edit…") { + store.presentEditor() + } + .keyboardShortcut("e", modifiers: .command) + + Divider() + + Button("Disable") { + store.requestDisable() + } + + Button("Move to Trash…", role: .destructive) { + store.requestTrash() + } + } + + CommandGroup(after: .sidebar) { + Button("Refresh") { + Task { await store.refresh() } + } + .keyboardShortcut("r", modifiers: .command) + } + + CommandGroup(after: .help) { + Button("Check for Updates…") { + Task { await store.checkForUpdates() } + } + } + } +} diff --git a/LaunchdTOC/Models/JobPresentation.swift b/LaunchdTOC/Models/JobPresentation.swift new file mode 100644 index 0000000..ababf8f --- /dev/null +++ b/LaunchdTOC/Models/JobPresentation.swift @@ -0,0 +1,125 @@ +import Foundation + +enum LaunchDurationUnit: String, CaseIterable, Identifiable, Sendable { + case seconds + case minutes + case hours + case days + + var id: String { rawValue } + + var multiplier: Int { + switch self { + case .seconds: 1 + case .minutes: 60 + case .hours: 3_600 + case .days: 86_400 + } + } + + var title: String { + rawValue.capitalized + } + + static func bestFit(seconds: Int) -> (amount: Int, unit: LaunchDurationUnit) { + for unit in [LaunchDurationUnit.days, .hours, .minutes] { + if seconds > 0, seconds.isMultiple(of: unit.multiplier) { + return (seconds / unit.multiplier, unit) + } + } + return (seconds, .seconds) + } + + func seconds(for amount: Int) -> Int? { + let result = amount.multipliedReportingOverflow(by: multiplier) + return result.overflow ? nil : result.partialValue + } +} + +enum LaunchDurationFormatter { + static func description(seconds: Int) -> String { + let fit = LaunchDurationUnit.bestFit(seconds: seconds) + let singular = fit.amount == 1 + let unit: String + switch fit.unit { + case .seconds: unit = singular ? "second" : "seconds" + case .minutes: unit = singular ? "minute" : "minutes" + case .hours: unit = singular ? "hour" : "hours" + case .days: unit = singular ? "day" : "days" + } + return "\(fit.amount) \(unit)" + } +} + +extension JobConfiguration { + var executableSourceDescription: String { + program == nil ? "First ProgramArguments value" : "Program key" + } + + var launchCommandTokens: [String] { + guard let executable, !executable.isEmpty else { return [] } + if program != nil { + return [executable] + programArguments.dropFirst() + } + return programArguments + } + + var commandArguments: [String] { + Array(programArguments.dropFirst()) + } + + var commandPreview: String { + launchCommandTokens.map(Self.quoteForDisplay).joined(separator: " ") + } + + var launchBehaviorSummary: String { + switch (runAtLoad, keepAlive) { + case (_, true): + "Starts when loaded, and launchd keeps it running." + case (true, nil): + "Starts when loaded and uses conditional keep-alive rules." + case (false, nil): + "Uses conditional keep-alive rules to decide when it should run." + case (true, _): + "Starts once when the job is loaded." + case (false, _): + if startInterval != nil || !calendarSchedules.isEmpty { + "Starts when one of its launchd schedule triggers fires." + } else { + "Runs on demand or when you start it manually." + } + } + } + + var repeatingScheduleDescription: String? { + var parts: [String] = [] + if let startInterval { + parts.append("Every \(LaunchDurationFormatter.description(seconds: startInterval))") + } + if calendarSchedules.count == 1 { + parts.append("1 calendar trigger") + } else if calendarSchedules.count > 1 { + parts.append("\(calendarSchedules.count) calendar triggers") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + var keepAliveDescription: String { + switch keepAlive { + case true: "Keeps running" + case false: "Not kept running" + case nil: "Conditional rules" + } + } + + private static func quoteForDisplay(_ value: String) -> String { + guard !value.isEmpty else { return "''" } + let safeCharacters = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-" + ) + if value.unicodeScalars.allSatisfy(safeCharacters.contains) { + return value + } + return "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" + } +} diff --git a/LaunchdTOC/Models/LaunchdModels.swift b/LaunchdTOC/Models/LaunchdModels.swift new file mode 100644 index 0000000..ddddab2 --- /dev/null +++ b/LaunchdTOC/Models/LaunchdModels.swift @@ -0,0 +1,581 @@ +import Foundation + +enum JobSource: String, CaseIterable, Codable, Hashable, Sendable { + case userAgent + case globalAgent + case daemon + case appleAgent + case appleDaemon + + var displayName: String { + switch self { + case .userAgent: "User Agent" + case .globalAgent: "Global Agent" + case .daemon: "Daemon" + case .appleAgent: "Apple Agent" + case .appleDaemon: "Apple Daemon" + } + } + + var isEditable: Bool { self == .userAgent } + var isAgent: Bool { self != .daemon && self != .appleDaemon } + + func domain(userID: uid_t = getuid()) -> String { + isAgent ? "gui/\(userID)" : "system" + } +} + +enum JobRuntimeState: Hashable, Sendable { + case disabled + case unloaded + case waiting + case running(pid: Int32) + case failed(exitCode: Int32) + case unknown + + var label: String { + switch self { + case .disabled: "Disabled" + case .unloaded: "Unloaded" + case .waiting: "Loaded" + case .running: "Running" + case .failed: "Failed" + case .unknown: "Unknown" + } + } + + var symbolName: String { + switch self { + case .disabled, .unloaded: "pause.circle" + case .waiting: "clock" + case .running: "play.circle.fill" + case .failed: "exclamationmark.triangle.fill" + case .unknown: "questionmark.circle" + } + } + + var pid: Int32? { + guard case let .running(pid) = self else { return nil } + return pid + } + + var sortValue: String { + switch self { + case .running: "0-running" + case .waiting: "1-waiting" + case .failed: "2-failed" + case .disabled: "3-disabled" + case .unloaded: "4-unloaded" + case .unknown: "5-unknown" + } + } +} + +enum PlistFormat: String, Codable, Hashable, Sendable { + case xml + case binary + + init(_ format: PropertyListSerialization.PropertyListFormat) { + self = format == .binary ? .binary : .xml + } + + var foundationValue: PropertyListSerialization.PropertyListFormat { + self == .binary ? .binary : .xml + } +} + +enum JobProcessType: Hashable, Identifiable, Sendable { + case standard + case background + case adaptive + case interactive + case other(String) + + static let knownCases: [JobProcessType] = [ + .standard, + .background, + .adaptive, + .interactive + ] + + init(rawValue: String) { + switch rawValue { + case "Standard": self = .standard + case "Background": self = .background + case "Adaptive": self = .adaptive + case "Interactive": self = .interactive + default: self = .other(rawValue) + } + } + + var id: String { rawValue } + + var rawValue: String { + switch self { + case .standard: "Standard" + case .background: "Background" + case .adaptive: "Adaptive" + case .interactive: "Interactive" + case let .other(value): value + } + } + + var displayName: String { + switch self { + case .standard: "Standard" + case .background: "Background" + case .adaptive: "Adaptive" + case .interactive: "Interactive" + case let .other(value): "\(value) (preserved)" + } + } + + var explanation: String { + switch self { + case .standard: + "Uses the system's standard resource policy." + case .background: + "Runs background work with limits intended to protect foreground responsiveness." + case .adaptive: + "Moves between background and interactive resource policies based on XPC activity." + case .interactive: + "Runs with the resource policy used for interactive apps." + case .other: + "This value is not recognized by Launchd TOC and will be preserved exactly." + } + } +} + +indirect enum PropertyListValue: Hashable, Sendable { + case string(String) + case integer(Int) + case real(Double) + case boolean(Bool) + case date(Date) + case data(Data) + case array([PropertyListValue]) + case dictionary([String: PropertyListValue]) + + init?(foundationValue value: Any) { + switch value { + case let value as String: + self = .string(value) + case let value as Bool: + self = .boolean(value) + case let value as Int: + self = .integer(value) + case let value as NSNumber: + self = .real(value.doubleValue) + case let value as Date: + self = .date(value) + case let value as Data: + self = .data(value) + case let value as [Any]: + let converted = value.compactMap(PropertyListValue.init(foundationValue:)) + guard converted.count == value.count else { return nil } + self = .array(converted) + case let value as [String: Any]: + var converted: [String: PropertyListValue] = [:] + for (key, child) in value { + guard let childValue = PropertyListValue(foundationValue: child) else { return nil } + converted[key] = childValue + } + self = .dictionary(converted) + default: + return nil + } + } + + var foundationValue: Any { + switch self { + case let .string(value): value + case let .integer(value): value + case let .real(value): value + case let .boolean(value): value + case let .date(value): value + case let .data(value): value + case let .array(value): value.map(\.foundationValue) + case let .dictionary(value): value.mapValues(\.foundationValue) + } + } + + var displayString: String { + switch self { + case let .string(value): "\"\(value)\"" + case let .integer(value): String(value) + case let .real(value): String(value) + case let .boolean(value): value ? "true" : "false" + case let .date(value): value.formatted(date: .abbreviated, time: .standard) + case let .data(value): "<\(value.count) bytes>" + case let .array(value): + "[\(value.map(\.displayString).joined(separator: ", "))]" + case let .dictionary(value): + "{ \(value.keys.sorted().map { "\($0): \(value[$0]?.displayString ?? "")" }.joined(separator: ", ")) }" + } + } +} + +struct CalendarSchedule: Hashable, Identifiable, Sendable { + var id = UUID() + var minute: Int? + var hour: Int? + var day: Int? + var weekday: Int? + var month: Int? + var rawValues: [String: PropertyListValue] = [:] + + init( + id: UUID = UUID(), + minute: Int? = nil, + hour: Int? = nil, + day: Int? = nil, + weekday: Int? = nil, + month: Int? = nil, + rawValues: [String: PropertyListValue] = [:] + ) { + self.id = id + self.minute = minute + self.hour = hour + self.day = day + self.weekday = weekday + self.month = month + self.rawValues = rawValues + } + + init(dictionary: [String: PropertyListValue]) { + rawValues = dictionary + minute = dictionary["Minute"]?.integerValue + hour = dictionary["Hour"]?.integerValue + day = dictionary["Day"]?.integerValue + weekday = dictionary["Weekday"]?.integerValue + month = dictionary["Month"]?.integerValue + } + + var propertyListValue: PropertyListValue { + var values = rawValues + values.setInteger(minute, forKey: "Minute") + values.setInteger(hour, forKey: "Hour") + values.setInteger(day, forKey: "Day") + values.setInteger(weekday, forKey: "Weekday") + values.setInteger(month, forKey: "Month") + return .dictionary(values) + } +} + +extension PropertyListValue { + var stringValue: String? { + guard case let .string(value) = self else { return nil } + return value + } + + var integerValue: Int? { + guard case let .integer(value) = self else { return nil } + return value + } + + var booleanValue: Bool? { + guard case let .boolean(value) = self else { return nil } + return value + } + + var dictionaryValue: [String: PropertyListValue]? { + guard case let .dictionary(value) = self else { return nil } + return value + } + + var arrayValue: [PropertyListValue]? { + guard case let .array(value) = self else { return nil } + return value + } +} + +private extension Dictionary where Key == String, Value == PropertyListValue { + mutating func setInteger(_ value: Int?, forKey key: String) { + if let value { + self[key] = .integer(value) + } else { + removeValue(forKey: key) + } + } +} + +struct JobConfiguration: Hashable, Sendable { + static let supportedKeys: Set = [ + "Label", "Program", "ProgramArguments", "RunAtLoad", "KeepAlive", + "StartInterval", "StartCalendarInterval", "WorkingDirectory", + "EnvironmentVariables", "StandardOutPath", "StandardErrorPath", + "ProcessType", "ThrottleInterval" + ] + + var label: String + var program: String? + var programArguments: [String] + var runAtLoad: Bool + var keepAlive: Bool? + var startInterval: Int? + var throttleInterval: Int? + var processType: JobProcessType? + var calendarSchedules: [CalendarSchedule] + var workingDirectory: String? + var environment: [String: String] + var standardOutPath: String? + var standardErrorPath: String? + var rawValues: [String: PropertyListValue] + var originalFormat: PlistFormat + var calendarWasArray: Bool + + init( + label: String, + program: String? = nil, + programArguments: [String] = [], + runAtLoad: Bool = false, + keepAlive: Bool? = false, + startInterval: Int? = nil, + throttleInterval: Int? = nil, + processType: JobProcessType? = nil, + calendarSchedules: [CalendarSchedule] = [], + workingDirectory: String? = nil, + environment: [String: String] = [:], + standardOutPath: String? = nil, + standardErrorPath: String? = nil, + rawValues: [String: PropertyListValue] = [:], + originalFormat: PlistFormat = .xml, + calendarWasArray: Bool = false + ) { + self.label = label + self.program = program + self.programArguments = programArguments + self.runAtLoad = runAtLoad + self.keepAlive = keepAlive + self.startInterval = startInterval + self.throttleInterval = throttleInterval + self.processType = processType + self.calendarSchedules = calendarSchedules + self.workingDirectory = workingDirectory + self.environment = environment + self.standardOutPath = standardOutPath + self.standardErrorPath = standardErrorPath + self.rawValues = rawValues + self.originalFormat = originalFormat + self.calendarWasArray = calendarWasArray + } + + init( + propertyList: [String: PropertyListValue], + format: PlistFormat, + fallbackLabel: String + ) { + rawValues = propertyList + originalFormat = format + label = propertyList["Label"]?.stringValue ?? fallbackLabel + program = propertyList["Program"]?.stringValue + programArguments = propertyList["ProgramArguments"]?.arrayValue?.compactMap(\.stringValue) ?? [] + runAtLoad = propertyList["RunAtLoad"]?.booleanValue ?? false + + if let keepAliveValue = propertyList["KeepAlive"]?.booleanValue { + keepAlive = keepAliveValue + } else if propertyList["KeepAlive"] == nil { + keepAlive = false + } else { + keepAlive = nil + } + + startInterval = propertyList["StartInterval"]?.integerValue + throttleInterval = propertyList["ThrottleInterval"]?.integerValue + processType = propertyList["ProcessType"]?.stringValue.map(JobProcessType.init(rawValue:)) + if let calendarArray = propertyList["StartCalendarInterval"]?.arrayValue { + calendarWasArray = true + calendarSchedules = calendarArray.compactMap(\.dictionaryValue).map(CalendarSchedule.init) + } else if let calendar = propertyList["StartCalendarInterval"]?.dictionaryValue { + calendarWasArray = false + calendarSchedules = [CalendarSchedule(dictionary: calendar)] + } else { + calendarWasArray = false + calendarSchedules = [] + } + + workingDirectory = propertyList["WorkingDirectory"]?.stringValue + environment = propertyList["EnvironmentVariables"]?.dictionaryValue? + .compactMapValues(\.stringValue) ?? [:] + standardOutPath = propertyList["StandardOutPath"]?.stringValue + standardErrorPath = propertyList["StandardErrorPath"]?.stringValue + } + + var executable: String? { + program ?? programArguments.first + } + + var advancedValues: [String: PropertyListValue] { + var values = rawValues.filter { !Self.supportedKeys.contains($0.key) } + if keepAlive == nil, let rawKeepAlive = rawValues["KeepAlive"] { + values["KeepAlive"] = rawKeepAlive + } + if let rawEnvironment = rawValues["EnvironmentVariables"]?.dictionaryValue { + let advancedEnvironment = rawEnvironment.filter { $0.value.stringValue == nil } + if !advancedEnvironment.isEmpty { + values["EnvironmentVariables"] = .dictionary(advancedEnvironment) + } + } + if processType == nil, let rawProcessType = rawValues["ProcessType"] { + values["ProcessType"] = rawProcessType + } + if throttleInterval == nil, + let rawThrottleInterval = rawValues["ThrottleInterval"], + rawThrottleInterval.integerValue == nil + { + values["ThrottleInterval"] = rawThrottleInterval + } + return values + } + + var serializedValues: [String: PropertyListValue] { + var values = rawValues + values["Label"] = .string(label) + values.setOptionalString(program, forKey: "Program") + values.setStringArray(programArguments, forKey: "ProgramArguments") + values["RunAtLoad"] = .boolean(runAtLoad) + + if let keepAlive { + values["KeepAlive"] = .boolean(keepAlive) + } + values.setOptionalInteger(startInterval, forKey: "StartInterval") + if let throttleInterval { + values["ThrottleInterval"] = .integer(throttleInterval) + } else if rawValues["ThrottleInterval"]?.integerValue != nil { + values.removeValue(forKey: "ThrottleInterval") + } + if let processType { + values["ProcessType"] = .string(processType.rawValue) + } else if rawValues["ProcessType"]?.stringValue != nil { + values.removeValue(forKey: "ProcessType") + } + + if calendarSchedules.isEmpty { + values.removeValue(forKey: "StartCalendarInterval") + } else if calendarSchedules.count == 1, !calendarWasArray { + values["StartCalendarInterval"] = calendarSchedules[0].propertyListValue + } else { + values["StartCalendarInterval"] = .array(calendarSchedules.map(\.propertyListValue)) + } + + values.setOptionalString(workingDirectory, forKey: "WorkingDirectory") + var mergedEnvironment = rawValues["EnvironmentVariables"]?.dictionaryValue? + .filter { $0.value.stringValue == nil } ?? [:] + for (key, value) in environment { + mergedEnvironment[key] = .string(value) + } + if mergedEnvironment.isEmpty { + values.removeValue(forKey: "EnvironmentVariables") + } else { + values["EnvironmentVariables"] = .dictionary(mergedEnvironment) + } + values.setOptionalString(standardOutPath, forKey: "StandardOutPath") + values.setOptionalString(standardErrorPath, forKey: "StandardErrorPath") + return values + } +} + +private extension Dictionary where Key == String, Value == PropertyListValue { + mutating func setOptionalString(_ value: String?, forKey key: String) { + if let value, !value.isEmpty { + self[key] = .string(value) + } else { + removeValue(forKey: key) + } + } + + mutating func setOptionalInteger(_ value: Int?, forKey key: String) { + if let value { + self[key] = .integer(value) + } else { + removeValue(forKey: key) + } + } + + mutating func setStringArray(_ values: [String], forKey key: String) { + if values.isEmpty { + removeValue(forKey: key) + } else { + self[key] = .array(values.map(PropertyListValue.string)) + } + } +} + +struct LaunchdJob: Identifiable, Hashable, Sendable { + let id: String + var label: String + let plistURL: URL + let source: JobSource + var configuration: JobConfiguration + var runtimeState: JobRuntimeState + var scheduleSummary: String + var predictedRuns: [Date] + var lastRun: Date? + var parseIssue: String? + + init( + plistURL: URL, + source: JobSource, + configuration: JobConfiguration, + runtimeState: JobRuntimeState = .unknown, + scheduleSummary: String = "Not scheduled", + predictedRuns: [Date] = [], + lastRun: Date? = nil, + parseIssue: String? = nil + ) { + id = "\(source.rawValue):\(plistURL.standardizedFileURL.path)" + label = configuration.label + self.plistURL = plistURL + self.source = source + self.configuration = configuration + self.runtimeState = runtimeState + self.scheduleSummary = scheduleSummary + self.predictedRuns = predictedRuns + self.lastRun = lastRun + self.parseIssue = parseIssue + } + + var stateSortValue: String { runtimeState.sortValue } + var lastRunText: String { + lastRun?.formatted(date: .abbreviated, time: .shortened) ?? "—" + } + var pidText: String { + runtimeState.pid.map(String.init) ?? "—" + } +} + +enum SidebarFilter: String, CaseIterable, Identifiable, Sendable { + case all + case userAgents + case globalAgents + case daemons + case running + case attention + case disabled + + var id: String { rawValue } + + var title: String { + switch self { + case .all: "All Jobs" + case .userAgents: "User Agents" + case .globalAgents: "Global Agents" + case .daemons: "Daemons" + case .running: "Running" + case .attention: "Needs Attention" + case .disabled: "Disabled" + } + } + + var symbolName: String { + switch self { + case .all: "list.bullet.rectangle" + case .userAgents: "person.crop.circle" + case .globalAgents: "person.2.circle" + case .daemons: "gearshape.2" + case .running: "play.circle.fill" + case .attention: "exclamationmark.triangle" + case .disabled: "pause.circle" + } + } +} diff --git a/LaunchdTOC/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/LaunchdTOC/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..2672ca0 --- /dev/null +++ b/LaunchdTOC/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.940", + "green" : "0.520", + "red" : "0.020" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..64dc11e --- /dev/null +++ b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "icon_16x16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "icon_16x16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "icon_32x32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "icon_32x32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "icon_128x128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "icon_128x128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "icon_256x256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "icon_256x256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "icon_512x512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "icon_512x512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..438599a Binary files /dev/null and b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..21a1708 Binary files /dev/null and b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..101b5d5 Binary files /dev/null and b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..5f81557 Binary files /dev/null and b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..21a1708 Binary files /dev/null and b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..4ce0eba Binary files /dev/null and b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..5f81557 Binary files /dev/null and b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..3f036c8 Binary files /dev/null and b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..4ce0eba Binary files /dev/null and b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..60e4d1a Binary files /dev/null and b/LaunchdTOC/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/LaunchdTOC/Resources/Assets.xcassets/Contents.json b/LaunchdTOC/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/LaunchdTOC/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LaunchdTOC/Resources/Info.plist b/LaunchdTOC/Resources/Info.plist new file mode 100644 index 0000000..4a9073d --- /dev/null +++ b/LaunchdTOC/Resources/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Launchd TOC + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconName + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Launchd TOC + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + 26.0 + NSHighResolutionCapable + + NSHumanReadableCopyright + Copyright © 2026 Thierry Charbonnel. All rights reserved. + + diff --git a/LaunchdTOC/Services/CommandRunner.swift b/LaunchdTOC/Services/CommandRunner.swift new file mode 100644 index 0000000..c0031ce --- /dev/null +++ b/LaunchdTOC/Services/CommandRunner.swift @@ -0,0 +1,77 @@ +import Foundation + +struct CommandResult: Equatable, Sendable { + let executableURL: URL + let arguments: [String] + let standardOutput: String + let standardError: String + let exitCode: Int32 +} + +enum CommandExecutionError: LocalizedError, Equatable, Sendable { + case executableNotAllowed(String) + case failedToStart(String) + + var errorDescription: String? { + switch self { + case let .executableNotAllowed(path): + "Launchd TOC refused to run an unexpected executable: \(path)" + case let .failedToStart(message): + "The command could not start: \(message)" + } + } +} + +protocol CommandExecuting: Sendable { + func run(executableURL: URL, arguments: [String]) async throws -> CommandResult +} + +struct SystemCommandRunner: CommandExecuting { + private let allowedExecutablePaths: Set + + init( + allowedExecutablePaths: Set = [ + "/bin/launchctl", + "/usr/bin/plutil" + ] + ) { + self.allowedExecutablePaths = allowedExecutablePaths + } + + func run(executableURL: URL, arguments: [String]) async throws -> CommandResult { + let allowlist = allowedExecutablePaths + return try await Task.detached(priority: .userInitiated) { + let path = executableURL.standardizedFileURL.path + guard allowlist.contains(path) else { + throw CommandExecutionError.executableNotAllowed(path) + } + + let process = Process() + let combinedOutput = Pipe() + process.executableURL = executableURL + process.arguments = arguments + process.standardOutput = combinedOutput + process.standardError = combinedOutput + + do { + try process.run() + } catch { + throw CommandExecutionError.failedToStart(error.localizedDescription) + } + + // Drain while the process runs so verbose launchctl output cannot fill + // a pipe buffer and deadlock. A combined stream is intentional: + // callers treat all command text as diagnostic data. + let outputData = combinedOutput.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + return CommandResult( + executableURL: executableURL, + arguments: arguments, + standardOutput: String(decoding: outputData, as: UTF8.self), + standardError: "", + exitCode: process.terminationStatus + ) + }.value + } +} diff --git a/LaunchdTOC/Services/LaunchctlClient.swift b/LaunchdTOC/Services/LaunchctlClient.swift new file mode 100644 index 0000000..d9c13de --- /dev/null +++ b/LaunchdTOC/Services/LaunchctlClient.swift @@ -0,0 +1,179 @@ +import Foundation + +enum LaunchctlAction: Sendable { + case load + case unload + case run + case restart + case enable + case disable +} + +protocol LaunchctlRunning: Sendable { + func runtimeState(for job: LaunchdJob) async -> JobRuntimeState + func disabledLabels(in domain: String) async -> Set + func perform(_ action: LaunchctlAction, on job: LaunchdJob) async throws +} + +enum LaunchctlError: LocalizedError { + case commandFailed(action: String, message: String) + + var errorDescription: String? { + switch self { + case let .commandFailed(action, message): + "\(action) failed: \(message)" + } + } +} + +actor LaunchctlClient: LaunchctlRunning { + static let executableURL = URL(filePath: "/bin/launchctl") + + private let commandRunner: any CommandExecuting + private let userID: uid_t + + init( + commandRunner: any CommandExecuting = SystemCommandRunner(), + userID: uid_t = getuid() + ) { + self.commandRunner = commandRunner + self.userID = userID + } + + func runtimeState(for job: LaunchdJob) async -> JobRuntimeState { + do { + let result = try await commandRunner.run( + executableURL: Self.executableURL, + arguments: ["print", serviceTarget(for: job)] + ) + return Self.parseRuntimeState(result) + } catch { + return .unknown + } + } + + func disabledLabels(in domain: String) async -> Set { + do { + let result = try await commandRunner.run( + executableURL: Self.executableURL, + arguments: ["print-disabled", domain] + ) + guard result.exitCode == 0 else { return [] } + return Self.parseDisabledLabels(result.standardOutput) + } catch { + return [] + } + } + + func perform(_ action: LaunchctlAction, on job: LaunchdJob) async throws { + let arguments = arguments(for: action, job: job) + let result = try await commandRunner.run( + executableURL: Self.executableURL, + arguments: arguments + ) + guard result.exitCode == 0 else { + let message = result.standardError.isEmpty ? result.standardOutput : result.standardError + throw LaunchctlError.commandFailed( + action: action.label, + message: message.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + } + + nonisolated func arguments(for action: LaunchctlAction, job: LaunchdJob) -> [String] { + let domain = job.source.domain(userID: userID) + let target = "\(domain)/\(job.label)" + switch action { + case .load: + return ["bootstrap", domain, job.plistURL.path] + case .unload: + return ["bootout", target] + case .run: + return ["kickstart", "-p", target] + case .restart: + return ["kickstart", "-kp", target] + case .enable: + return ["enable", target] + case .disable: + return ["disable", target] + } + } + + nonisolated func serviceTarget(for job: LaunchdJob) -> String { + "\(job.source.domain(userID: userID))/\(job.label)" + } + + static func parseRuntimeState(_ result: CommandResult) -> JobRuntimeState { + let combined = "\(result.standardOutput)\n\(result.standardError)" + if result.exitCode != 0 { + let lowercased = combined.lowercased() + if lowercased.contains("could not find service") + || lowercased.contains("service not found") + || lowercased.contains("bad request") + { + return .unloaded + } + return .unknown + } + + var state: String? + var pid: Int32? + var lastExitCode: Int32? + var dictionaryDepth = result.standardOutput.contains(" = {") ? 0 : 1 + for rawLine in result.standardOutput.split(separator: "\n") { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if dictionaryDepth == 1 { + if line.hasPrefix("state = ") { + state = String(line.dropFirst("state = ".count)) + } else if line.hasPrefix("pid = ") { + pid = Int32(line.dropFirst("pid = ".count)) + } else if line.hasPrefix("last exit code = ") { + lastExitCode = Int32(line.dropFirst("last exit code = ".count)) + } + } + dictionaryDepth += rawLine.filter { $0 == "{" }.count + dictionaryDepth -= rawLine.filter { $0 == "}" }.count + } + + if state == "running", let pid { + return .running(pid: pid) + } + if let lastExitCode, lastExitCode != 0 { + return .failed(exitCode: lastExitCode) + } + if state == "waiting" || state == "not running" || state == "exited" { + return .waiting + } + return .unknown + } + + static func parseDisabledLabels(_ output: String) -> Set { + var labels = Set() + for rawLine in output.split(separator: "\n") { + let line = String(rawLine) + guard line.contains("=> true") else { continue } + guard + let firstQuote = line.firstIndex(of: "\""), + let secondQuote = line[line.index(after: firstQuote)...].firstIndex(of: "\"") + else { + continue + } + let label = String(line[line.index(after: firstQuote).. String { + guard let url else { throw LogTailerError.unavailable } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + + let end = try handle.seekToEnd() + let start = end > maximumBytes ? end - maximumBytes : 0 + try handle.seek(toOffset: start) + let data = try handle.readToEnd() ?? Data() + var text = String(decoding: data, as: UTF8.self) + if start > 0, let firstNewline = text.firstIndex(of: "\n") { + text = String(text[text.index(after: firstNewline)...]) + } + return text + } + + func canClear(url: URL?) -> Bool { + guard let url else { return false } + let home = fileManager.homeDirectoryForCurrentUser + .standardizedFileURL + .resolvingSymlinksInPath() + let standardized = url.standardizedFileURL + guard standardized.path.hasPrefix(home.path + "/") else { return false } + guard + let values = try? standardized.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]), + values.isRegularFile == true, + values.isSymbolicLink != true + else { + return false + } + return standardized.resolvingSymlinksInPath().path.hasPrefix(home.path + "/") + } + + func clear(url: URL?) throws { + guard canClear(url: url), let url else { throw LogTailerError.unsafePath } + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.truncate(atOffset: 0) + } + + nonisolated func open(url: URL?) { + guard let url else { return } + NSWorkspace.shared.open(url) + } +} diff --git a/LaunchdTOC/Services/PlistRepository.swift b/LaunchdTOC/Services/PlistRepository.swift new file mode 100644 index 0000000..2a9d1ca --- /dev/null +++ b/LaunchdTOC/Services/PlistRepository.swift @@ -0,0 +1,374 @@ +import Foundation + +struct LaunchdLocations: Sendable { + let userAgents: URL + let globalAgents: URL + let globalDaemons: URL + let appleAgents: URL + let appleDaemons: URL + let applicationSupport: URL + + static func live(fileManager: FileManager = .default) -> LaunchdLocations { + let home = fileManager.homeDirectoryForCurrentUser + let support = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? home.appending(path: "Library/Application Support", directoryHint: .isDirectory) + + return LaunchdLocations( + userAgents: home.appending(path: "Library/LaunchAgents", directoryHint: .isDirectory), + globalAgents: URL(filePath: "/Library/LaunchAgents", directoryHint: .isDirectory), + globalDaemons: URL(filePath: "/Library/LaunchDaemons", directoryHint: .isDirectory), + appleAgents: URL(filePath: "/System/Library/LaunchAgents", directoryHint: .isDirectory), + appleDaemons: URL(filePath: "/System/Library/LaunchDaemons", directoryHint: .isDirectory), + applicationSupport: support.appending(path: "Launchd TOC", directoryHint: .isDirectory) + ) + } +} + +enum PlistRepositoryError: LocalizedError, Equatable { + case invalidRoot + case invalidPropertyList(String) + case invalidLabel + case invalidExecutable + case invalidInterval + case invalidThrottleInterval + case invalidCalendarValue(String) + case unauthorizedPath(String) + case symbolicLink(String) + case malformedFile(String) + case lintFailed(String) + case targetAlreadyExists + + var errorDescription: String? { + switch self { + case .invalidRoot: + "The property list root must be a dictionary." + case let .invalidPropertyList(message): + "The property list is invalid: \(message)" + case .invalidLabel: + "The label may contain letters, numbers, periods, hyphens, and underscores only." + case .invalidExecutable: + "Provide either Program or at least one ProgramArguments entry." + case .invalidInterval: + "StartInterval must be greater than zero." + case .invalidThrottleInterval: + "ThrottleInterval must be greater than zero." + case let .invalidCalendarValue(name): + "The calendar value for \(name) is outside its allowed range." + case let .unauthorizedPath(path): + "Launchd TOC cannot modify \(path). Only ~/Library/LaunchAgents is writable." + case let .symbolicLink(path): + "Launchd TOC refused a symbolic-link operation at \(path)." + case let .malformedFile(message): + "The property list could not be read: \(message)" + case let .lintFailed(message): + "plutil rejected the property list: \(message)" + case .targetAlreadyExists: + "A launch agent with this filename already exists." + } + } +} + +final class PlistRepository: @unchecked Sendable { + private let fileManager: FileManager + private let commandRunner: any CommandExecuting + let locations: LaunchdLocations + private let now: @Sendable () -> Date + + init( + fileManager: FileManager = .default, + commandRunner: any CommandExecuting = SystemCommandRunner(), + locations: LaunchdLocations? = nil, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.fileManager = fileManager + self.commandRunner = commandRunner + self.locations = locations ?? .live(fileManager: fileManager) + self.now = now + } + + func scan(showAppleServices: Bool) -> [LaunchdJob] { + var sources: [(URL, JobSource)] = [ + (locations.userAgents, .userAgent), + (locations.globalAgents, .globalAgent), + (locations.globalDaemons, .daemon) + ] + if showAppleServices { + sources.append((locations.appleAgents, .appleAgent)) + sources.append((locations.appleDaemons, .appleDaemon)) + } + + return sources.flatMap { directory, source in + scan(directory: directory, source: source) + } + } + + func loadConfiguration(at url: URL) throws -> JobConfiguration { + let data: Data + do { + data = try Data(contentsOf: url, options: .mappedIfSafe) + } catch { + throw PlistRepositoryError.malformedFile(error.localizedDescription) + } + + var format = PropertyListSerialization.PropertyListFormat.xml + let object: Any + do { + object = try PropertyListSerialization.propertyList( + from: data, + options: [], + format: &format + ) + } catch { + throw PlistRepositoryError.malformedFile(error.localizedDescription) + } + + guard let dictionary = object as? [String: Any] else { + throw PlistRepositoryError.invalidRoot + } + var converted: [String: PropertyListValue] = [:] + for (key, value) in dictionary { + guard let convertedValue = PropertyListValue(foundationValue: value) else { + throw PlistRepositoryError.invalidPropertyList("Unsupported value for \(key)") + } + converted[key] = convertedValue + } + + return JobConfiguration( + propertyList: converted, + format: PlistFormat(format), + fallbackLabel: url.deletingPathExtension().lastPathComponent + ) + } + + func validate(_ configuration: JobConfiguration) async throws { + _ = try await serializedData(for: configuration) + } + + @discardableResult + func save(_ configuration: JobConfiguration, to url: URL) async throws -> URL { + let safeURL = try validatedUserAgentURL(url, allowMissing: true) + let data = try await serializedData(for: configuration) + + try fileManager.createDirectory( + at: locations.userAgents, + withIntermediateDirectories: true + ) + if fileManager.fileExists(atPath: safeURL.path) { + try backUp(safeURL, label: configuration.label) + } + try data.write(to: safeURL, options: [.atomic]) + return safeURL + } + + func newAgentURL(label: String) throws -> URL { + guard Self.isValidLabel(label) else { + throw PlistRepositoryError.invalidLabel + } + let url = locations.userAgents.appending( + path: "\(label).plist", + directoryHint: .notDirectory + ) + _ = try validatedUserAgentURL(url, allowMissing: true) + guard !fileManager.fileExists(atPath: url.path) else { + throw PlistRepositoryError.targetAlreadyExists + } + return url + } + + @discardableResult + func trash(_ url: URL) throws -> URL? { + let safeURL = try validatedUserAgentURL(url, allowMissing: false) + var resultingURL: NSURL? + try fileManager.trashItem(at: safeURL, resultingItemURL: &resultingURL) + return resultingURL as URL? + } + + func validatedUserAgentURL(_ url: URL, allowMissing: Bool) throws -> URL { + let standardized = url.standardizedFileURL + let fileName = standardized.lastPathComponent + guard + fileName == url.lastPathComponent, + standardized.pathExtension.lowercased() == "plist", + !fileName.isEmpty, + fileName != ".plist", + fileName.unicodeScalars.allSatisfy({ $0.value >= 0x20 && $0.value != 0x7F }), + !fileName.contains("/"), + !fileName.contains("\\") + else { + throw PlistRepositoryError.unauthorizedPath(url.path) + } + + let root = locations.userAgents.standardizedFileURL.resolvingSymlinksInPath() + let parent = standardized.deletingLastPathComponent().resolvingSymlinksInPath() + guard parent.path == root.path else { + throw PlistRepositoryError.unauthorizedPath(url.path) + } + + let exists = fileManager.fileExists(atPath: standardized.path) + if exists { + let resourceValues = try standardized.resourceValues(forKeys: [.isSymbolicLinkKey]) + if resourceValues.isSymbolicLink == true { + throw PlistRepositoryError.symbolicLink(standardized.path) + } + let canonical = standardized.resolvingSymlinksInPath() + guard canonical.deletingLastPathComponent().path == root.path else { + throw PlistRepositoryError.unauthorizedPath(url.path) + } + } else if !allowMissing { + throw CocoaError(.fileNoSuchFile) + } + + return standardized + } + + static func isValidLabel(_ label: String) -> Bool { + guard !label.isEmpty, label.count <= 255 else { return false } + return label.unicodeScalars.allSatisfy { scalar in + CharacterSet.alphanumerics.contains(scalar) + || scalar == "." + || scalar == "-" + || scalar == "_" + } + } + + private func scan(directory: URL, source: JobSource) -> [LaunchdJob] { + let urls: [URL] + do { + urls = try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + ) + } catch { + return [] + } + + return urls + .filter { $0.pathExtension.lowercased() == "plist" } + .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } + .map { url in + do { + let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey]) + if values.isSymbolicLink == true { + throw PlistRepositoryError.symbolicLink(url.path) + } + let configuration = try loadConfiguration(at: url) + return LaunchdJob( + plistURL: url, + source: source, + configuration: configuration + ) + } catch { + let fallback = JobConfiguration( + label: url.deletingPathExtension().lastPathComponent, + rawValues: [:] + ) + return LaunchdJob( + plistURL: url, + source: source, + configuration: fallback, + runtimeState: .unknown, + parseIssue: error.localizedDescription + ) + } + } + } + + private func serializedData(for configuration: JobConfiguration) async throws -> Data { + guard Self.isValidLabel(configuration.label) else { + throw PlistRepositoryError.invalidLabel + } + guard configuration.executable?.isEmpty == false else { + throw PlistRepositoryError.invalidExecutable + } + if let interval = configuration.startInterval, interval <= 0 { + throw PlistRepositoryError.invalidInterval + } + if let interval = configuration.throttleInterval, interval <= 0 { + throw PlistRepositoryError.invalidThrottleInterval + } + try validateCalendar(configuration.calendarSchedules) + + let object = configuration.serializedValues.mapValues(\.foundationValue) + guard PropertyListSerialization.propertyList(object, isValidFor: configuration.originalFormat.foundationValue) else { + throw PlistRepositoryError.invalidPropertyList("Foundation rejected the value tree.") + } + + let data: Data + do { + data = try PropertyListSerialization.data( + fromPropertyList: object, + format: configuration.originalFormat.foundationValue, + options: 0 + ) + } catch { + throw PlistRepositoryError.invalidPropertyList(error.localizedDescription) + } + + let temporaryURL = fileManager.temporaryDirectory.appending( + path: "launchd-toc-\(UUID().uuidString).plist", + directoryHint: .notDirectory + ) + try data.write(to: temporaryURL, options: [.atomic]) + defer { try? fileManager.removeItem(at: temporaryURL) } + + let result = try await commandRunner.run( + executableURL: URL(filePath: "/usr/bin/plutil"), + arguments: ["-lint", temporaryURL.path] + ) + guard result.exitCode == 0 else { + let message = result.standardError.isEmpty ? result.standardOutput : result.standardError + throw PlistRepositoryError.lintFailed(message.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return data + } + + private func validateCalendar(_ entries: [CalendarSchedule]) throws { + for entry in entries { + let bounds: [(String, Int?, ClosedRange)] = [ + ("Minute", entry.minute, 0...59), + ("Hour", entry.hour, 0...23), + ("Day", entry.day, 1...31), + ("Weekday", entry.weekday, 0...7), + ("Month", entry.month, 1...12) + ] + for (name, value, range) in bounds { + if let value, !range.contains(value) { + throw PlistRepositoryError.invalidCalendarValue(name) + } + } + } + } + + private func backUp(_ sourceURL: URL, label: String) throws { + let backupRoot = locations.applicationSupport + .appending(path: "Backups", directoryHint: .isDirectory) + .appending(path: label, directoryHint: .isDirectory) + try fileManager.createDirectory(at: backupRoot, withIntermediateDirectories: true) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let timestamp = formatter.string(from: now()) + .replacingOccurrences(of: ":", with: "-") + let destination = backupRoot.appending( + path: "\(timestamp).plist", + directoryHint: .notDirectory + ) + try fileManager.copyItem(at: sourceURL, to: destination) + + let backups = try fileManager.contentsOfDirectory( + at: backupRoot, + includingPropertiesForKeys: [.creationDateKey], + options: [.skipsHiddenFiles] + ).sorted { first, second in + let firstDate = (try? first.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? .distantPast + let secondDate = (try? second.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? .distantPast + return firstDate > secondDate + } + for obsolete in backups.dropFirst(10) { + try fileManager.removeItem(at: obsolete) + } + } +} diff --git a/LaunchdTOC/Services/SchedulePreview.swift b/LaunchdTOC/Services/SchedulePreview.swift new file mode 100644 index 0000000..a517d8a --- /dev/null +++ b/LaunchdTOC/Services/SchedulePreview.swift @@ -0,0 +1,115 @@ +import Foundation + +struct SchedulePreview: Sendable { + private let calendar: Calendar + private let now: @Sendable () -> Date + + init( + calendar: Calendar = .autoupdatingCurrent, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.calendar = calendar + self.now = now + } + + func summary(for configuration: JobConfiguration) -> String { + var parts: [String] = [] + if configuration.runAtLoad { + parts.append("At load") + } + if let interval = configuration.startInterval { + parts.append(intervalSummary(interval)) + } + if !configuration.calendarSchedules.isEmpty { + if configuration.calendarSchedules.count == 1 { + parts.append(calendarSummary(configuration.calendarSchedules[0])) + } else { + parts.append("\(configuration.calendarSchedules.count) calendar times") + } + } + return parts.isEmpty ? "Not scheduled" : parts.joined(separator: " · ") + } + + func nextRuns( + for configuration: JobConfiguration, + count: Int = 5, + after startDate: Date? = nil + ) -> [Date] { + let start = startDate ?? now() + var candidates: [Date] = [] + + if let interval = configuration.startInterval, interval > 0 { + candidates.append(contentsOf: (1...count).map { + start.addingTimeInterval(TimeInterval(interval * $0)) + }) + } + + for schedule in configuration.calendarSchedules { + var cursor = start + for _ in 0.. String { + if seconds.isMultiple(of: 86_400) { + let days = seconds / 86_400 + return days == 1 ? "Every day" : "Every \(days) days" + } + if seconds.isMultiple(of: 3_600) { + let hours = seconds / 3_600 + return hours == 1 ? "Every hour" : "Every \(hours) hours" + } + if seconds.isMultiple(of: 60) { + let minutes = seconds / 60 + return minutes == 1 ? "Every minute" : "Every \(minutes) minutes" + } + return "Every \(seconds) seconds" + } + + private func calendarSummary(_ schedule: CalendarSchedule) -> String { + let time: String + if let hour = schedule.hour { + time = String(format: "%02d:%02d", hour, schedule.minute ?? 0) + } else if let minute = schedule.minute { + time = "minute \(minute)" + } else { + time = "calendar schedule" + } + + if let weekday = schedule.weekday { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = calendar.locale ?? .autoupdatingCurrent + let names = formatter.weekdaySymbols ?? calendar.weekdaySymbols + let index = weekday == 0 || weekday == 7 ? 0 : weekday + if names.indices.contains(index) { + return "\(names[index]) at \(time)" + } + } + if let day = schedule.day { + return "Day \(day) at \(time)" + } + return "At \(time)" + } +} diff --git a/LaunchdTOC/Services/UpdateChecker.swift b/LaunchdTOC/Services/UpdateChecker.swift new file mode 100644 index 0000000..ffc7ad2 --- /dev/null +++ b/LaunchdTOC/Services/UpdateChecker.swift @@ -0,0 +1,166 @@ +import Foundation + +struct ReleaseAsset: Decodable, Hashable, Sendable { + let name: String + let browserDownloadURL: URL + + enum CodingKeys: String, CodingKey { + case name + case browserDownloadURL = "browser_download_url" + } +} + +struct GitHubRelease: Decodable, Hashable, Sendable { + let tagName: String + let name: String? + let body: String? + let htmlURL: URL + let draft: Bool + let prerelease: Bool + let assets: [ReleaseAsset] + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case name + case body + case htmlURL = "html_url" + case draft + case prerelease + case assets + } +} + +struct HTTPResponse: Sendable { + let data: Data + let statusCode: Int +} + +protocol HTTPFetching: Sendable { + func get(_ url: URL) async throws -> HTTPResponse +} + +struct URLSessionHTTPClient: HTTPFetching { + func get(_ url: URL) async throws -> HTTPResponse { + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("Launchd-TOC", forHTTPHeaderField: "User-Agent") + request.timeoutInterval = 15 + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + return HTTPResponse(data: data, statusCode: status) + } +} + +enum UpdateResult: Hashable, Sendable { + case updateAvailable(GitHubRelease, downloadURL: URL?) + case upToDate + case noRelease +} + +enum UpdateCheckError: LocalizedError, Equatable, Sendable { + case rateLimited + case server(Int) + case malformedResponse + + var errorDescription: String? { + switch self { + case .rateLimited: + "GitHub’s anonymous API rate limit has been reached. Try again later." + case let .server(status): + "GitHub returned HTTP \(status)." + case .malformedResponse: + "The release response was not in the expected format." + } + } +} + +actor UpdateChecker { + static let latestReleaseURL = URL( + string: "https://api.github.com/repos/thierryc/launchd-toc/releases/latest" + )! + + private let httpClient: any HTTPFetching + + init(httpClient: any HTTPFetching = URLSessionHTTPClient()) { + self.httpClient = httpClient + } + + func check(currentVersion: String) async throws -> UpdateResult { + let response = try await httpClient.get(Self.latestReleaseURL) + switch response.statusCode { + case 200: + break + case 404: + return .noRelease + case 403, 429: + throw UpdateCheckError.rateLimited + default: + throw UpdateCheckError.server(response.statusCode) + } + + let release: GitHubRelease + do { + release = try JSONDecoder().decode(GitHubRelease.self, from: response.data) + } catch { + throw UpdateCheckError.malformedResponse + } + guard !release.draft, !release.prerelease else { return .noRelease } + + guard + let available = SemanticVersion(release.tagName), + let current = SemanticVersion(currentVersion) + else { + throw UpdateCheckError.malformedResponse + } + guard available > current else { return .upToDate } + + let dmg = release.assets.first { + $0.name.lowercased().hasSuffix(".dmg") + && $0.name.lowercased().contains("universal") + }?.browserDownloadURL + return .updateAvailable(release, downloadURL: dmg) + } +} + +struct SemanticVersion: Comparable, Hashable, Sendable { + let components: [Int] + + init?(_ rawValue: String) { + let normalized = rawValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "v", with: "", options: [.anchored]) + let stable = normalized.split(separator: "-", maxSplits: 1).first.map(String.init) ?? normalized + let parsed = stable.split(separator: ".").compactMap { Int($0) } + guard !parsed.isEmpty, parsed.count == stable.split(separator: ".").count else { return nil } + components = parsed + } + + static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { + let count = max(lhs.components.count, rhs.components.count) + for index in 0.. Bool { + let count = max(lhs.components.count, rhs.components.count) + for index in 0.. Void)? + + init( + repository: PlistRepository = PlistRepository(), + launchctl: any LaunchctlRunning = LaunchctlClient(), + schedulePreview: SchedulePreview = SchedulePreview(), + logTailer: LogTailer = LogTailer(), + updateChecker: UpdateChecker = UpdateChecker(), + defaults: UserDefaults = .standard, + userID: uid_t = getuid(), + initialJobs: [LaunchdJob] = [], + inventoryRefreshEnabled: Bool = true + ) { + self.repository = repository + self.launchctl = launchctl + self.schedulePreview = schedulePreview + self.logTailer = logTailer + self.updateChecker = updateChecker + self.defaults = defaults + self.userID = userID + self.inventoryRefreshEnabled = inventoryRefreshEnabled + jobs = initialJobs + + selectionID = defaults.string(forKey: PreferenceKey.selectionID) + sidebarSelection = SidebarFilter( + rawValue: defaults.string(forKey: PreferenceKey.sidebarSelection) ?? "" + ) ?? .all + showAppleServices = defaults.bool(forKey: PreferenceKey.showAppleServices) + showPIDColumn = defaults.object(forKey: PreferenceKey.showPIDColumn) as? Bool ?? true + sortField = JobSortField( + rawValue: defaults.string(forKey: PreferenceKey.sortField) ?? "" + ) ?? .label + sortAscending = defaults.object(forKey: PreferenceKey.sortAscending) as? Bool ?? true + } + + var selectedJob: LaunchdJob? { + jobs.first { $0.id == selectionID } + } + + var filteredJobs: [LaunchdJob] { + let filtered = jobs.filter(matchesSidebar).filter(matchesSearch) + return filtered.sorted { first, second in + let result: ComparisonResult + switch sortField { + case .label: + result = first.label.localizedStandardCompare(second.label) + case .state: + result = first.stateSortValue.localizedStandardCompare(second.stateSortValue) + case .schedule: + result = first.scheduleSummary.localizedStandardCompare(second.scheduleSummary) + case .lastRun: + result = compare(first.lastRun, second.lastRun) + } + return sortAscending ? result == .orderedAscending : result == .orderedDescending + } + } + + var runningJobCount: Int { + count(for: .running) + } + + func setRunningJobCountObserver(_ observer: @escaping (Int) -> Void) { + runningJobCountObserver = observer + observer(runningJobCount) + } + + func count(for filter: SidebarFilter) -> Int { + jobs.filter { matches($0, filter: filter) }.count + } + + func refresh() async { + guard inventoryRefreshEnabled else { return } + guard !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + + let showApple = showAppleServices + let repository = repository + let scanned = await Task.detached(priority: .userInitiated) { + repository.scan(showAppleServices: showApple) + }.value + + let guiDomain = JobSource.userAgent.domain(userID: userID) + let systemDomain = JobSource.daemon.domain(userID: userID) + async let disabledGUI = launchctl.disabledLabels(in: guiDomain) + async let disabledSystem = launchctl.disabledLabels(in: systemDomain) + let (disabledAgents, disabledDaemons) = await (disabledGUI, disabledSystem) + + var initialStates: [String: JobRuntimeState] = [:] + var runtimeJobs: [LaunchdJob] = [] + for job in scanned where job.parseIssue == nil { + let isDisabled = job.source.isAgent + ? disabledAgents.contains(job.label) + : disabledDaemons.contains(job.label) + if isDisabled { + initialStates[job.id] = .disabled + } else { + runtimeJobs.append(job) + } + } + + let runtimeByID = await withTaskGroup( + of: (String, JobRuntimeState).self, + returning: [String: JobRuntimeState].self + ) { group in + var iterator = runtimeJobs.makeIterator() + let concurrencyLimit = min(12, runtimeJobs.count) + for _ in 0.. Bool { + do { + let destination: URL + if let existingJob { + guard existingJob.source.isEditable else { throw JobStoreError.readOnly } + destination = existingJob.plistURL + if reload, existingJob.runtimeState != .unloaded, existingJob.runtimeState != .disabled { + try? await launchctl.perform(.unload, on: existingJob) + } + } else { + destination = try repository.newAgentURL(label: configuration.label) + } + + let savedURL = try await repository.save(configuration, to: destination) + if reload { + let savedJob = LaunchdJob( + plistURL: savedURL, + source: .userAgent, + configuration: configuration + ) + try await launchctl.perform(.load, on: savedJob) + } + await refresh() + let savedID = "\(JobSource.userAgent.rawValue):\(savedURL.standardizedFileURL.path)" + selectionID = savedID + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func moveToTrash(_ job: LaunchdJob) async { + do { + let target = try editableJob(job) + switch target.runtimeState { + case .running, .waiting: + try await launchctl.perform(.unload, on: target) + default: + break + } + if target.runtimeState == .disabled { + try await launchctl.perform(.enable, on: target) + } + _ = try repository.trash(target.plistURL) + await refresh() + } catch { + errorMessage = error.localizedDescription + } + } + + func checkForUpdates() async { + guard !isCheckingForUpdates else { return } + isCheckingForUpdates = true + defer { isCheckingForUpdates = false } + + let current = Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String ?? "0.1.0" + do { + switch try await updateChecker.check(currentVersion: current) { + case let .updateAvailable(release, downloadURL): + availableRelease = release + availableDownloadURL = downloadURL + case .upToDate: + noticeTitle = "You’re up to date" + noticeMessage = "Launchd TOC \(current) is the latest stable release." + case .noRelease: + noticeTitle = "No stable release" + noticeMessage = "There is no stable GitHub release available yet." + } + } catch { + errorMessage = "Update check failed. \(error.localizedDescription)" + } + } + + func openAvailableUpdate() { + guard let release = availableRelease else { return } + NSWorkspace.shared.open(availableDownloadURL ?? release.htmlURL) + } + + private func editableJob(_ supplied: LaunchdJob?) throws -> LaunchdJob { + guard let job = supplied ?? selectedJob else { throw JobStoreError.noSelection } + guard job.source.isEditable else { throw JobStoreError.readOnly } + return job + } + + private func matchesSidebar(_ job: LaunchdJob) -> Bool { + matches(job, filter: sidebarSelection) + } + + private func matches(_ job: LaunchdJob, filter: SidebarFilter) -> Bool { + switch filter { + case .all: + true + case .userAgents: + job.source == .userAgent + case .globalAgents: + job.source == .globalAgent || job.source == .appleAgent + case .daemons: + job.source == .daemon || job.source == .appleDaemon + case .running: + if case .running = job.runtimeState { true } else { false } + case .attention: + if case .failed = job.runtimeState { true } + else { job.parseIssue != nil || job.runtimeState == .unknown } + case .disabled: + job.runtimeState == .disabled || job.runtimeState == .unloaded + } + } + + private func matchesSearch(_ job: LaunchdJob) -> Bool { + let needle = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !needle.isEmpty else { return true } + return job.label.localizedCaseInsensitiveContains(needle) + || job.plistURL.path.localizedCaseInsensitiveContains(needle) + || (job.configuration.executable?.localizedCaseInsensitiveContains(needle) ?? false) + } + + private func compare(_ first: Date?, _ second: Date?) -> ComparisonResult { + switch (first, second) { + case let (first?, second?): first.compare(second) + case (nil, nil): .orderedSame + case (nil, _): .orderedDescending + case (_, nil): .orderedAscending + } + } +} + +private enum PreferenceKey { + static let selectionID = "selectionID" + static let sidebarSelection = "sidebarSelection" + static let showAppleServices = "showAppleServices" + static let showPIDColumn = "showPIDColumn" + static let sortField = "sortField" + static let sortAscending = "sortAscending" +} diff --git a/LaunchdTOC/Views/AboutView.swift b/LaunchdTOC/Views/AboutView.swift new file mode 100644 index 0000000..e710f59 --- /dev/null +++ b/LaunchdTOC/Views/AboutView.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct AboutView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 14) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .frame(width: 96, height: 96) + .accessibilityHidden(true) + Text("Launchd TOC") + .font(.title2.weight(.semibold)) + Text("Version \(version)") + .foregroundStyle(.secondary) + Text("A native utility for inspecting and managing user launch agents.") + .multilineTextAlignment(.center) + .frame(maxWidth: 360) + Divider() + Text("Copyright © 2026 Thierry Charbonnel. All rights reserved.") + .font(.caption) + .foregroundStyle(.secondary) + Text("Independent implementation. No source, assets, interface copy, or icons from azu/launchd-ui are included.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 390) + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + } + .padding(28) + .frame(width: 460) + } + + private var version: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0" + } +} + +struct UpdateAvailableView: View { + @Environment(\.dismiss) private var dismiss + let release: GitHubRelease + let hasDirectDownload: Bool + let openUpdate: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(.blue) + VStack(alignment: .leading) { + Text("A new version is available") + .font(.title3.weight(.semibold)) + Text(release.name ?? release.tagName) + .foregroundStyle(.secondary) + } + } + + ScrollView { + Text(release.body?.isEmpty == false ? release.body ?? "" : "No release notes were provided.") + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(minHeight: 180) + + HStack { + Spacer() + Button("Not Now") { dismiss() } + Button(hasDirectDownload ? "Download Universal DMG" : "Open Release Page") { + openUpdate() + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(width: 560, height: 360) + } +} diff --git a/LaunchdTOC/Views/AgentEditorView.swift b/LaunchdTOC/Views/AgentEditorView.swift new file mode 100644 index 0000000..cabeef2 --- /dev/null +++ b/LaunchdTOC/Views/AgentEditorView.swift @@ -0,0 +1,854 @@ +import AppKit +import SwiftUI + +private struct EditableStringRow: Identifiable, Hashable { + let id = UUID() + var value: String +} + +private struct EnvironmentRow: Identifiable, Hashable { + let id = UUID() + var key: String + var value: String +} + +private enum AgentEditorTab: String, CaseIterable, Identifiable { + case general + case launch + case environment + case advanced + + var id: String { rawValue } + + var title: String { + switch self { + case .general: "General" + case .launch: "Launch & Schedule" + case .environment: "Environment & Logs" + case .advanced: "Advanced" + } + } +} + +struct AgentEditorView: View { + @Environment(\.dismiss) private var dismiss + let job: LaunchdJob? + @Bindable var store: JobStore + + private let initialConfiguration: JobConfiguration + private let usesProgramKey: Bool + + @State private var draft: JobConfiguration + @State private var executableText: String + @State private var argumentRows: [EditableStringRow] + @State private var environmentRows: [EnvironmentRow] + @State private var intervalEnabled: Bool + @State private var intervalAmount: Int + @State private var intervalUnit: LaunchDurationUnit + @State private var throttleEnabled: Bool + @State private var throttleAmount: Int + @State private var selectedTab: AgentEditorTab = .general + @State private var isSaving = false + @State private var showsDiscardConfirmation = false + + init(job: LaunchdJob?, store: JobStore) { + self.job = job + self.store = store + + let initial = job?.configuration ?? JobConfiguration(label: "", keepAlive: false) + initialConfiguration = initial + usesProgramKey = job == nil || initial.program != nil + + _draft = State(initialValue: initial) + _executableText = State(initialValue: initial.program ?? initial.programArguments.first ?? "") + + let editableArguments = usesProgramKey + ? initial.programArguments + : Array(initial.programArguments.dropFirst()) + _argumentRows = State( + initialValue: editableArguments.map { EditableStringRow(value: $0) } + ) + _environmentRows = State( + initialValue: initial.environment.keys.sorted().map { + EnvironmentRow(key: $0, value: initial.environment[$0] ?? "") + } + ) + + let interval = initial.startInterval ?? 60 + let intervalFit = LaunchDurationUnit.bestFit(seconds: interval) + _intervalEnabled = State(initialValue: initial.startInterval != nil) + _intervalAmount = State(initialValue: intervalFit.amount) + _intervalUnit = State(initialValue: intervalFit.unit) + + _throttleEnabled = State(initialValue: initial.throttleInterval != nil) + _throttleAmount = State(initialValue: initial.throttleInterval ?? 10) + } + + var body: some View { + VStack(spacing: 0) { + editorHeader + Divider() + + Group { + switch selectedTab { + case .general: + generalForm + case .launch: + launchForm + case .environment: + environmentForm + case .advanced: + advancedForm + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + editorFooter + } + .frame(width: 860, height: 720) + .navigationTitle(job == nil ? "New Agent" : "Edit Agent") + .interactiveDismissDisabled(hasChanges) + .confirmationDialog( + "Discard unsaved changes?", + isPresented: $showsDiscardConfirmation, + titleVisibility: .visible + ) { + Button("Discard Changes", role: .destructive) { + dismiss() + } + Button("Keep Editing", role: .cancel) {} + } message: { + Text("Your changes to this launch agent have not been saved.") + } + } + + private var editorHeader: some View { + HStack(spacing: 20) { + VStack(alignment: .leading, spacing: 2) { + Text(job == nil ? "New User Agent" : "Edit User Agent") + .font(.headline) + Text("Configure what runs, when launchd starts it, and where its output goes.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Picker("Editor Section", selection: $selectedTab) { + ForEach(AgentEditorTab.allCases) { tab in + Text(tab.title) + .tag(tab) + .accessibilityIdentifier("editor.tab.\(tab.rawValue)") + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 490) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + + private var generalForm: some View { + Form { + Section("Identity") { + fullWidthTextField("Label", text: $draft.label) + .textContentType(.none) + .accessibilityIdentifier("editor.label") + .accessibilityHint("Reverse-domain launchd label") + + LabeledContent("Property List") { + Text(job?.plistURL.path ?? "~/Library/LaunchAgents/