diff --git a/.agents/skills/implement-story/SKILL.md b/.agents/skills/implement-story/SKILL.md new file mode 100644 index 0000000..08f9ea2 --- /dev/null +++ b/.agents/skills/implement-story/SKILL.md @@ -0,0 +1,14 @@ +--- +name: implement-story +description: Implement an approved project story end to end and close its single Markdown record. +argument-hint: Story ID, for example 009 +--- + +# Implement Story + +1. Read `AGENTS.md`, `docs/stories/INDEX.md`, and the requested story. Continue only for `OPEN` or `IN_PROGRESS`. +2. Set the story and index to `IN_PROGRESS`. +3. Implement the smallest change that satisfies every acceptance criterion. Keep business logic in services or models and preserve existing changes. +4. After each edit slice, run the cheapest focused format, build, or test check available. +5. Update the story with completed criteria, outcome, tests, and validation. Set it and the index to `CLOSED` only when all criteria are met. +6. Do not commit or push unless explicitly requested; use the story's Conventional Commit type if a commit is requested. diff --git a/.agents/skills/new-story/SKILL.md b/.agents/skills/new-story/SKILL.md new file mode 100644 index 0000000..9d11791 --- /dev/null +++ b/.agents/skills/new-story/SKILL.md @@ -0,0 +1,14 @@ +--- +name: new-story +description: Create and register a concise project story for a feature, fix, refactor, test, documentation, or configuration change. +argument-hint: Describe the change +--- + +# New Story + +1. Read `AGENTS.md`, `docs/stories/INDEX.md`, and the relevant existing stories. +2. Confirm the request is not already covered by an `OPEN` or `IN_PROGRESS` story. +3. Choose the next numeric ID and a Conventional Commit type: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore`, or `ci`. +4. Copy `docs/stories/TEMPLATE.md` to `docs/stories/NNN-short-name.md` and fill it with status `OPEN`, intent, concise acceptance criteria, and validation expectations. +5. Add it to `docs/stories/INDEX.md` in descending numeric order. +6. Do not implement the story until it is approved. diff --git a/.agents/skills/review-codebase/SKILL.md b/.agents/skills/review-codebase/SKILL.md new file mode 100644 index 0000000..b9e6e2e --- /dev/null +++ b/.agents/skills/review-codebase/SKILL.md @@ -0,0 +1,13 @@ +--- +name: review-codebase +description: Perform a read-only codebase review for bugs, risks, spec drift, test gaps, and candidate project stories. +argument-hint: Optional review focus +--- + +# Review Codebase + +1. Inspect the current worktree and branch without modifying files or performing Git operations. +2. Read `AGENTS.md`, `docs/stories/INDEX.md`, and relevant stories. +3. Look for reproducible bugs, security or data risks, contract drift, CI gaps, and meaningful missing tests. +4. Report findings first, ordered by severity, with file and line links. Distinguish evidence from assumptions. +5. Suggest new stories only for work not already covered by an `OPEN` or `IN_PROGRESS` story. Do not create or implement them without approval. diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..402fb95 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,16 @@ +# Cursor rules + +Agent guidance for this project lives in a single, provider-neutral source of truth. + +Read AGENTS.md at the repository root before making changes. It covers architecture, +build/test/format commands, code style, testing policy, Conventional-Commit + model-attribution +rules, and the Spec-Driven Development workflow. + +Highlights: +- Conventional Commits are mandatory; PR titles must be valid Conventional Commits. +- Business logic belongs in Services/ and Models/, never in SwiftUI views. +- Use the Swift Testing framework; keep tests minimal and focused on real business logic. +- Agent commits must include a `Generated-by: ` trailer (e.g. Generated-by: claude-opus-4-8). + Do NOT add Co-Authored-By: — the human user is the author. +- Never include personal contact information (emails, phone numbers) in any file. +- Do not add third-party dependencies; the app is intentionally dependency-free. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..40362dc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json,rb}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..0ebe4b9 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,76 @@ +#!/bin/sh +# +# Mounty commit-msg hook: +# 1. Enforce Conventional Commits. +# 2. When Generated-by: is present, validate its format. +# 3. Reject Co-Authored-By: lines that reference AI tools — use Generated-by: instead. +# Install with: ./scripts/install-hooks.sh +# +set -eu + +msg_file="$1" + +# First non-comment, non-empty line = the subject. +subject=$(grep -vE '^\s*#' "$msg_file" | grep -vE '^\s*$' | head -n1 || true) + +# Allow merge/revert/fixup commits through untouched. +case "$subject" in + "Merge "*|"Revert "*|"fixup!"*|"squash!"*) exit 0 ;; +esac + +# ── 1. Conventional Commits ─────────────────────────────────────────────────── +conventional='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._/-]+\))?!?: .+' + +if ! printf '%s' "$subject" | grep -Eq "$conventional"; then + cat >&2 <<'EOF' +✖ commit-msg: subject is not a valid Conventional Commit. + + Expected: [(scope)][!]: + Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert + + Examples: + feat: add reconnect backoff + fix(mount): treat EEXIST as success + feat!: drop macOS 25 support + +See https://www.conventionalcommits.org/ +EOF + exit 1 +fi + +body=$(cat "$msg_file") + +# ── 2. Validate Generated-by: format when present ──────────────────────────── +if printf '%s' "$body" | grep -qiE '^Generated-by:'; then + # Must be exactly: Generated-by: + if ! printf '%s' "$body" | grep -qE '^Generated-by: [a-zA-Z0-9._+-]+$'; then + cat >&2 <<'EOF' +✖ commit-msg: malformed Generated-by: trailer. + + Expected: Generated-by: + Examples: Generated-by: claude-sonnet-4-6 + Generated-by: gpt-5 + Generated-by: gemini-2.5-pro + + The model id must be a single token on the same line, no extra text. +EOF + exit 1 + fi +fi + +# ── 3. Reject Co-Authored-By: from AI tools ────────────────────────────────── +if printf '%s' "$body" | grep -qiE '^Co-Authored-By:.*\b(Claude|GPT|Copilot|Gemini|Codex|Cursor|OpenAI|Anthropic)\b'; then + cat >&2 <<'EOF' +✖ commit-msg: AI Co-Authored-By: trailer found. Use Generated-by: instead. + + The human user is the commit author. Record the model as a tool, not a co-author: + + Generated-by: claude-sonnet-4-6 + + Remove the Co-Authored-By: line and add Generated-by: instead. + See CONTRIBUTING.md for details. +EOF + exit 1 +fi + +exit 0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..265aace --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,29 @@ +#!/bin/sh +# +# Mounty pre-commit hook: auto-format staged Swift files with swift-format. +# Install with: ./scripts/install-hooks.sh +# +set -eu + +# Resolve swift-format (prefer the Xcode toolchain version). +if command -v swift-format >/dev/null 2>&1; then + FORMAT="swift-format" +elif xcrun --find swift-format >/dev/null 2>&1; then + FORMAT="xcrun swift-format" +else + echo "pre-commit: swift-format not found; skipping formatting." >&2 + exit 0 +fi + +# Collect staged Swift files (added/copied/modified). +staged_swift=$(git diff --cached --name-only --diff-filter=ACM -- '*.swift') +[ -z "$staged_swift" ] && exit 0 + +echo "pre-commit: formatting staged Swift files with swift-format…" +printf '%s\n' "$staged_swift" | while IFS= read -r file; do + [ -f "$file" ] || continue + $FORMAT format -i "$file" + git add "$file" +done + +exit 0 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..9899076 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,34 @@ +name: Bug report +description: Report a problem with Mounty +labels: ["bug"] +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug and what you expected instead. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + placeholder: | + 1. Add a volume smb://... + 2. Enable automount + 3. Toggle VPN + validations: + required: true + - type: input + id: versions + attributes: + label: Mounty & macOS version + placeholder: "Mounty 1.1.0, macOS 26.1" + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs + description: "Console.app logs filtered by subsystem `ch.maptic.Mounty` (or `Mounty`)." + render: shell diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e372d33 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,21 @@ +name: Feature request +description: Suggest an idea for Mounty +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What problem would this feature solve? What are you trying to do? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4f9a786 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ + + +## What & why + + + +## Type of change + +- [ ] `fix` — bug fix (patch release) +- [ ] `feat` — new feature (minor release) +- [ ] breaking change (`!` / `BREAKING CHANGE:` — major release) +- [ ] `docs` / `chore` / `refactor` / `test` / `ci` (no release) + +## Checklist + +- [ ] PR title is a valid Conventional Commit +- [ ] Code is formatted (`swift-format`) and CI is green +- [ ] Business logic changes are covered by tests +- [ ] If AI-assisted, commits include a `Generated-by: ` trailer diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..861ab02 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,17 @@ +# GitHub Copilot instructions + +This project's agent guidance is maintained in a single, provider-neutral source of truth. + +👉 **Read [`AGENTS.md`](../AGENTS.md)** at the repository root. It covers the architecture, +build/test/format commands, code style, testing policy, Conventional-Commit + model-attribution +rules, and the Spec-Driven Development workflow. + +Key reminders for generated code and commits: + +- Follow **Conventional Commits**; PR titles must be valid Conventional Commits. +- Business logic goes in `Services/`/`Models/`, not in SwiftUI views. +- Use the **Swift Testing** framework for tests; keep them minimal and meaningful. +- Agent commits must include a `Generated-by: ` trailer (e.g. `Generated-by: gpt-5`). + Do **not** add `Co-Authored-By:` — the human user is the author. +- Never include personal contact information (emails, phone numbers) in any file. Use only the + GitHub advisory URL for security reporting. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..60aacd4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # Ensure the PR title is a valid Conventional Commit (it becomes the squash commit + # that drives release-please). + pr-title: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + format: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: swift-format lint + run: xcrun swift-format lint --strict --recursive Mounty MountyTests + + build-test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Show toolchain + run: xcodebuild -version && swift --version + - name: Build & test + run: | + xcodebuild \ + -project Mounty.xcodeproj \ + -scheme Mounty \ + -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGNING_ALLOWED=NO \ + SWIFT_TREAT_WARNINGS_AS_ERRORS=YES \ + GCC_TREAT_WARNINGS_AS_ERRORS=YES \ + clean test diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000..14c2c64 --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,140 @@ +name: Release Build + +# Fires when release-please's PR is merged and it publishes a GitHub Release. +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build-dmg: + runs-on: macos-latest + env: + TAG: ${{ github.event.release.tag_name }} + # 'true' only when Developer-ID signing secrets are configured. + HAS_SIGNING: ${{ secrets.MACOS_CERTIFICATE != '' }} + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Derive version + id: ver + run: echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Build (Release, unsigned) + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + xcodebuild \ + -project Mounty.xcodeproj \ + -scheme Mounty \ + -configuration Release \ + -derivedDataPath build \ + MARKETING_VERSION="$VERSION" \ + CURRENT_PROJECT_VERSION="${GITHUB_RUN_NUMBER}" \ + CODE_SIGNING_ALLOWED=NO \ + clean build + echo "APP=build/Build/Products/Release/Mounty.app" >> "$GITHUB_ENV" + + # -------- Signing -------- + # Notarized, Developer-ID signing runs ONLY if the signing secrets exist. + # Otherwise we ad-hoc sign (app still runs after the one-time Gatekeeper bypass + # documented in the README). + - name: Ad-hoc sign + if: env.HAS_SIGNING != 'true' + run: codesign --force --deep --sign - "$APP" + + - name: Developer-ID sign + notarize + if: env.HAS_SIGNING == 'true' + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} + AC_APPLE_ID: ${{ secrets.AC_APPLE_ID }} + AC_TEAM_ID: ${{ secrets.AC_TEAM_ID }} + AC_PASSWORD: ${{ secrets.AC_PASSWORD }} + run: | + # Import cert into a temporary keychain. + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + KEYCHAIN_PWD=$(openssl rand -base64 24) + security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$MACOS_CERTIFICATE_PWD" \ + -T /usr/bin/codesign + security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PWD" "$KEYCHAIN" + # Sign with hardened runtime. + codesign --force --deep --options runtime --timestamp \ + --sign "$MACOS_SIGN_IDENTITY" "$APP" + # Notarize the .app (zipped) then staple. + ditto -c -k --keepParent "$APP" "$RUNNER_TEMP/Mounty.zip" + xcrun notarytool submit "$RUNNER_TEMP/Mounty.zip" \ + --apple-id "$AC_APPLE_ID" --team-id "$AC_TEAM_ID" --password "$AC_PASSWORD" --wait + xcrun stapler staple "$APP" + + # -------- Package -------- + - name: Create DMG + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + STAGE="$RUNNER_TEMP/dmg" + rm -rf "$STAGE" && mkdir -p "$STAGE" + cp -R "$APP" "$STAGE/" + ln -s /Applications "$STAGE/Applications" + hdiutil create -volname "Mounty" -srcfolder "$STAGE" -ov -format UDZO \ + "Mounty-$VERSION.dmg" + shasum -a 256 "Mounty-$VERSION.dmg" | tee "Mounty-$VERSION.dmg.sha256" + + - name: Upload assets to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + gh release upload "$TAG" \ + "Mounty-$VERSION.dmg" "Mounty-$VERSION.dmg.sha256" --clobber + + # -------- Homebrew cask -------- + - name: Update Homebrew cask + env: + TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + if [ -z "${TAP_TOKEN:-}" ]; then + echo "HOMEBREW_TAP_TOKEN not set; skipping cask update." + exit 0 + fi + SHA=$(shasum -a 256 "Mounty-$VERSION.dmg" | awk '{print $1}') + git clone "https://x-access-token:${TAP_TOKEN}@github.com/maptic/homebrew-tap.git" tap + mkdir -p tap/Casks + cat > tap/Casks/mounty.rb <= :tahoe" + + app "Mounty.app" + + zap trash: "~/Library/Preferences/ch.maptic.Mounty.plist" + end + EOF + + cd tap + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Casks/mounty.rb + git commit -m "chore: update mounty to $VERSION" + git push diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..151077c --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,20 @@ +name: Release Please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + # Opens/updates a release PR based on Conventional Commits. Merging that PR + # creates the git tag + GitHub Release, which triggers release-build.yml. + - uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37252a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# macOS +.DS_Store + +# Xcode +build/ +DerivedData/ +*.xcuserstate +*.xcuserdatad +xcuserdata/ +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Xcode - shared data is kept; per-user data is ignored +Mounty.xcodeproj/project.xcworkspace/xcuserdata/ +Mounty.xcodeproj/xcuserdata/ + +# Swift Package Manager +.build/ +.swiftpm/ + +# Release artifacts +*.dmg +*.app.zip +dist/ + +# Fastlane / secrets (if ever added) +*.p12 +*.mobileprovision +AuthKey_*.p8 diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..5fdd883 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "1.1.0" +} diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..8ede655 --- /dev/null +++ b/.swift-format @@ -0,0 +1,21 @@ +{ + "version": 1, + "lineLength": 100, + "indentation": { + "spaces": 4 + }, + "tabWidth": 4, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": false, + "indentConditionalCompilationBlocks": false, + "prioritizeKeepingFunctionOutputTogether": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLowerCamelCase": true, + "OrderedImports": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fc360d3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,163 @@ +# AGENTS.md + +> **Single source of truth for all AI coding agents** working on Mounty — Claude, GPT/Copilot, +> Cursor, Gemini, and any other assistant. Tool-specific files (`CLAUDE.md`, +> `.github/copilot-instructions.md`, `.cursorrules`) are thin pointers to this file. Keep this file +> up to date; do not duplicate its content elsewhere. + +## Project overview + +Mounty is a macOS **menu-bar app** (SwiftUI) that keeps SMB network shares mounted automatically. +It reacts to network/VPN/reachability changes and re-mounts shares as soon as their server is +reachable. + +- Language: Swift 6 / Swift Concurrency (`async`/`await`). **Avoid Combine entirely** — use + `AsyncStream` for event sequences and `async`/`await` everywhere else. +- UI: SwiftUI, `MenuBarExtra` (`.window` style). +- Target: macOS 26.1+, Xcode 26+. Bundle id `ch.maptic.Mounty`. +- Concurrency: default actor isolation is `MainActor` (see build settings); services that touch the + kernel/network are `nonisolated` and hop to detached tasks. + +## Architecture (MVVM) + +``` +Mounty/Mounty/ +├─ MountyApp.swift # @main App, MenuBarExtra scene +├─ Models/ +│ ├─ LogEntry.swift # Categorized in-app log value type +│ └─ Volume.swift # Volume value type, SMB normalization (+ AppViewMode enum) +├─ Services/ # Side-effecting/state-owning units — the business logic +│ ├─ AppLogger.swift # Unified Logging + thread-safe AsyncStream history +│ ├─ MountService.swift # mount/unmount via NetFS, open in Finder/terminal, login item +│ ├─ SystemMountService.swift # query kernel mounts (getmntinfo), match config → mount path +│ ├─ ReachabilityService.swift # TCP (port 445) + I/O liveness checks +│ ├─ SpeedTestService.swift # uncached SMB read/write throughput measurement +│ ├─ EventMonitorService.swift # NWPathMonitor + workspace notifications via AsyncStream +│ └─ PersistenceService.swift # UserDefaults-backed storage (injectable defaults) +├─ ViewModels/ +│ └─ VolumeManager.swift # @MainActor @Observable class; orchestrates detection/automount/state +└─ Views/ # SwiftUI views only — no business logic +``` + +**Rule:** business logic lives in `Services/` and `Models/`. Views stay declarative; `VolumeManager` +orchestrates. New testable logic should be a `Service` (pure/injectable), not embedded in the view model. + +## Build, test, format + +```sh +# Build +xcodebuild -scheme Mounty -configuration Debug build + +# Test (unit tests only, Swift Testing framework) +xcodebuild -scheme Mounty -destination 'platform=macOS' test + +# Format (in place) / lint +xcrun swift-format format -i -r Mounty MountyTests +xcrun swift-format lint --strict -r Mounty MountyTests +``` + +When running inside an IDE with MCP tools available (e.g. Xcode), prefer `BuildProject` and +`RunAllTests` over shelling out. + +## Code style + +- 4-space indentation; formatted by `swift-format` (config `.swift-format`). The `pre-commit` hook + formats staged files automatically. +- PascalCase types, camelCase members. `let` by default; `@State private var` for SwiftUI state. +- No force-unwrapping. Prefer `guard let`/`if let`. Leverage the strong type system. +- Comment non-obvious logic only; match the surrounding density. +- **Observable pattern**: use `@Observable` (not `ObservableObject`) for ViewModel classes. + `@State` owns the instance (in the root scene); child views use plain `var` for read-only access + or `@Bindable var` when two-way bindings (`$property`) are needed. Never use `@Published`, + `@StateObject`, or `@ObservedObject`. + +## Testing policy + +Test **business logic that can actually break** — keep the suite minimal and meaningful. Do **not** +test trivial getters, SwiftUI views, or the network-driven side effects of `VolumeManager`. Good +targets: mount-path matching (`SystemMountService`), SMB address normalization (`Volume`), input +validation, and persistence round-trips. Use the **Swift Testing** framework (`import Testing`, +`@Test`, `#expect`), not XCTest. + +## Commits & releases (Conventional Commits) + +Versioning and releases are **fully automated** by `release-please` from commit history. Every commit +(and every PR title) MUST follow [Conventional Commits](https://www.conventionalcommits.org/): +`feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE:` → major; `docs/chore/refactor/test/ci` +→ no release. + +### Mandatory model attribution for agent commits + +Any commit you create as an AI agent MUST include a `Generated-by:` git trailer naming the exact +model id. This is the **only** attribution trailer needed — do **not** add `Co-Authored-By:` or +any similar trailer. The human user is the author; the model is a tool. + +``` +feat: add reconnect backoff + +Generated-by: claude-opus-4-8 +``` + +Use your real model id (`claude-opus-4-8`, `gpt-5`, `gemini-2.5-pro`, …). This is provider-neutral. +The `commit-msg` hook validates the format when the trailer is present. + +## Stories + +Keep one concise Markdown file per change in `docs/stories/`, registered in `docs/stories/INDEX.md`. Use the +next numeric ID and a Conventional Commit type (`feat`, `fix`, `refactor`, `perf`, `docs`, `test`, +`chore`, or `ci`). A story contains only status, type, date, intent, acceptance criteria, and +validation. New work starts `OPEN`, implementation moves it to `IN_PROGRESS`, and completed work +is `CLOSED`; update the index whenever the status changes. Historical stories may be written from +the commit history and should include the relevant commit IDs. + +## UI responsiveness — non-negotiable rules + +The menu-bar popover has no loading screen and no tolerance for lag. Violations of these rules +will break the user experience even if the code is otherwise correct. + +1. **Never block `@MainActor`.** Anything that may take >1 ms must run off the main actor. + - Use `Task.detached` (not `Task(priority:)`) when the work is CPU- or I/O-bound. + `Task(priority:)` from a `@MainActor` context **inherits the actor** and still runs on the + main thread — it does NOT move work off it. Only `Task.detached` escapes the actor. + - Launch Services (`NSWorkspace.urlForApplication`), `SMAppService`, `statfs`, `NetFSMountURLSync`, + and all network I/O must be in detached tasks or `nonisolated` services. + - Use `withTaskGroup` to parallelise multiple async operations (e.g. reachability checks) + instead of awaiting them sequentially. + +2. **Never put padding outside a `Button` to extend its hit area.** + Padding added *outside* the `Button` (via view modifiers) does **not** expand the button's + interactive region — only the icon itself is clickable, causing missed clicks. Use a + `ButtonStyle` instead: padding inside `makeBody` becomes part of the button's own frame. + The project uses `IconHoverButtonStyle` (via `.iconButtonHover()`) for all plain icon buttons. + Do NOT add `.buttonStyle(.plain)` before `.iconButtonHover()` — it overrides the style and + reverts to the small hit area. + +3. **Never use `.onTapGesture(count:)` on a parent that contains `Button` children.** + A multi-tap gesture on an ancestor blocks single-tap recognition on child buttons until the + system determines whether a second tap is coming. Use `.simultaneousGesture(TapGesture(count:))` + instead so both recognizers run concurrently. + +4. **The heartbeat uses `Task.sleep`, not a `Timer`.** + `Task.sleep(for:)` fires from the cooperative thread pool and has no RunLoop mode — it never + interferes with UI event tracking regardless of what the user is doing. The heartbeat task + calls `refreshState()` which immediately delegates all I/O to `Task.detached`. + +## Guardrails + +- **Zero warnings policy.** The project must build and test with zero warnings. Before submitting a + PR, use `XcodeListNavigatorIssues` (severity: `warning`) and confirm the list is empty. + Pay particular attention to Swift Concurrency warnings — the project uses + `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, meaning every type and method is implicitly + `@MainActor` unless explicitly marked `nonisolated`. Common patterns to follow: + - Value types shared across actors: add an explicit `nonisolated static func ==` (see + `Volume.swift`). + - Shared mutable state in synchronous callbacks: prefer `Synchronization.Mutex`. Avoid + `@unchecked Sendable` and `nonisolated(unsafe)` unless an external API provides the + synchronization guarantee and that guarantee is documented locally. + - Methods called from `@Sendable` closures: mark `nonisolated`. +- Keep changes scoped to the request; don't refactor unrelated code. +- Never commit secrets, `.p12`, provisioning profiles, or notarization keys. +- Don't add third-party dependencies without discussion — this app is intentionally dependency-free. +- **Never include personal contact information** (email addresses, phone numbers, social handles) + in any file you create or modify. Use only the GitHub advisory form URL for security reporting. + If you need to attribute a maintainer, use their GitHub username, never a private email address. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eadf2a9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +# CLAUDE.md + +This project's agent guidance is maintained in a single, provider-neutral source of truth. + +👉 **Read [`AGENTS.md`](./AGENTS.md).** It covers the architecture, build/test/format commands, +code style, testing policy, Conventional-Commit + model-attribution rules, and the Spec-Driven +Development workflow. + +Do not duplicate guidance here — update `AGENTS.md` instead. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7aaad78 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,79 @@ +# Contributing to Mounty + +Thanks for helping improve Mounty! This project is designed to be worked on by both humans and AI +coding agents, with an automated release pipeline. Please follow the conventions below. + +## Getting started + +```sh +git clone https://github.com/maptic/mounty.git +cd mounty +./scripts/install-hooks.sh # installs the pre-commit + commit-msg git hooks +``` + +Open `Mounty.xcodeproj` in Xcode 26+ (macOS 26.1+), or build/test from the CLI: + +```sh +xcodebuild -scheme Mounty -configuration Debug build +xcodebuild -scheme Mounty -destination 'platform=macOS' test +``` + +## Code style + +- 4-space indentation, formatted by **`swift-format`** (config: [`.swift-format`](./.swift-format)). +- The `pre-commit` hook formats staged Swift files automatically. To format manually: + ```sh + xcrun swift-format format -i -r Mounty MountyTests + ``` +- CI fails if code is not formatted (`swift-format lint --strict`). + +## Commits — Conventional Commits (required) + +Releases and version bumps are **fully automated** from commit history, so commit messages matter. +Use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +| Type | Release effect | +| ------------------- | --------------------- | +| `fix:` | patch (x.y.**z**) | +| `feat:` | minor (x.**y**.0) | +| `feat!:` / `BREAKING CHANGE:` footer | major (**x**.0.0) | +| `docs:` `chore:` `refactor:` `test:` `ci:` `style:` `perf:` | no release | + +The `commit-msg` hook validates this format locally, and CI validates the **PR title**. + +### AI agent attribution (required for agent commits) + +If a commit was produced with the help of an AI coding agent, it **must** record the model used via +a `Generated-by:` git trailer. This is the **only** attribution trailer needed — do **not** add +`Co-Authored-By:` or any other trailer. The human is the author; the model is a tool. + +``` +feat: add reconnect backoff + +Generated-by: claude-opus-4-8 +``` + +Use the exact model **id** (e.g. `claude-opus-4-8`, `gpt-5`, `gemini-2.5-pro`). The `commit-msg` +hook validates the format when the trailer is present. + +## Stories + +Non-trivial changes start with a short story in [`docs/stories/`](./docs/stories/). Copy +[`TEMPLATE.md`](./docs/stories/TEMPLATE.md), register the story in [`INDEX.md`](./docs/stories/INDEX.md), +and keep its acceptance criteria and validation notes current. + +See [`AGENTS.md`](./AGENTS.md) for the full agent-facing guide (architecture, commands, conventions). + +## Pull requests + +- Keep PRs focused. The **PR title must be a valid Conventional Commit** (it becomes the squash + commit and drives the release). +- CI must be green: format check, build, and unit tests. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e4edc1b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Merlin Unterfinger / maptic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Mounty.xcodeproj/project.pbxproj b/Mounty.xcodeproj/project.pbxproj index 5285f74..b8a8968 100644 --- a/Mounty.xcodeproj/project.pbxproj +++ b/Mounty.xcodeproj/project.pbxproj @@ -6,8 +6,19 @@ objectVersion = 77; objects = { +/* Begin PBXContainerItemProxy section */ + EFBF74463025D609004DC3F6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EF8AFA192EEB0B44000100D8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EF8AFA202EEB0B44000100D8; + remoteInfo = Mounty; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ EF8AFA212EEB0B44000100D8 /* Mounty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mounty.app; sourceTree = BUILT_PRODUCTS_DIR; }; + EFBF74423025D609004DC3F6 /* MountyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MountyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -16,6 +27,11 @@ path = Mounty; sourceTree = ""; }; + EFBF74433025D609004DC3F6 /* MountyTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = MountyTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +42,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EFBF743F3025D609004DC3F6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -33,6 +56,7 @@ isa = PBXGroup; children = ( EF8AFA232EEB0B44000100D8 /* Mounty */, + EFBF74433025D609004DC3F6 /* MountyTests */, EF8AFA222EEB0B44000100D8 /* Products */, ); sourceTree = ""; @@ -41,6 +65,7 @@ isa = PBXGroup; children = ( EF8AFA212EEB0B44000100D8 /* Mounty.app */, + EFBF74423025D609004DC3F6 /* MountyTests.xctest */, ); name = Products; sourceTree = ""; @@ -70,6 +95,29 @@ productReference = EF8AFA212EEB0B44000100D8 /* Mounty.app */; productType = "com.apple.product-type.application"; }; + EFBF74413025D609004DC3F6 /* MountyTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EFBF744A3025D609004DC3F6 /* Build configuration list for PBXNativeTarget "MountyTests" */; + buildPhases = ( + EFBF743E3025D609004DC3F6 /* Sources */, + EFBF743F3025D609004DC3F6 /* Frameworks */, + EFBF74403025D609004DC3F6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + EFBF74473025D609004DC3F6 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + EFBF74433025D609004DC3F6 /* MountyTests */, + ); + name = MountyTests; + packageProductDependencies = ( + ); + productName = MountyTests; + productReference = EFBF74423025D609004DC3F6 /* MountyTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -77,12 +125,16 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2610; - LastUpgradeCheck = 2610; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2660; TargetAttributes = { EF8AFA202EEB0B44000100D8 = { CreatedOnToolsVersion = 26.1.1; }; + EFBF74413025D609004DC3F6 = { + CreatedOnToolsVersion = 26.6; + TestTargetID = EF8AFA202EEB0B44000100D8; + }; }; }; buildConfigurationList = EF8AFA1C2EEB0B44000100D8 /* Build configuration list for PBXProject "Mounty" */; @@ -100,6 +152,7 @@ projectRoot = ""; targets = ( EF8AFA202EEB0B44000100D8 /* Mounty */, + EFBF74413025D609004DC3F6 /* MountyTests */, ); }; /* End PBXProject section */ @@ -112,6 +165,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EFBF74403025D609004DC3F6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -122,8 +182,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EFBF743E3025D609004DC3F6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + EFBF74473025D609004DC3F6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EF8AFA202EEB0B44000100D8 /* Mounty */; + targetProxy = EFBF74463025D609004DC3F6 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ EF8AFA2A2EEB0B45000100D8 /* Debug */ = { isa = XCBuildConfiguration; @@ -159,6 +234,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -183,6 +259,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -222,6 +299,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -239,6 +317,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; @@ -251,6 +330,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -271,7 +351,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -283,6 +363,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -303,7 +384,49 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + EFBF74483025D609004DC3F6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ch.maptic.MountyTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mounty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Mounty"; + }; + name = Debug; + }; + EFBF74493025D609004DC3F6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ch.maptic.MountyTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mounty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Mounty"; }; name = Release; }; @@ -328,6 +451,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + EFBF744A3025D609004DC3F6 /* Build configuration list for PBXNativeTarget "MountyTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EFBF74483025D609004DC3F6 /* Debug */, + EFBF74493025D609004DC3F6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = EF8AFA192EEB0B44000100D8 /* Project object */; diff --git a/Mounty.xcodeproj/xcuserdata/mufix.xcuserdatad/xcschemes/xcschememanagement.plist b/Mounty.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 50% rename from Mounty.xcodeproj/xcuserdata/mufix.xcuserdatad/xcschemes/xcschememanagement.plist rename to Mounty.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings index 645b459..0c67376 100644 --- a/Mounty.xcodeproj/xcuserdata/mufix.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mounty.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -1,14 +1,5 @@ - - SchemeUserState - - Mounty.xcscheme_^#shared#^_ - - orderHint - 0 - - - + diff --git a/Mounty.xcodeproj/xcshareddata/xcschemes/Mounty.xcscheme b/Mounty.xcodeproj/xcshareddata/xcschemes/Mounty.xcscheme new file mode 100644 index 0000000..a7eb615 --- /dev/null +++ b/Mounty.xcodeproj/xcshareddata/xcschemes/Mounty.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mounty/Models/LogEntry.swift b/Mounty/Models/LogEntry.swift new file mode 100644 index 0000000..1072c24 --- /dev/null +++ b/Mounty/Models/LogEntry.swift @@ -0,0 +1,83 @@ +import Foundation +import SwiftUI + +struct LogEntry: Identifiable, Sendable { + nonisolated let id: UUID + nonisolated let timestamp: Date + nonisolated let level: Level + nonisolated let source: Source + nonisolated let message: String + + nonisolated init( + id: UUID = UUID(), + timestamp: Date, + level: Level, + source: Source, + message: String + ) { + self.id = id + self.timestamp = timestamp + self.level = level + self.source = source + self.message = message + } + + enum Source: String, Sendable { + case manager = "Manager" + case mountService = "MountService" + case eventMonitor = "EventMonitor" + case reachability = "Reachability" + + nonisolated var label: String { rawValue } + } + + enum Level: String, Sendable, Comparable, CaseIterable { + case debug, info, warning, error + + nonisolated static func < (lhs: Level, rhs: Level) -> Bool { + lhs.severity < rhs.severity + } + + nonisolated private var severity: Int { + switch self { + case .debug: 0 + case .info: 1 + case .warning: 2 + case .error: 3 + } + } + + var color: Color { + switch self { + case .debug: .secondary.opacity(0.6) + case .info: .secondary + case .warning: .orange + case .error: .red + } + } + + var symbol: String { + switch self { + case .debug: "circle" + case .info: "circle.fill" + case .warning: "exclamationmark.triangle.fill" + case .error: "xmark.circle.fill" + } + } + + nonisolated var label: String { + switch self { + case .debug: "DEBUG" + case .info: "INFO" + case .warning: "WARN" + case .error: "ERROR" + } + } + } + + // Full-fidelity string used for clipboard export. + nonisolated var formatted: String { + let ts = timestamp.formatted(.dateTime.year().month().day().hour().minute().second()) + return "[\(ts)] [\(level.label)] [\(source.label)] \(message)" + } +} diff --git a/Mounty/Models/Volume.swift b/Mounty/Models/Volume.swift index 7e8fe70..274f5ad 100644 --- a/Mounty/Models/Volume.swift +++ b/Mounty/Models/Volume.swift @@ -7,7 +7,25 @@ struct Volume: Identifiable, Codable, Equatable, Sendable { var isAutomountEnabled: Bool = false var dateAdded: Date = Date() - var host: String? { URL(string: serverAddress)?.host } + nonisolated static func shareAddress(from value: String) -> String { + guard let separator = value.range(of: "://") else { return value } + return String(value[separator.upperBound...]) + } + + nonisolated static func smbServerAddress(from value: String) -> String { + "smb://\(shareAddress(from: value))" + } + + // Explicit nonisolated conformance so Equatable can be used freely across + // actor boundaries (otherwise the implicit @MainActor isolation from the + // project-wide default would produce a warning in nonisolated contexts). + nonisolated static func == (lhs: Volume, rhs: Volume) -> Bool { + lhs.id == rhs.id + && lhs.name == rhs.name + && lhs.serverAddress == rhs.serverAddress + && lhs.isAutomountEnabled == rhs.isAutomountEnabled + && lhs.dateAdded == rhs.dateAdded + } } -enum AppViewMode { case list, add, settings } +enum AppViewMode: Equatable { case list, add, settings, logs, edit(Volume) } diff --git a/Mounty/MountyApp.swift b/Mounty/MountyApp.swift index 69d3b09..0ef36c1 100644 --- a/Mounty/MountyApp.swift +++ b/Mounty/MountyApp.swift @@ -3,7 +3,10 @@ import SwiftUI @main @MainActor struct MountyApp: App { - @StateObject var manager = VolumeManager() + + private var isRunningTests: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } private static let paddedIcon: NSImage = { guard let image = NSImage(named: "MenuIcon") else { return NSImage() } @@ -20,7 +23,11 @@ struct MountyApp: App { var body: some Scene { MenuBarExtra { - RootView(manager: manager) + if isRunningTests { + EmptyView() + } else { + RootView() + } } label: { Image(nsImage: Self.paddedIcon) } diff --git a/Mounty/Services/AppLogger.swift b/Mounty/Services/AppLogger.swift new file mode 100644 index 0000000..7209c01 --- /dev/null +++ b/Mounty/Services/AppLogger.swift @@ -0,0 +1,79 @@ +import Foundation +import Synchronization +import os + +/// Routes Mounty-owned diagnostics to Unified Logging and the in-app log stream. +struct AppLogger { + nonisolated private static let hub = LogHub() + + nonisolated static var entries: AsyncStream { + hub.makeStream() + } + + nonisolated static func log( + _ message: String, + level: LogEntry.Level = .info, + source: LogEntry.Source + ) { + let logger = Logger(subsystem: "ch.maptic.Mounty", category: source.label) + switch level { + case .debug: logger.debug("\(message, privacy: .public)") + case .info: logger.info("\(message, privacy: .public)") + case .warning: logger.warning("\(message, privacy: .public)") + case .error: logger.error("\(message, privacy: .public)") + } + + hub.emit( + LogEntry(timestamp: Date(), level: level, source: source, message: message) + ) + } + + nonisolated static func clearHistory() { + hub.clearHistory() + } +} + +private final class LogHub: Sendable { + private struct State { + var history: [LogEntry] = [] + var subscribers: [UUID: AsyncStream.Continuation] = [:] + } + + private let state = Mutex(State()) + + nonisolated func makeStream() -> AsyncStream { + let subscriberID = UUID() + return AsyncStream(bufferingPolicy: .bufferingNewest(500)) { continuation in + state.withLock { state in + state.subscribers[subscriberID] = continuation + for entry in state.history { + continuation.yield(entry) + } + } + continuation.onTermination = { [weak self] _ in + self?.removeSubscriber(subscriberID) + } + } + } + + nonisolated func emit(_ entry: LogEntry) { + let continuations = state.withLock { state in + state.history.append(entry) + if state.history.count > 500 { + state.history.removeFirst(state.history.count - 500) + } + return Array(state.subscribers.values) + } + for continuation in continuations { + continuation.yield(entry) + } + } + + nonisolated func clearHistory() { + state.withLock { $0.history.removeAll() } + } + + nonisolated private func removeSubscriber(_ id: UUID) { + state.withLock { _ = $0.subscribers.removeValue(forKey: id) } + } +} diff --git a/Mounty/Services/EventMonitorService.swift b/Mounty/Services/EventMonitorService.swift index c1aef08..09cfd3a 100644 --- a/Mounty/Services/EventMonitorService.swift +++ b/Mounty/Services/EventMonitorService.swift @@ -1,30 +1,38 @@ import AppKit -import Combine import Foundation import Network -import os +import Synchronization -/// Monitors OS events to trigger application logic. -/// Observes Network Status, Interface Fingerprints (VPN), and Kernel Mount events. -class EventMonitorService { +private struct InterfaceMonitorState: Sendable { + var fingerprint = "" + var pendingChange: Task? +} + +/// Monitors OS events and exposes them as async sequences. +final class EventMonitorService { + + let networkStatusStream: AsyncStream + let interfacesChangedStream: AsyncStream + let fileSystemChangedStream: AsyncStream - let networkStatus = CurrentValueSubject(.satisfied) - let interfacesChanged = PassthroughSubject() - let fileSystemChanged = PassthroughSubject() + private let networkStatusContinuation: AsyncStream.Continuation + private let interfacesChangedContinuation: AsyncStream.Continuation + private let fileSystemChangedContinuation: AsyncStream.Continuation private let monitor = NWPathMonitor() - private let queue = DispatchQueue( - label: "com.mounty.network", - qos: .background - ) - private var cancellables = Set() - private var lastInterfaceFingerprint: String = "" - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "Mounty", - category: "EventMonitor" - ) + private let monitorQueue = DispatchQueue(label: "com.mounty.network", qos: .background) + nonisolated private let interfaceState = Mutex(InterfaceMonitorState()) init() { + (networkStatusStream, networkStatusContinuation) = AsyncStream.makeStream( + of: NWPath.Status.self, bufferingPolicy: .bufferingNewest(1) + ) + (interfacesChangedStream, interfacesChangedContinuation) = AsyncStream.makeStream( + of: Void.self, bufferingPolicy: .bufferingNewest(1) + ) + (fileSystemChangedStream, fileSystemChangedContinuation) = AsyncStream.makeStream( + of: Void.self, bufferingPolicy: .bufferingNewest(1) + ) startNetworkMonitoring() startFileSystemMonitoring() } @@ -32,39 +40,55 @@ class EventMonitorService { private func startNetworkMonitoring() { monitor.pathUpdateHandler = { [weak self] path in guard let self else { return } + networkStatusContinuation.yield(path.status) - self.networkStatus.send(path.status) - - // Fingerprint interfaces to detect VPN tunnels let currentInterfaces = path.availableInterfaces .map { "\($0.name):\($0.type)" } .sorted() .joined(separator: ",") - if currentInterfaces != self.lastInterfaceFingerprint { - self.logger.debug( - "Interface topology changed: \(currentInterfaces, privacy: .public)" - ) - self.lastInterfaceFingerprint = currentInterfaces - - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.interfacesChanged.send() + let continuation = interfacesChangedContinuation + let didChange = interfaceState.withLock { state in + guard currentInterfaces != state.fingerprint else { return false } + state.fingerprint = currentInterfaces + state.pendingChange?.cancel() + state.pendingChange = Task { + do { + try await Task.sleep(for: .seconds(1)) + continuation.yield() + } catch { + // A newer interface update superseded this one. + } } + return true + } + + if didChange { + AppLogger.log( + "Interface topology changed: \(currentInterfaces)", + level: .debug, + source: .eventMonitor + ) } } - monitor.start(queue: queue) + monitor.start(queue: monitorQueue) } private func startFileSystemMonitoring() { let center = NSWorkspace.shared.notificationCenter - Publishers.Merge3( - center.publisher(for: NSWorkspace.didMountNotification), - center.publisher(for: NSWorkspace.didUnmountNotification), - center.publisher(for: NSWorkspace.didRenameVolumeNotification) - ).sink { [weak self] _ in - self?.logger.debug("Kernel filesystem event received") - self?.fileSystemChanged.send() + for name in [ + NSWorkspace.didMountNotification, + NSWorkspace.didUnmountNotification, + NSWorkspace.didRenameVolumeNotification, + ] { + center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + AppLogger.log( + "Kernel filesystem event received: \(name.rawValue)", + level: .debug, + source: .eventMonitor + ) + self?.fileSystemChangedContinuation.yield() + } } - .store(in: &cancellables) } } diff --git a/Mounty/Services/MountService.swift b/Mounty/Services/MountService.swift index 73d1cc1..9ab571a 100644 --- a/Mounty/Services/MountService.swift +++ b/Mounty/Services/MountService.swift @@ -3,133 +3,230 @@ import Darwin import Foundation import NetFS import ServiceManagement -import os /// Action Service. struct MountService { + nonisolated private static let mountGate = MountGate() - // MARK: - Logger - nonisolated private static let logger = Logger( - subsystem: "Mounty", - category: "MountService" - ) + // MARK: - Mount Result + + enum MountResult: Sendable { + case success(path: String) + case failed(code: Int32) + + nonisolated var path: String? { + if case .success(let path) = self { return path } + return nil + } + + nonisolated var debugDescription: String { + switch self { + case .success(let path): return "success → \(path)" + case .failed(let code): + let detail = + code > 0 + ? String(cString: strerror(code)) + : NSError(domain: NSOSStatusErrorDomain, code: Int(code)).localizedDescription + return "NetFS error \(code): \(detail)" + } + } + } // MARK: - Mounting - /// Mounts network share via NetFSMountURLSync. - /// Returns the mount path if successful, nil otherwise. - static func mount(url: URL) async -> String? { - await Task.detached(priority: .userInitiated) { + /// Mounts a network share with NetFS without blocking MainActor. + nonisolated static func mount(url: URL) async -> MountResult { + await mountGate.acquire() + let result = await mountExclusively(url: url) + await mountGate.release() + return result + } - // 1. Check if already mounted - if let existing = SystemMountService.findMountPath(forURL: url) { - logger.info( - "Share already mounted: \(url.absoluteString) -> \(existing)" + nonisolated private static func mountExclusively(url: URL) async -> MountResult { + if let existing = await Task.detached( + priority: .userInitiated, + operation: { + SystemMountService.findMountPath(forURL: url) + } + ).value { + if await ReachabilityService.isMountPointAlive(path: existing) { + AppLogger.log( + "Mount skipped; already mounted and responsive: \(mountTarget(for: url)) -> \(existing)", + source: .mountService ) - return existing + return .success(path: existing) } - var mountpoints: Unmanaged? = nil - let cfUrl = url as CFURL - - let openOptions: [String: Any] = [ - "AllowUserInteraction": true, "NoMountOnDir": true, - ] - let mutableOpenOptions = CFDictionaryCreateMutableCopy( - nil, - 0, - openOptions as CFDictionary + AppLogger.log( + "Existing mount is unresponsive: \(existing); unmounting before retry", + level: .warning, + source: .mountService ) + guard await unmount(path: existing) else { + return .failed(code: EBUSY) + } + } - let result = NetFSMountURLSync( - cfUrl, - nil, - nil, - nil, - mutableOpenOptions, - nil, - &mountpoints + return await Task.detached(priority: .userInitiated) { + performMount(url: url) + }.value + } + + nonisolated private static func performMount(url: URL) -> MountResult { + let target = mountTarget(for: url) + let startedAt = ContinuousClock.now + AppLogger.log("Mount started: \(target)", level: .debug, source: .mountService) + + if let existing = SystemMountService.findMountPath(forURL: url) { + AppLogger.log( + "Mount resolved an existing share after retry preparation: \(target) -> \(existing)", + source: .mountService ) + return .success(path: existing) + } - // 2. Success - if result == 0, - let points = mountpoints?.takeRetainedValue() as? [String], - let path = points.first - { - logger.info( - "Mount successful: \(url.absoluteString) -> \(path)" + let watchdog = Task.detached(priority: .utility) { + do { + try await Task.sleep(for: .seconds(30)) + AppLogger.log( + "Mount still pending after 30 s: \(target); waiting for NetFS or authentication UI", + level: .warning, + source: .mountService ) - return path + } catch { + // Completion cancels the watchdog. } + } + defer { watchdog.cancel() } + + var mountpoints: Unmanaged? + let status = NetFSMountURLSync( + url as CFURL, + nil, + nil, + nil, + nil, + nil, + &mountpoints + ) + let elapsed = startedAt.duration(to: .now) - // 3. Treat Error 17 (already exists) as success - if result == 17, - let existing = SystemMountService.findMountPath(forURL: url) + if status == 0 { + if let paths = mountpoints?.takeRetainedValue() as? [String], + let path = paths.first { - logger.info( - "Share already mounted (NetFSMountURLSync EEXIST): \(url.absoluteString) -> \(existing)" + AppLogger.log( + "Mount succeeded: \(target) -> \(path); duration=\(elapsed)", + source: .mountService + ) + return .success(path: path) + } + if let existing = SystemMountService.findMountPath(forURL: url) { + AppLogger.log( + "Mount succeeded and resolved from system mounts: \(target) -> \(existing); duration=\(elapsed)", + source: .mountService ) - return existing + return .success(path: existing) } + AppLogger.log( + "NetFS reported success without a mount path: \(target); duration=\(elapsed)", + level: .error, + source: .mountService + ) + return .failed(code: EIO) + } - // 4. Real failure - logger.error( - "Mount failed: \(url.absoluteString). Error: \(result)" + if status == EEXIST, + let existing = SystemMountService.findMountPath(forURL: url) + { + AppLogger.log( + "Mount resolved existing share: \(target) -> \(existing); duration=\(elapsed)", + source: .mountService ) - return nil + return .success(path: existing) + } - }.value + let result = MountResult.failed(code: status) + AppLogger.log( + "Mount failed: \(target); \(result.debugDescription); duration=\(elapsed)", + level: .error, + source: .mountService + ) + return result + } + + nonisolated private static func mountTarget(for url: URL) -> String { + "\(url.host ?? "unknown-host")\(url.path)" } /// Unmounts path via NSWorkspace, falling back to kernel-level force unmount. - static func unmount(path: String) async { + @discardableResult + static func unmount(path: String) async -> Bool { await Task.detached(priority: .userInitiated) { let url = URL(fileURLWithPath: path) do { try NSWorkspace.shared.unmountAndEjectDevice(at: url) - logger.info("Polite unmount successful: \(path)") + AppLogger.log("Polite unmount succeeded: \(path)", source: .mountService) + return true } catch { - logger.warning("Polite unmount failed. Executing MNT_FORCE.") - _ = Darwin.unmount(path, MNT_FORCE) + AppLogger.log( + "Polite unmount failed: \(path); \(error.localizedDescription); trying MNT_FORCE", + level: .warning, + source: .mountService + ) + let forceResult = Darwin.unmount(path, MNT_FORCE) + if forceResult == 0 { + AppLogger.log("Force unmount succeeded: \(path)", source: .mountService) + return true + } else { + AppLogger.log( + "Force unmount failed: \(path); errno=\(errno): \(String(cString: strerror(errno)))", + level: .error, + source: .mountService + ) + return false + } } }.value } // MARK: - UI Actions - @MainActor - static func openInFinder(path: String) { - NSWorkspace.shared.open(URL(fileURLWithPath: path)) + nonisolated static func openInFinder(path: String) { + Task.detached(priority: .userInitiated) { + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } } - @MainActor - static func openInTerminal(path: String, with bundleId: String? = nil) { - let url = URL(fileURLWithPath: path) - let terminalId = bundleId ?? "com.apple.Terminal" + nonisolated static func openInTerminal(path: String, with bundleId: String? = nil) { + Task.detached(priority: .userInitiated) { + let url = URL(fileURLWithPath: path) + let terminalId = bundleId ?? "com.apple.Terminal" + + guard + let appUrl = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: terminalId + ) + else { + NSWorkspace.shared.open(url) + return + } - guard - let appUrl = NSWorkspace.shared.urlForApplication( - withBundleIdentifier: terminalId + let config = NSWorkspace.OpenConfiguration() + config.activates = true + NSWorkspace.shared.open( + [url], + withApplicationAt: appUrl, + configuration: config, + completionHandler: nil ) - else { - NSWorkspace.shared.open(url) - return } - - let config = NSWorkspace.OpenConfiguration() - config.activates = true - NSWorkspace.shared.open( - [url], - withApplicationAt: appUrl, - configuration: config, - completionHandler: nil - ) } // MARK: - Login Item - @MainActor - static func toggleLoginItem(enabled: Bool) { + // SMAppService is thread-safe; no main-actor requirement. + nonisolated static func toggleLoginItem(enabled: Bool) { do { if enabled { try SMAppService.mainApp.register() @@ -137,14 +234,38 @@ struct MountService { try SMAppService.mainApp.unregister() } } catch { - logger.error( - "Login Item toggle failed: \(error.localizedDescription)" + AppLogger.log( + "Login item update failed: \(error.localizedDescription)", + level: .error, + source: .mountService ) } } - @MainActor - static func isLoginItemEnabled() -> Bool { - return SMAppService.mainApp.status == .enabled + nonisolated static func isLoginItemEnabled() -> Bool { + SMAppService.mainApp.status == .enabled + } +} + +private actor MountGate { + private var isHeld = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + guard isHeld else { + isHeld = true + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + guard !waiters.isEmpty else { + isHeld = false + return + } + waiters.removeFirst().resume() } } diff --git a/Mounty/Services/PersistenceService.swift b/Mounty/Services/PersistenceService.swift index a12facf..bb1bea5 100644 --- a/Mounty/Services/PersistenceService.swift +++ b/Mounty/Services/PersistenceService.swift @@ -4,7 +4,15 @@ import Foundation struct PersistenceService { private let keyVolumes = "SavedVolumes" private let keyTerminal = "PreferredTerminal" - private let defaults = UserDefaults.standard + private let keyMinimumLogLevel = "MinimumLogLevel" + private let keySortOrder = "SortOrder" + private let keySortDirection = "SortDirection" + private let defaults: UserDefaults + + /// - Parameter defaults: injectable store; defaults to `.standard` (override in tests). + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } func saveVolumes(_ volumes: [Volume]) { if let encoded = try? JSONEncoder().encode(volumes) { @@ -27,4 +35,29 @@ struct PersistenceService { func loadTerminalBundleID() -> String { defaults.string(forKey: keyTerminal) ?? "com.apple.Terminal" } + + func saveMinimumLogLevel(_ level: LogEntry.Level) { + defaults.set(level.rawValue, forKey: keyMinimumLogLevel) + } + + func loadMinimumLogLevel() -> LogEntry.Level { + guard let rawValue = defaults.string(forKey: keyMinimumLogLevel) else { return .info } + return LogEntry.Level(rawValue: rawValue) ?? .info + } + + func saveSortOrder(_ rawValue: String) { + defaults.set(rawValue, forKey: keySortOrder) + } + + func loadSortOrder() -> String? { + defaults.string(forKey: keySortOrder) + } + + func saveSortDirection(_ rawValue: String) { + defaults.set(rawValue, forKey: keySortDirection) + } + + func loadSortDirection() -> String? { + defaults.string(forKey: keySortDirection) + } } diff --git a/Mounty/Services/ReachabilityService.swift b/Mounty/Services/ReachabilityService.swift index f0c4f6e..b84315f 100644 --- a/Mounty/Services/ReachabilityService.swift +++ b/Mounty/Services/ReachabilityService.swift @@ -1,30 +1,61 @@ +import Darwin import Foundation import Network +import Synchronization /// Verifies server and mount point responsiveness. struct ReachabilityService { + nonisolated private static let mountProbes = MountProbeRegistry() - /// Validates filesystem responsiveness via I/O. - nonisolated static func isMountPointAlive(path: String) -> Bool { - let group = DispatchGroup() - group.enter() - var isAlive = false - - DispatchQueue.global(qos: .userInteractive).async { - if (try? FileManager.default.contentsOfDirectory(atPath: path)) - != nil - { - isAlive = true + /// Validates filesystem responsiveness by calling statfs(2) on the mount path. + /// + /// statfs() queries kernel-level filesystem metadata without reading file content, + /// so it never triggers the macOS TCC "access files on a network volume" prompt. + /// It will block (and thus timeout) on a hung/dead mount, which is exactly the + /// behaviour we need to detect silently dead kernel mounts. + /// + /// Async: dispatches statfs to a background thread so the Swift cooperative + /// thread pool is never blocked waiting for a hung mount. + nonisolated static func isMountPointAlive(path: String) async -> Bool { + return await withCheckedContinuation { continuation in + guard mountProbes.register(path: path, continuation: continuation) else { return } + + DispatchQueue.global(qos: .utility).async { + defer { mountProbes.finish(path: path) } + // Allocate uninitialized memory instead of calling statfs.init(), + // which is @MainActor under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. + // The C statfs(2) syscall writes the struct entirely so zero-init + // is unnecessary and the @MainActor init can be bypassed safely. + let buf = UnsafeMutablePointer.allocate(capacity: 1) + defer { buf.deallocate() } + let status = statfs(path, buf) + let errorCode = errno + let alive = status == 0 + if mountProbes.resolve(path: path, result: alive) { + if !alive { + AppLogger.log( + "Mount probe failed: \(path); errno=\(errorCode): \(String(cString: strerror(errorCode)))", + level: .warning, + source: .reachability + ) + } + } } - group.leave() - } - let result = group.wait(timeout: .now() + 1.0) - return result == .success && isAlive + DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { + if mountProbes.resolve(path: path, result: false) { + AppLogger.log( + "Mount probe timed out after 1 s: \(path)", + level: .warning, + source: .reachability + ) + } + } + } } /// Validates TCP connectivity to SMB port (445). - static func isServerReachable(address: String) async -> Bool { + nonisolated static func isServerReachable(address: String) async -> Bool { guard let host = URL(string: address)?.host else { return false } return await withCheckedContinuation { continuation in @@ -35,26 +66,40 @@ struct ReachabilityService { using: .tcp ) - let workItem = DispatchWorkItem { [weak conn] in - if conn?.state != .ready { - conn?.cancel() + let gate = ResumeGate() + + DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) { + if gate.tryResume() { + conn.cancel() + AppLogger.log( + "SMB probe timed out: host=\(host); port=445; timeout=2 s", + level: .debug, + source: .reachability + ) continuation.resume(returning: false) } } - DispatchQueue.global().asyncAfter( - deadline: .now() + 2.0, - execute: workItem - ) - conn.stateUpdateHandler = { state in switch state { case .ready: - workItem.cancel() - conn.cancel() - continuation.resume(returning: true) - case .failed(_), .cancelled: - workItem.cancel() + if gate.tryResume() { + conn.cancel() + continuation.resume(returning: true) + } + case .failed(let error): + if gate.tryResume() { + AppLogger.log( + "SMB probe failed: host=\(host); port=445; error=\(error)", + level: .debug, + source: .reachability + ) + continuation.resume(returning: false) + } + case .cancelled: + if gate.tryResume() { + continuation.resume(returning: false) + } default: break } } @@ -62,3 +107,74 @@ struct ReachabilityService { } } } + +private final class MountProbeRegistry: Sendable { + private struct ProbeState { + var result: Bool? + var waiters: [CheckedContinuation] + } + + private let probes = Mutex([String: ProbeState]()) + + /// Registers a caller and returns true only when it must start the underlying syscall. + nonisolated func register( + path: String, + continuation: CheckedContinuation + ) -> Bool { + var immediateResult: Bool? + let shouldStart = probes.withLock { probes in + guard var state = probes[path] else { + probes[path] = ProbeState(result: nil, waiters: [continuation]) + return true + } + if let result = state.result { + immediateResult = result + } else { + state.waiters.append(continuation) + probes[path] = state + } + return false + } + if let immediateResult { + continuation.resume(returning: immediateResult) + } + return shouldStart + } + + /// Resolves all current and future waiters while the non-cancellable syscall remains active. + @discardableResult + nonisolated func resolve(path: String, result: Bool) -> Bool { + let waiters: [CheckedContinuation]? = probes.withLock { probes in + guard var state = probes[path], state.result == nil else { return nil } + state.result = result + let waiters = state.waiters + state.waiters.removeAll() + probes[path] = state + return waiters + } + guard let waiters else { return false } + for continuation in waiters { + continuation.resume(returning: result) + } + return true + } + + nonisolated func finish(path: String) { + probes.withLock { _ = $0.removeValue(forKey: path) } + } +} + +private final class ResumeGate: Sendable { + private let resumed = Mutex(false) + + nonisolated init() {} + + /// Returns `true` the first time it is called; `false` on all subsequent calls. + nonisolated func tryResume() -> Bool { + resumed.withLock { resumed in + guard !resumed else { return false } + resumed = true + return true + } + } +} diff --git a/Mounty/Services/SpeedTestService.swift b/Mounty/Services/SpeedTestService.swift new file mode 100644 index 0000000..cf91e20 --- /dev/null +++ b/Mounty/Services/SpeedTestService.swift @@ -0,0 +1,146 @@ +import Darwin +import Foundation + +struct SpeedTestService { + struct Result: Sendable { + let writeSpeed: Double // MB/s + let readSpeed: Double // MB/s + let fileSizeMB: Double + } + + // All file I/O runs on a global queue so the Swift cooperative thread pool + // is never blocked during multi-second network transfers. + nonisolated static func measure( + at mountPath: String, fileSizeMB: Double = 10 + ) async throws -> Result { + // UUID suffix guarantees the file name never collides with an existing + // user file, even if two tests run concurrently on the same share. + let testURL = URL(fileURLWithPath: mountPath) + .appendingPathComponent(".mounty_speed_\(UUID().uuidString)") + let path = testURL.path + let byteCount = Int(fileSizeMB * 1024 * 1024) + guard byteCount > 0 else { throw SpeedTestError.invalidFileSize } + + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + let data = Data(count: byteCount) + + // --- Write --- + // F_FULLFSYNC tells the SMB client to commit buffered bytes + // to the server before we stop the clock. Without it, write() + // returns as soon as the kernel accepts the data locally, which + // can be near-instant even for slow links. + let writeStart = Date() + try data.write(to: testURL) + let writeDescriptor = Darwin.open(path, O_RDONLY) + guard writeDescriptor >= 0 else { throw posixError() } + defer { Darwin.close(writeDescriptor) } + try synchronize(writeDescriptor) + let writeDuration = max(Date().timeIntervalSince(writeStart), 0.001) + + // --- Read --- + // F_NOCACHE bypasses the unified buffer cache. Without it the + // OS would serve the just-written bytes from RAM, reporting + // multi-GB/s "speeds" that have nothing to do with the network. + let readDescriptor = Darwin.open(path, O_RDONLY) + guard readDescriptor >= 0 else { throw posixError() } + defer { Darwin.close(readDescriptor) } + try disableCaching(readDescriptor) + + let readStart = Date() + var buffer = [UInt8](repeating: 0, count: byteCount) + let bytesRead = try buffer.withUnsafeMutableBytes { pointer in + guard let baseAddress = pointer.baseAddress else { + throw SpeedTestError.invalidFileSize + } + var offset = 0 + while offset < byteCount { + let count = Darwin.read( + readDescriptor, + baseAddress.advanced(by: offset), + byteCount - offset + ) + if count < 0 { throw posixError() } + if count == 0 { break } + offset += count + } + return offset + } + guard bytesRead == byteCount else { + throw SpeedTestError.incompleteRead(expected: byteCount, actual: bytesRead) + } + let readDuration = max(Date().timeIntervalSince(readStart), 0.001) + + continuation.resume( + returning: Result( + writeSpeed: fileSizeMB / writeDuration, + readSpeed: fileSizeMB / readDuration, + fileSizeMB: fileSizeMB + )) + } catch { + continuation.resume(throwing: error) + } + + // Resume the caller before cleanup. Removing a file from an + // unavailable SMB share can block or retry, but must never delay + // publishing the result back to the UI. + removeWithRetry(at: testURL) + } + } + } + + // Retries up to 3 times so a transient network hiccup doesn't leave + // the test file on the server permanently. + private nonisolated static func removeWithRetry(at url: URL) { + for attempt in 1...3 { + do { + try FileManager.default.removeItem(at: url) + return + } catch { + if attempt < 3 { Thread.sleep(forTimeInterval: 0.2) } + } + } + } + + private nonisolated static func synchronize(_ descriptor: Int32) throws { + if Darwin.fcntl(descriptor, F_FULLFSYNC) == 0 { return } + + let fullSyncError = errno + guard isUnsupportedFileControl(fullSyncError) else { + throw posixError(code: fullSyncError) + } + guard Darwin.fsync(descriptor) == 0 else { throw posixError() } + } + + private nonisolated static func disableCaching(_ descriptor: Int32) throws { + if Darwin.fcntl(descriptor, F_NOCACHE, 1) == 0 { return } + + let noCacheError = errno + guard isUnsupportedFileControl(noCacheError) else { + throw posixError(code: noCacheError) + } + } + + private nonisolated static func isUnsupportedFileControl(_ code: Int32) -> Bool { + code == ENOTSUP || code == EINVAL || code == ENOTTY + } + + private nonisolated static func posixError(code: Int32 = errno) -> NSError { + NSError(domain: NSPOSIXErrorDomain, code: Int(code)) + } + + private enum SpeedTestError: LocalizedError { + case invalidFileSize + case incompleteRead(expected: Int, actual: Int) + + nonisolated var errorDescription: String? { + switch self { + case .invalidFileSize: + "Speed test size must be greater than zero." + case .incompleteRead(let expected, let actual): + "Speed test read \(actual) of \(expected) bytes." + } + } + } +} diff --git a/Mounty/Services/SystemMountService.swift b/Mounty/Services/SystemMountService.swift index 786b384..829bf17 100644 --- a/Mounty/Services/SystemMountService.swift +++ b/Mounty/Services/SystemMountService.swift @@ -11,10 +11,10 @@ struct SystemMountService { /// Fetches system mounts via `getmntinfo` (MNT_NOWAIT). nonisolated static func getSystemMounts() -> [MountPoint] { var mounts: [MountPoint] = [] - var mntbuf: UnsafeMutablePointer? = nil + var mntbuf: UnsafeMutablePointer? let count = getmntinfo(&mntbuf, MNT_NOWAIT) - if count > 0, let mntbuf = mntbuf { + if count > 0, let mntbuf { for i in 0.. 1 { - if source.hasSuffix(configPath) { return mount.path } - } else { - return mount.path - } - } - } - return nil + guard let host = configUrl.host else { return nil } + return findMountPath(host: host, path: configUrl.path, in: mounts) } /// Checks if a network URL is already mounted. nonisolated static func findMountPath(forURL url: URL) -> String? { - let mounts = getSystemMounts() - let host = url.host?.lowercased() ?? "unknown" - let path = url.path.lowercased() + guard let host = url.host else { return nil } + return findMountPath(host: host, path: url.path, in: getSystemMounts()) + } - for mount in mounts { - let source = mount.source.lowercased() - if source.contains(host) { - if path.count > 1 { - if source.hasSuffix(path) { return mount.path } - } else { - return mount.path - } - } - } - return nil + private nonisolated static func findMountPath( + host: String, + path: String, + in mounts: [MountPoint] + ) -> String? { + let normalizedHost = host.lowercased() + let normalizedPath = path.lowercased() + return mounts.first { mount in + guard extractHost(from: mount.source) == normalizedHost else { return false } + return normalizedPath.count <= 1 + || mount.source.lowercased().hasSuffix(normalizedPath) + }?.path + } + + /// Extracts the hostname from a kernel mount source of the form + /// "//[domain;user@]host/share". Uses string splitting rather than URL + /// parsing to correctly handle SMB sources with "domain;user@host" userinfo + /// that Foundation's URL parser may reject. + private nonisolated static func extractHost(from source: String) -> String? { + guard source.hasPrefix("//") else { return nil } + let withoutSlashes = String(source.dropFirst(2)) + // Take the segment after the last "@" to strip any "domain;user@" prefix. + let afterAt = withoutSlashes.components(separatedBy: "@").last ?? withoutSlashes + let host = afterAt.components(separatedBy: "/").first ?? afterAt + return host.isEmpty ? nil : host.lowercased() } } diff --git a/Mounty/Services/VolumeConfigurationService.swift b/Mounty/Services/VolumeConfigurationService.swift new file mode 100644 index 0000000..176d5fb --- /dev/null +++ b/Mounty/Services/VolumeConfigurationService.swift @@ -0,0 +1,68 @@ +import Foundation + +struct VolumeConfigurationService { + struct MergeResult: Sendable { + let volumes: [Volume] + let importedCount: Int + } + + static func merging( + _ importedVolumes: [Volume], + into existingVolumes: [Volume] + ) -> MergeResult { + var mergedVolumes = existingVolumes + var knownIDs = Set(existingVolumes.map(\.id)) + var knownIdentities = Set(existingVolumes.map { serverIdentity(for: $0.serverAddress) }) + var importedCount = 0 + + for volume in importedVolumes { + guard isValidServerAddress(volume.serverAddress) else { continue } + let identity = serverIdentity(for: volume.serverAddress) + guard !knownIDs.contains(volume.id), !knownIdentities.contains(identity) + else { continue } + + knownIDs.insert(volume.id) + knownIdentities.insert(identity) + mergedVolumes.append(volume) + importedCount += 1 + } + + return MergeResult(volumes: mergedVolumes, importedCount: importedCount) + } + + static func hasDuplicateServerIdentity( + for serverAddress: String, + in volumes: [Volume], + excludingID: UUID? = nil + ) -> Bool { + let identity = serverIdentity(for: serverAddress) + return volumes.contains { + $0.id != excludingID && serverIdentity(for: $0.serverAddress) == identity + } + } + + static func isValidServerAddress(_ serverAddress: String) -> Bool { + let normalizedAddress = Volume.smbServerAddress(from: serverAddress) + guard + let url = URL(string: normalizedAddress), + let host = url.host, + !host.isEmpty + else { + return false + } + return url.port == nil || url.port == 445 + } + + static func serverIdentity(for serverAddress: String) -> String { + let normalizedAddress = Volume.smbServerAddress(from: serverAddress) + guard let url = URL(string: normalizedAddress), let host = url.host else { + return normalizedAddress.lowercased() + } + + let normalizedPath = url.path + .split(separator: "/", omittingEmptySubsequences: true) + .map { $0.lowercased() } + .joined(separator: "/") + return "\(host.lowercased())/\(normalizedPath)" + } +} diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index b3a70c4..8ac1cc8 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -1,47 +1,55 @@ -import Combine import Network import SwiftUI -import UniformTypeIdentifiers -import os /// ViewModel: Orchestrates detection logic, automounting, state management, and data persistence. @MainActor -class VolumeManager: ObservableObject { +@Observable +final class VolumeManager { // MARK: - UI State - @Published var volumes: [Volume] = [] - @Published var mountPaths: [UUID: String] = [:] - @Published var busyVolumes: Set = [] + var volumes: [Volume] = [] + var mountPaths: [UUID: String] = [:] + var busyVolumes: Set = [] + private(set) var isClearingVolumes = false // UI Controls - @Published var searchText = "" - @Published var sortOrder: SortOrder = .name - @Published var sortDirection: SortDirection = .ascending - @Published var showSearch = false + var searchText = "" + var sortOrder: SortOrder = .name { + didSet { storage.saveSortOrder(sortOrder.rawValue) } + } + var sortDirection: SortDirection = .ascending { + didSet { storage.saveSortDirection(sortDirection.rawValue) } + } + var showSearch = false // Preferences - @Published var launchAtLogin: Bool = MountService.isLoginItemEnabled() - @Published var preferredTerminal: String - @Published var availableTerminals: [(name: String, id: String)] = [] + var launchAtLogin = false + var preferredTerminal: String + var availableTerminals: [(name: String, id: String)] = [] // Feedback & Errors - @Published var lastError: String? = nil - @Published var showError: Bool = false - @Published var successMessage: String? = nil - @Published var showSuccess: Bool = false + var lastError: String? + var showError = false + var successMessage: String? + var showSuccess = false + + // In-app log buffer (capped at maxLogEntries) + var logEntries: [LogEntry] = [] + var minimumLogLevel: LogEntry.Level - private var isNetworkUp: Bool = true + // Speed test state + var speedTestVolumeId: UUID? + var isRunningSpeedTest = false + var speedTestResult: SpeedTestService.Result? + var speedTestError: String? + private var speedTestTask: Task? + + private var isNetworkUp = true + private let maxLogEntries = 200 // Dependencies private let storage = PersistenceService() private let eventMonitor = EventMonitorService() - private var cancellables = Set() - - // Logger - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "Mounty", - category: "Manager" - ) private let knownTerminals = [ ("Terminal", "com.apple.Terminal"), @@ -55,9 +63,15 @@ class VolumeManager: ObservableObject { init() { self.volumes = storage.loadVolumes() self.preferredTerminal = storage.loadTerminalBundleID() + self.minimumLogLevel = storage.loadMinimumLogLevel() + self.sortOrder = SortOrder(rawValue: storage.loadSortOrder() ?? "") ?? .name + self.sortDirection = + SortDirection(rawValue: storage.loadSortDirection() ?? "") ?? .ascending - setupPipelines() + startLogObservation() + startEventObservation() refreshInstalledTerminals() + refreshLoginItemStatus() Task { await refreshState() @@ -72,126 +86,251 @@ class VolumeManager: ObservableObject { searchText.isEmpty ? true : $0.name.localizedCaseInsensitiveContains(searchText) } - - let sorted = filtered.sorted { - let comparisonResult: ComparisonResult + return filtered.sorted { lhs, rhs in switch sortOrder { case .name: - comparisonResult = $0.name.localizedStandardCompare($1.name) + let cmp = lhs.name.localizedStandardCompare(rhs.name) + return sortDirection == .ascending + ? cmp == .orderedAscending : cmp == .orderedDescending case .dateAdded: - comparisonResult = $0.dateAdded.compare($1.dateAdded) + let cmp = lhs.dateAdded.compare(rhs.dateAdded) + return sortDirection == .ascending + ? cmp == .orderedAscending : cmp == .orderedDescending + case .state: + let lp = statePriority(lhs), rp = statePriority(rhs) + if lp != rp { + return sortDirection == .ascending ? lp < rp : lp > rp + } + let cmp = lhs.name.localizedStandardCompare(rhs.name) + return sortDirection == .ascending + ? cmp == .orderedAscending : cmp == .orderedDescending } - return sortDirection == .ascending - ? (comparisonResult == .orderedAscending) - : (comparisonResult == .orderedDescending) } - return sorted + } + + private func statePriority(_ volume: Volume) -> Int { + if mountPaths[volume.id] != nil { return 0 } + if busyVolumes.contains(volume.id) { return 1 } + return 2 + } + + var speedTestVolumeName: String { + volumes.first { $0.id == speedTestVolumeId }?.name ?? "Volume" + } + + var hasActiveVolumeOperations: Bool { + isClearingVolumes || !busyVolumes.isEmpty || isRunningSpeedTest } enum SortOrder: String, CaseIterable { case name = "Name" case dateAdded = "Date Added" + case state = "State" } - enum SortDirection { - case ascending, descending + enum SortDirection: String { + case ascending + case descending } - // MARK: - Event Pipelines - - private func setupPipelines() { - // 1. Network Status (Reachability) - High Priority Reaction - eventMonitor.networkStatus - .receive(on: RunLoop.main) - .sink { [weak self] status in - guard let self else { return } + // MARK: - Logging - // Update internal state - let wasUp = self.isNetworkUp - self.isNetworkUp = (status == .satisfied) + private func log(_ message: String, level: LogEntry.Level = .info) { + AppLogger.log(message, level: level, source: .manager) + } - if self.isNetworkUp != wasUp { - self.logger.info( - "Global Network Changed: \(self.isNetworkUp ? "UP" : "DOWN")" - ) + private func startLogObservation() { + Task { [weak self] in + for await entry in AppLogger.entries { + guard let self else { break } + logEntries.append(entry) + if logEntries.count > maxLogEntries { + logEntries.removeFirst(logEntries.count - maxLogEntries) } + } + } + } + + func clearLogs() { + logEntries.removeAll() + AppLogger.clearHistory() + } - // Trigger refresh immediately on ANY status update. - // Priority: .userInitiated (High) for responsiveness. - Task(priority: .userInitiated) { - await self.refreshState() - if self.isNetworkUp { await self.runAutomount() } + func setMinimumLogLevel(_ level: LogEntry.Level) { + minimumLogLevel = level + storage.saveMinimumLogLevel(level) + } + + // MARK: - Speed Test + + func runSpeedTest(for volume: Volume) { + guard !isClearingVolumes, !busyVolumes.contains(volume.id), !isRunningSpeedTest else { + return + } + guard let path = mountPaths[volume.id] else { return } + speedTestVolumeId = volume.id + isRunningSpeedTest = true + speedTestResult = nil + speedTestError = nil + let volumeID = volume.id + let volumeName = volume.name + + speedTestTask = Task.detached(priority: .userInitiated) { [weak self] in + AppLogger.log( + "Speed test started for \(volumeName)", + source: .manager + ) + do { + let result = try await SpeedTestService.measure(at: path) + AppLogger.log( + "Speed test (\(volumeName)): " + + "write \(String(format: "%.1f", result.writeSpeed)) MB/s, " + + "read \(String(format: "%.1f", result.readSpeed)) MB/s", + source: .manager + ) + await MainActor.run { + guard self?.speedTestVolumeId == volumeID else { return } + self?.speedTestResult = result + self?.isRunningSpeedTest = false + self?.speedTestTask = nil } - } - .store(in: &cancellables) - - // 2. Interfaces Changed (VPN Toggles) - High Priority Reaction - eventMonitor.interfacesChanged - .receive(on: RunLoop.main) - .sink { [weak self] in - self?.logger.info( - "Interface topology changed. Retrying connections." + } catch { + let message = error.localizedDescription + AppLogger.log( + "Speed test failed for \(volumeName): \(message)", + level: .error, + source: .manager ) - // Priority: .userInitiated (High) to catch VPNs quickly - Task(priority: .userInitiated) { - await self?.refreshState() - await self?.runAutomount() + await MainActor.run { + guard self?.speedTestVolumeId == volumeID else { return } + self?.speedTestError = message + self?.isRunningSpeedTest = false + self?.speedTestTask = nil } } - .store(in: &cancellables) + } + } - // 3. File System (Manual Mounts) - eventMonitor.fileSystemChanged - .receive(on: RunLoop.main) - .sink { [weak self] in - Task(priority: .utility) { await self?.refreshState() } + func clearSpeedTest() { + speedTestTask?.cancel() + speedTestTask = nil + speedTestVolumeId = nil + isRunningSpeedTest = false + speedTestResult = nil + speedTestError = nil + } + + // MARK: - Event Observation + + private func startEventObservation() { + // The observer tasks inherit MainActor and release it at each `for await` suspension. + // Services move kernel, network, and filesystem work off the actor. + + // 1. Network Status — high-priority reaction + let networkStatusStream = eventMonitor.networkStatusStream + Task { [weak self] in + for await status in networkStatusStream { + guard let self else { break } + let wasUp = isNetworkUp + isNetworkUp = (status == .satisfied) + if isNetworkUp != wasUp { + log("Network: \(isNetworkUp ? "UP" : "DOWN")") + } + await refreshState() + if isNetworkUp { await runAutomount() } + } + } + + // 2. Interface changes (VPN) — debounce applied in EventMonitorService + let interfacesChangedStream = eventMonitor.interfacesChangedStream + Task { [weak self] in + for await _ in interfacesChangedStream { + guard let self else { break } + log("Network interface changed — retrying connections") + await refreshState() + await runAutomount() } - .store(in: &cancellables) - - // 4. Heartbeat Timer (Silent Death Check) - // Interval: 5s (Snappy) - // Optimization: Gated by Network Status & Lower QoS - Timer.publish(every: 5, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in - guard let self = self, self.isNetworkUp else { return } - // Priority: .utility (Low/Efficiency). - // This allows the OS to use E-Cores, saving battery for routine checks. - Task(priority: .utility) { await self.refreshState() } + } + + // 3. File system (manual mounts by other apps) + let fileSystemChangedStream = eventMonitor.fileSystemChangedStream + Task { [weak self] in + for await _ in fileSystemChangedStream { + guard let self else { break } + await refreshState() + } + } + + // 4. Heartbeat (silent death check, every 5 s) + // Task.sleep is RunLoop-independent and does not interact with event-tracking + // modes — it fires from the cooperative thread pool after the sleep interval. + Task(priority: .utility) { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(5)) + guard let self else { break } + guard isNetworkUp else { continue } + await refreshState() + await runAutomount() } - .store(in: &cancellables) + } } // MARK: - Logic private func runAutomount() async { - guard isNetworkUp else { return } + guard isNetworkUp, !isClearingVolumes else { return } - for volume in volumes where volume.isAutomountEnabled { - // Check if not mounted AND not currently processing - if mountPaths[volume.id] == nil && !busyVolumes.contains(volume.id) - { - busyVolumes.insert(volume.id) + let candidates = volumes.filter { + $0.isAutomountEnabled && mountPaths[$0.id] == nil && !busyVolumes.contains($0.id) + } + guard !candidates.isEmpty else { return } + log( + "Automount: \(candidates.count) candidate(s) — \(candidates.map(\.name).joined(separator: ", "))", + level: .debug + ) + for v in candidates { busyVolumes.insert(v.id) } + + let reachableIDs = await withTaskGroup(of: (UUID, Bool).self) { group in + for volume in candidates { + let id = volume.id + group.addTask { + let reachable = await ReachabilityService.isServerReachable( + address: volume.serverAddress + ) + return (id, reachable) + } + } - let isReachable = await ReachabilityService.isServerReachable( - address: volume.serverAddress - ) + var ids = Set() + for await (id, reachable) in group where reachable { + ids.insert(id) + } + return ids + } - // Double-check mountPaths after reachability (async race protection) - if isReachable && mountPaths[volume.id] == nil { - guard let url = URL(string: volume.serverAddress) else { - busyVolumes.remove(volume.id) - continue - } - logger.info( - "Automounting volume: \(volume.name, privacy: .public)" - ) + // Multiple simultaneous NetFS authentication sessions can interfere with one another, + // so only the inexpensive TCP probes are parallelized. Mount requests run one at a time. + for volume in candidates { + defer { busyVolumes.remove(volume.id) } - if let path = await MountService.mount(url: url) { - self.mountPaths[volume.id] = path - } - } - busyVolumes.remove(volume.id) + guard reachableIDs.contains(volume.id) else { + log("Automount skipped: \(volume.name); SMB port 445 is unreachable", level: .info) + continue + } + guard let url = URL(string: volume.serverAddress) else { + log("Automount skipped: \(volume.name); invalid server URL", level: .error) + continue + } + + let result = await MountService.mount(url: url) + if let path = result.path { + mountPaths[volume.id] = path + log("Automounted: \(volume.name) → \(path)") + } else { + log( + "Automount failed: \(volume.name); \(result.debugDescription)", + level: .warning + ) } } } @@ -199,22 +338,27 @@ class VolumeManager: ObservableObject { func refreshState() async { let currentVolumes = self.volumes let networkAvailable = self.isNetworkUp + let prevPaths = self.mountPaths - // Run detection. - // NOTE: Task inherits priority from the caller. - // Events call this with .userInitiated (Fast). - // Timer calls this with .utility (Efficient). let newPaths = await Task.detached { - return await VolumeManager.detectMounts( + await VolumeManager.detectMounts( volumes: currentVolumes, isNetworkUp: networkAvailable ) }.value + // Log volumes that disappeared from the kernel mount table since last check. + for (id, _) in prevPaths where newPaths[id] == nil { + let name = currentVolumes.first(where: { $0.id == id })?.name ?? id.uuidString + log("Lost connection: \(name)") + } + + // Plain assignment — no withAnimation here. Background-triggered state + // changes must not inject an animation transaction that could delay + // visual feedback for concurrent user interactions (button presses, etc.). + // Mount-state icon transitions are animated locally in VolumeRow instead. if self.mountPaths != newPaths { - withAnimation(.easeInOut(duration: 0.2)) { - self.mountPaths = newPaths - } + self.mountPaths = newPaths } } @@ -230,24 +374,21 @@ class VolumeManager: ObservableObject { await withTaskGroup(of: (UUID, String?).self) { group in for volume in volumes { group.addTask { - if let path = SystemMountService.findMountPath( - for: volume, - in: systemMounts - ) { - // 1. TCP Reachability (Fastest fail for dropped VPNs) - // Note: This only runs for volumes that appear to be mounted. - // It does not waste battery pinging unmounted servers. - if await ReachabilityService.isServerReachable( + guard + let path = SystemMountService.findMountPath( + for: volume, + in: systemMounts + ) + else { return (volume.id, nil) } + guard + await ReachabilityService.isServerReachable( address: volume.serverAddress - ) { - // 2. IO Reachability (Catches hung kernel mounts) - if ReachabilityService.isMountPointAlive(path: path) - { - return (volume.id, path) - } - } + ) + else { return (volume.id, nil) } + guard await ReachabilityService.isMountPointAlive(path: path) else { + return (volume.id, nil) } - return (volume.id, nil) + return (volume.id, path) } } for await (id, path) in group { @@ -259,30 +400,73 @@ class VolumeManager: ObservableObject { // MARK: - Actions + // Runs the 6 NSWorkspace lookups off the main actor so the first UI frame + // is never blocked by Launch Services queries. + // Task.detached is required here — Task(priority:) inherits @MainActor and + // would run the synchronous lookups on the main thread. private func refreshInstalledTerminals() { - self.availableTerminals = knownTerminals.filter { (_, bundleID) in - NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) - != nil + let known = knownTerminals + Task.detached(priority: .utility) { [weak self] in + let installed = known.filter { (_, bundleID) in + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) != nil + } + await MainActor.run { [weak self] in + guard let self else { return } + self.availableTerminals = installed + if !self.availableTerminals.contains(where: { $0.id == self.preferredTerminal }) { + self.preferredTerminal = "com.apple.Terminal" + } + } } - if !availableTerminals.contains(where: { $0.id == preferredTerminal }) { - preferredTerminal = "com.apple.Terminal" + } + + private func refreshLoginItemStatus() { + Task.detached(priority: .utility) { [weak self] in + let isEnabled = MountService.isLoginItemEnabled() + await MainActor.run { self?.launchAtLogin = isEnabled } } } func mount(_ volume: Volume) { + guard !isClearingVolumes, !busyVolumes.contains(volume.id) else { return } guard let url = URL(string: volume.serverAddress) else { return } busyVolumes.insert(volume.id) + log("Connecting \(volume.name)…") Task { - if let path = await MountService.mount(url: url) { - self.mountPaths[volume.id] = path - logger.info( - "Manually mounted volume: \(volume.name, privacy: .public)" + // Fail fast: surface an unreachable server immediately rather than + // burning 90 s on a NetFS call that will never complete. + let reachable = await ReachabilityService.isServerReachable( + address: volume.serverAddress + ) + guard reachable else { + let host = URL(string: volume.serverAddress)?.host ?? "unknown-host" + log( + "SMB probe failed: \(volume.name); host=\(host); port=445", + level: .debug ) - } else { + self.lastError = "Cannot reach \(volume.name). Check network and VPN." + self.showError = true + self.log("Not reachable: \(volume.name)", level: .error) + self.busyVolumes.remove(volume.id) + return + } + + let result = await MountService.mount(url: url) + log("NetFS: \(volume.name) — \(result.debugDescription)", level: .debug) + + switch result { + case .success(let path): + self.mountPaths[volume.id] = path + self.log("Connected: \(volume.name) → \(path)") + case .failed(let code): self.lastError = - "Connection failed. Verify address and keychain credentials." + "Connection failed for \(volume.name) (error \(code))." self.showError = true + self.log( + "Failed: \(volume.name); \(result.debugDescription)", + level: .error + ) } self.busyVolumes.remove(volume.id) await self.refreshState() @@ -290,15 +474,22 @@ class VolumeManager: ObservableObject { } func unmount(_ volume: Volume) { + guard !isClearingVolumes, !busyVolumes.contains(volume.id) else { return } disableAutomount(for: volume) guard let path = mountPaths[volume.id] else { return } - mountPaths.removeValue(forKey: volume.id) busyVolumes.insert(volume.id) + log("Disconnecting \(volume.name)") Task { - await MountService.unmount(path: path) + guard await MountService.unmount(path: path) else { + self.reportUnmountFailure(for: volume, action: "Disconnect") + await self.refreshState() + return + } + self.mountPaths.removeValue(forKey: volume.id) self.busyVolumes.remove(volume.id) + self.log("Disconnected: \(volume.name)") await self.refreshState() } } @@ -317,29 +508,227 @@ class VolumeManager: ObservableObject { // MARK: - Persistence - func addVolume(_ volume: Volume) { + @discardableResult + func addVolume(_ volume: Volume) -> Bool { + guard !isClearingVolumes else { return false } + guard VolumeConfigurationService.isValidServerAddress(volume.serverAddress) else { + reportInvalidServerAddress() + return false + } + guard + !VolumeConfigurationService.hasDuplicateServerIdentity( + for: volume.serverAddress, + in: volumes + ) + else { + reportDuplicateVolume() + return false + } volumes.append(volume) storage.saveVolumes(volumes) + log("Added volume: \(volume.name)") Task { await refreshState() } + return true } func removeVolume(_ id: UUID) { - volumes.removeAll { $0.id == id } - storage.saveVolumes(volumes) - Task { await refreshState() } + guard !isClearingVolumes, !busyVolumes.contains(id) else { return } + guard speedTestVolumeId != id || !isRunningSpeedTest else { return } + guard let volume = volumes.first(where: { $0.id == id }) else { return } + + busyVolumes.insert(id) + log("Removing volume: \(volume.name)") + Task { + guard let path = await kernelMountPath(for: volume) else { + self.busyVolumes.remove(id) + self.removeVolumeConfiguration(id: id, name: volume.name) + await self.refreshState() + return + } + guard await MountService.unmount(path: path) else { + self.reportUnmountFailure(for: volume, action: "Remove") + await self.refreshState() + return + } + self.mountPaths.removeValue(forKey: id) + self.busyVolumes.remove(id) + self.removeVolumeConfiguration(id: id, name: volume.name) + await self.refreshState() + } + } + + @discardableResult + func editVolume(id: UUID, name: String, serverAddress: String) -> Bool { + guard !isClearingVolumes, !busyVolumes.contains(id) else { return false } + guard speedTestVolumeId != id || !isRunningSpeedTest else { return false } + guard let idx = volumes.firstIndex(where: { $0.id == id }) else { return false } + guard VolumeConfigurationService.isValidServerAddress(serverAddress) else { + reportInvalidServerAddress() + return false + } + guard + !VolumeConfigurationService.hasDuplicateServerIdentity( + for: serverAddress, + in: volumes, + excludingID: id + ) + else { + reportDuplicateVolume() + return false + } + let old = volumes[idx] + let addressChanged = + VolumeConfigurationService.serverIdentity(for: old.serverAddress) + != VolumeConfigurationService.serverIdentity(for: serverAddress) + + if !addressChanged { + volumes[idx].name = name + volumes[idx].serverAddress = serverAddress + storage.saveVolumes(volumes) + log("Updated volume: \(name)") + return true + } + + busyVolumes.insert(id) + Task { + guard let oldPath = await kernelMountPath(for: old) else { + guard let currentIndex = self.volumes.firstIndex(where: { $0.id == id }) else { + self.busyVolumes.remove(id) + return + } + self.volumes[currentIndex].name = name + self.volumes[currentIndex].serverAddress = serverAddress + self.storage.saveVolumes(self.volumes) + self.log("Updated volume: \(name)") + self.busyVolumes.remove(id) + await self.refreshState() + return + } + + // Unmount the old connection by its discovered path, then remount at the new address. + // Looking up the kernel mount keeps the old share managed even when a network + // transition has cleared the UI's cached mount state. + guard await MountService.unmount(path: oldPath) else { + self.reportUnmountFailure(for: old, action: "Reconnect") + await self.refreshState() + return + } + self.mountPaths.removeValue(forKey: id) + guard let currentIndex = self.volumes.firstIndex(where: { $0.id == id }) else { + self.busyVolumes.remove(id) + return + } + self.volumes[currentIndex].name = name + self.volumes[currentIndex].serverAddress = serverAddress + self.storage.saveVolumes(self.volumes) + self.log("Updated volume: \(name)") + guard let url = URL(string: serverAddress) else { + self.busyVolumes.remove(id) + return + } + let result = await MountService.mount(url: url) + self.log("Reconnect: \(name) — \(result.debugDescription)", level: .debug) + if let newPath = result.path { + self.mountPaths[id] = newPath + self.log("Reconnected \(name) → \(newPath)") + } else { + self.log("Reconnect failed after edit: \(name)", level: .error) + self.lastError = "Reconnect failed. Verify address and credentials." + self.showError = true + } + self.busyVolumes.remove(id) + await self.refreshState() + } + return true } func clearAllVolumes() { - volumes.removeAll() + guard !hasActiveVolumeOperations else { + lastError = "Wait for active volume operations to finish before clearing." + showError = true + return + } + + isClearingVolumes = true + let configuredVolumes = volumes + let configuredIDs = Set(configuredVolumes.map(\.id)) + + Task { + defer { self.isClearingVolumes = false } + let mountedVolumes = await Task.detached(priority: .userInitiated) { + let systemMounts = SystemMountService.getSystemMounts() + return configuredVolumes.compactMap { volume in + SystemMountService.findMountPath(for: volume, in: systemMounts).map { + (volume, $0) + } + } + }.value + for (volume, _) in mountedVolumes { self.busyVolumes.insert(volume.id) } + var retainedIDs = Set() + for (volume, path) in mountedVolumes { + guard await MountService.unmount(path: path) else { + retainedIDs.insert(volume.id) + self.reportUnmountFailure(for: volume, action: "Clear") + continue + } + self.mountPaths.removeValue(forKey: volume.id) + self.busyVolumes.remove(volume.id) + } + self.volumes.removeAll { + configuredIDs.contains($0.id) && !retainedIDs.contains($0.id) + } + self.storage.saveVolumes(self.volumes) + if retainedIDs.isEmpty { + self.log("Cleared all volumes") + } else { + self.log("Clear retained \(retainedIDs.count) mounted volume(s)", level: .warning) + } + await self.refreshState() + } + } + + private func removeVolumeConfiguration(id: UUID, name: String) { + volumes.removeAll { $0.id == id } storage.saveVolumes(volumes) - Task { await refreshState() } + log("Removed volume: \(name)") + } + + private func reportDuplicateVolume() { + lastError = "A volume for this SMB share already exists." + showError = true + log("Volume update skipped: duplicate SMB share", level: .warning) + } + + private func reportInvalidServerAddress() { + lastError = "Enter a valid SMB server and optional share. Custom ports are not supported." + showError = true + log("Volume update skipped: invalid SMB address", level: .warning) + } + + private func kernelMountPath(for volume: Volume) async -> String? { + await Task.detached(priority: .userInitiated) { + SystemMountService.findMountPath( + for: volume, + in: SystemMountService.getSystemMounts() + ) + }.value + } + + private func reportUnmountFailure(for volume: Volume, action: String) { + busyVolumes.remove(volume.id) + lastError = "Could not disconnect \(volume.name). The share remains mounted." + showError = true + log("\(action) failed; \(volume.name) remains mounted", level: .error) } func toggleAutomount(_ id: UUID) { + guard !isClearingVolumes, !busyVolumes.contains(id) else { return } if let idx = volumes.firstIndex(where: { $0.id == id }) { volumes[idx].isAutomountEnabled.toggle() storage.saveVolumes(volumes) - if volumes[idx].isAutomountEnabled { Task { await runAutomount() } } + let v = volumes[idx] + log("Automount \(v.isAutomountEnabled ? "enabled" : "disabled") for \(v.name)") + if v.isAutomountEnabled { Task { await runAutomount() } } } } @@ -351,8 +740,16 @@ class VolumeManager: ObservableObject { } func toggleLaunchAtLogin(_ enabled: Bool) { - MountService.toggleLoginItem(enabled: enabled) - launchAtLogin = MountService.isLoginItemEnabled() + // SMAppService calls can be slow; run off the main actor to avoid + // blocking the UI when the toggle is flipped in Settings. + Task.detached(priority: .userInitiated) { [weak self] in + MountService.toggleLoginItem(enabled: enabled) + let isEnabled = MountService.isLoginItemEnabled() + // Rebind as 'let' so MainActor.run captures a constant, not the + // 'var' weak-optional 'self' — fixes the Swift 6 concurrency warning. + let ref = self + await MainActor.run { ref?.launchAtLogin = isEnabled } + } } func setPreferredTerminal(_ bundleID: String) { @@ -360,62 +757,51 @@ class VolumeManager: ObservableObject { storage.saveTerminalBundleID(bundleID) } - // MARK: - Import / Export Logic + // MARK: - Import / Export - func importVolumes(fromPath pathString: String) { - let expandedPath = (pathString as NSString).expandingTildeInPath - let url = URL(fileURLWithPath: expandedPath) - - do { - let data = try Data(contentsOf: url) - let importedVolumes = try JSONDecoder().decode( - [Volume].self, - from: data - ) - var count = 0 - for volume in importedVolumes { - if !volumes.contains(where: { - $0.serverAddress == volume.serverAddress - }) { - volumes.append(volume) - count += 1 - } + func importVolumes(fromURL url: URL) { + // Data(contentsOf:) is synchronous blocking I/O — run it off the main actor. + Task { + do { + let data = try await Task.detached(priority: .utility) { + try Data(contentsOf: url) + }.value + let importedVolumes = try JSONDecoder().decode([Volume].self, from: data) + let result = VolumeConfigurationService.merging( + importedVolumes, + into: self.volumes + ) + self.volumes = result.volumes + self.storage.saveVolumes(self.volumes) + await self.refreshState() + self.log("Imported \(result.importedCount) volume(s) from backup") + self.successMessage = "Imported \(result.importedCount) volumes successfully." + self.showSuccess = true + } catch { + self.log("Import failed: \(error.localizedDescription)", level: .error) + self.lastError = "Could not import: \(error.localizedDescription)" + self.showError = true } - storage.saveVolumes(volumes) - Task { await refreshState() } - - self.successMessage = "Imported \(count) volumes successfully." - self.showSuccess = true - - } catch { - lastError = "Could not import: \(error.localizedDescription)" - showError = true } } - @discardableResult - func exportToDownloads() -> Bool { - do { - let downloadsURL = try FileManager.default.url( - for: .downloadsDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: false - ) - let fileURL = downloadsURL.appendingPathComponent( - "MountyBackup.json" - ) - - let data = try JSONEncoder().encode(volumes) - try data.write(to: fileURL) - - successMessage = "Backup saved to Downloads." - showSuccess = true - return true - } catch { - lastError = "Export failed: \(error.localizedDescription)" - showError = true - return false + func exportToURL(_ url: URL) { + let snapshot = volumes + // data.write(to:) is synchronous blocking I/O — run it off the main actor. + Task { + do { + let data = try JSONEncoder().encode(snapshot) + try await Task.detached(priority: .utility) { + try data.write(to: url) + }.value + self.log("Exported \(snapshot.count) volume(s)") + self.successMessage = "Backup saved successfully." + self.showSuccess = true + } catch { + self.log("Export failed: \(error.localizedDescription)", level: .error) + self.lastError = "Export failed: \(error.localizedDescription)" + self.showError = true + } } } } diff --git a/Mounty/Views/AddVolumeView.swift b/Mounty/Views/AddVolumeView.swift index 9d5833c..9624e50 100644 --- a/Mounty/Views/AddVolumeView.swift +++ b/Mounty/Views/AddVolumeView.swift @@ -1,80 +1,81 @@ import SwiftUI +// MARK: - Shared form fields + +/// Reusable form body used by both AddVolumeView and EditVolumeView. +/// Manages its own focus state so callers only need to bind the name and address. +struct VolumeFormFields: View { + @Binding var name: String + @Binding var address: String + var onSubmit: () -> Void = {} + + @FocusState private var focusedField: Field? + private enum Field { case name, address } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + TextField("Display Name (e.g. 'Work Drive')", text: $name) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .name) + .submitLabel(.next) + .onSubmit { focusedField = .address } + + HStack(spacing: 4) { + Text("smb://") + .font(.body) + .foregroundColor(.secondary) + + TextField("server/share", text: $address) + .textFieldStyle(.plain) + .focused($focusedField, equals: .address) + .submitLabel(.done) + .onSubmit { onSubmit() } + .autocorrectionDisabled(true) + .onChange(of: address) { _, newValue in + let normalized = Volume.shareAddress(from: newValue) + if normalized != newValue { address = normalized } + } + } + .padding(8) + .background(Color(NSColor.textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + .onAppear { + Task { + try? await Task.sleep(for: .milliseconds(500)) + focusedField = .name + } + } + } +} + +// MARK: - Add Volume View + struct AddVolumeView: View { - @ObservedObject var manager: VolumeManager + var manager: VolumeManager @Binding var viewMode: AppViewMode @State private var name = "" @State private var address = "" - @State private var selectedProtocol: ProtocolType = .smb - @FocusState private var focusedField: Field? - - enum Field { case name, address } - enum ProtocolType: String, CaseIterable, Identifiable { - case smb = "SMB" - case afp = "AFP" - case nfs = "NFS" - case ftp = "FTP" - var id: String { self.rawValue } - var scheme: String { self.rawValue.lowercased() + "://" } - } var body: some View { VStack(alignment: .leading, spacing: 0) { HeaderView( title: "Add New Volume", - backAction: { - focusedField = nil - withAnimation { viewMode = .list } - } + backAction: { viewMode = .list } ) Divider() - VStack(alignment: .leading, spacing: 18) { - TextField("Display Name (e.g. 'Work Drive')", text: $name) - .textFieldStyle(.roundedBorder) - .focused($focusedField, equals: .name) - .submitLabel(.next) - .onSubmit { focusedField = .address } - - Picker("Protocol", selection: $selectedProtocol) { - ForEach(ProtocolType.allCases) { Text($0.rawValue).tag($0) } - } - .pickerStyle(.segmented) - - HStack(spacing: 4) { - Text(selectedProtocol.scheme) - .font(.body) - .foregroundColor(.secondary) - - TextField("server/share", text: $address) - .textFieldStyle(.plain) - .focused($focusedField, equals: .address) - .submitLabel(.done) - .onSubmit { save() } - .autocorrectionDisabled(true) - .onChange(of: address) { _, newValue in - for proto in ProtocolType.allCases { - if newValue.lowercased().hasPrefix(proto.scheme) - { - selectedProtocol = proto - address = String( - newValue.dropFirst(proto.scheme.count) - ) - return - } - } - } - } - .padding(8) - .background(Color(NSColor.textBackgroundColor)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - ) - } + VolumeFormFields( + name: $name, + address: $address, + onSubmit: save + ) .padding(20) Spacer() @@ -88,19 +89,76 @@ struct AddVolumeView: View { } .padding(20) } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - focusedField = .name + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func save() { + guard !name.isEmpty, !address.isEmpty else { return } + let fullAddress = Volume.smbServerAddress(from: address) + guard manager.addVolume(Volume(name: name, serverAddress: fullAddress)) else { return } + viewMode = .list + } +} + +// MARK: - Edit Volume View + +struct EditVolumeView: View { + let volume: Volume + var manager: VolumeManager + @Binding var viewMode: AppViewMode + + @State private var name: String + @State private var address: String + + init(volume: Volume, manager: VolumeManager, viewMode: Binding) { + self.volume = volume + self.manager = manager + self._viewMode = viewMode + + self._name = State(initialValue: volume.name) + self._address = State(initialValue: Volume.shareAddress(from: volume.serverAddress)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HeaderView( + title: "Edit Volume", + backAction: { viewMode = .list } + ) + + Divider() + + VolumeFormFields( + name: $name, + address: $address, + onSubmit: save + ) + .padding(20) + + Spacer() + + HStack { + Spacer() + Button("Save Changes") { save() } + .buttonStyle(.borderedProminent) + .disabled(name.isEmpty || address.isEmpty) + .keyboardShortcut(.defaultAction) } + .padding(20) } - .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) } private func save() { guard !name.isEmpty, !address.isEmpty else { return } - focusedField = nil - let fullAddress = selectedProtocol.scheme + address - manager.addVolume(Volume(name: name, serverAddress: fullAddress)) + let fullAddress = Volume.smbServerAddress(from: address) + guard + manager.editVolume( + id: volume.id, + name: name, + serverAddress: fullAddress + ) + else { return } viewMode = .list } } diff --git a/Mounty/Views/HeaderView.swift b/Mounty/Views/HeaderView.swift index d883db7..f7da019 100644 --- a/Mounty/Views/HeaderView.swift +++ b/Mounty/Views/HeaderView.swift @@ -8,6 +8,14 @@ struct HeaderView: View { var trailingAction: (() -> Void)? = nil var trailingIcon: (String, Color)? = nil var trailingHelp: String = "" + // Optional second trailing button — rendered to the LEFT of the primary one. + var trailingAction2: (() -> Void)? = nil + var trailingIcon2: (String, Color)? = nil + var trailingHelp2: String = "" + var trailingShortcut2: KeyboardShortcut? = nil + + // When two trailing buttons are present, both sides widen to keep the title centered. + private var sideWidth: CGFloat { trailingAction2 != nil ? 64 : 32 } var body: some View { HStack { @@ -16,19 +24,18 @@ struct HeaderView: View { if let backAction = backAction { Button(action: backAction) { Image(systemName: "chevron.left") - .font(.system(size: 16, weight: .semibold)) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.accentColor) } - .buttonStyle(.plain) - .foregroundColor(.accentColor) + .iconButtonHover() } else if showLogo { - // Transparent Asset "Logo" Image("Logo") .resizable() .scaledToFit() .cornerRadius(4) } } - .frame(width: 32, height: 32, alignment: .leading) + .frame(width: sideWidth, height: 32, alignment: .leading) Spacer() @@ -40,23 +47,30 @@ struct HeaderView: View { Spacer() - // Trailing - ZStack(alignment: .trailing) { + // Trailing — second button (if any) sits to the left of the primary. + HStack(spacing: 0) { + if let action2 = trailingAction2, let icon2 = trailingIcon2 { + Button(action: action2) { + Image(systemName: icon2.0) + .font(.system(size: 15)) + .foregroundColor(icon2.1) + } + .iconButtonHover() + .keyboardShortcut(trailingShortcut2) + .help(trailingHelp2) + } if let action = trailingAction, let icon = trailingIcon { Button(action: action) { Image(systemName: icon.0) - .font(.system(size: 16)) + .font(.system(size: 15)) .foregroundColor(icon.1) } - .buttonStyle(.plain) + .iconButtonHover() .help(trailingHelp) } } - // Matches leading size to keep title centered - .frame(width: 32, height: 32, alignment: .trailing) + .frame(width: sideWidth, height: 32, alignment: .trailing) } - // Reduced vertical padding (12 -> 10) to compensate for larger icon - // Total height remains: 32 + 10 + 10 = 52 (Same as previous 28 + 12 + 12) .padding(.horizontal, 12) .padding(.vertical, 10) .background(.regularMaterial) diff --git a/Mounty/Views/LogsView.swift b/Mounty/Views/LogsView.swift new file mode 100644 index 0000000..504ba00 --- /dev/null +++ b/Mounty/Views/LogsView.swift @@ -0,0 +1,144 @@ +import SwiftUI + +struct LogsView: View { + var manager: VolumeManager + @Binding var viewMode: AppViewMode + + private var visibleEntries: [LogEntry] { + manager.logEntries.filter { $0.level >= manager.minimumLogLevel } + } + + var body: some View { + VStack(spacing: 0) { + HeaderView( + title: "Logs", + backAction: { viewMode = .list } + ) + + Divider() + + if visibleEntries.isEmpty { + VStack(spacing: 8) { + Spacer() + Image(systemName: "doc.text") + .font(.system(size: 28)) + .foregroundColor(.secondary.opacity(0.5)) + Text(manager.logEntries.isEmpty ? "No Log Entries" : "No Entries at This Level") + .font(.callout) + .foregroundColor(.secondary) + Spacer() + } + .frame(maxHeight: .infinity) + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(visibleEntries) { entry in + LogEntryRow(entry: entry) + } + Color.clear.frame(height: 1).id("logsBottom") + } + .padding(.vertical, 4) + } + .frame(maxHeight: .infinity) + .onChange(of: visibleEntries.count) { _, _ in + proxy.scrollTo("logsBottom", anchor: .bottom) + } + .onAppear { + proxy.scrollTo("logsBottom", anchor: .bottom) + } + } + } + + Divider() + + HStack { + Button { + manager.clearLogs() + } label: { + Label("Clear", systemImage: "trash") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + .iconButtonHover(cornerRadius: 5, padding: 4) + .help("Clear all log entries") + + Spacer() + + Menu { + ForEach(LogEntry.Level.allCases, id: \.self) { level in + Button { + manager.setMinimumLogLevel(level) + } label: { + HStack { + if manager.minimumLogLevel == level { + Image(systemName: "checkmark") + } + Text(level.label) + } + } + } + } label: { + HStack(spacing: 3) { + Image(systemName: "line.3.horizontal.decrease") + Text(manager.minimumLogLevel.label) + } + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Minimum log level to display") + + Spacer() + + Button { + let text = visibleEntries.map { $0.formatted }.joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } label: { + Label("Copy", systemImage: "doc.on.doc") + .font(.system(size: 12)) + .foregroundColor( + visibleEntries.isEmpty ? .secondary.opacity(0.4) : .secondary) + } + .iconButtonHover(cornerRadius: 5, padding: 4) + .disabled(visibleEntries.isEmpty) + .help("Copy visible log entries to clipboard") + } + .appFooterLayout() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct LogEntryRow: View { + let entry: LogEntry + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: entry.level.symbol) + .font(.system(size: 9)) + .foregroundColor(entry.level.color) + .frame(width: 11) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 1) { + Text(entry.message) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.primary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + + Text( + "\(entry.source.label) · " + + entry.timestamp.formatted(.dateTime.hour().minute().second()) + ) + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 3) + } +} diff --git a/Mounty/Views/MainListView.swift b/Mounty/Views/MainListView.swift index e266b1c..2b2aa96 100644 --- a/Mounty/Views/MainListView.swift +++ b/Mounty/Views/MainListView.swift @@ -1,16 +1,20 @@ import SwiftUI struct MainListView: View { - @ObservedObject var manager: VolumeManager + @Bindable var manager: VolumeManager @Binding var viewMode: AppViewMode private let rowHeight: CGFloat = 50 - private let maxVisibleRows = 5 + private let searchHeight: CGFloat = 44 + private let minVisibleRows = 3 + private let maxVisibleRowsCap = 12 + @AppStorage("mounty.maxVisibleRows") private var maxVisibleRows: Int = 5 + + @State private var isResizeHovered = false + @State private var dragStartRows = 0 private var listHeight: CGFloat { - let count = manager.filteredAndSortedVolumes.count - if count == 0 { return 120 } - return min(CGFloat(count), CGFloat(maxVisibleRows)) * rowHeight + CGFloat(maxVisibleRows) * rowHeight } private var isSearchVisible: Bool { @@ -19,34 +23,33 @@ struct MainListView: View { var body: some View { ZStack { - // Main Content Layer VStack(spacing: 0) { - // Hidden shortcut trigger - Button("") { withAnimation { manager.showSearch.toggle() } } - .keyboardShortcut("f", modifiers: .command) - .frame(width: 0, height: 0) - .opacity(0) - HeaderView( title: "Mounty", showLogo: true, trailingAction: { viewMode = .settings }, trailingIcon: ("gearshape.fill", .secondary), - trailingHelp: "Settings" + trailingHelp: "Settings", + trailingAction2: { manager.showSearch.toggle() }, + trailingIcon2: ( + "magnifyingglass", isSearchVisible ? .accentColor : .secondary + ), + trailingHelp2: "Search Volumes (⌘F)", + trailingShortcut2: KeyboardShortcut("f", modifiers: .command) ) .transaction { $0.animation = nil } - // Search Bar - Background matches Window Header - if isSearchVisible { - VStack(spacing: 0) { + VStack(spacing: 0) { + // Search and list share a fixed-height region so toggling search + // cannot move the resize handle or footer. + if isSearchVisible { HStack(alignment: .center) { TextField("Search...", text: $manager.searchText) .textFieldStyle(.roundedBorder) .frame(height: 28) Menu { - Picker("Sort By", selection: $manager.sortOrder) - { + Picker("Sort By", selection: $manager.sortOrder) { ForEach( VolumeManager.SortOrder.allCases, id: \.self @@ -68,8 +71,7 @@ struct MainListView: View { ? .descending : .ascending } label: { Image( - systemName: manager.sortDirection - == .ascending + systemName: manager.sortDirection == .ascending ? "arrow.down" : "arrow.up" ) } @@ -78,61 +80,108 @@ struct MainListView: View { .help("Toggle Sort Direction") } .padding(.horizontal, 12) - .padding(.vertical, 8) + .frame(height: searchHeight) + .frame(maxWidth: .infinity) + .background(Color(NSColor.windowBackgroundColor)) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(1) } - .background(Color(NSColor.windowBackgroundColor)) - .transition(.move(edge: .top).combined(with: .opacity)) - .zIndex(1) - } - Divider() + Divider() - // List - if manager.filteredAndSortedVolumes.isEmpty { - VStack { - Spacer() - Text( - manager.volumes.isEmpty - ? "No Volumes Configured" - : "No Matching Volumes" - ) - .foregroundColor(.secondary) - Spacer() - }.frame(height: listHeight) - } else { - ScrollViewReader { _ in + // List content + if manager.filteredAndSortedVolumes.isEmpty { + VStack(spacing: 8) { + Spacer() + Image( + systemName: manager.volumes.isEmpty + ? "externaldrive.badge.plus" + : "magnifyingglass" + ) + .font(.system(size: 28)) + .foregroundColor(.secondary.opacity(0.5)) + Text( + manager.volumes.isEmpty + ? "No Volumes Configured" + : "No Matching Volumes" + ) + .font(.callout) + .foregroundColor(.secondary) + Spacer() + } + .frame(height: listHeight - (isSearchVisible ? searchHeight : 0)) + } else { ScrollView { VStack(spacing: 0) { - ForEach(manager.filteredAndSortedVolumes) { - volume in - VolumeRow(volume: volume, manager: manager) - .frame(height: rowHeight) + ForEach(manager.filteredAndSortedVolumes) { volume in + VolumeRow( + volume: volume, manager: manager, + onEdit: { + viewMode = .edit(volume) + } + ) + .frame(height: rowHeight) Divider() } } } - .frame(height: listHeight) + .frame(height: listHeight - (isSearchVisible ? searchHeight : 0)) .scrollDisabled( - manager.filteredAndSortedVolumes.count - <= maxVisibleRows + manager.filteredAndSortedVolumes.count <= maxVisibleRows ) } } + .frame(height: listHeight, alignment: .top) + .clipped() + + // Resize handle — drag vertically to reveal more or fewer rows. + // Shows a grab pill on hover; the resize cursor confirms the gesture. + ZStack { + Color.clear.frame(height: 8) + Divider() + if isResizeHovered || dragStartRows != 0 { + RoundedRectangle(cornerRadius: 2) + .fill(Color.secondary.opacity(0.45)) + .frame(width: 36, height: 3) + } + } + .contentShape(Rectangle()) + .onHover { hovering in + isResizeHovered = hovering + if hovering { + NSCursor.resizeUpDown.push() + } else if dragStartRows == 0 { + NSCursor.pop() + } + } + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + if dragStartRows == 0 { dragStartRows = maxVisibleRows } + let delta = Int(round(value.translation.height / rowHeight)) + maxVisibleRows = min( + max(minVisibleRows, dragStartRows + delta), + maxVisibleRowsCap + ) + } + .onEnded { _ in + dragStartRows = 0 + if !isResizeHovered { NSCursor.pop() } + } + ) - Divider() - - // Footer + // Footer — maxWidth: .infinity guarantees the Spacer always fills + // the same distance regardless of what the list content does. HStack { Button { - withAnimation { manager.showSearch.toggle() } + viewMode = .logs } label: { - Image(systemName: "magnifyingglass") - .foregroundColor( - isSearchVisible ? .accentColor : .secondary - ) + Image(systemName: "doc.text") + .font(.system(size: 13)) + .foregroundColor(.secondary) } - .buttonStyle(.plain) - .help("Search Volumes (⌘F)") + .iconButtonHover() + .help("Logs") Spacer() @@ -145,13 +194,16 @@ struct MainListView: View { .controlSize(.small) .help("Add Volume") } - .padding(12) + .appFooterLayout() .background(Color(NSColor.windowBackgroundColor)) } + // Declarative animation: fires reliably on every isSearchVisible change + // because it's value-driven, not action-driven. The HeaderView's own + // .transaction suppressor prevents it from animating along. + .animation(.easeOut(duration: 0.18), value: isSearchVisible) .blur(radius: manager.showError ? 2 : 0) - .disabled(manager.showError) + .disabled(manager.showError || manager.isClearingVolumes) - // Overlay Layer if manager.showError { AlertOverlay( title: "Error", @@ -161,6 +213,6 @@ struct MainListView: View { ) } } - .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/Mounty/Views/Overlays.swift b/Mounty/Views/Overlays.swift index 40600c5..1084b9b 100644 --- a/Mounty/Views/Overlays.swift +++ b/Mounty/Views/Overlays.swift @@ -1,5 +1,59 @@ import SwiftUI +// MARK: - Icon Button Hover Style +// ButtonStyle is the correct mechanism: padding added inside makeBody becomes +// part of the button's own rendered frame, so the full padded area is the hit +// target. The old ViewModifier approach added padding *outside* the Button, +// leaving the hit area as only the small icon — causing missed clicks. +struct IconHoverButtonStyle: ButtonStyle { + var cornerRadius: CGFloat = 5 + var padding: CGFloat = 4 + + func makeBody(configuration: Configuration) -> some View { + IconHoverBody( + configuration: configuration, + cornerRadius: cornerRadius, + padding: padding + ) + } +} + +private struct IconHoverBody: View { + let configuration: ButtonStyleConfiguration + let cornerRadius: CGFloat + let padding: CGFloat + @State private var isHovered = false + + var body: some View { + configuration.label + .padding(padding) + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill( + (isHovered || configuration.isPressed) + ? Color.primary.opacity(0.08) : .clear + ) + .animation(.easeOut(duration: 0.12), value: isHovered) + ) + .contentShape(Rectangle()) + .onHover { isHovered = $0 } + } +} + +extension View { + // Callers must NOT also apply .buttonStyle(.plain) — that would take + // precedence over this style and revert to a tiny hit area. + func iconButtonHover(cornerRadius: CGFloat = 5, padding: CGFloat = 4) -> some View { + buttonStyle(IconHoverButtonStyle(cornerRadius: cornerRadius, padding: padding)) + } + + func appFooterLayout() -> some View { + frame(height: 24) + .padding(12) + .frame(maxWidth: .infinity) + } +} + // MARK: - Status Alert Overlay /// Displays success (Green) or error (Red) messages non-intrusively. struct AlertOverlay: View { @@ -36,7 +90,7 @@ struct AlertOverlay: View { .padding(20) .frame(width: 280) .background(.regularMaterial) - .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(radius: 10) .transition(.scale.combined(with: .opacity)) } @@ -78,7 +132,7 @@ struct ConfirmationOverlay: View { .padding(20) .frame(width: 280) .background(.regularMaterial) - .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(radius: 10) .transition(.scale.combined(with: .opacity)) } @@ -86,64 +140,56 @@ struct ConfirmationOverlay: View { } } -// MARK: - Input Overlay -/// Modal for text entry (e.g., Import Paths). -struct InputOverlay: View { - let title: String - let message: String - let placeholder: String - @Binding var inputText: String +// MARK: - Speed Test Result Overlay +struct SpeedTestOverlay: View { + let volumeName: String + let result: SpeedTestService.Result @Binding var isPresented: Bool - let onConfirm: () -> Void - - @FocusState private var isFocused: Bool var body: some View { ZStack { Color.black.opacity(0.2).ignoresSafeArea() - .onTapGesture { - isFocused = false - withAnimation { isPresented = false } - } + .onTapGesture { withAnimation { isPresented = false } } VStack(spacing: 16) { - Text(title).font(.headline) - Text(message).font(.caption).multilineTextAlignment(.center) - .foregroundColor(.secondary) - - TextField(placeholder, text: $inputText) - .textFieldStyle(.roundedBorder) - .focused($isFocused) - - HStack(spacing: 12) { - Button("Cancel") { - isFocused = false - withAnimation { isPresented = false } + Image(systemName: "speedometer") + .font(.system(size: 32)) + .foregroundColor(.accentColor) + + Text(volumeName).font(.headline) + + Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 6) { + GridRow { + Label("Write", systemImage: "arrow.up.circle") + .foregroundColor(.secondary) + Text(String(format: "%.1f MB/s", result.writeSpeed)) + .fontWeight(.medium) + .gridColumnAlignment(.trailing) } - .keyboardShortcut(.cancelAction) - - Button("Import") { - isFocused = false - withAnimation { isPresented = false } - onConfirm() + GridRow { + Label("Read", systemImage: "arrow.down.circle") + .foregroundColor(.secondary) + Text(String(format: "%.1f MB/s", result.readSpeed)) + .fontWeight(.medium) + .gridColumnAlignment(.trailing) } - .buttonStyle(.borderedProminent) - .disabled(inputText.isEmpty) - .keyboardShortcut(.defaultAction) } + .font(.callout) + + Text("Test size: \(String(format: "%.0f", result.fileSizeMB)) MB") + .font(.caption) + .foregroundColor(.secondary) + + Button("Done") { withAnimation { isPresented = false } } + .keyboardShortcut(.defaultAction) } - .padding(20) + .padding(24) .frame(width: 280) .background(.regularMaterial) - .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(radius: 10) .transition(.scale.combined(with: .opacity)) } .zIndex(100) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isFocused = true - } - } } } diff --git a/Mounty/Views/RootView.swift b/Mounty/Views/RootView.swift index b48541e..14fc1c1 100644 --- a/Mounty/Views/RootView.swift +++ b/Mounty/Views/RootView.swift @@ -1,26 +1,119 @@ import SwiftUI struct RootView: View { - @StateObject var manager = VolumeManager() + @State private var manager = VolumeManager() @State private var viewMode: AppViewMode = .list + @AppStorage("mounty.maxVisibleRows") private var maxVisibleRows: Int = 5 + + private let windowWidth: CGFloat = 420 + private let headerHeight: CGFloat = 52 + private let rowHeight: CGFloat = 50 + private let resizeHandleHeight: CGFloat = 8 + private let footerHeight: CGFloat = 48 + + private var windowHeight: CGFloat { + headerHeight + (CGFloat(maxVisibleRows) * rowHeight) + resizeHandleHeight + footerHeight + } var body: some View { - ZStack { + ZStack(alignment: .top) { Color(NSColor.windowBackgroundColor).ignoresSafeArea() + activeView + speedTestDialogs + } + .animation(.easeOut(duration: 0.2), value: viewMode) + .frame(width: windowWidth, height: windowHeight) + } + + @ViewBuilder + private var activeView: some View { + switch viewMode { + case .list: + MainListView(manager: manager, viewMode: $viewMode) + .transition( + .asymmetric( + insertion: .move(edge: .leading), + removal: .move(edge: .leading) + )) + case .add: + AddVolumeView(manager: manager, viewMode: $viewMode) + .transition( + .asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .trailing) + )) + case .settings: + SettingsView(manager: manager, viewMode: $viewMode) + .transition( + .asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .trailing) + )) + case .logs: + LogsView(manager: manager, viewMode: $viewMode) + .transition( + .asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .trailing) + )) + case .edit(let volume): + EditVolumeView(volume: volume, manager: manager, viewMode: $viewMode) + .transition( + .asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .trailing) + )) + } + } + + @ViewBuilder + private var speedTestDialogs: some View { + if let result = manager.speedTestResult { + SpeedTestResultDialog( + volumeName: manager.speedTestVolumeName, + result: result, + onDismiss: manager.clearSpeedTest + ) + } + + if let error = manager.speedTestError { + SpeedTestErrorDialog(message: error, onDismiss: manager.clearSpeedTest) + } + } +} - switch viewMode { - case .list: - MainListView(manager: manager, viewMode: $viewMode) - .transition(.move(edge: .leading)) - case .add: - AddVolumeView(manager: manager, viewMode: $viewMode) - .transition(.move(edge: .trailing)) - case .settings: - SettingsView(manager: manager, viewMode: $viewMode) - .transition(.move(edge: .trailing)) - } +private struct SpeedTestResultDialog: View { + let volumeName: String + let result: SpeedTestService.Result + let onDismiss: () -> Void + @State private var isPresented = true + + var body: some View { + SpeedTestOverlay( + volumeName: volumeName, + result: result, + isPresented: $isPresented + ) + .onChange(of: isPresented) { _, isPresented in + if !isPresented { onDismiss() } + } + } +} + +private struct SpeedTestErrorDialog: View { + let message: String + let onDismiss: () -> Void + @State private var isPresented = true + + var body: some View { + AlertOverlay( + title: "Speed Test Failed", + message: message, + isPresented: $isPresented, + isError: true + ) + .onChange(of: isPresented) { _, isPresented in + if !isPresented { onDismiss() } } - .animation(.default, value: viewMode) - .frame(width: 420) } } diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index 3083624..a75d304 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -1,22 +1,21 @@ +import AppKit import SwiftUI +import UniformTypeIdentifiers struct SettingsView: View { - @ObservedObject var manager: VolumeManager + @Bindable var manager: VolumeManager @Binding var viewMode: AppViewMode // Overlay State @State private var showResetConfirmation = false @State private var showQuitConfirmation = false - @State private var showImportDialog = false - - // Import Logic - @State private var importPath = "" let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" + private let repositoryURL = URL(string: "https://github.com/maptic/mounty") var body: some View { ZStack { @@ -24,10 +23,8 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 0) { HeaderView( title: "Settings", - backAction: { withAnimation { viewMode = .list } }, - trailingAction: { - withAnimation { showQuitConfirmation = true } - }, + backAction: { viewMode = .list }, + trailingAction: { showQuitConfirmation = true }, trailingIcon: ("xmark.circle.fill", .red), trailingHelp: "Quit Mounty" ) @@ -55,38 +52,41 @@ struct SettingsView: View { } Section(header: Text("Volumes")) { - HStack(spacing: 12) { + HStack(spacing: 8) { Button { - importPath = "" - withAnimation { showImportDialog = true } + showOpenPanel() } label: { - Image(systemName: "square.and.arrow.up") + Label("Import", systemImage: "square.and.arrow.up") + .font(.callout) .frame(maxWidth: .infinity) } - .buttonStyle(.bordered) - .help("Import volumes from JSON") + .iconButtonHover(cornerRadius: 6, padding: 6) + .help("Import volumes from a JSON backup file") Button { - manager.exportToDownloads() + showSavePanel() } label: { - Image(systemName: "square.and.arrow.down") + Label("Export", systemImage: "square.and.arrow.down") + .font(.callout) .frame(maxWidth: .infinity) } - .buttonStyle(.bordered) - .help("Export volumes to Downloads") + .iconButtonHover(cornerRadius: 6, padding: 6) + .help("Export volumes to a JSON backup file") Button { withAnimation { showResetConfirmation = true } } label: { - Image(systemName: "trash") + Label("Reset", systemImage: "trash") + .font(.callout) .frame(maxWidth: .infinity) + .foregroundColor(.red) } - .buttonStyle(.bordered) - .tint(.red) + .iconButtonHover(cornerRadius: 6, padding: 6) + .disabled(manager.hasActiveVolumeOperations) .help("Clear all volumes") } .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) } Section(header: Text("Application Info")) { @@ -102,10 +102,19 @@ struct SettingsView: View { .scaledToFit() .frame(width: 48, height: 48) - Text("Mounty \(appVersion)").font(.headline) + Text("Mounty").font(.headline) Text("Version \(appVersion) (\(buildNumber))") .font(.caption) .foregroundColor(.secondary) + + if let repositoryURL { + Link(destination: repositoryURL) { + Image(systemName: "link") + .font(.caption) + .foregroundColor(.secondary) + } + .help("View Mounty on GitHub") + } } Spacer() } @@ -115,16 +124,14 @@ struct SettingsView: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) - .scrollDisabled(true) + .scrollIndicators(.automatic) .disabled( showResetConfirmation || showQuitConfirmation - || showImportDialog || manager.showSuccess - || manager.showError + || manager.showSuccess || manager.showError ) .blur( radius: (showResetConfirmation || showQuitConfirmation - || showImportDialog || manager.showSuccess - || manager.showError) ? 2 : 0 + || manager.showSuccess || manager.showError) ? 2 : 0 ) } @@ -138,7 +145,7 @@ struct SettingsView: View { isPresented: $showResetConfirmation ) { manager.clearAllVolumes() - withAnimation { viewMode = .list } + viewMode = .list } } @@ -153,18 +160,6 @@ struct SettingsView: View { } } - if showImportDialog { - InputOverlay( - title: "Import Volumes", - message: "Enter full path to backup file", - placeholder: "~/Downloads/MountyBackup.json", - inputText: $importPath, - isPresented: $showImportDialog - ) { - manager.importVolumes(fromPath: importPath) - } - } - // ViewModel Feedback Overlays if manager.showSuccess { AlertOverlay( @@ -184,6 +179,38 @@ struct SettingsView: View { ) } } - .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - File Panels + + // NSOpenPanel and NSSavePanel are presented app-modally (no parent window) + // because MenuBarExtra windows cannot host sheets. The popover dismisses + // naturally when the panel steals focus, but the panel remains fully usable. + // NSApp.activate ensures the panel appears in the foreground. + + private func showOpenPanel() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.message = "Select a Mounty backup file to import" + NSApp.activate(ignoringOtherApps: true) + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + manager.importVolumes(fromURL: url) + } + } + + private func showSavePanel() { + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "MountyBackup.json" + panel.message = "Choose where to save your Mounty backup" + NSApp.activate(ignoringOtherApps: true) + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + manager.exportToURL(url) + } } } diff --git a/Mounty/Views/VolumeRow.swift b/Mounty/Views/VolumeRow.swift index a125345..5ee3257 100644 --- a/Mounty/Views/VolumeRow.swift +++ b/Mounty/Views/VolumeRow.swift @@ -2,20 +2,25 @@ import SwiftUI struct VolumeRow: View { let volume: Volume - @ObservedObject var manager: VolumeManager + var manager: VolumeManager + var onEdit: () -> Void = {} + @State private var isRowHovered = false var isMounted: Bool { manager.mountPaths[volume.id] != nil } var isBusy: Bool { manager.busyVolumes.contains(volume.id) } + var isTesting: Bool { manager.speedTestVolumeId == volume.id && manager.isRunningSpeedTest } var currentPath: String { manager.mountPaths[volume.id] ?? "Disconnected" } var body: some View { HStack(spacing: 12) { // Icon Image(systemName: "server.rack") - .font(.system(size: 24)) + .font(.system(size: 22)) .foregroundColor( - isMounted ? .accentColor : .secondary.opacity(0.5) + isMounted ? .accentColor : .secondary.opacity(0.4) ) + .frame(width: 26) + .animation(.easeOut(duration: 0.2), value: isMounted) .help( isMounted ? "Mounted at: \(currentPath)" @@ -27,7 +32,7 @@ struct VolumeRow: View { Text(volume.name) .font(.system(size: 13, weight: .medium)) Text(isMounted ? currentPath : volume.serverAddress) - .font(.caption2) + .font(.caption) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) @@ -37,7 +42,7 @@ struct VolumeRow: View { Spacer() // Actions - HStack(spacing: 8) { + HStack(spacing: 4) { // 1. Automount Toggle Button { @@ -47,17 +52,18 @@ struct VolumeRow: View { systemName: volume.isAutomountEnabled ? "bolt.fill" : "bolt" ) - .font(.system(size: 14)) + .font(.system(size: 13)) .foregroundColor( volume.isAutomountEnabled - ? .orange : .secondary.opacity(0.3) + ? .orange : .secondary.opacity(0.35) ) } - .buttonStyle(.plain) + .iconButtonHover(padding: 3) .help( volume.isAutomountEnabled ? "Disable Automount" : "Enable Automount" ) + .disabled(isBusy || isTesting || manager.isClearingVolumes) // 2. Open in Finder (Only when mounted) if isMounted { @@ -68,7 +74,7 @@ struct VolumeRow: View { .font(.system(size: 12)) .foregroundColor(.secondary) } - .buttonStyle(.plain) + .iconButtonHover(padding: 3) .help("Show in Finder") } @@ -81,11 +87,11 @@ struct VolumeRow: View { .font(.system(size: 12)) .foregroundColor(.secondary) } - .buttonStyle(.plain) + .iconButtonHover(padding: 3) .help("Open in Terminal") } - // 4. Mount / Unmount + // 4. Mount / Unmount (also shows speed-test progress) Button { if isMounted { manager.unmount(volume) @@ -93,31 +99,67 @@ struct VolumeRow: View { manager.mount(volume) } } label: { - if isBusy { + if isBusy || isTesting { ProgressView().controlSize(.mini).scaleEffect(0.7) + .frame(width: 20, height: 20) } else { Image( systemName: isMounted ? "network.slash" : "network" ) - .font(.system(size: 16, weight: .medium)) + .font(.system(size: 15, weight: .medium)) .foregroundColor(isMounted ? .red : .primary) } } - .buttonStyle(.plain) - .disabled(isBusy) - .help(isMounted ? "Disconnect" : "Connect") + .iconButtonHover(padding: 3) + .disabled(isBusy || isTesting) + .help(isTesting ? "Speed test running…" : (isMounted ? "Disconnect" : "Connect")) } } .padding(.horizontal, 12) - .onTapGesture(count: 2) { - if isMounted { manager.openInFinder(volume) } - } + .frame(maxHeight: .infinity) + .background(isRowHovered ? Color.primary.opacity(0.04) : .clear) + .contentShape(Rectangle()) + .animation(.easeOut(duration: 0.1), value: isRowHovered) + .onHover { isRowHovered = $0 } + // simultaneousGesture lets the double-tap and child Button taps resolve + // without blocking each other, eliminating the click-delay on Buttons. + .simultaneousGesture( + TapGesture(count: 2).onEnded { + if isMounted { manager.openInFinder(volume) } + } + ) .contextMenu { + Button { + onEdit() + } label: { + Label("Edit Volume…", systemImage: "pencil") + } + .disabled(isBusy || isTesting || manager.isClearingVolumes) + + if isMounted { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(currentPath, forType: .string) + } label: { + Label("Copy Mount Path", systemImage: "doc.on.doc") + } + + Button { + manager.runSpeedTest(for: volume) + } label: { + Label("Measure Speed…", systemImage: "speedometer") + } + .disabled(isBusy || manager.isRunningSpeedTest || manager.isClearingVolumes) + } + + Divider() + Button(role: .destructive) { manager.removeVolume(volume.id) } label: { - Text("Remove Volume") + Label("Remove Volume", systemImage: "trash") } + .disabled(isBusy || isTesting || manager.isClearingVolumes) } } } diff --git a/MountyTests/AppLoggerTests.swift b/MountyTests/AppLoggerTests.swift new file mode 100644 index 0000000..a94ecc0 --- /dev/null +++ b/MountyTests/AppLoggerTests.swift @@ -0,0 +1,23 @@ +import Testing + +@testable import Mounty + +struct AppLoggerTests { + @Test func emitsCategorizedEntryForInAppLog() async { + let expectedMessage = "Mount started: filer.example/share" + let nextEntry = Task { + for await entry in AppLogger.entries where entry.message == expectedMessage { + return entry + } + return nil + } + + AppLogger.log(expectedMessage, level: .debug, source: .mountService) + + let entry = await nextEntry.value + #expect(entry?.level == .debug) + #expect(entry?.source == .mountService) + #expect(entry?.message == expectedMessage) + #expect(entry?.formatted.contains("[MountService] \(expectedMessage)") == true) + } +} diff --git a/MountyTests/PersistenceServiceTests.swift b/MountyTests/PersistenceServiceTests.swift new file mode 100644 index 0000000..479d5a9 --- /dev/null +++ b/MountyTests/PersistenceServiceTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing + +@testable import Mounty + +@MainActor +struct PersistenceServiceTests { + + /// Round-trips volumes through an isolated UserDefaults suite so real preferences are untouched. + @Test func savesAndLoadsVolumes() { + let suiteName = "MountyTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let sut = PersistenceService(defaults: defaults) + let volumes = [ + Volume(name: "NAS", serverAddress: "smb://nas.local/media", isAutomountEnabled: true), + Volume(name: "Backup", serverAddress: "smb://nas.local/backup"), + ] + + sut.saveVolumes(volumes) + + #expect(sut.loadVolumes() == volumes) + } + + @Test func loadReturnsEmptyWhenNothingSaved() { + let suiteName = "MountyTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let sut = PersistenceService(defaults: defaults) + #expect(sut.loadVolumes().isEmpty) + } + + @Test func savesAndLoadsMinimumLogLevel() { + let suiteName = "MountyTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let sut = PersistenceService(defaults: defaults) + #expect(sut.loadMinimumLogLevel() == .info) + + sut.saveMinimumLogLevel(.warning) + + #expect(sut.loadMinimumLogLevel() == .warning) + } + + @Test func savesAndLoadsSortPreferences() { + let suiteName = "MountyTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let sut = PersistenceService(defaults: defaults) + #expect(sut.loadSortOrder() == nil) + #expect(sut.loadSortDirection() == nil) + + sut.saveSortOrder("State") + sut.saveSortDirection("descending") + + #expect(sut.loadSortOrder() == "State") + #expect(sut.loadSortDirection() == "descending") + } +} diff --git a/MountyTests/SpeedTestServiceTests.swift b/MountyTests/SpeedTestServiceTests.swift new file mode 100644 index 0000000..8e41986 --- /dev/null +++ b/MountyTests/SpeedTestServiceTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing + +@testable import Mounty + +struct SpeedTestServiceTests { + @Test func rejectsNonpositiveFileSize() async { + await #expect(throws: (any Error).self) { + try await SpeedTestService.measure(at: "/", fileSizeMB: 0) + } + await #expect(throws: (any Error).self) { + try await SpeedTestService.measure(at: "/", fileSizeMB: -1) + } + } + + @Test func reportsFailureBeforeCleanupRetriesFinish() async { + let missingMount = "/tmp/mounty-missing-\(UUID().uuidString)/share" + let clock = ContinuousClock() + let start = clock.now + + await #expect(throws: (any Error).self) { + try await SpeedTestService.measure(at: missingMount, fileSizeMB: 0.001) + } + + #expect(start.duration(to: clock.now) < .milliseconds(300)) + } +} diff --git a/MountyTests/SystemMountServiceTests.swift b/MountyTests/SystemMountServiceTests.swift new file mode 100644 index 0000000..ad88af0 --- /dev/null +++ b/MountyTests/SystemMountServiceTests.swift @@ -0,0 +1,116 @@ +import Foundation +import Testing + +@testable import Mounty + +/// Tests the mount-detection matching — the core business logic that decides whether a +/// configured volume is currently mounted, by correlating its address with the kernel mount table. +struct SystemMountServiceTests { + + private func volume(_ address: String) -> Volume { + Volume(name: "test", serverAddress: address) + } + + @Test func matchesHostAndPathSuffix() { + let mounts = [ + SystemMountService.MountPoint( + path: "/Volumes/media", + source: "//user@nas.local/media" + ) + ] + let result = SystemMountService.findMountPath( + for: volume("smb://nas.local/media"), + in: mounts + ) + #expect(result == "/Volumes/media") + } + + @Test func matchesHostOnlyWhenNoPath() { + // A bare server address (no share path) matches any mount from that host. + let mounts = [ + SystemMountService.MountPoint( + path: "/Volumes/share", + source: "//nas.local/share" + ) + ] + let result = SystemMountService.findMountPath( + for: volume("smb://nas.local"), + in: mounts + ) + #expect(result == "/Volumes/share") + } + + @Test func matchingIsCaseInsensitive() { + let mounts = [ + SystemMountService.MountPoint( + path: "/Volumes/media", + source: "//nas.local/media" + ) + ] + let result = SystemMountService.findMountPath( + for: volume("smb://NAS.local/Media"), + in: mounts + ) + #expect(result == "/Volumes/media") + } + + @Test func doesNotMatchDifferentHost() { + let mounts = [ + SystemMountService.MountPoint( + path: "/Volumes/media", + source: "//nas.local/media" + ) + ] + let result = SystemMountService.findMountPath( + for: volume("smb://other.local/media"), + in: mounts + ) + #expect(result == nil) + } + + @Test func doesNotMatchWrongSharePath() { + // Same host, but the configured share path is not the one that is mounted. + let mounts = [ + SystemMountService.MountPoint( + path: "/Volumes/media", + source: "//nas.local/media" + ) + ] + let result = SystemMountService.findMountPath( + for: volume("smb://nas.local/docs"), + in: mounts + ) + #expect(result == nil) + } + + @Test func doesNotMatchOnHostSubstring() { + // "nas.local" must NOT match a source from "other-nas.local". + // Guards against substring `contains` producing a false positive. + let mounts = [ + SystemMountService.MountPoint( + path: "/Volumes/media", + source: "//other-nas.local/media" + ) + ] + let result = SystemMountService.findMountPath( + for: volume("smb://nas.local/media"), + in: mounts + ) + #expect(result == nil) + } + + @Test func matchesSourceWithDomainUserAtHost() { + // Windows-style sources embed "domain;user@host/share". + let mounts = [ + SystemMountService.MountPoint( + path: "/Volumes/media", + source: "//DOMAIN;alice@nas.local/media" + ) + ] + let result = SystemMountService.findMountPath( + for: volume("smb://nas.local/media"), + in: mounts + ) + #expect(result == "/Volumes/media") + } +} diff --git a/MountyTests/VolumeConfigurationServiceTests.swift b/MountyTests/VolumeConfigurationServiceTests.swift new file mode 100644 index 0000000..a4cc644 --- /dev/null +++ b/MountyTests/VolumeConfigurationServiceTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing + +@testable import Mounty + +@MainActor +struct VolumeConfigurationServiceTests { + @Test func skipsExistingIDsAndAddresses() { + let existing = Volume( + id: UUID(), + name: "Media", + serverAddress: "smb://nas.local/media" + ) + let valid = Volume( + id: UUID(), + name: "Archive", + serverAddress: "smb://nas.local/archive" + ) + let imported = [ + Volume( + id: existing.id, + name: "Duplicate ID", + serverAddress: "smb://nas.local/other" + ), + Volume( + id: UUID(), + name: "Duplicate Address", + serverAddress: existing.serverAddress + ), + valid, + ] + + let result = VolumeConfigurationService.merging(imported, into: [existing]) + + #expect(result.importedCount == 1) + #expect(result.volumes == [existing, valid]) + } + + @Test func skipsDuplicateIdentitiesWithinImport() { + let sharedID = UUID() + let first = Volume( + id: sharedID, + name: "First", + serverAddress: "smb://nas.local/first" + ) + let imported = [ + first, + Volume( + id: sharedID, + name: "Duplicate ID", + serverAddress: "smb://nas.local/second" + ), + Volume( + id: UUID(), + name: "Duplicate Address", + serverAddress: first.serverAddress + ), + ] + + let result = VolumeConfigurationService.merging(imported, into: []) + + #expect(result.importedCount == 1) + #expect(result.volumes == [first]) + } + + @Test func treatsSMBHostsAndPathsAsCaseInsensitive() { + let existing = Volume( + name: "Media", + serverAddress: "smb://NAS.local/Media/" + ) + let duplicate = Volume( + name: "Duplicate", + serverAddress: "smb://nas.local/media" + ) + + #expect( + VolumeConfigurationService.hasDuplicateServerIdentity( + for: duplicate.serverAddress, + in: [existing] + ) + ) + + let result = VolumeConfigurationService.merging([duplicate], into: [existing]) + #expect(result.importedCount == 0) + #expect(result.volumes == [existing]) + } + + @Test func excludesEditedVolumeFromDuplicateCheck() { + let existing = Volume( + name: "Media", + serverAddress: "smb://nas.local/media" + ) + + #expect( + !VolumeConfigurationService.hasDuplicateServerIdentity( + for: "smb://NAS.local/Media/", + in: [existing], + excludingID: existing.id + ) + ) + } + + @Test func validatesReachableSMBEndpoints() { + #expect(VolumeConfigurationService.isValidServerAddress("smb://nas.local/media")) + #expect(VolumeConfigurationService.isValidServerAddress("nas.local/media")) + #expect(!VolumeConfigurationService.isValidServerAddress("/media")) + #expect(!VolumeConfigurationService.isValidServerAddress("smb:///media")) + #expect(!VolumeConfigurationService.isValidServerAddress("smb://nas.local:1445/media")) + } + + @Test func skipsInvalidAddressesDuringImport() { + let invalid = Volume(name: "Invalid", serverAddress: "smb:///media") + let valid = Volume(name: "Media", serverAddress: "smb://nas.local/media") + + let result = VolumeConfigurationService.merging([invalid, valid], into: []) + + #expect(result.importedCount == 1) + #expect(result.volumes == [valid]) + } +} diff --git a/MountyTests/VolumeTests.swift b/MountyTests/VolumeTests.swift new file mode 100644 index 0000000..c960c8a --- /dev/null +++ b/MountyTests/VolumeTests.swift @@ -0,0 +1,11 @@ +import Testing + +@testable import Mounty + +struct VolumeTests { + @Test func normalizesSMBServerAddresses() { + #expect(Volume.shareAddress(from: "nas.local/media") == "nas.local/media") + #expect(Volume.shareAddress(from: "smb://nas.local/media") == "nas.local/media") + #expect(Volume.smbServerAddress(from: "ftp://nas.local/media") == "smb://nas.local/media") + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..75e2e5c --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +
+ +

