diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cce3be4..3312152 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: run: | mkdir -p build xcrun swiftc -O -parse-as-library src/*.swift -o build/desktidy-sort - codesign -s - -i com.desktidy.sort build/desktidy-sort || true + codesign -s - -i com.desktidy.sort build/desktidy-sort - name: Self-test (deterministic safety checks) run: ./build/desktidy-sort --self-test @@ -76,10 +76,11 @@ jobs: - name: R1A effective-state gates (matrix, parity, fail-closed UI, read-only) run: ./build/desktidy-sort --state-test - - name: R1A app build + headless smokes (no GUI session needed) + - name: R1A app build + hermetic fixture smokes run: | + chmod +x scripts/smoke-app.sh scripts/test-smoke-isolation.sh scripts/test-cli-status.sh + ./scripts/test-smoke-isolation.sh ./scripts/build-app.sh build - # conflict fixture: the app binary must fail closed, never claim running AG=$(mktemp -d); TG=$(mktemp -d); AP=$(mktemp -d) PROG=$(mktemp -d)/mover; printf '#!/bin/sh\n' > "$PROG" /usr/bin/python3 - "$AG" "$TG" "$PROG" <<'PYEOF2' @@ -90,12 +91,17 @@ jobs: with open(os.path.join(ag, 'state.json'), 'w') as f: json.dump({'com.example.fixture-mover': 'running'}, f) PYEOF2 - OUT=$(DESKTIDY_AGENTS_DIR="$AG" DESKTIDY_TARGET_DIR="$TG" DESKTIDY_APP_DIR="$AP" DESKTIDY_LAUNCHD_STATE_FILE="$AG/state.json" ./build/DeskTidy.app/Contents/MacOS/DeskTidy --smoke | tail -1) - echo "$OUT"; test "$OUT" = "SMOKE overall=foreignConflict" - # clean fixture: pausedNotLoaded + DESKTIDY_AGENTS_DIR="$AG" DESKTIDY_TARGET_DIR="$TG" DESKTIDY_APP_DIR="$AP" \ + DESKTIDY_LAUNCHD_STATE_FILE="$AG/state.json" EXPECTED_OVERALL=foreignConflict \ + ./scripts/smoke-app.sh ./build/DeskTidy.app/Contents/MacOS/DeskTidy AG2=$(mktemp -d); TG2=$(mktemp -d); AP2=$(mktemp -d) - OUT2=$(DESKTIDY_AGENTS_DIR="$AG2" DESKTIDY_TARGET_DIR="$TG2" DESKTIDY_APP_DIR="$AP2" ./build/DeskTidy.app/Contents/MacOS/DeskTidy --smoke | tail -1) - echo "$OUT2"; test "$OUT2" = "SMOKE overall=pausedNotLoaded" + printf '%s\n' '{}' > "$AG2/state.json" + DESKTIDY_AGENTS_DIR="$AG2" DESKTIDY_TARGET_DIR="$TG2" DESKTIDY_APP_DIR="$AP2" \ + DESKTIDY_LAUNCHD_STATE_FILE="$AG2/state.json" EXPECTED_OVERALL=pausedNotLoaded \ + ./scripts/smoke-app.sh ./build/DeskTidy.app/Contents/MacOS/DeskTidy + + - name: Public CLI status consumes shared effective-state + run: ./scripts/test-cli-status.sh ./build/desktidy-sort - name: R1A read-only confinement grep (app sources contain no mutation calls) run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc2b1d..02ea930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased (branch r1b/phase0-unified-truth — stacked on R1A, non-final) + +- **R1B Phase 0 (no live service migration):** one target resolver, one + app-support/receipt path helper, public `desktidy status` consumes + `--effective-state`, requested-but-invalid launchd fixtures fail closed, + product identity is centralized without widening the accepted self set. + Native `config.json` is a reader/model only — nothing writes it yet. + Schema-1 native config is parsed from raw UTF-8 with a strict object + parser; duplicate keys (including escaped-equivalent spellings) fail + closed and do not fall through. + ## Unreleased (branch r1a/public-trust-surface) - **Experimental menu-bar app (read-only trust surface):** build from source diff --git a/README.md b/README.md index 2fe70ac..a64cb12 100644 --- a/README.md +++ b/README.md @@ -186,18 +186,21 @@ records every final path; subfolders (including `Inbox/`) are never re-sorted; ## Roadmap -- Menu-bar app: an **experimental read-only status surface exists** (build it - from source with `scripts/build-app.sh` — it shows the watched folder, - movement authority, and receipt-ledger health, and refuses to claim - "running" under any conflict or ambiguity). Pause/resume, activity feed, - and notifications are still to come; it is not shipped or packaged. +- Menu-bar app: an **experimental read-only status surface exists** on the + R1A/R1B source branches (build it with `scripts/build-app.sh`). It is not + packaged, not in the Homebrew formula, and not a public release. Pause/resume, + activity feed, and notifications are still to come. - Per-folder rules and user-defined categories via a JSON config (no rebuild). - Suggestion previews you can approve in one click — per the [ML authority policy](docs/ML_AUTHORITY_POLICY.md), model output never moves files on its own; approval stays human. -- Homebrew tap for one-line install. +- Homebrew tap (`anubisquantumcipher/tap/desktidy`) already exists; the + published formula remains **v1.1.2** and does **not** install R0 receipts, + R1A, or R1B. This source tree is experimental and unmerged. ## Contributing -Issues and PRs welcome. The whole thing is ~650 lines of Swift plus three small shell scripts — easy to read and hack on. Run `desktidy-sort --self-test` after changes. +Issues and PRs welcome. The sorter, receipts, and tests are a small Swift +tree plus a few shell scripts — read `src/` rather than trusting a line +count. Run `desktidy-sort --self-test` after changes. ## License diff --git a/app/DeskTidyApp.swift b/app/DeskTidyApp.swift index 373f722..52491c2 100644 --- a/app/DeskTidyApp.swift +++ b/app/DeskTidyApp.swift @@ -149,25 +149,14 @@ struct ContentView: View { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) } - private func receiptsDir() -> URL { - let env = ProcessInfo.processInfo.environment - let base: URL - if let a = env["DESKTIDY_APP_DIR"], !a.isEmpty { - base = URL(fileURLWithPath: (a as NSString).expandingTildeInPath, isDirectory: true) - } else { - base = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/DeskTidy", isDirectory: true) - } - return base.appendingPathComponent("receipts", isDirectory: true) - } + private func receiptsDir() -> URL { DeskTidyPaths.receiptsDirectory() } private func receiptsExist() -> Bool { - FileManager.default.fileExists(atPath: receiptsDir().appendingPathComponent("ledger.jsonl").path) + FileManager.default.fileExists(atPath: DeskTidyPaths.ledgerURL().path) } private func revealReceipts() { - NSWorkspace.shared.activateFileViewerSelecting( - [receiptsDir().appendingPathComponent("ledger.jsonl")]) + NSWorkspace.shared.activateFileViewerSelecting([DeskTidyPaths.ledgerURL()]) } private func copyDiagnostic() { diff --git a/docs/R1B_SERVICE_IDENTITY_PROPOSAL.md b/docs/R1B_SERVICE_IDENTITY_PROPOSAL.md new file mode 100644 index 0000000..a0c4ae5 --- /dev/null +++ b/docs/R1B_SERVICE_IDENTITY_PROPOSAL.md @@ -0,0 +1,45 @@ +# R1B Service Identity Proposal — Phase 1 input (NOT APPLIED) + +_Phase 0 proposal only. The accepted self set remains `com.desktidy.sort` and +`com.desktidy.notify`. This document does not widen trust._ + +## Current accepted identity (Phase 0, executable) + +| Role | Label | Expected program basename | +|---|---|---| +| sorter | `com.desktidy.sort` | `desktidy-sort` | +| notifier | `com.desktidy.notify` | `desktidy-notify` | +| menu-bar app (not an agent) | bundle id `com.desktidy.app` | `DeskTidy` | + +A label from this set with a **contradictory existing executable** (basename +outside the expected set) is **not** self. A future or unloaded label such as +`com.desktidy.app.sort` is **not** self. + +## What Phase 1 must add — only after sacrificial observation + +`SMAppService.agent(plistName:)` typically registers a bundle-scoped agent +whose launchd label is not the CLI pair above. Until that label is observed +on a sacrificial, non-live root, it must not be added to `ProductIdentity.selfLabels`. + +Proposed binding to confirm in the authorized Phase 1 observation: + +1. **Label** — exact string printed by `launchctl print` after + `SMAppService.agent(plistName:).register()` (likely + `com.desktidy.app.` or the embedded plist's `Label`). +2. **Program** — the bundle executable or a `BundleProgram` relative path + inside `Contents/Library/LaunchAgents`. +3. **Bundle id** — `com.desktidy.app`. +4. **Coexistence** — if a legacy `com.desktidy.sort` plist and the new app + label both watch the same root, EffectiveState must be `ambiguous` / + refuse. Never treat dual self-presence as healthy. +5. **Atomic catalog update** — `selfLabels`, expected basenames, and the + target resolver's product-plist name must change in the same commit as + registration code. A half-updated catalog is an accept-condition change + and is prohibited. + +## Explicitly not authorized by Phase 0 + +- Adding any SMAppService/app-agent label to the accepted self set +- Treating label-only match as self when program evidence contradicts it + (Phase 0 already fails closed on that evidence) +- Registering, unregistering, or launching a new agent diff --git a/docs/evidence/R1B_PHASE0_ABA_DUPLICATE_JSON_KEYS.md b/docs/evidence/R1B_PHASE0_ABA_DUPLICATE_JSON_KEYS.md new file mode 100644 index 0000000..01ac59c --- /dev/null +++ b/docs/evidence/R1B_PHASE0_ABA_DUPLICATE_JSON_KEYS.md @@ -0,0 +1,63 @@ +# R1B Phase 0 A→B→A — duplicate native-config keys must fail closed + +Semantic mutation of `src/NativeConfigParser.swift`: after JSON escape +decoding, a repeated object key is ignored instead of rejected. + +No live Desktop paths, no private file contents. + +## A (green) + +- file: `src/NativeConfigParser.swift` +- SHA-256: `00efb21e99cce717f73fe812c879599b7087449a9b1a137bf037a23b96a02e5d` +- command: `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-r1b-dupe-aba/desktidy-sort && /tmp/desktidy-r1b-dupe-aba/desktidy-sort --state-test` +- exit: `0` +- excerpt: + +``` +PASS D01 duplicate target keys (different values) → invalid +PASS D04 escaped-equivalent duplicate target key → invalid +PASS D08 engine refuses duplicate-key config (exit 3, no move) +R1A GATES: 63 passed, 0 failed +``` + +## B (duplicate rejection disabled) + +- SHA-256: `713477b521b5173073bfe0bde57dc5a7cf3a05d0079efaa60519428b1217b644` +- rebuild: previous binary deleted, then `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-r1b-dupe-aba/desktidy-sort` +- command: `/tmp/desktidy-r1b-dupe-aba/desktidy-sort --state-test` +- exit: `1` +- failing IDs: + +``` +FAIL D01 duplicate target keys (different values) → invalid — got pausedNotLoaded res=resolved src=nativeConfig target=.../target-b-... +FAIL D02 duplicate target keys (identical values) → invalid — got pausedNotLoaded res=resolved +FAIL D03 duplicate schema keys → invalid — got pausedNotLoaded +FAIL D04 escaped-equivalent duplicate target key → invalid — got pausedNotLoaded target=.../target-b-... +FAIL D08 engine refuses duplicate-key config (exit 3, no move) — exit=0 stayed=true +R1A GATES: 58 passed, 5 failed +``` + +D01/D04's intended reason is last-wins resolution (`res=resolved`, `nativeConfig`) +instead of `invalid`. D08's `exit=0` shows the engine accepted a selected +duplicate-key config and did not refuse movement. + +Diff (B vs A): + +```diff + if seen.contains(key) { +- return .failed("native config has a duplicate key") +- } ++ // B-MUTATION: ignore duplicate keys after escape decoding ++ } else { + seen.insert(key) ++ } +``` + +## Restore (A bytes) + +- `cp` of the A snapshot over `src/NativeConfigParser.swift` +- SHA-256: `00efb21e99cce717f73fe812c879599b7087449a9b1a137bf037a23b96a02e5d` (equals A) +- rebuild after deleting the B binary +- `--state-test` exit 0 — `R1A GATES: 63 passed, 0 failed`; D01 PASS +- `--self-test` exit 0 — `PASS: 17 deterministic safety checks` +- `--r0-test` exit 0 — `R0 CONTROLS: 31 passed, 0 failed` diff --git a/docs/evidence/R1B_PHASE0_ABA_TARGET_FAIL_CLOSED.md b/docs/evidence/R1B_PHASE0_ABA_TARGET_FAIL_CLOSED.md new file mode 100644 index 0000000..c8a799e --- /dev/null +++ b/docs/evidence/R1B_PHASE0_ABA_TARGET_FAIL_CLOSED.md @@ -0,0 +1,66 @@ +# R1B Phase 0 A→B→A — malformed native config must not fall through + +Semantic mutation of `src/TargetResolver.swift`: invalid native `config.json` +falls through to env/plist/default instead of failing closed. + +No live Desktop paths, no private file contents. + +## A (green) + +- file: `src/TargetResolver.swift` +- SHA-256: `f84821ab5a8b0a6fc302f9d695bb53fd7c88a6d5d7ae5762e509f33d6111c619` +- command: `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-r1b-phase0-aba/desktidy-sort && /tmp/desktidy-r1b-phase0-aba/desktidy-sort --state-test` +- exit: `0` +- excerpt: + +``` +PASS T01 malformed native config refuses instead of env/default fallback +R1A GATES: 55 passed, 0 failed +``` + +## B (semantic fail-open) + +Mutation: if native config exists but `readNativeConfig` fails, `break` and +continue to plist/env/default. + +- SHA-256: `aa6c1e278563c177b1580a1b6779527eaea219d73f125da958268cb6267f2436` +- rebuild: previous binary deleted, then `xcrun swiftc -O -parse-as-library src/*.swift -o /tmp/desktidy-r1b-phase0-aba/desktidy-sort` +- command: `/tmp/desktidy-r1b-phase0-aba/desktidy-sort --state-test` +- exit: `1` +- failing IDs: + +``` +FAIL T01 malformed native config refuses instead of env/default fallback — got pausedNotLoaded res=resolved: no conflicting authority, and DeskTidy's agent is not loaded +FAIL T06 empty/wrong-type native target refuses — empty=pausedNotLoaded wrong=pausedNotLoaded +FAIL T07 unreadable native config refuses — got pausedNotLoaded: no conflicting authority, and DeskTidy's agent is not loaded +FAIL T10 engine refuses ambiguous target (exit 3, no move) — exit=0 stayed=false +R1A GATES: 51 passed, 4 failed +``` + +T01's intended reason is env fallback (`pausedNotLoaded` / `res=resolved`). +T10's `exit=0 stayed=false` shows the engine moved the fixture witness after +the invalid config was ignored. + +Diff (B vs A): + +```diff + if fm.fileExists(atPath: configURL.path) { +- return finish(readNativeConfig(configURL, fm: fm), source: .nativeConfig, fm: fm) ++ let parsed = readNativeConfig(configURL, fm: fm) ++ switch parsed { ++ case .ok: ++ return finish(parsed, source: .nativeConfig, fm: fm) ++ case .failed: ++ break // B-MUTATION: fall through instead of fail-closed ++ } + } +``` + +## Restore (A bytes) + +- `cp` of the A snapshot over `src/TargetResolver.swift` +- SHA-256: `f84821ab5a8b0a6fc302f9d695bb53fd7c88a6d5d7ae5762e509f33d6111c619` (equals A) +- rebuild after deleting the B binary +- `--state-test` exit 0 — `R1A GATES: 55 passed, 0 failed`; T01 PASS +- `--self-test` exit 0 — `PASS: 17 deterministic safety checks` +- `--r0-test` exit 0 — `R0 CONTROLS: 31 passed, 0 failed` diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 11873ac..0ae5aa7 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -19,6 +19,10 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" xcrun swiftc -O -parse-as-library \ -target "arm64-apple-macosx$MACOS_MIN" \ "$REPO/src/Config.swift" \ + "$REPO/src/Paths.swift" \ + "$REPO/src/TargetResolver.swift" \ + "$REPO/src/NativeConfigParser.swift" \ + "$REPO/src/ProductIdentity.swift" \ "$REPO/src/Authority.swift" \ "$REPO/src/Receipts.swift" \ "$REPO/src/EffectiveState.swift" \ @@ -46,6 +50,5 @@ cat > "$APP/Contents/Info.plist" < PLIST -codesign -s - -i com.desktidy.app --force "$APP" >/dev/null 2>&1 || true +codesign -s - -i com.desktidy.app --force "$APP" echo "built: $APP" -"$APP/Contents/MacOS/DeskTidy" --smoke 2>/dev/null || true diff --git a/scripts/smoke-app.sh b/scripts/smoke-app.sh new file mode 100755 index 0000000..ddfca74 --- /dev/null +++ b/scripts/smoke-app.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Hermetic headless smoke of the menu-bar binary. Requires isolated fixtures. +# Never probes the live Desktop or live launchd. +set -euo pipefail + +need() { + local n="$1" + if [ -z "${!n:-}" ]; then + echo "smoke: missing required fixture variable $n" >&2 + exit 2 + fi +} + +need DESKTIDY_AGENTS_DIR +need DESKTIDY_TARGET_DIR +need DESKTIDY_APP_DIR +need DESKTIDY_LAUNCHD_STATE_FILE +need EXPECTED_OVERALL + +APP_BIN="${1:-${DESKTIDY_APP_BIN:-}}" +if [ -z "$APP_BIN" ] || [ ! -x "$APP_BIN" ]; then + echo "smoke: app binary required as \$1 or DESKTIDY_APP_BIN" >&2 + exit 2 +fi + +[ -d "$DESKTIDY_AGENTS_DIR" ] || { echo "smoke: agents dir does not exist" >&2; exit 2; } +[ -d "$DESKTIDY_TARGET_DIR" ] || { echo "smoke: target dir does not exist" >&2; exit 2; } +[ -d "$DESKTIDY_APP_DIR" ] || { echo "smoke: app-support dir does not exist" >&2; exit 2; } +[ -f "$DESKTIDY_LAUNCHD_STATE_FILE" ] || { echo "smoke: launchd fixture file is absent" >&2; exit 2; } + +/usr/bin/python3 -c 'import json,sys; json.load(open(sys.argv[1]))' \ + "$DESKTIDY_LAUNCHD_STATE_FILE" || { + echo "smoke: launchd fixture is not valid JSON" >&2 + exit 2 +} + +OUT="$("$APP_BIN" --smoke)" +echo "$OUT" +last="$(printf '%s\n' "$OUT" | tail -1)" +expected="SMOKE overall=$EXPECTED_OVERALL" +if [ "$last" != "$expected" ]; then + echo "smoke: expected $expected, got $last" >&2 + exit 1 +fi diff --git a/scripts/test-cli-status.sh b/scripts/test-cli-status.sh new file mode 100755 index 0000000..d6ea464 --- /dev/null +++ b/scripts/test-cli-status.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Isolated integration: public `desktidy status` must consume --effective-state +# and must not reconstruct target precedence in shell. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SORT_BIN="${1:-}" +if [ -z "$SORT_BIN" ] || [ ! -x "$SORT_BIN" ]; then + echo "usage: $0 /path/to/desktidy-sort" >&2 + exit 2 +fi + +if grep -n 'PlistBuddy' "$ROOT/src/desktidy-cli.sh"; then + echo "FAIL: desktidy-cli.sh still has a PlistBuddy target bypass" >&2 + exit 1 +fi + +PREFIX="$(mktemp -d /tmp/desktidy-status-prefix-XXXXXX)" +AG="$(mktemp -d /tmp/desktidy-status-ag-XXXXXX)" +TG_ENV="$(mktemp -d /tmp/desktidy-status-env-XXXXXX)" +TG_PLIST="$(mktemp -d /tmp/desktidy-status-plist-XXXXXX)" +TG_CFG="$(mktemp -d /tmp/desktidy-status-cfg-XXXXXX)" +AP="$(mktemp -d /tmp/desktidy-status-ap-XXXXXX)" + +mkdir -p "$PREFIX/bin" "$PREFIX/libexec" "$PREFIX/share/desktidy" +cp "$ROOT/src/desktidy-cli.sh" "$PREFIX/bin/desktidy" +chmod +x "$PREFIX/bin/desktidy" +cp "$SORT_BIN" "$PREFIX/bin/desktidy-sort" +chmod +x "$PREFIX/bin/desktidy-sort" +cp "$ROOT/src/desktidy-notify.sh" "$PREFIX/libexec/desktidy-notify.sh" +cp "$ROOT/launchagents/"*.template "$PREFIX/share/desktidy/" + +/usr/bin/python3 - "$AG" "$TG_PLIST" <<'PY' +import plistlib, sys, os, json +ag, tg = sys.argv[1], sys.argv[2] +prog = os.path.join(ag, "desktidy-sort") +open(prog, "w").write("#!/bin/sh\n") +with open(os.path.join(ag, "com.desktidy.sort.plist"), "wb") as f: + plistlib.dump({ + "Label": "com.desktidy.sort", + "ProgramArguments": [prog], + "WatchPaths": [tg], + "EnvironmentVariables": {"DESKTIDY_TARGET_DIR": tg}, + }, f) +with open(os.path.join(ag, "state.json"), "w") as f: + json.dump({}, f) +PY +printf '%s\n' '{"schema":1,"target":"'"$TG_CFG"'"}' > "$AP/config.json" + +export DESKTIDY_AGENTS_DIR="$AG" +export DESKTIDY_TARGET_DIR="$TG_ENV" +export DESKTIDY_APP_DIR="$AP" +export DESKTIDY_LAUNCHD_STATE_FILE="$AG/state.json" + +JSON="$("$PREFIX/bin/desktidy-sort" --effective-state --json)" +STATUS="$("$PREFIX/bin/desktidy" status)" + +echo "$JSON" +echo "$STATUS" + +echo "$STATUS" | grep -F "target: $TG_CFG" >/dev/null +echo "$STATUS" | grep -F "overall: pausedNotLoaded" >/dev/null +if echo "$STATUS" | grep -F "$TG_PLIST" >/dev/null; then + echo "FAIL: status echoed plist target instead of shared-state config target" >&2 + exit 1 +fi +if echo "$STATUS" | grep -F "$TG_ENV" >/dev/null; then + echo "FAIL: status echoed env target instead of shared-state config target" >&2 + exit 1 +fi +echo "cli-status: PASS (shared effective-state target, no PlistBuddy)" diff --git a/scripts/test-smoke-isolation.sh b/scripts/test-smoke-isolation.sh new file mode 100755 index 0000000..799a288 --- /dev/null +++ b/scripts/test-smoke-isolation.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Negative control: the smoke harness must refuse missing fixture isolation +# rather than probing the live machine. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SMOKE="$ROOT/scripts/smoke-app.sh" +chmod +x "$SMOKE" "$ROOT/scripts/test-cli-status.sh" + +# Clear any inherited fixture vars from a parent test process. +unset DESKTIDY_AGENTS_DIR DESKTIDY_TARGET_DIR DESKTIDY_APP_DIR +unset DESKTIDY_LAUNCHD_STATE_FILE EXPECTED_OVERALL DESKTIDY_APP_BIN + +set +e +OUT="$("$SMOKE" /usr/bin/true 2>&1)" +CODE=$? +set -e +echo "$OUT" +if [ "$CODE" -eq 0 ]; then + echo "FAIL: smoke succeeded without fixture isolation" >&2 + exit 1 +fi +echo "$OUT" | grep -q 'missing required fixture' || { + echo "FAIL: smoke did not report missing fixture isolation" >&2 + exit 1 +} +echo "smoke-isolation: PASS (exit $CODE)" diff --git a/src/Authority.swift b/src/Authority.swift index 416ac86..ed7f6bc 100644 --- a/src/Authority.swift +++ b/src/Authority.swift @@ -63,37 +63,68 @@ struct CanonicalPath: Equatable { final class AuthorityGuard { /// DeskTidy's own agent labels — never counted as foreign authorities. - static let selfLabels: Set = ["com.desktidy.sort", "com.desktidy.notify"] + static let selfLabels: Set = ProductIdentity.selfLabels let agentsDir: URL - private let fixtureStates: [String: String]? // label -> state, from fixture file + private let fixtureStates: [String: String]? // label -> state, from a valid fixture + private let fixtureFailure: String? // requested fixture was unusable init() { let env = ProcessInfo.processInfo.environment - let fm = FileManager.default - if let d = env["DESKTIDY_AGENTS_DIR"], !d.isEmpty { - agentsDir = URL(fileURLWithPath: (d as NSString).expandingTildeInPath, isDirectory: true) - } else { - agentsDir = fm.homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents", isDirectory: true) - } - if let f = env["DESKTIDY_LAUNCHD_STATE_FILE"], !f.isEmpty, - let data = fm.contents(atPath: (f as NSString).expandingTildeInPath), - let dict = try? JSONDecoder().decode([String: String].self, from: data) { - fixtureStates = dict - } else if let f = env["DESKTIDY_LAUNCHD_STATE_FILE"], !f.isEmpty { - // A fixture was requested but is unreadable: that is itself - // ambiguity — surface it rather than silently probing live launchd. - fixtureStates = [:] + agentsDir = DeskTidyPaths.agentsDirectory() + if let f = env["DESKTIDY_LAUNCHD_STATE_FILE"], !f.isEmpty { + switch AuthorityGuard.loadFixtureFile((f as NSString).expandingTildeInPath) { + case .ok(let dict): + fixtureStates = dict + fixtureFailure = nil + case .failed(let reason): + fixtureStates = [:] + fixtureFailure = reason + } } else { fixtureStates = nil + fixtureFailure = nil } } - /// Explicit-injection initializer for tests. + /// Explicit-injection initializer for tests (valid fixture or live-nil). init(agentsDir: URL, fixtureStates: [String: String]?) { self.agentsDir = agentsDir self.fixtureStates = fixtureStates + self.fixtureFailure = nil + } + + enum FixtureFile { + case ok([String: String]) + case failed(String) + } + + static func loadFixtureFile(_ path: String) -> FixtureFile { + let fm = FileManager.default + guard fm.fileExists(atPath: path) else { + return .failed("requested launchd fixture is absent") + } + guard let data = fm.contents(atPath: path) else { + return .failed("requested launchd fixture is unreadable") + } + guard let obj = try? JSONSerialization.jsonObject(with: data) else { + return .failed("requested launchd fixture is malformed JSON") + } + guard let dict = obj as? [String: Any] else { + return .failed("requested launchd fixture has the wrong type") + } + var out: [String: String] = [:] + let allowed: Set = ["running", "loaded", "not-loaded"] + for (k, v) in dict { + guard let s = v as? String else { + return .failed("requested launchd fixture has a non-string state") + } + if !allowed.contains(s) { + return .failed("requested launchd fixture has an invalid state value") + } + out[k] = s + } + return .ok(out) } // -- canonicalization ---------------------------------------------------- @@ -113,6 +144,7 @@ final class AuthorityGuard { // -- launchd state ------------------------------------------------------- private func loadState(label: String, programExists: Bool) -> MoverLoadState { + if fixtureFailure != nil { return .uninspectable } if let fixtures = fixtureStates { switch fixtures[label] { case "running": return .running @@ -191,7 +223,7 @@ final class AuthorityGuard { watchedPaths: canonWatched.map { $0.path }, programPath: program, state: state, - isSelf: AuthorityGuard.selfLabels.contains(label) + isSelf: ProductIdentity.isSelf(label: label, programPath: program, programExists: programExists) )) } return (records, unreadable) @@ -199,6 +231,9 @@ final class AuthorityGuard { // -- decision ------------------------------------------------------------ func evaluate(rootPath: String) -> AuthorityDecision { + if let fixtureFailure { + return .ambiguous(reason: fixtureFailure, records: []) + } let root = AuthorityGuard.canonicalize(rootPath) let (records, unreadable) = relevantMovers(for: root) diff --git a/src/DeskTidy.swift b/src/DeskTidy.swift index 3953a4e..c788100 100644 --- a/src/DeskTidy.swift +++ b/src/DeskTidy.swift @@ -60,6 +60,8 @@ final class DeskTidy { let smartCompiledIn = false #endif + let targetResolution: TargetResolution + lazy var ledger = ReceiptLedger(appDirectory: appDirectory) lazy var movement = MovementService(root: target, ledger: ledger, moverVersion: DeskTidyVersion.string, @@ -69,18 +71,16 @@ final class DeskTidy { var reservedRootNames: Set { Set(Category.allCases.map { $0.folderName }) } init() { - let env = ProcessInfo.processInfo.environment home = fm.homeDirectoryForCurrentUser - - if let t = env["DESKTIDY_TARGET_DIR"], !t.isEmpty { - target = URL(fileURLWithPath: (t as NSString).expandingTildeInPath, isDirectory: true) - } else { - target = home.appendingPathComponent(Config.targetDirName, isDirectory: true) - } - if let a = env["DESKTIDY_APP_DIR"], !a.isEmpty { - appDirectory = URL(fileURLWithPath: (a as NSString).expandingTildeInPath, isDirectory: true) - } else { - appDirectory = home.appendingPathComponent("Library/Application Support/DeskTidy", isDirectory: true) + appDirectory = DeskTidyPaths.appDirectory() + targetResolution = TargetResolver.resolve() + switch targetResolution { + case .resolved(let path, _, _): + target = URL(fileURLWithPath: path, isDirectory: true) + case .invalid: + // Not a movement target. run() refuses before any access/sweep. + // Keep the URL off ~/Desktop so a forgotten check cannot sort live. + target = appDirectory.appendingPathComponent(".unresolved-target", isDirectory: true) } inbox = target.appendingPathComponent(Config.folderInbox, isDirectory: true) logURL = appDirectory.appendingPathComponent("desktidy.log") @@ -140,6 +140,11 @@ final class DeskTidy { return 0 } + if case .invalid(let reason, let source, _) = targetResolution { + fputs("DeskTidy: target resolution failed (\(source.rawValue): \(reason)) — refusing movement\n", stderr) + return 3 + } + guard acquireLock() else { return 0 } // another instance is already running defer { releaseLock() } diff --git a/src/EffectiveState.swift b/src/EffectiveState.swift index ab4516f..e9f9668 100644 --- a/src/EffectiveState.swift +++ b/src/EffectiveState.swift @@ -45,6 +45,10 @@ struct EffectiveStateReport: Codable { var watchedTarget: String var watchedTargetCanonical: String? var targetExists: Bool + var targetSource: String // nativeConfig | installedPlist | environment | defaultDesktop + var targetResolution: String // resolved | invalid + var appDirectory: String + var ledgerPath: String var productAgentLoaded: Bool var productAgentState: String // running | loadedIdle | notLoaded | stale | uninspectable var effectiveMoverLabel: String? // provable mover of this root, if any @@ -62,46 +66,52 @@ enum EffectiveState { /// The single derivation. `now` is injectable for deterministic tests. static func compute() -> EffectiveStateReport { let fm = FileManager.default - let env = ProcessInfo.processInfo.environment - let home = fm.homeDirectoryForCurrentUser - // --- watched target: installed plist env > DESKTIDY_TARGET_DIR > default - let agentsDir: URL = { - if let d = env["DESKTIDY_AGENTS_DIR"], !d.isEmpty { - return URL(fileURLWithPath: (d as NSString).expandingTildeInPath, isDirectory: true) - } - return home.appendingPathComponent("Library/LaunchAgents", isDirectory: true) - }() - var target: String - var targetSource: String - if let fromPlist = installedTarget(agentsDir: agentsDir) { - target = fromPlist; targetSource = "installed plist" - } else if let t = env["DESKTIDY_TARGET_DIR"], !t.isEmpty { - target = (t as NSString).expandingTildeInPath; targetSource = "environment" - } else { - target = home.appendingPathComponent(Config.targetDirName).path; targetSource = "default" + // --- watched target: one resolver shared with the movement engine + let resolution = TargetResolver.resolve() + let target: String + let targetSource: String + let targetExists: Bool + let canonical: String? + let invalidReason: String? + switch resolution { + case .resolved(let path, let source, let exists): + target = path + targetSource = source.rawValue + targetExists = exists + canonical = exists ? AuthorityGuard.canonicalize(path).path : nil + invalidReason = nil + case .invalid(let reason, let source, let attempted): + target = attempted ?? "" + targetSource = source.rawValue + targetExists = false + canonical = nil + invalidReason = reason } - var isDir: ObjCBool = false - let targetExists = fm.fileExists(atPath: target, isDirectory: &isDir) && isDir.boolValue - let canonical = targetExists ? AuthorityGuard.canonicalize(target).path : nil - // --- authority (the R0 guard, unmodified) + // --- authority (the R0 guard, unmodified). Invalid target selection + // never falls back to a live default root for the probe. let guardian = AuthorityGuard() - let decision = guardian.evaluate(rootPath: target) + let decision: AuthorityDecision + if let invalidReason { + decision = .ambiguous(reason: invalidReason, records: []) + } else { + decision = guardian.evaluate(rootPath: target) + } // --- product agent state (same probe mechanism, self labels) - let (records, _) = guardian.relevantMovers(for: AuthorityGuard.canonicalize(target)) - let selfRecord = records.first { $0.isSelf && $0.label == "com.desktidy.sort" } + let selfRecord: MoverRecord? + if invalidReason == nil { + let (records, _) = guardian.relevantMovers(for: AuthorityGuard.canonicalize(target)) + selfRecord = records.first { $0.isSelf && $0.label == ProductIdentity.sortLabel } + } else { + selfRecord = nil + } let productState = selfRecord?.state ?? .notLoaded let productLoaded = productState == .running || productState == .loadedIdle // --- ledger health - let appDir: URL = { - if let a = env["DESKTIDY_APP_DIR"], !a.isEmpty { - return URL(fileURLWithPath: (a as NSString).expandingTildeInPath, isDirectory: true) - } - return home.appendingPathComponent("Library/Application Support/DeskTidy", isDirectory: true) - }() + let appDir = DeskTidyPaths.appDirectory() let ledger = ReceiptLedger(appDirectory: appDir) let ledgerHealth: LedgerHealth if !fm.fileExists(atPath: ledger.ledgerURL.path) { @@ -123,7 +133,7 @@ enum EffectiveState { moverLabel = live.label; moverProgram = live.programPath } case .sole, .soleWithStale: - if productLoaded { moverLabel = "com.desktidy.sort"; moverProgram = selfRecord?.programPath } + if productLoaded { moverLabel = ProductIdentity.sortLabel; moverProgram = selfRecord?.programPath } case .ambiguous: break // unprovable — leave nil rather than guess } @@ -132,7 +142,11 @@ enum EffectiveState { var overall: OverallState var reason: String var ambiguity: String? - if !targetExists { + if let invalidReason { + overall = .ambiguous + reason = "target resolution failed (\(targetSource): \(invalidReason))" + ambiguity = invalidReason + } else if !targetExists { overall = .ambiguous reason = "watched target does not exist (\(targetSource): \(target))" ambiguity = reason @@ -180,6 +194,10 @@ enum EffectiveState { watchedTarget: target, watchedTargetCanonical: canonical, targetExists: targetExists, + targetSource: targetSource, + targetResolution: invalidReason == nil ? "resolved" : "invalid", + appDirectory: appDir.path, + ledgerPath: ledger.ledgerURL.path, productAgentLoaded: productLoaded, productAgentState: productState.rawValue, effectiveMoverLabel: moverLabel, @@ -193,17 +211,6 @@ enum EffectiveState { ) } - /// Watched target recorded in the installed product plist, if any. - private static func installedTarget(agentsDir: URL) -> String? { - let plist = agentsDir.appendingPathComponent("com.desktidy.sort.plist") - guard let data = FileManager.default.contents(atPath: plist.path), - let obj = try? PropertyListSerialization.propertyList(from: data, format: nil), - let dict = obj as? [String: Any], - let envDict = dict["EnvironmentVariables"] as? [String: String], - let t = envDict["DESKTIDY_TARGET_DIR"], !t.isEmpty else { return nil } - return t - } - // ------------------------------------------------------------------ UI mapping // The menu-bar presentation derives from the report ONLY through these // pure functions, so fail-closed rendering is testable without a GUI. @@ -238,7 +245,7 @@ enum EffectiveState { """ DeskTidy effective state (\(r.generatedAt)) overall: \(r.overall.rawValue) — \(r.overallReason) - target: \(r.watchedTarget) (exists: \(r.targetExists)) + target: \(r.watchedTarget) (exists: \(r.targetExists), source: \(r.targetSource), resolution: \(r.targetResolution)) product agent: \(r.productAgentState) effective mover: \(r.effectiveMoverLabel ?? "unprovable")\(r.effectiveMoverProgram.map { " (\($0))" } ?? "") foreign movers: \(r.foreignMovers.isEmpty ? "none" : r.foreignMovers.joined(separator: ", ")) diff --git a/src/NativeConfigParser.swift b/src/NativeConfigParser.swift new file mode 100644 index 0000000..2e2a6c3 --- /dev/null +++ b/src/NativeConfigParser.swift @@ -0,0 +1,197 @@ +import Foundation + +// ============================================================================ +// Strict native-config parser (schema 1). +// +// Operates on raw UTF-8 bytes *before* any dictionary conversion. +// JSONSerialization / JSONDecoder collapse duplicate keys and cannot +// enforce uniqueness. +// +// Schema-1 exact key set: `schema` and `target` only. +// Duplicate keys are rejected after JSON string-escape decoding, so +// "target" and "targ\u0065t" are the same key. +// ============================================================================ + +enum NativeConfigParser { + enum Outcome: Equatable { + case ok(target: String) + case failed(String) + } + + static func parse(_ data: Data) -> Outcome { + guard let text = String(data: data, encoding: .utf8) else { + return .failed("native config is not valid UTF-8") + } + var i = text.startIndex + skipWS(text, &i) + guard i < text.endIndex, text[i] == "{" else { + return .failed("native config is not a JSON object") + } + text.formIndex(after: &i) + skipWS(text, &i) + + var seen = Set() + var schema: Int? + var target: String? + + if i < text.endIndex, text[i] == "}" { + text.formIndex(after: &i) + return finish(text, i, schema: schema, target: target) + } + + var expectPair = true + while expectPair { + skipWS(text, &i) + guard let key = parseString(text, &i) else { + return .failed("native config has a malformed key") + } + if seen.contains(key) { + return .failed("native config has a duplicate key") + } + seen.insert(key) + skipWS(text, &i) + guard i < text.endIndex, text[i] == ":" else { + return .failed("native config is malformed") + } + text.formIndex(after: &i) + skipWS(text, &i) + + switch key { + case "schema": + guard let n = parseInteger(text, &i) else { + return .failed("native config schema has the wrong field type") + } + schema = n + case "target": + guard let s = parseString(text, &i) else { + return .failed("native config target has the wrong field type") + } + target = s + default: + return .failed("native config has an unknown field") + } + + skipWS(text, &i) + if i < text.endIndex, text[i] == "," { + text.formIndex(after: &i) + expectPair = true + continue + } + expectPair = false + } + + skipWS(text, &i) + guard i < text.endIndex, text[i] == "}" else { + return .failed("native config is malformed") + } + text.formIndex(after: &i) + return finish(text, i, schema: schema, target: target) + } + + private static func finish(_ text: String, _ i: String.Index, schema: Int?, target: String?) -> Outcome { + var j = i + skipWS(text, &j) + if j != text.endIndex { + return .failed("native config has trailing non-whitespace") + } + guard let schema else { return .failed("native config missing schema") } + guard schema == 1 else { return .failed("native config unknown schema") } + guard let target else { return .failed("native config missing target") } + if target.isEmpty { return .failed("native config target is empty") } + return .ok(target: target) + } + + private static func skipWS(_ text: String, _ i: inout String.Index) { + while i < text.endIndex { + switch text[i] { + case " ", "\t", "\n", "\r": text.formIndex(after: &i) + default: return + } + } + } + + private static func parseString(_ text: String, _ i: inout String.Index) -> String? { + guard i < text.endIndex, text[i] == "\"" else { return nil } + text.formIndex(after: &i) + var out = "" + while i < text.endIndex { + let c = text[i] + if c == "\"" { + text.formIndex(after: &i) + return out + } + if c == "\\" { + text.formIndex(after: &i) + guard i < text.endIndex else { return nil } + let e = text[i] + text.formIndex(after: &i) + switch e { + case "\"", "\\", "/": out.append(e) + case "b": out.append("\u{0008}") + case "f": out.append("\u{000C}") + case "n": out.append("\n") + case "r": out.append("\r") + case "t": out.append("\t") + case "u": + guard let scalar = parseUnicodeEscape(text, &i) else { return nil } + out.unicodeScalars.append(scalar) + default: + return nil + } + continue + } + if let v = c.asciiValue, v < 0x20 { return nil } + out.append(c) + text.formIndex(after: &i) + } + return nil + } + + private static func parseUnicodeEscape(_ text: String, _ i: inout String.Index) -> Unicode.Scalar? { + guard let unit = parseHex4(text, &i) else { return nil } + if (0xD800...0xDBFF).contains(unit) { + guard i < text.endIndex, text[i] == "\\" else { return nil } + text.formIndex(after: &i) + guard i < text.endIndex, text[i] == "u" else { return nil } + text.formIndex(after: &i) + guard let low = parseHex4(text, &i), (0xDC00...0xDFFF).contains(low) else { return nil } + let combined = 0x10000 + (Int(unit) - 0xD800) * 0x400 + (Int(low) - 0xDC00) + return Unicode.Scalar(combined) + } + if (0xDC00...0xDFFF).contains(unit) { return nil } + return Unicode.Scalar(unit) + } + + private static func parseHex4(_ text: String, _ i: inout String.Index) -> UInt32? { + var n: UInt32 = 0 + for _ in 0..<4 { + guard i < text.endIndex, let v = text[i].hexDigitValue else { return nil } + n = (n << 4) + UInt32(v) + text.formIndex(after: &i) + } + return n + } + + /// JSON integer token only (no fraction/exponent). Schema must be an integer. + private static func parseInteger(_ text: String, _ i: inout String.Index) -> Int? { + guard i < text.endIndex else { return nil } + let start = i + if text[i] == "-" { text.formIndex(after: &i) } + guard i < text.endIndex, text[i].isASCII && text[i].isNumber else { + i = start + return nil + } + if text[i] == "0" { + text.formIndex(after: &i) + } else { + while i < text.endIndex, text[i].isASCII && text[i].isNumber { + text.formIndex(after: &i) + } + } + if i < text.endIndex, text[i] == "." || text[i] == "e" || text[i] == "E" { + i = start + return nil + } + return Int(text[start.. URL { + if let a = env["DESKTIDY_APP_DIR"], !a.isEmpty { + return URL(fileURLWithPath: (a as NSString).expandingTildeInPath, isDirectory: true) + } + return home.appendingPathComponent("Library/Application Support/DeskTidy", isDirectory: true) + } + + static func receiptsDirectory( + env: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + appDirectory(env: env, home: home).appendingPathComponent("receipts", isDirectory: true) + } + + static func ledgerURL( + env: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + receiptsDirectory(env: env, home: home).appendingPathComponent("ledger.jsonl") + } + + static func nativeConfigURL( + env: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + appDirectory(env: env, home: home).appendingPathComponent("config.json") + } + + static func agentsDirectory( + env: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + if let d = env["DESKTIDY_AGENTS_DIR"], !d.isEmpty { + return URL(fileURLWithPath: (d as NSString).expandingTildeInPath, isDirectory: true) + } + return home.appendingPathComponent("Library/LaunchAgents", isDirectory: true) + } + + static func sortPlistURL( + env: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + agentsDirectory(env: env, home: home).appendingPathComponent("\(ProductIdentity.sortLabel).plist") + } +} diff --git a/src/ProductIdentity.swift b/src/ProductIdentity.swift new file mode 100644 index 0000000..ea28e6b --- /dev/null +++ b/src/ProductIdentity.swift @@ -0,0 +1,22 @@ +import Foundation + +// ============================================================================ +// Product identity catalog. +// +// Phase 0 accepts exactly the existing self-label set. A future SMAppService +// label is not self. See docs/R1B_SERVICE_IDENTITY_PROPOSAL.md. +// ============================================================================ + +enum ProductIdentity { + static let sortLabel = "com.desktidy.sort" + static let notifyLabel = "com.desktidy.notify" + static let selfLabels: Set = [sortLabel, notifyLabel] + static let expectedProgramBasenames: Set = ["desktidy-sort", "desktidy-notify", "DeskTidy"] + + static func isSelf(label: String, programPath: String?, programExists: Bool) -> Bool { + guard selfLabels.contains(label) else { return false } + guard programExists, let programPath, !programPath.isEmpty else { return true } + let base = URL(fileURLWithPath: programPath).lastPathComponent + return expectedProgramBasenames.contains(base) + } +} diff --git a/src/R1ATests.swift b/src/R1ATests.swift index f39c637..db114e2 100644 --- a/src/R1ATests.swift +++ b/src/R1ATests.swift @@ -155,10 +155,528 @@ final class R1ATests { check("R02", "state computation writes no ledger", !fm.fileExists(atPath: ledgerFile.path)) } + runTargetResolutionGates() + runDuplicateKeyGates() + runPathParityGates() + runLaunchdFixtureGates() + runIdentityGates() + print("R1A GATES: \(passCount) passed, \(failCount) failed") return failCount == 0 } + private func writeNativeConfig(_ app: URL, object: [String: Any]) { + let data = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + try! data.write(to: app.appendingPathComponent("config.json")) + } + + private func applyFixtureEnv(_ f: Fixture, omitTargetEnv: Bool = false) -> URL { + if omitTargetEnv { unsetenv("DESKTIDY_TARGET_DIR") } + else { setenv("DESKTIDY_TARGET_DIR", f.target.path, 1) } + setenv("DESKTIDY_AGENTS_DIR", f.agents.path, 1) + setenv("DESKTIDY_APP_DIR", f.app.path, 1) + let stateFile = f.agents.appendingPathComponent("launchd-state.json") + let enc = try! JSONSerialization.data(withJSONObject: f.states) + try! enc.write(to: stateFile) + setenv("DESKTIDY_LAUNCHD_STATE_FILE", stateFile.path, 1) + return stateFile + } + + private func clearFixtureEnv() { + unsetenv("DESKTIDY_AGENTS_DIR"); unsetenv("DESKTIDY_TARGET_DIR") + unsetenv("DESKTIDY_APP_DIR"); unsetenv("DESKTIDY_LAUNCHD_STATE_FILE") + } + + private func runTargetResolutionGates() { + func world(_ name: String) -> Fixture { + Fixture(name: name, expected: .ambiguous, + agents: tempDir("agents"), states: [:], + target: tempDir("target"), app: tempDir("app")) + } + + // T01: malformed native config refuses instead of env/default fallback. + do { + let f = world("malformed-native-config") + try? Data("not-json{".utf8).write(to: f.app.appendingPathComponent("config.json")) + let report = modelState(f) + check("T01", "malformed native config refuses instead of env/default fallback", + report.overall == .ambiguous && report.targetResolution == "invalid", + "got \(report.overall.rawValue) res=\(report.targetResolution): \(report.overallReason)") + } + + // T02: no config/plist/env → default Desktop path (fixture agents, no live probe). + do { + let f = world("default") + _ = applyFixtureEnv(f, omitTargetEnv: true) + defer { clearFixtureEnv() } + let report = EffectiveState.compute() + let expected = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(Config.targetDirName).path + check("T02", "no config/plist/env → default Desktop", + report.targetSource == TargetSource.defaultDesktop.rawValue && report.watchedTarget == expected, + "source=\(report.targetSource) target=\(report.watchedTarget)") + } + + // T03: env only. + do { + let f = world("env-only") + let report = modelState(f) + check("T03", "env only → environment source", + report.targetSource == TargetSource.environment.rawValue && report.watchedTarget == f.target.path, + "source=\(report.targetSource) target=\(report.watchedTarget)") + } + + // T04: plist overrides env. + do { + let f = world("plist-over-env") + let plistTarget = tempDir("plist-target") + writePlist(f.agents, label: "com.desktidy.sort", watch: [plistTarget.path], + program: makeProgram("desktidy-sort"), targetEnv: plistTarget.path) + let report = modelState(f) // env still points at f.target + check("T04", "plist overrides env", + report.targetSource == TargetSource.installedPlist.rawValue && report.watchedTarget == plistTarget.path, + "source=\(report.targetSource) target=\(report.watchedTarget)") + } + + // T05: valid native config overrides plist and env. + do { + let f = world("config-over-plist") + let plistTarget = tempDir("plist-target") + let configTarget = tempDir("config-target") + writePlist(f.agents, label: "com.desktidy.sort", watch: [plistTarget.path], + program: makeProgram("desktidy-sort"), targetEnv: plistTarget.path) + writeNativeConfig(f.app, object: ["schema": 1, "target": configTarget.path]) + let report = modelState(f) + check("T05", "valid native config overrides plist", + report.targetSource == TargetSource.nativeConfig.rawValue && report.watchedTarget == configTarget.path, + "source=\(report.targetSource) target=\(report.watchedTarget)") + } + + // T06: empty / wrong-type native target refuses. + do { + let f = world("empty-native-target") + writeNativeConfig(f.app, object: ["schema": 1, "target": ""]) + let empty = modelState(f) + writeNativeConfig(f.app, object: ["schema": 1, "target": 12]) + let wrong = modelState(f) + check("T06", "empty/wrong-type native target refuses", + empty.overall == .ambiguous && wrong.overall == .ambiguous + && empty.targetResolution == "invalid" && wrong.targetResolution == "invalid", + "empty=\(empty.overall.rawValue) wrong=\(wrong.overall.rawValue)") + } + + // T07: unreadable selected config refuses (no env fallback). + do { + let f = world("unreadable-config") + let cfg = f.app.appendingPathComponent("config.json") + try? Data("{\"schema\":1,\"target\":\"/tmp\"}".utf8).write(to: cfg) + try? fm.setAttributes([.posixPermissions: 0o000], ofItemAtPath: cfg.path) + let report = modelState(f) + try? fm.setAttributes([.posixPermissions: 0o644], ofItemAtPath: cfg.path) + check("T07", "unreadable native config refuses", + report.overall == .ambiguous && report.targetResolution == "invalid", + "got \(report.overall.rawValue): \(report.overallReason)") + } + + // T08: missing/non-directory target cannot be healthy. + do { + let f = world("missing-resolved-target") + try? fm.removeItem(at: f.target) + let report = modelState(f) + check("T08", "missing target cannot be runningHealthy", + report.overall == .ambiguous && report.overall != .runningHealthy, + "got \(report.overall.rawValue)") + } + + // T09: engine target equals EffectiveState target for every valid source. + do { + var same = true + var detail = "" + // env + let envF = world("parity-env") + _ = applyFixtureEnv(envF) + let envReport = EffectiveState.compute() + let envEngine = DeskTidy() + if envEngine.target.path != envReport.watchedTarget { same = false; detail += " env" } + // plist + let plistF = world("parity-plist") + let plistTarget = tempDir("parity-plist-target") + writePlist(plistF.agents, label: "com.desktidy.sort", watch: [plistTarget.path], + program: makeProgram("desktidy-sort"), targetEnv: plistTarget.path) + _ = applyFixtureEnv(plistF) + let plistReport = EffectiveState.compute() + let plistEngine = DeskTidy() + if plistEngine.target.path != plistReport.watchedTarget { same = false; detail += " plist" } + // native config + let cfgF = world("parity-config") + let cfgTarget = tempDir("parity-cfg-target") + writeNativeConfig(cfgF.app, object: ["schema": 1, "target": cfgTarget.path]) + _ = applyFixtureEnv(cfgF) + let cfgReport = EffectiveState.compute() + let cfgEngine = DeskTidy() + if cfgEngine.target.path != cfgReport.watchedTarget { same = false; detail += " config" } + clearFixtureEnv() + check("T09", "engine target equals EffectiveState target for valid sources", + same && envReport.targetSource == "environment" + && plistReport.targetSource == "installedPlist" + && cfgReport.targetSource == "nativeConfig", + "mismatch=\(detail) sources=\(envReport.targetSource),\(plistReport.targetSource),\(cfgReport.targetSource)") + } + + // T10: engine refuses every ambiguous target fixture (exit 3, no move). + do { + let f = world("engine-refuse") + try? Data("not-json{".utf8).write(to: f.app.appendingPathComponent("config.json")) + let witness = f.target.appendingPathComponent("stay.pdf") + fm.createFile(atPath: witness.path, contents: Data("x".utf8)) + try? fm.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -3600)], ofItemAtPath: witness.path) + let stateFile = applyFixtureEnv(f) + let p = Process() + p.executableURL = URL(fileURLWithPath: binaryPath) + p.arguments = [] + var env = ProcessInfo.processInfo.environment + env["DESKTIDY_AGENTS_DIR"] = f.agents.path + env["DESKTIDY_TARGET_DIR"] = f.target.path + env["DESKTIDY_APP_DIR"] = f.app.path + env["DESKTIDY_LAUNCHD_STATE_FILE"] = stateFile.path + p.environment = env + p.standardOutput = Pipe(); p.standardError = Pipe() + try? p.run(); p.waitUntilExit() + clearFixtureEnv() + let stayed = fm.fileExists(atPath: witness.path) + check("T10", "engine refuses ambiguous target (exit 3, no move)", + p.terminationStatus == 3 && stayed, "exit=\(p.terminationStatus) stayed=\(stayed)") + } + + // T11: malformed installed sort plist refuses rather than env fallback. + do { + let f = world("malformed-plist") + try? Data("not a plist".utf8).write(to: f.agents.appendingPathComponent("com.desktidy.sort.plist")) + let report = modelState(f) + check("T11", "malformed sort plist refuses instead of env fallback", + report.overall == .ambiguous && report.targetSource == TargetSource.installedPlist.rawValue, + "got \(report.overall.rawValue) source=\(report.targetSource): \(report.overallReason)") + } + } + + /// Public binary --effective-state --json under an isolated fixture world. + private func publicState(_ f: Fixture, configBytes: Data) -> EffectiveStateReport? { + try! configBytes.write(to: f.app.appendingPathComponent("config.json")) + let stateFile = applyFixtureEnv(f) + defer { clearFixtureEnv() } + let p = Process() + p.executableURL = URL(fileURLWithPath: binaryPath) + p.arguments = ["--effective-state", "--json"] + var env = ProcessInfo.processInfo.environment + env["DESKTIDY_AGENTS_DIR"] = f.agents.path + env["DESKTIDY_TARGET_DIR"] = f.target.path + env["DESKTIDY_APP_DIR"] = f.app.path + env["DESKTIDY_LAUNCHD_STATE_FILE"] = stateFile.path + p.environment = env + let out = Pipe(); p.standardOutput = out; p.standardError = Pipe() + do { try p.run(); p.waitUntilExit() } catch { return nil } + let data = out.fileHandleForReading.readDataToEndOfFile() + return try? JSONDecoder().decode(EffectiveStateReport.self, from: data) + } + + private func runDuplicateKeyGates() { + func world(_ name: String) -> Fixture { + Fixture(name: name, expected: .ambiguous, + agents: tempDir("agents"), states: [:], + target: tempDir("target"), app: tempDir("app")) + } + + let a = tempDir("target-a") + let b = tempDir("target-b") + + // D01: duplicate target keys, different values — public file/parser boundary. + do { + let f = world("dupe-target-diff") + let raw = Data("{\"schema\":1,\"target\":\"\(a.path)\",\"target\":\"\(b.path)\"}".utf8) + let report = publicState(f, configBytes: raw) + check("D01", "duplicate target keys (different values) → invalid", + report?.overall == .ambiguous && report?.targetResolution == "invalid" + && report?.targetSource == TargetSource.nativeConfig.rawValue, + "got \(report?.overall.rawValue ?? "nil") res=\(report?.targetResolution ?? "nil") src=\(report?.targetSource ?? "nil") target=\(report?.watchedTarget ?? "nil")") + } + + // D02: duplicate target keys, identical values still fail closed. + do { + let f = world("dupe-target-same") + let raw = Data("{\"schema\":1,\"target\":\"\(a.path)\",\"target\":\"\(a.path)\"}".utf8) + let report = publicState(f, configBytes: raw) + check("D02", "duplicate target keys (identical values) → invalid", + report?.overall == .ambiguous && report?.targetResolution == "invalid", + "got \(report?.overall.rawValue ?? "nil") res=\(report?.targetResolution ?? "nil")") + } + + // D03: duplicate schema keys. + do { + let f = world("dupe-schema") + let raw = Data("{\"schema\":1,\"schema\":1,\"target\":\"\(a.path)\"}".utf8) + let report = publicState(f, configBytes: raw) + check("D03", "duplicate schema keys → invalid", + report?.overall == .ambiguous && report?.targetResolution == "invalid", + "got \(report?.overall.rawValue ?? "nil")") + } + + // D04: escaped-equivalent duplicate key target / targ\u0065t. + do { + let f = world("dupe-escaped") + let raw = Data("{\"schema\":1,\"target\":\"\(a.path)\",\"targ\\u0065t\":\"\(b.path)\"}".utf8) + let report = publicState(f, configBytes: raw) + check("D04", "escaped-equivalent duplicate target key → invalid", + report?.overall == .ambiguous && report?.targetResolution == "invalid", + "got \(report?.overall.rawValue ?? "nil") target=\(report?.watchedTarget ?? "nil")") + } + + // D05: leading/trailing whitespace around a valid object is accepted. + do { + let f = world("ws-valid") + let raw = Data(" \n{\"schema\":1,\"target\":\"\(a.path)\"}\n ".utf8) + let report = publicState(f, configBytes: raw) + check("D05", "leading/trailing whitespace valid baseline", + report?.targetResolution == "resolved" && report?.targetSource == "nativeConfig" + && report?.watchedTarget == a.path, + "got \(report?.targetResolution ?? "nil") target=\(report?.watchedTarget ?? "nil")") + } + + // D06: trailing non-whitespace after the object is rejected. + do { + let f = world("trailing-junk") + let raw = Data("{\"schema\":1,\"target\":\"\(a.path)\"} true".utf8) + let report = publicState(f, configBytes: raw) + check("D06", "trailing non-whitespace rejected", + report?.overall == .ambiguous && report?.targetResolution == "invalid", + "got \(report?.overall.rawValue ?? "nil") res=\(report?.targetResolution ?? "nil")") + } + + // D07: valid schema-1 baseline still resolves. + do { + let f = world("schema1-ok") + let raw = Data("{\"schema\":1,\"target\":\"\(a.path)\"}".utf8) + let report = publicState(f, configBytes: raw) + check("D07", "valid schema-1 baseline still resolves", + report?.targetResolution == "resolved" && report?.targetSource == "nativeConfig" + && report?.watchedTarget == a.path, + "got \(report?.targetResolution ?? "nil") target=\(report?.watchedTarget ?? "nil")") + } + + // D08: engine no-move witness for duplicate-key config. + do { + let f = world("engine-dupe") + let raw = Data("{\"schema\":1,\"target\":\"\(a.path)\",\"target\":\"\(b.path)\"}".utf8) + try! raw.write(to: f.app.appendingPathComponent("config.json")) + let witness = f.target.appendingPathComponent("stay.pdf") + fm.createFile(atPath: witness.path, contents: Data("x".utf8)) + try? fm.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -3600)], ofItemAtPath: witness.path) + let stateFile = applyFixtureEnv(f) + let p = Process() + p.executableURL = URL(fileURLWithPath: binaryPath) + p.arguments = [] + var env = ProcessInfo.processInfo.environment + env["DESKTIDY_AGENTS_DIR"] = f.agents.path + env["DESKTIDY_TARGET_DIR"] = f.target.path + env["DESKTIDY_APP_DIR"] = f.app.path + env["DESKTIDY_LAUNCHD_STATE_FILE"] = stateFile.path + p.environment = env + p.standardOutput = Pipe(); p.standardError = Pipe() + try? p.run(); p.waitUntilExit() + clearFixtureEnv() + let stayed = fm.fileExists(atPath: witness.path) + check("D08", "engine refuses duplicate-key config (exit 3, no move)", + p.terminationStatus == 3 && stayed, + "exit=\(p.terminationStatus) stayed=\(stayed)") + } + } + + private func runPathParityGates() { + let f = Fixture(name: "path-parity", expected: .pausedNotLoaded, + agents: tempDir("agents"), states: [:], + target: tempDir("target"), app: tempDir("app")) + _ = applyFixtureEnv(f) + defer { clearFixtureEnv() } + let engine = DeskTidy() + let report = EffectiveState.compute() + let pathsApp = DeskTidyPaths.appDirectory().path + let pathsLedger = DeskTidyPaths.ledgerURL().path + let pathsCfg = DeskTidyPaths.nativeConfigURL().path + let pathsReceipts = DeskTidyPaths.receiptsDirectory().path + check("K01", "engine appDirectory equals DeskTidyPaths", + engine.appDirectory.path == pathsApp, "engine=\(engine.appDirectory.path) paths=\(pathsApp)") + check("K02", "EffectiveState appDirectory/ledgerPath equal DeskTidyPaths", + report.appDirectory == pathsApp && report.ledgerPath == pathsLedger, + "report.app=\(report.appDirectory) report.ledger=\(report.ledgerPath)") + check("K03", "receipts/config/ledger share one app-support root", + pathsLedger.hasPrefix(pathsReceipts) && pathsCfg.hasPrefix(pathsApp) + && pathsCfg.hasSuffix("/config.json") && pathsLedger.hasSuffix("/ledger.jsonl"), + "cfg=\(pathsCfg) ledger=\(pathsLedger)") + } + + private func runLaunchdFixtureGates() { + func world() -> Fixture { + Fixture(name: "launchd-fixture", expected: .ambiguous, + agents: tempDir("agents"), states: [:], + target: tempDir("target"), app: tempDir("app")) + } + + // L01: requested fixture path is absent → explicit ambiguous, not live/notLoaded. + do { + let f = world() + setenv("DESKTIDY_AGENTS_DIR", f.agents.path, 1) + setenv("DESKTIDY_TARGET_DIR", f.target.path, 1) + setenv("DESKTIDY_APP_DIR", f.app.path, 1) + setenv("DESKTIDY_LAUNCHD_STATE_FILE", f.agents.appendingPathComponent("missing-state.json").path, 1) + defer { clearFixtureEnv() } + let report = EffectiveState.compute() + check("L01", "absent launchd fixture file → ambiguous", + report.overall == .ambiguous, + "got \(report.overall.rawValue): \(report.overallReason)") + } + + // L02: malformed JSON fixture → ambiguous (public compute/binary path). + do { + let f = world() + let bad = f.agents.appendingPathComponent("state.json") + try? Data("[1,2,3]".utf8).write(to: bad) + setenv("DESKTIDY_AGENTS_DIR", f.agents.path, 1) + setenv("DESKTIDY_TARGET_DIR", f.target.path, 1) + setenv("DESKTIDY_APP_DIR", f.app.path, 1) + setenv("DESKTIDY_LAUNCHD_STATE_FILE", bad.path, 1) + defer { clearFixtureEnv() } + let report = EffectiveState.compute() + check("L02", "malformed launchd fixture JSON → ambiguous", + report.overall == .ambiguous, + "got \(report.overall.rawValue): \(report.overallReason)") + } + + // L03: invalid state value → ambiguous, never notLoaded. + do { + let f = world() + let bad = f.agents.appendingPathComponent("state.json") + try? Data(#"{"com.desktidy.sort":"exploded"}"#.utf8).write(to: bad) + setenv("DESKTIDY_AGENTS_DIR", f.agents.path, 1) + setenv("DESKTIDY_TARGET_DIR", f.target.path, 1) + setenv("DESKTIDY_APP_DIR", f.app.path, 1) + setenv("DESKTIDY_LAUNCHD_STATE_FILE", bad.path, 1) + defer { clearFixtureEnv() } + let report = EffectiveState.compute() + check("L03", "invalid launchd fixture state value → ambiguous", + report.overall == .ambiguous, + "got \(report.overall.rawValue) agent=\(report.productAgentState): \(report.overallReason)") + } + + // L04: valid fixture still hermetic (paused). + do { + let f = world() + let report = modelState(f) + check("L04", "valid empty launchd fixture remains pausedNotLoaded", + report.overall == .pausedNotLoaded, + "got \(report.overall.rawValue)") + } + + // L05: public binary --effective-state --json on malformed fixture. + do { + let f = world() + let bad = f.agents.appendingPathComponent("state.json") + try? Data("not-json".utf8).write(to: bad) + let p = Process() + p.executableURL = URL(fileURLWithPath: binaryPath) + p.arguments = ["--effective-state", "--json"] + var env = ProcessInfo.processInfo.environment + env["DESKTIDY_AGENTS_DIR"] = f.agents.path + env["DESKTIDY_TARGET_DIR"] = f.target.path + env["DESKTIDY_APP_DIR"] = f.app.path + env["DESKTIDY_LAUNCHD_STATE_FILE"] = bad.path + p.environment = env + let out = Pipe(); p.standardOutput = out; p.standardError = Pipe() + try? p.run(); p.waitUntilExit() + let data = out.fileHandleForReading.readDataToEndOfFile() + let report = try? JSONDecoder().decode(EffectiveStateReport.self, from: data) + check("L05", "public binary reports ambiguous for malformed launchd fixture", + p.terminationStatus == 0 && report?.overall == .ambiguous, + "exit=\(p.terminationStatus) overall=\(report?.overall.rawValue ?? "nil")") + } + } + + private func runIdentityGates() { + func world() -> Fixture { + Fixture(name: "identity", expected: .pausedNotLoaded, + agents: tempDir("agents"), states: [:], + target: tempDir("target"), app: tempDir("app")) + } + + // I01: expected sort label with expected program is self; no conflict. + do { + let f = world() + writePlist(f.agents, label: ProductIdentity.sortLabel, watch: [f.target.path], + program: makeProgram("desktidy-sort"), targetEnv: f.target.path) + var ff = f + ff = Fixture(name: f.name, expected: .runningHealthy, agents: f.agents, + states: [ProductIdentity.sortLabel: "running"], target: f.target, app: f.app) + let report = modelState(ff) + check("I01", "expected sort identity remains self/runningHealthy", + report.overall == .runningHealthy && report.effectiveMoverLabel == ProductIdentity.sortLabel, + "got \(report.overall.rawValue) mover=\(report.effectiveMoverLabel ?? "nil")") + } + + // I02: expected notify label is self (not foreign) even if sort is absent. + do { + let f = world() + writePlist(f.agents, label: ProductIdentity.notifyLabel, watch: [f.target.path], + program: makeProgram("desktidy-notify")) + var ff = f + ff = Fixture(name: f.name, expected: .pausedNotLoaded, agents: f.agents, + states: [ProductIdentity.notifyLabel: "running"], target: f.target, app: f.app) + let report = modelState(ff) + check("I02", "expected notify identity is not a foreign conflict", + report.overall == .pausedNotLoaded && report.foreignMovers.isEmpty, + "got \(report.overall.rawValue) foreign=\(report.foreignMovers)") + } + + // I03: foreign label remains foreign. + do { + let f = world() + writePlist(f.agents, label: "com.example.other-mover", watch: [f.target.path], + program: makeProgram("other")) + var ff = f + ff = Fixture(name: f.name, expected: .foreignConflict, agents: f.agents, + states: ["com.example.other-mover": "running"], target: f.target, app: f.app) + let report = modelState(ff) + check("I03", "foreign label remains foreign", + report.overall == .foreignConflict && report.foreignMovers.contains("com.example.other-mover"), + "got \(report.overall.rawValue)") + } + + // I04: stolen self-label with contradictory executable fails closed. + do { + let f = world() + writePlist(f.agents, label: ProductIdentity.sortLabel, watch: [f.target.path], + program: makeProgram("not-desktidy"), targetEnv: f.target.path) + var ff = f + ff = Fixture(name: f.name, expected: .foreignConflict, agents: f.agents, + states: [ProductIdentity.sortLabel: "running"], target: f.target, app: f.app) + let report = modelState(ff) + check("I04", "self label + contradictory program fails closed", + report.overall == .foreignConflict || report.overall == .ambiguous, + "got \(report.overall.rawValue): \(report.overallReason)") + } + + // I05: future/unloaded app-agent label is not silently trusted as self. + do { + let f = world() + writePlist(f.agents, label: "com.desktidy.app.sort", watch: [f.target.path], + program: makeProgram("DeskTidy")) + var ff = f + ff = Fixture(name: f.name, expected: .foreignConflict, agents: f.agents, + states: ["com.desktidy.app.sort": "running"], target: f.target, app: f.app) + let report = modelState(ff) + check("I05", "future SMAppService label is not accepted as self", + report.overall == .foreignConflict && report.foreignMovers.contains("com.desktidy.app.sort"), + "got \(report.overall.rawValue) foreign=\(report.foreignMovers)") + } + } + // ------------------------------------------------------------ fixtures private func buildFixtures() -> [Fixture] { var out: [Fixture] = [] diff --git a/src/TargetResolver.swift b/src/TargetResolver.swift new file mode 100644 index 0000000..771ce7a --- /dev/null +++ b/src/TargetResolver.swift @@ -0,0 +1,120 @@ +import Foundation + +// ============================================================================ +// One target-resolution model for the movement engine and EffectiveState. +// +// Precedence (first selected source wins; invalid selected source does not +// fall through): +// 1. native config ~/Library/Application Support/DeskTidy/config.json +// (or $DESKTIDY_APP_DIR/config.json) — schema 1, field `target` +// 2. installed com.desktidy.sort plist EnvironmentVariables.DESKTIDY_TARGET_DIR +// 3. process DESKTIDY_TARGET_DIR +// 4. ~/Desktop +// +// Phase 0 implements the reader only. Nothing here writes a native config. +// ============================================================================ + +enum TargetSource: String, Codable { + case nativeConfig + case installedPlist + case environment + case defaultDesktop +} + +enum TargetResolution: Equatable { + case resolved(path: String, source: TargetSource, exists: Bool) + case invalid(reason: String, source: TargetSource, attemptedPath: String?) +} + +enum TargetResolver { + static let nativeSchema = 1 + + static func resolve( + env: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser, + fm: FileManager = .default + ) -> TargetResolution { + let configURL = DeskTidyPaths.nativeConfigURL(env: env, home: home) + if fm.fileExists(atPath: configURL.path) { + return finish(readNativeConfig(configURL, fm: fm), source: .nativeConfig, fm: fm) + } + + let plistURL = DeskTidyPaths.sortPlistURL(env: env, home: home) + if fm.fileExists(atPath: plistURL.path) { + switch readPlistTarget(plistURL, fm: fm) { + case .value(let path): + return finish(.ok(path), source: .installedPlist, fm: fm) + case .noTargetKey: + break + case .failed(let reason): + return .invalid(reason: reason, source: .installedPlist, attemptedPath: nil) + } + } + + if let raw = env["DESKTIDY_TARGET_DIR"] { + if raw.isEmpty { + return .invalid(reason: "empty DESKTIDY_TARGET_DIR", source: .environment, attemptedPath: nil) + } + return finish(.ok(raw), source: .environment, fm: fm) + } + + let fallback = home.appendingPathComponent(Config.targetDirName).path + return finish(.ok(fallback), source: .defaultDesktop, fm: fm) + } + + static func standardize(_ path: String) -> String { + (path as NSString).expandingTildeInPath + } + + // -- internals ----------------------------------------------------------- + private enum Parsed { + case ok(String) + case failed(String, String?) + } + + private enum PlistRead { + case value(String) + case noTargetKey + case failed(String) + } + + private static func finish(_ parsed: Parsed, source: TargetSource, fm: FileManager) -> TargetResolution { + switch parsed { + case .ok(let raw): + let path = standardize(raw) + var isDir: ObjCBool = false + let exists = fm.fileExists(atPath: path, isDirectory: &isDir) && isDir.boolValue + return .resolved(path: path, source: source, exists: exists) + case .failed(let reason, let attempted): + return .invalid(reason: reason, source: source, attemptedPath: attempted) + } + } + + private static func readNativeConfig(_ url: URL, fm: FileManager) -> Parsed { + guard let data = fm.contents(atPath: url.path) else { + return .failed("native config exists but is unreadable", nil) + } + switch NativeConfigParser.parse(data) { + case .ok(let path): + return .ok(path) + case .failed(let reason): + return .failed(reason, nil) + } + } + + private static func readPlistTarget(_ url: URL, fm: FileManager) -> PlistRead { + guard let data = fm.contents(atPath: url.path) else { + return .failed("installed sort plist exists but is unreadable") + } + guard let obj = try? PropertyListSerialization.propertyList(from: data, format: nil), + let dict = obj as? [String: Any] else { + return .failed("installed sort plist is malformed") + } + guard let envDict = dict["EnvironmentVariables"] as? [String: String] else { + return .noTargetKey + } + guard let t = envDict["DESKTIDY_TARGET_DIR"] else { return .noTargetKey } + if t.isEmpty { return .failed("installed sort plist DESKTIDY_TARGET_DIR is empty") } + return .value(t) + } +} diff --git a/src/desktidy-cli.sh b/src/desktidy-cli.sh index e4990ad..9e746a5 100755 --- a/src/desktidy-cli.sh +++ b/src/desktidy-cli.sh @@ -133,38 +133,21 @@ cmd_teardown() { cmd_status() { need_components say "DeskTidy status" - "$SORT_BIN" --health | sed 's/^/ /' - echo - local target_now - target_now="$(/usr/libexec/PlistBuddy -c 'Print :EnvironmentVariables:DESKTIDY_TARGET_DIR' "$LA/com.desktidy.sort.plist" 2>/dev/null || echo "$HOME/Desktop")" - DESKTIDY_TARGET_DIR="$target_now" "$SORT_BIN" --authority-diagnose | sed 's/^/ /' + "$SORT_BIN" --effective-state --json | /usr/bin/python3 -c ' +import json, sys +r = json.load(sys.stdin) +print(" overall: %s" % r.get("overall")) +print(" reason: %s" % r.get("overallReason")) +print(" target: %s" % r.get("watchedTarget")) +print(" target_exists: %s" % r.get("targetExists")) +print(" target_source: %s" % r.get("targetSource")) +print(" product_agent: %s" % r.get("productAgentState")) +print(" effective_mover: %s" % (r.get("effectiveMoverLabel") or "unprovable")) +print(" ledger: %s" % r.get("ledger")) +' || die "effective-state failed" echo say "Recent movement receipts" "$SORT_BIN" --history 5 | sed 's/^/ /' - echo - local lbl st - for lbl in com.desktidy.sort com.desktidy.notify; do - if launchctl print "gui/$UID_NUM/$lbl" >/dev/null 2>&1; then - st="loaded" - else - st="NOT loaded (run: desktidy setup)" - fi - printf ' agent %-22s %s\n' "$lbl" "$st" - done - echo - local target - target="$(/usr/libexec/PlistBuddy -c 'Print :EnvironmentVariables:DESKTIDY_TARGET_DIR' "$LA/com.desktidy.sort.plist" 2>/dev/null || echo "$HOME/Desktop")" - if DESKTIDY_TARGET_DIR="$target" "$SORT_BIN" --check-access >/dev/null 2>&1; then - ok "Full Disk Access: granted (target: $target)" - else - warn "Full Disk Access: NOT granted — DeskTidy cannot sort until you allow it." - echo " System Settings → Privacy & Security → Full Disk Access → + → $SELF_DIR → desktidy-sort" - fi - echo - if [ -f "$APPDIR/desktidy.log" ]; then - say "Recent moves" - tail -5 "$APPDIR/desktidy.log" | sed 's/^/ /' - fi } cmd_sort_now() { need_components; exec "$SORT_BIN" --smart-now --verbose; } diff --git a/website/app/components/Faq.tsx b/website/app/components/Faq.tsx index 9ab76a6..f7d05bf 100644 --- a/website/app/components/Faq.tsx +++ b/website/app/components/Faq.tsx @@ -12,7 +12,7 @@ const QA: { q: string; a: React.ReactNode }[] = [ }, { q: "Does it upload my files anywhere?", - a: "No. There isn't a single network call in the source code — no telemetry, no analytics, no update checks. The code is ~700 lines of Swift; you can verify this yourself on GitHub.", + a: "No. There isn't a single network call in the source code — no telemetry, no analytics, no update checks. The implementation is a small, readable Swift tree on GitHub.", }, { q: "Why does it need Full Disk Access?", @@ -64,11 +64,11 @@ const QA: { q: string; a: React.ReactNode }[] = [ }, { q: "Is it open source?", - a: "Yes — MIT licensed, on GitHub. The sorter is ~700 lines of readable Swift with a public security policy and CI that tests the safety guarantees on every commit.", + a: "Yes — MIT licensed, on GitHub. The sorter is a small readable Swift tree with a public security policy and CI that tests the safety guarantees on every commit.", }, { q: "Is there a native menu-bar app?", - a: "Not yet — today DeskTidy is a command-line install with a background service. A native menu-bar app (pause/resume, activity feed, rules, undo) is planned; join early access above to hear when it ships.", + a: "An experimental read-only menu-bar status surface exists in source (R1A/R1B branches) and is not shipped, packaged, or included in the Homebrew v1.1.2 formula. Pause/resume, activity feed, and undo are still planned — join early access above to hear when a real app release ships.", }, ]; diff --git a/website/app/page.tsx b/website/app/page.tsx index 254e51e..4acfa1f 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -369,13 +369,15 @@ export default function Home() {

DeskTidy is MIT-licensed and public on GitHub — every guarantee on this page is a - claim you can check against ~700 lines of Swift. The command-line version you can - install today stays free. + claim you can check against the source. The command-line version you can + install today stays free. The Homebrew formula remains v1.1.2 and does not + include the experimental R1A/R1B app work.

- Next up: a native menu-bar app — pause and resume from the menu bar, a live activity - feed, one-click undo, and visual rules — as a one-time-purchase, no-subscription - product. It’s in development, not for sale yet. Want in when it’s ready? + A read-only experimental menu-bar status surface exists in source only — not + packaged, not in Homebrew. Next up for a real app release: pause and resume, + a live activity feed, one-click undo, and visual rules — as a one-time-purchase, + no-subscription product. Undo is planned, not shipped. Want in when it’s ready?