Mounty logo Mounty

+ +**A tiny macOS menu-bar app that keeps your SMB network shares mounted — automatically.** + +[![CI](https://github.com/maptic/mounty/actions/workflows/ci.yml/badge.svg)](https://github.com/maptic/mounty/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/maptic/mounty?sort=semver)](https://github.com/maptic/mounty/releases/latest) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) + +
+ +Mounty lives in your menu bar and reconnects your network volumes the moment they become +reachable again — after a VPN toggles, Wi-Fi reconnects, or the Mac wakes from sleep. No more +manually re-mounting shares in Finder. + +## Features + +- **Automount** — enable per-volume automounting; Mounty reconnects as soon as the server is reachable. +- **Reachability-aware** — reacts to network changes, VPN tunnels, and detects silently dead mounts. +- **Quick actions** — open any mounted share in Finder or your preferred terminal (Terminal, iTerm2, Warp, …). +- **Import / export** — back up and restore your volume list as JSON. +- **Launch at login** — optional, one toggle. +- **Native & lightweight** — SwiftUI menu-bar app, no background daemons. + +## Install + +### Homebrew (recommended) + +```sh +brew install --cask maptic/tap/mounty +``` + +### Direct download + +1. Download the latest `Mounty.dmg` from the [**Releases**](https://github.com/maptic/mounty/releases/latest) page. +2. Open the DMG and drag **Mounty** into `Applications`. + +> [!IMPORTANT] +> **First-launch Gatekeeper note.** Releases are signed and notarized when the release workflow has +> Developer ID credentials. For an ad-hoc-signed release, macOS may refuse to open it on the first +> try. To allow that build: +> +> - **Right-click** `Mounty.app` → **Open** → **Open** in the dialog, **or** +> - remove the quarantine flag from a terminal: +> ```sh +> xattr -dr com.apple.quarantine /Applications/Mounty.app +> ``` +> +> This is a one-time step and is unnecessary for notarized releases. + +## Build from source + +Requirements: macOS 26.1+, Xcode 26+. + +```sh +git clone https://github.com/maptic/mounty.git +cd mounty +./scripts/install-hooks.sh # one-time: install formatting + commit-msg hooks +xcodebuild -scheme Mounty -configuration Debug build +``` + +## Contributing + +Contributions are welcome! Please read [CONTRIBUTING.md](./CONTRIBUTING.md) first. In short: + +- Commits follow [**Conventional Commits**](https://www.conventionalcommits.org/) — releases are + fully automated from commit history via [release-please](https://github.com/googleapis/release-please). +- Code is auto-formatted with `swift-format` on commit (via the provided git hook). +- The project uses lightweight, provider-neutral **story records** — see [`AGENTS.md`](./AGENTS.md) + and [`docs/stories/`](./docs/stories/). + +## License + +[MIT](./LICENSE) © Merlin Unterfinger / maptic diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..57bc47f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security Policy + +## Reporting a vulnerability + +Please **do not** open a public issue for security vulnerabilities. + +Report privately via GitHub's built-in +[**Report a vulnerability**](https://github.com/maptic/mounty/security/advisories/new) form. + +We aim to acknowledge reports within 5 business days and to provide a remediation timeline after +triage. + +## Scope notes + +- Mounty stores its volume list and preferences in `UserDefaults`. It does **not** store share + credentials — authentication is delegated to the macOS Keychain / NetFS. +- Mounty performs TCP reachability checks (SMB port 445) and mounts network shares via the system + `NetFS` framework. It executes no remote code. + +## Supported versions + +Only the latest released version receives security fixes. diff --git a/MenuIcon.svg b/docs/assets/MenuIcon.svg similarity index 100% rename from MenuIcon.svg rename to docs/assets/MenuIcon.svg diff --git a/docs/assets/logo-readme.svg b/docs/assets/logo-readme.svg new file mode 100644 index 0000000..e85f2bf --- /dev/null +++ b/docs/assets/logo-readme.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo.png b/docs/assets/logo.png similarity index 100% rename from logo.png rename to docs/assets/logo.png diff --git a/logo.svg b/docs/assets/logo.svg similarity index 100% rename from logo.svg rename to docs/assets/logo.svg diff --git a/docs/stories/001-open-source-foundation.md b/docs/stories/001-open-source-foundation.md new file mode 100644 index 0000000..fa0a0eb --- /dev/null +++ b/docs/stories/001-open-source-foundation.md @@ -0,0 +1,21 @@ +# STORY-001: Open-source project foundation + +- Status: CLOSED +- Type: chore +- Date: 2026-08-07 +- Commits: `e710dbc`, `f60659a` + +## Intent + +Prepare Mounty for public collaboration with CI, release automation, tests, formatting, security guidance, contribution rules, and a shared agent guide. + +## Acceptance criteria + +- [x] Build and test automation is checked into the repository. +- [x] Existing business logic has focused Swift Testing coverage. +- [x] Formatting, commit, release, and security conventions are documented. +- [x] Agent guidance is shared through a provider-neutral root document. + +## Validation + +CI configuration, hooks, documentation, and test targets were added and the project was validated with the repository build and test commands. diff --git a/docs/stories/002-reachability-privacy.md b/docs/stories/002-reachability-privacy.md new file mode 100644 index 0000000..7042aca --- /dev/null +++ b/docs/stories/002-reachability-privacy.md @@ -0,0 +1,20 @@ +# STORY-002: Privacy-safe reachability checks + +- Status: CLOSED +- Type: fix +- Date: 2026-08-07 +- Commit: `380f761` + +## Intent + +Stop reachability checks from enumerating mounted directories and triggering unnecessary macOS TCC prompts. + +## Acceptance criteria + +- [x] Filesystem liveness uses `statfs` rather than directory enumeration. +- [x] Reachability checks retain the existing mounted-volume behavior. +- [x] No unnecessary privacy prompt is caused by the liveness probe. + +## Validation + +The reachability service was updated and covered by the project build and test validation. diff --git a/docs/stories/003-native-macos-ui.md b/docs/stories/003-native-macos-ui.md new file mode 100644 index 0000000..6b6bba6 --- /dev/null +++ b/docs/stories/003-native-macos-ui.md @@ -0,0 +1,20 @@ +# STORY-003: Native macOS interface + +- Status: CLOSED +- Type: feat +- Date: 2026-08-07 +- Commits: `1b66e31`, `25396fd`, `972dde6` + +## Intent + +Modernize the menu-bar experience with native macOS controls and a consistent settings and volume-row layout. + +## Acceptance criteria + +- [x] Main, settings, add-volume, overlay, header, and volume-row views use native macOS patterns. +- [x] Settings actions remain discoverable without custom control chrome. +- [x] Icon-only row actions retain clear hover affordances. + +## Validation + +The affected SwiftUI views were formatted and verified through the macOS build and test suite. diff --git a/docs/stories/004-responsive-menu-bar.md b/docs/stories/004-responsive-menu-bar.md new file mode 100644 index 0000000..862e61a --- /dev/null +++ b/docs/stories/004-responsive-menu-bar.md @@ -0,0 +1,21 @@ +# STORY-004: Responsive menu-bar interaction + +- Status: CLOSED +- Type: fix +- Date: 2026-08-08 +- Commits: `d6c42a7`, `7943079`, `4168d36`, `ebf6c05`, `bcc44e7`, `5734c41`, `09ba72a` + +## Intent + +Keep the popover responsive while users resize it, navigate settings, and interact with row actions. + +## Acceptance criteria + +- [x] Blocking reachability and mount work stays off the main actor. +- [x] Animations, resizing, navigation, and footer layout remain stable. +- [x] Child buttons respond immediately and have reliable hit areas. +- [x] Settings actions preserve their intended icon-only layout and hover behavior. + +## Validation + +Strict Swift format lint and the complete macOS Swift Testing suite passed after the interaction and concurrency fixes. diff --git a/docs/stories/005-diagnostics-and-speed-tests.md b/docs/stories/005-diagnostics-and-speed-tests.md new file mode 100644 index 0000000..629c4f4 --- /dev/null +++ b/docs/stories/005-diagnostics-and-speed-tests.md @@ -0,0 +1,21 @@ +# STORY-005: In-app diagnostics and speed tests + +- Status: CLOSED +- Type: feat +- Date: 2026-08-08 +- Commits: `a045687`, `502cd57`, `13351cf` + +## Intent + +Give users an in-app log viewer and a one-shot SMB speed test with honest measurements and dependable cleanup. + +## Acceptance criteria + +- [x] Logs can be viewed from the menu-bar interface with useful filtering. +- [x] A configured volume can run a one-shot read/write speed test. +- [x] Speed results use uncached measurements and clean up temporary data. +- [x] Header, controls, and footer remain usable at narrow widths. + +## Validation + +Focused logger and speed-test tests, strict format lint, and the macOS test suite passed. diff --git a/docs/stories/006-concurrency-correctness.md b/docs/stories/006-concurrency-correctness.md new file mode 100644 index 0000000..809c1fc --- /dev/null +++ b/docs/stories/006-concurrency-correctness.md @@ -0,0 +1,20 @@ +# STORY-006: Swift concurrency correctness + +- Status: CLOSED +- Type: fix +- Date: 2026-08-08 +- Commits: `78369b9`, `c0c8b3b` + +## Intent + +Parallelize independent automount checks while keeping blocking work off `MainActor` and remove Swift 6 actor-isolation warnings. + +## Acceptance criteria + +- [x] Independent reachability checks run concurrently where safe. +- [x] Blocking filesystem and mount operations do not run on the UI actor. +- [x] The project builds without Swift concurrency warnings. + +## Validation + +The project passed strict format lint and `xcodebuild ... test` with signing disabled. diff --git a/docs/stories/007-modern-swift-and-mount-state.md b/docs/stories/007-modern-swift-and-mount-state.md new file mode 100644 index 0000000..0ec1e5f --- /dev/null +++ b/docs/stories/007-modern-swift-and-mount-state.md @@ -0,0 +1,21 @@ +# STORY-007: Modern Swift and accurate mount state + +- Status: CLOSED +- Type: refactor +- Date: 2026-08-09 +- Commits: `89fc09d`, `1b0ff8e`, `9e4379d`, `34233ca` + +## Intent + +Align the implementation with Swift 6 and the `@Observable` architecture while making mount matching and unmount diagnostics exact. + +## Acceptance criteria + +- [x] View-model observation uses the modern `@Observable` pattern. +- [x] Mount matching compares the extracted host exactly rather than by substring. +- [x] Reads complete across partial `read(2)` results and force-unmount outcomes are logged. +- [x] Services and views use the modernized Swift implementation without introducing warnings. + +## Validation + +Strict Swift format lint and the complete macOS test suite passed. diff --git a/docs/stories/008-mount-reliability-and-logging.md b/docs/stories/008-mount-reliability-and-logging.md new file mode 100644 index 0000000..682c91c --- /dev/null +++ b/docs/stories/008-mount-reliability-and-logging.md @@ -0,0 +1,22 @@ +# STORY-008: Reliable mounting and unified logging + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commits: `d008b35`, `29d8dc2` + +## Intent + +Restore reliable SMB mounting, make Mounty-owned service logs visible in the app and Console, retry lost automounts, and keep liveness work bounded. + +## Acceptance criteria + +- [x] Reachable automount candidates use the proven synchronous NetFS operation off the UI actor. +- [x] Mounty-owned service and view-model diagnostics share one categorized log stream. +- [x] Heartbeat refreshes mount state and retries eligible lost shares. +- [x] Repeated liveness checks cannot create an unbounded number of blocked workers. +- [x] Speed-test and footer behavior remains responsive after the final layout cleanup. + +## Validation + +`swift-format lint --strict -r Mounty MountyTests`, `xcodebuild -scheme Mounty -destination 'platform=macOS,arch=arm64' test CODE_SIGNING_ALLOWED=NO`, and `git diff --check` passed. diff --git a/docs/stories/009-mount-lifecycle-cleanup.md b/docs/stories/009-mount-lifecycle-cleanup.md new file mode 100644 index 0000000..55ff683 --- /dev/null +++ b/docs/stories/009-mount-lifecycle-cleanup.md @@ -0,0 +1,23 @@ +# STORY-009: Mount lifecycle cleanup and failure handling + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Keep SMB shares managed during removal, reset, and edits by unmounting explicitly and preserving configuration when unmounting fails. + +## Acceptance criteria + +- [x] Removing or resetting a mounted volume unmounts it before deleting its configuration. +- [x] Failed unmounts retain the volume configuration and mounted state, with actionable feedback. +- [x] Editing a mounted volume stops before reconnecting when its old mount cannot be unmounted. +- [x] Manual unmount failure is reported accurately. + +## Validation + +`swift-format lint --strict Mounty/ViewModels/VolumeManager.swift` and +`xcodebuild -scheme Mounty -destination 'platform=macOS,arch=arm64' test +CODE_SIGNING_ALLOWED=NO` passed. Editor diagnostics reported no errors. \ No newline at end of file diff --git a/docs/stories/010-volume-operation-safety.md b/docs/stories/010-volume-operation-safety.md new file mode 100644 index 0000000..ec368d1 --- /dev/null +++ b/docs/stories/010-volume-operation-safety.md @@ -0,0 +1,21 @@ +# STORY-010: Volume operation safety + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Prevent overlapping mount, edit, remove, and reset operations from leaving unmanaged mounts or deleting configurations created after a reset begins. + +## Acceptance criteria + +- [x] Destructive or mutating actions cannot overlap an active operation for the same volume. +- [x] Reset does not overlap active volume operations or start new automounts while unmounting. +- [x] Reset removes only configurations that existed when it began and retains configurations whose unmount fails. +- [x] Relevant focused checks and the full test suite pass. + +## Validation + +`swift-format lint --strict` passed for the four touched Swift files. Editor diagnostics reported no errors. `xcodebuild -scheme Mounty -destination 'platform=macOS,arch=arm64' test CODE_SIGNING_ALLOWED=NO` passed. diff --git a/docs/stories/011-import-volume-identity.md b/docs/stories/011-import-volume-identity.md new file mode 100644 index 0000000..59269c2 --- /dev/null +++ b/docs/stories/011-import-volume-identity.md @@ -0,0 +1,20 @@ +# STORY-011: Import volume identity integrity + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Keep imported volume configurations uniquely identifiable so mount state and actions cannot collide across duplicate UUIDs. + +## Acceptance criteria + +- [x] Imports skip entries whose UUID or server address already exists. +- [x] Duplicate UUIDs and addresses within one import are skipped consistently. +- [x] Meaningful merge tests and the full test suite pass. + +## Validation + +Focused `VolumeConfigurationServiceTests`, strict formatting for touched Swift files, editor diagnostics, and the full `xcodebuild` test suite passed. diff --git a/docs/stories/012-ci-warning-enforcement.md b/docs/stories/012-ci-warning-enforcement.md new file mode 100644 index 0000000..8525c03 --- /dev/null +++ b/docs/stories/012-ci-warning-enforcement.md @@ -0,0 +1,19 @@ +# STORY-012: CI warning enforcement + +- Status: CLOSED +- Type: ci +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Make CI enforce the documented zero-warning policy instead of allowing compiler warnings to merge. + +## Acceptance criteria + +- [x] Swift and Clang compiler warnings fail the CI build-test job. +- [x] The warning-enforced build and test command passes locally. + +## Validation + +`xcodebuild -project Mounty.xcodeproj -scheme Mounty -configuration Debug -destination 'platform=macOS,arch=arm64' CODE_SIGNING_ALLOWED=NO SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_ERRORS=YES clean test` passed. diff --git a/docs/stories/013-offline-mount-lifecycle.md b/docs/stories/013-offline-mount-lifecycle.md new file mode 100644 index 0000000..a75ef95 --- /dev/null +++ b/docs/stories/013-offline-mount-lifecycle.md @@ -0,0 +1,20 @@ +# STORY-013: Offline mount lifecycle preservation + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Keep configurations managed when network state clears the cached mount path but the SMB share remains present in the kernel mount table. + +## Acceptance criteria + +- [x] Remove resolves a kernel mount before deleting its configuration. +- [x] Clear resolves kernel mounts before deciding which configurations to delete. +- [x] Focused checks and the full test suite pass. + +## Validation + +Focused `VolumeConfigurationServiceTests`, strict Swift formatting for touched files, editor diagnostics, and the full `xcodebuild` test suite passed. diff --git a/docs/stories/014-volume-identity-enforcement.md b/docs/stories/014-volume-identity-enforcement.md new file mode 100644 index 0000000..bae384b --- /dev/null +++ b/docs/stories/014-volume-identity-enforcement.md @@ -0,0 +1,20 @@ +# STORY-014: Volume identity enforcement + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Prevent manual additions, edits, and imports from creating multiple configurations for the same SMB share. + +## Acceptance criteria + +- [x] SMB host and share identity is compared case-insensitively with normalized paths. +- [x] Add, edit, and import reject duplicate SMB identities. +- [x] Focused checks and the full test suite pass. + +## Validation + +Focused `VolumeConfigurationServiceTests`, strict Swift formatting for touched files, editor diagnostics, and the full `xcodebuild` test suite passed. diff --git a/docs/stories/015-smb-endpoint-validation.md b/docs/stories/015-smb-endpoint-validation.md new file mode 100644 index 0000000..d021e68 --- /dev/null +++ b/docs/stories/015-smb-endpoint-validation.md @@ -0,0 +1,20 @@ +# STORY-015: SMB endpoint validation and stable edits + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Reject SMB configurations Mounty cannot reach and avoid reconnecting a mounted share when an edit only changes its normalized address representation. + +## Acceptance criteria + +- [x] Add, edit, and import reject SMB addresses without a host or with an unsupported port. +- [x] Case-only and trailing-slash-only edits preserve an existing mount while updating the saved address. +- [x] Focused tests and the full validation suite pass. + +## Validation + +Focused `VolumeConfigurationServiceTests`, strict Swift formatting, the full `xcodebuild` test suite, and `git diff --check` passed. \ No newline at end of file diff --git a/docs/stories/016-menu-bar-layout-and-speed-test-responsiveness.md b/docs/stories/016-menu-bar-layout-and-speed-test-responsiveness.md new file mode 100644 index 0000000..1db4e33 --- /dev/null +++ b/docs/stories/016-menu-bar-layout-and-speed-test-responsiveness.md @@ -0,0 +1,26 @@ +# STORY-016: Menu-bar layout and speed-test responsiveness + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Keep Mounty interactive after a speed-test result is dismissed, preserve a stable menu-bar window frame across every view, and retain the selected volume sort preferences. + +## Acceptance criteria + +- [x] Dismissing a speed-test result or failure clears all speed-test presentation and operation state. +- [x] Main, log, add, edit, and settings views use one stable window width and height. +- [x] The log content and footer use the same bottom spacing as the main view at every window size. +- [x] Sort method and direction persist after returning to the main list and after relaunching. +- [x] Focused tests and project validation pass without project-code warnings. + +## Validation + +`xcrun swift-format lint --strict -r Mounty MountyTests`, the complete macOS +`xcodebuild` test suite, and `git diff --check` passed. The persistence test +suite includes a sort-preference round trip. Xcode emitted its existing +AppIntents metadata notice because the app has no AppIntents framework +dependency. \ No newline at end of file diff --git a/docs/stories/017-main-list-resize-layout.md b/docs/stories/017-main-list-resize-layout.md new file mode 100644 index 0000000..1bdca57 --- /dev/null +++ b/docs/stories/017-main-list-resize-layout.md @@ -0,0 +1,21 @@ +# STORY-017: Main list resize layout + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Keep the main list header and footer at their fixed geometry when users resize the menu-bar window beyond the number of configured volumes. + +## Acceptance criteria + +- [x] Increasing the visible row capacity expands only the main list region. +- [x] Header, resize handle, and footer retain their dimensions with fewer volumes than the selected capacity. +- [x] Focused validation passes. + +## Validation + +`xcrun swift-format lint --strict -r Mounty MountyTests`, the complete macOS +`xcodebuild` test suite, and `git diff --check` passed. \ No newline at end of file diff --git a/docs/stories/018-settings-scrolling-and-dialog-presentation.md b/docs/stories/018-settings-scrolling-and-dialog-presentation.md new file mode 100644 index 0000000..c16b018 --- /dev/null +++ b/docs/stories/018-settings-scrolling-and-dialog-presentation.md @@ -0,0 +1,21 @@ +# STORY-018: Settings scrolling and dialog presentation + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Keep settings accessible when they exceed the menu-bar frame and ensure dismissing speed-test dialogs immediately restores interaction with Mounty. + +## Acceptance criteria + +- [x] Settings scroll with automatic indicators when their content exceeds the available height. +- [x] Speed-test result and error dialogs use direct presentation state and clear the completed test state on dismissal. +- [x] Focused validation passes. + +## Validation + +`xcrun swift-format lint --strict -r Mounty MountyTests`, the complete macOS +`xcodebuild` test suite, and `git diff --check` passed. \ No newline at end of file diff --git a/docs/stories/019-copy-mounted-path.md b/docs/stories/019-copy-mounted-path.md new file mode 100644 index 0000000..4481f28 --- /dev/null +++ b/docs/stories/019-copy-mounted-path.md @@ -0,0 +1,22 @@ +# STORY-019: Copy mounted path + +- Status: CLOSED +- Type: feat +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Let users copy a mounted volume's local filesystem path from the context menu for use in scripts and other tools. + +## Acceptance criteria + +- [x] Mounted volume context menus include a Copy Mount Path action. +- [x] The action copies the resolved local mount path to the pasteboard. +- [x] The action is unavailable for disconnected volumes. +- [x] Focused validation passes. + +## Validation + +`xcrun swift-format lint --strict -r Mounty MountyTests`, the complete macOS +`xcodebuild` test suite, and `git diff --check` passed. \ No newline at end of file diff --git a/docs/stories/020-repository-link-and-asset-organization.md b/docs/stories/020-repository-link-and-asset-organization.md new file mode 100644 index 0000000..8daf6fa --- /dev/null +++ b/docs/stories/020-repository-link-and-asset-organization.md @@ -0,0 +1,22 @@ +# STORY-020: Repository link and asset organization + +- Status: CLOSED +- Type: feat +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Make Mounty's repository easy to find from the About section, show its transparent logo in the README, and organize raw source graphics without changing Xcode's asset-catalog layout. + +## Acceptance criteria + +- [x] Settings includes a subtle link to the Mounty repository. +- [x] The README header displays the transparent Mounty logo. +- [x] Root-level source graphics are grouped outside the Xcode catalog while its layout remains unchanged. +- [x] Focused validation passes. + +## Validation + +The restored asset catalog compiled successfully. `xcodebuild` ran the focused +Volume tests, source-asset path checks, and `git diff --check` passed. \ No newline at end of file diff --git a/docs/stories/021-readme-logo-alignment.md b/docs/stories/021-readme-logo-alignment.md new file mode 100644 index 0000000..467520f --- /dev/null +++ b/docs/stories/021-readme-logo-alignment.md @@ -0,0 +1,20 @@ +# STORY-021: README logo alignment + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Align the Mounty logo cleanly beside the README title and increase it slightly for better visual balance. + +## Acceptance criteria + +- [x] The README logo is vertically centered with the Mounty title. +- [x] The README logo is slightly larger while remaining compact. +- [x] Documentation validation passes. + +## Validation + +`git diff --check` and the README image-path and heading-markup checks passed. \ No newline at end of file diff --git a/docs/stories/022-documentation-source-artwork.md b/docs/stories/022-documentation-source-artwork.md new file mode 100644 index 0000000..2fce361 --- /dev/null +++ b/docs/stories/022-documentation-source-artwork.md @@ -0,0 +1,23 @@ +# STORY-022: Documentation source artwork + +- Status: CLOSED +- Type: docs +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Keep the raw logo and menu-icon source files with documentation assets while leaving Xcode's runtime asset catalog unchanged. + +## Acceptance criteria + +- [x] Raw logo and menu-icon source files live in `docs/assets/`. +- [x] The README logo resolves from the documentation asset path. +- [x] Xcode asset-catalog paths remain unchanged. +- [x] Focused validation passes. + +## Validation + +The asset catalog compiled successfully. `xcodebuild` ran the focused Volume +tests, all project tests, source-asset path checks, and `git diff --check` +passed. \ No newline at end of file diff --git a/docs/stories/023-readme-logo-visual-alignment.md b/docs/stories/023-readme-logo-visual-alignment.md new file mode 100644 index 0000000..52fabb2 --- /dev/null +++ b/docs/stories/023-readme-logo-visual-alignment.md @@ -0,0 +1,21 @@ +# STORY-023: README logo visual alignment + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Nudge the README title logo upward at its fixed 50px display width without relying on GitHub-stripped inline CSS. + +## Acceptance criteria + +- [x] The README logo artwork is shifted upward by approximately three CSS pixels at its fixed 50px display width. +- [x] The adjustment remains stable across display densities and viewport sizes. +- [x] Documentation validation passes. + +## Validation + +The README renders a fixed 50px SVG whose artwork is shifted by 51 of 1024 +viewBox units. Strict formatting, all macOS tests, and `git diff --check` passed. \ No newline at end of file diff --git a/docs/stories/INDEX.md b/docs/stories/INDEX.md new file mode 100644 index 0000000..a3b1c9c --- /dev/null +++ b/docs/stories/INDEX.md @@ -0,0 +1,29 @@ +# Story Index + +Newest stories first. Statuses: `OPEN`, `IN_PROGRESS`, `CLOSED`. + +| ID | Type | Story | Status | Date | +| --- | --- | --- | --- | --- | +| [023](./023-readme-logo-visual-alignment.md) | fix | README logo visual alignment | CLOSED | 2026-08-10 | +| [022](./022-documentation-source-artwork.md) | docs | Documentation source artwork | CLOSED | 2026-08-10 | +| [021](./021-readme-logo-alignment.md) | fix | README logo alignment | CLOSED | 2026-08-10 | +| [020](./020-repository-link-and-asset-organization.md) | feat | Repository link and asset organization | CLOSED | 2026-08-10 | +| [019](./019-copy-mounted-path.md) | feat | Copy mounted path | CLOSED | 2026-08-10 | +| [018](./018-settings-scrolling-and-dialog-presentation.md) | fix | Settings scrolling and dialog presentation | CLOSED | 2026-08-10 | +| [017](./017-main-list-resize-layout.md) | fix | Main list resize layout | CLOSED | 2026-08-10 | +| [016](./016-menu-bar-layout-and-speed-test-responsiveness.md) | fix | Menu-bar layout and speed-test responsiveness | CLOSED | 2026-08-10 | +| [015](./015-smb-endpoint-validation.md) | fix | SMB endpoint validation and stable edits | CLOSED | 2026-08-10 | +| [014](./014-volume-identity-enforcement.md) | fix | Volume identity enforcement | CLOSED | 2026-08-10 | +| [013](./013-offline-mount-lifecycle.md) | fix | Offline mount lifecycle preservation | CLOSED | 2026-08-10 | +| [012](./012-ci-warning-enforcement.md) | ci | CI warning enforcement | CLOSED | 2026-08-10 | +| [011](./011-import-volume-identity.md) | fix | Import volume identity integrity | CLOSED | 2026-08-10 | +| [010](./010-volume-operation-safety.md) | fix | Volume operation safety | CLOSED | 2026-08-10 | +| [009](./009-mount-lifecycle-cleanup.md) | fix | Mount lifecycle cleanup and failure handling | CLOSED | 2026-08-10 | +| [008](./008-mount-reliability-and-logging.md) | fix | Reliable mounting and unified logging | CLOSED | 2026-08-10 | +| [007](./007-modern-swift-and-mount-state.md) | refactor | Modern Swift and accurate mount state | CLOSED | 2026-08-09 | +| [006](./006-concurrency-correctness.md) | fix | Swift concurrency correctness | CLOSED | 2026-08-08 | +| [005](./005-diagnostics-and-speed-tests.md) | feat | In-app diagnostics and speed tests | CLOSED | 2026-08-08 | +| [004](./004-responsive-menu-bar.md) | fix | Responsive menu-bar interaction | CLOSED | 2026-08-08 | +| [003](./003-native-macos-ui.md) | feat | Native macOS interface | CLOSED | 2026-08-07 | +| [002](./002-reachability-privacy.md) | fix | Privacy-safe reachability checks | CLOSED | 2026-08-07 | +| [001](./001-open-source-foundation.md) | chore | Open-source project foundation | CLOSED | 2026-08-07 | diff --git a/docs/stories/README.md b/docs/stories/README.md new file mode 100644 index 0000000..3b59f71 --- /dev/null +++ b/docs/stories/README.md @@ -0,0 +1,11 @@ +# Stories + +This directory is a small, tool-neutral record of planned and completed work. Keep one Markdown +file per story and register it in [`INDEX.md`](./INDEX.md), newest first. + +Each story has a numeric ID, a Conventional Commit type, a short intent, checkable acceptance +criteria, and validation notes. Use `OPEN`, `IN_PROGRESS`, or `CLOSED`. Historical stories can be +reconstructed from commits and should include the relevant commit IDs in the story. + +The repository workflows live in `.agents/skills/` and are plain `SKILL.md` files, independent of a +specific editor, CLI, or AI vendor. diff --git a/docs/stories/TEMPLATE.md b/docs/stories/TEMPLATE.md new file mode 100644 index 0000000..1f6c06b --- /dev/null +++ b/docs/stories/TEMPLATE.md @@ -0,0 +1,19 @@ +# STORY-NNN: Short title + +- Status: OPEN +- Type: feat +- Date: YYYY-MM-DD +- Commit: _none_ + +## Intent + +Describe the problem and desired outcome in one or two sentences. + +## Acceptance criteria + +- [ ] A concise, observable outcome is met. +- [ ] Relevant tests or checks are covered. + +## Validation + +Record the focused checks, tests, or manual verification performed. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..49b11e6 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "simple", + "package-name": "mounty", + "changelog-path": "CHANGELOG.md", + "bump-minor-pre-major": false, + "draft": false, + "prerelease": false, + "include-v-in-tag": true, + "extra-files": [ + "version.txt" + ] + } + } +} diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..c156cd2 --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# +# Point git at the versioned hooks in .githooks/ and make them executable. +# Run once after cloning: ./scripts/install-hooks.sh +# +set -eu + +repo_root=$(git rev-parse --show-toplevel) +cd "$repo_root" + +chmod +x .githooks/pre-commit .githooks/commit-msg +git config core.hooksPath .githooks + +echo "✓ Installed git hooks (core.hooksPath = .githooks)." +echo " • pre-commit → formats staged Swift files with swift-format" +echo " • commit-msg → enforces Conventional Commits + agent model attribution" diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..9084fa2 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +1.1.0