From c42a7566b1e18a3a0e7f9bd7b864df7d3f8753bb Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Fri, 7 Aug 2026 15:14:01 +0200 Subject: [PATCH 01/38] =?UTF-8?q?chore:=20open-source=20setup=20=E2=80=94?= =?UTF-8?q?=20CI/CD,=20SDD,=20tests,=20release=20automation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LICENSE (MIT), README, CONTRIBUTING, SECURITY, .editorconfig - Add provider-neutral SDD scaffolding: AGENTS.md (source of truth), CLAUDE.md, .github/copilot-instructions.md, .cursorrules, specs/templates - Add .gitignore, shared Xcode scheme, untrack xcuserdata - Add .swift-format config + .githooks (pre-commit formatter, commit-msg enforcer) + install-hooks.sh - Add GitHub Actions: ci.yml, release-please.yml, release-build.yml - Add version.txt + release-please manifests (seeded at 1.1.0) - Inject UserDefaults into PersistenceService for testability - Add nonisolated Equatable on Volume (zero-warning Swift concurrency) - Add MountyTests: 9 Swift Testing unit tests (9/9 pass, 0 warnings) - Fix ReachabilityService: replace DispatchWorkItem with ResumeGate - Apply swift-format to all pre-existing source files Generated-by: claude-sonnet-4-6 --- .cursorrules | 14 ++ .editorconfig | 15 ++ .githooks/commit-msg | 76 ++++++++++ .githooks/pre-commit | 29 ++++ .github/ISSUE_TEMPLATE/bug_report.yml | 34 +++++ .github/ISSUE_TEMPLATE/feature_request.yml | 21 +++ .github/PULL_REQUEST_TEMPLATE.md | 22 +++ .github/copilot-instructions.md | 14 ++ .github/workflows/ci.yml | 57 +++++++ .github/workflows/release-build.yml | 140 ++++++++++++++++++ .github/workflows/release-please.yml | 20 +++ .gitignore | 32 ++++ .release-please-manifest.json | 3 + .swift-format | 21 +++ AGENTS.md | 120 +++++++++++++++ CLAUDE.md | 9 ++ CONTRIBUTING.md | 83 +++++++++++ LICENSE | 21 +++ Mounty.xcodeproj/project.pbxproj | 136 ++++++++++++++++- .../WorkspaceSettings.xcsettings} | 11 +- .../xcshareddata/xcschemes/Mounty.xcscheme | 92 ++++++++++++ Mounty/Models/Volume.swift | 11 ++ Mounty/Services/PersistenceService.swift | 7 +- Mounty/Services/ReachabilityService.swift | 46 ++++-- Mounty/ViewModels/VolumeManager.swift | 6 +- Mounty/Views/AddVolumeView.swift | 3 +- Mounty/Views/MainListView.swift | 3 +- MountyTests/PersistenceServiceTests.swift | 33 +++++ MountyTests/SystemMountServiceTests.swift | 85 +++++++++++ MountyTests/VolumeTests.swift | 17 +++ README.md | 75 ++++++++++ SECURITY.md | 22 +++ release-please-config.json | 17 +++ scripts/install-hooks.sh | 16 ++ specs/README.md | 38 +++++ specs/templates/plan-template.md | 31 ++++ specs/templates/spec-template.md | 31 ++++ specs/templates/tasks-template.md | 17 +++ version.txt | 1 + 39 files changed, 1395 insertions(+), 34 deletions(-) create mode 100644 .cursorrules create mode 100644 .editorconfig create mode 100755 .githooks/commit-msg create mode 100755 .githooks/pre-commit create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-build.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .gitignore create mode 100644 .release-please-manifest.json create mode 100644 .swift-format create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE rename Mounty.xcodeproj/{xcuserdata/mufix.xcuserdatad/xcschemes/xcschememanagement.plist => project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings} (50%) create mode 100644 Mounty.xcodeproj/xcshareddata/xcschemes/Mounty.xcscheme create mode 100644 MountyTests/PersistenceServiceTests.swift create mode 100644 MountyTests/SystemMountServiceTests.swift create mode 100644 MountyTests/VolumeTests.swift create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 release-please-config.json create mode 100755 scripts/install-hooks.sh create mode 100644 specs/README.md create mode 100644 specs/templates/plan-template.md create mode 100644 specs/templates/spec-template.md create mode 100644 specs/templates/tasks-template.md create mode 100644 version.txt diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..7c1884a --- /dev/null +++ b/.cursorrules @@ -0,0 +1,14 @@ +# 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 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..eebb1a1 --- /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..3d59e89 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,14 @@ +# 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`). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3bc4562 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +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 \ + 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..933093a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,120 @@ +# 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 5 / Swift Concurrency (`async`/`await`). **Avoid Combine for new code** — prefer + async APIs (existing `EventMonitorService` still uses Combine; do not expand that pattern). +- 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/ +│ └─ Volume.swift # Volume value type (+ AppViewMode enum) +├─ Services/ # Stateless/side-effecting units — the business logic +│ ├─ 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 +│ ├─ EventMonitorService.swift # NWPathMonitor + workspace mount notifications (Combine subjects) +│ └─ PersistenceService.swift # UserDefaults-backed storage (injectable defaults) +├─ ViewModels/ +│ └─ VolumeManager.swift # @MainActor ObservableObject; 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. + +## 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`), URL parsing (`Volume.host`), 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, e.g.: + +``` +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. +Keep any `Co-Authored-By:` line as well if your harness adds one. + +## Spec-Driven Development (SDD) + +For non-trivial work, write the spec before the code. Templates live in `specs/templates/`: + +1. **Specify** *what & why* → `specs/templates/spec-template.md` +2. **Plan** *how* → `specs/templates/plan-template.md` +3. **Tasks** breakdown → `specs/templates/tasks-template.md` + +Copy the templates into `specs//` for the feature you are working on. + +## 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`). + - Classes shared across `@Sendable` closures: mark as `@unchecked Sendable`, protect mutable + state with `NSLock`, and annotate mutable properties `nonisolated(unsafe)` (see + `ReachabilityService.ResumeGate`). + - 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. 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..5e09a89 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ +# 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 is authored or co-authored by an AI coding agent, it **must** record the model used via +a `Generated-by:` git trailer. This is provider-neutral — it applies to Claude, GPT, Copilot, +Gemini, or any other assistant: + +``` +feat: add reconnect backoff + +Generated-by: claude-opus-4-8 +Co-Authored-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 warns if a commit looks agent-authored but omits the trailer. + +## Spec-Driven Development (SDD) + +Non-trivial changes start with a short spec, not code. The workflow is provider-neutral and lives +in [`specs/`](./specs/): + +1. **Specify** — write *what* and *why* using [`specs/templates/spec-template.md`](./specs/templates/spec-template.md). +2. **Plan** — write *how* using [`specs/templates/plan-template.md`](./specs/templates/plan-template.md). +3. **Tasks** — break the plan into steps using [`specs/templates/tasks-template.md`](./specs/templates/tasks-template.md). + +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..57d82aa 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; @@ -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; @@ -307,6 +388,48 @@ }; 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.5; + 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 = 5.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.5; + 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 = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mounty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Mounty"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -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/Volume.swift b/Mounty/Models/Volume.swift index 7e8fe70..f662b55 100644 --- a/Mounty/Models/Volume.swift +++ b/Mounty/Models/Volume.swift @@ -8,6 +8,17 @@ struct Volume: Identifiable, Codable, Equatable, Sendable { var dateAdded: Date = Date() var host: String? { URL(string: serverAddress)?.host } + + // 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 } diff --git a/Mounty/Services/PersistenceService.swift b/Mounty/Services/PersistenceService.swift index a12facf..f93688b 100644 --- a/Mounty/Services/PersistenceService.swift +++ b/Mounty/Services/PersistenceService.swift @@ -4,7 +4,12 @@ import Foundation struct PersistenceService { private let keyVolumes = "SavedVolumes" private let keyTerminal = "PreferredTerminal" - private let defaults = UserDefaults.standard + 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) { diff --git a/Mounty/Services/ReachabilityService.swift b/Mounty/Services/ReachabilityService.swift index f0c4f6e..fefe59c 100644 --- a/Mounty/Services/ReachabilityService.swift +++ b/Mounty/Services/ReachabilityService.swift @@ -35,26 +35,29 @@ struct ReachabilityService { using: .tcp ) - let workItem = DispatchWorkItem { [weak conn] in - if conn?.state != .ready { - conn?.cancel() + // Thread-safe gate: ensures continuation.resume is called exactly once + // even when the timeout and stateUpdateHandler fire concurrently. + // @unchecked Sendable is safe here because NSLock guards the mutation. + let gate = ResumeGate() + + DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) { + if gate.tryResume() { + conn.cancel() 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, .cancelled: + if gate.tryResume() { + continuation.resume(returning: false) + } default: break } } @@ -62,3 +65,20 @@ struct ReachabilityService { } } } + +/// Single-use boolean flag protected by NSLock; safe to share across @Sendable closures. +private final class ResumeGate: @unchecked Sendable { + private let lock = NSLock() + // nonisolated(unsafe): opts out of implicit @MainActor isolation; + // thread safety is guaranteed by `lock`. + private nonisolated(unsafe) var resumed = false + + /// Returns `true` the first time it is called; `false` on all subsequent calls. + nonisolated func tryResume() -> Bool { + lock.withLock { + guard !resumed else { return false } + resumed = true + return true + } + } +} diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index b3a70c4..e577b98 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -169,8 +169,7 @@ class VolumeManager: ObservableObject { for volume in volumes where volume.isAutomountEnabled { // Check if not mounted AND not currently processing - if mountPaths[volume.id] == nil && !busyVolumes.contains(volume.id) - { + if mountPaths[volume.id] == nil && !busyVolumes.contains(volume.id) { busyVolumes.insert(volume.id) let isReachable = await ReachabilityService.isServerReachable( @@ -241,8 +240,7 @@ class VolumeManager: ObservableObject { address: volume.serverAddress ) { // 2. IO Reachability (Catches hung kernel mounts) - if ReachabilityService.isMountPointAlive(path: path) - { + if ReachabilityService.isMountPointAlive(path: path) { return (volume.id, path) } } diff --git a/Mounty/Views/AddVolumeView.swift b/Mounty/Views/AddVolumeView.swift index 9d5833c..cb25488 100644 --- a/Mounty/Views/AddVolumeView.swift +++ b/Mounty/Views/AddVolumeView.swift @@ -56,8 +56,7 @@ struct AddVolumeView: View { .autocorrectionDisabled(true) .onChange(of: address) { _, newValue in for proto in ProtocolType.allCases { - if newValue.lowercased().hasPrefix(proto.scheme) - { + if newValue.lowercased().hasPrefix(proto.scheme) { selectedProtocol = proto address = String( newValue.dropFirst(proto.scheme.count) diff --git a/Mounty/Views/MainListView.swift b/Mounty/Views/MainListView.swift index e266b1c..5543698 100644 --- a/Mounty/Views/MainListView.swift +++ b/Mounty/Views/MainListView.swift @@ -45,8 +45,7 @@ struct MainListView: View { .frame(height: 28) Menu { - Picker("Sort By", selection: $manager.sortOrder) - { + Picker("Sort By", selection: $manager.sortOrder) { ForEach( VolumeManager.SortOrder.allCases, id: \.self diff --git a/MountyTests/PersistenceServiceTests.swift b/MountyTests/PersistenceServiceTests.swift new file mode 100644 index 0000000..f399bcc --- /dev/null +++ b/MountyTests/PersistenceServiceTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing + +@testable import Mounty + +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) + } +} diff --git a/MountyTests/SystemMountServiceTests.swift b/MountyTests/SystemMountServiceTests.swift new file mode 100644 index 0000000..5ef9055 --- /dev/null +++ b/MountyTests/SystemMountServiceTests.swift @@ -0,0 +1,85 @@ +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) + } +} diff --git a/MountyTests/VolumeTests.swift b/MountyTests/VolumeTests.swift new file mode 100644 index 0000000..19aea7c --- /dev/null +++ b/MountyTests/VolumeTests.swift @@ -0,0 +1,17 @@ +import Foundation +import Testing + +@testable import Mounty + +struct VolumeTests { + + @Test func hostIsParsedFromServerAddress() { + let volume = Volume(name: "NAS", serverAddress: "smb://nas.local/media") + #expect(volume.host == "nas.local") + } + + @Test func hostIsNilForAddressWithoutHost() { + let volume = Volume(name: "bad", serverAddress: "not-a-url") + #expect(volume.host == nil) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ef3a5a --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +
+ +# 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.** Mounty is currently distributed **unsigned** (we do not yet +> have an Apple Developer ID). macOS will refuse to open it on the first try. To allow it: +> +> - **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. Once we obtain a Developer ID, releases will be notarized and this step +> will no longer be necessary. + +## 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 a lightweight, provider-neutral **Spec-Driven Development** workflow — see + [`AGENTS.md`](./AGENTS.md) and [`specs/`](./specs/). + +## 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/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/specs/README.md b/specs/README.md new file mode 100644 index 0000000..08caa47 --- /dev/null +++ b/specs/README.md @@ -0,0 +1,38 @@ +# Specs — Spec-Driven Development + +Non-trivial changes to Mounty start with a spec, not code. This keeps intent explicit and lets any +AI agent (Claude, GPT/Copilot, Cursor, …) or human pick up the work with full context. + +## Workflow + +``` +specify → plan → tasks → implement + (what) (how) (steps) (code + tests) +``` + +1. **Specify** — capture *what* and *why* (the problem, goals, user-visible behavior, acceptance + criteria). Template: [`templates/spec-template.md`](./templates/spec-template.md). +2. **Plan** — capture *how* (architecture, files to touch, data flow, trade-offs). Template: + [`templates/plan-template.md`](./templates/plan-template.md). +3. **Tasks** — break the plan into small, verifiable steps. Template: + [`templates/tasks-template.md`](./templates/tasks-template.md). + +## How to use + +Create a numbered folder per feature and copy the templates into it: + +``` +specs/ +├─ README.md +├─ templates/ +│ ├─ spec-template.md +│ ├─ plan-template.md +│ └─ tasks-template.md +└─ 001-example-feature/ + ├─ spec.md + ├─ plan.md + └─ tasks.md +``` + +This structure is intentionally tool-agnostic — it is plain Markdown, works with any assistant, and +requires no extra tooling. See [`../AGENTS.md`](../AGENTS.md) for the full agent guide. diff --git a/specs/templates/plan-template.md b/specs/templates/plan-template.md new file mode 100644 index 0000000..738c068 --- /dev/null +++ b/specs/templates/plan-template.md @@ -0,0 +1,31 @@ +# Plan: + +- **Spec:** ./spec.md +- **Status:** draft | approved + +## Approach + + + +## Architecture & data flow + + + +## Files to change + +| File | Change | +| ---- | ------ | +| `Mounty/Mounty/Services/...` | | +| `MountyTests/...` | | + +## Reused existing code + + + +## Trade-offs / risks + +- + +## Verification + + diff --git a/specs/templates/spec-template.md b/specs/templates/spec-template.md new file mode 100644 index 0000000..bc9108c --- /dev/null +++ b/specs/templates/spec-template.md @@ -0,0 +1,31 @@ +# Spec: + +- **Status:** draft | approved | implemented +- **Author:** +- **Date:** + +## Problem / motivation + + + +## Goals + +- +- + +## Non-goals + +- + +## User-visible behavior + + + +## Acceptance criteria + +- [ ] +- [ ] + +## Open questions + +- diff --git a/specs/templates/tasks-template.md b/specs/templates/tasks-template.md new file mode 100644 index 0000000..2b52bfd --- /dev/null +++ b/specs/templates/tasks-template.md @@ -0,0 +1,17 @@ +# Tasks: + +- **Plan:** ./plan.md + +Break the plan into small, independently verifiable steps. Each task should map to a focused commit +with a Conventional-Commit message. + +- [ ] **T1** — _(commit: `feat: ...`)_ +- [ ] **T2** — _(commit: `test: ...`)_ +- [ ] **T3** — _(commit: `docs: ...`)_ + +## Definition of done + +- [ ] All acceptance criteria in `spec.md` met +- [ ] Business logic covered by Swift Testing tests +- [ ] `swift-format lint --strict` clean +- [ ] CI green 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 From 455ebc2bc83157957ca71d9af8ef71f4b5175915 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Fri, 7 Aug 2026 15:15:00 +0200 Subject: [PATCH 02/38] =?UTF-8?q?docs:=20update=20attribution=20convention?= =?UTF-8?q?=20=E2=80=94=20Generated-by:=20only,=20no=20Co-Authored-By:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify across AGENTS.md, CONTRIBUTING.md, copilot-instructions.md, and .cursorrules that agent commits use only the Generated-by: trailer. The human user is the commit author; Co-Authored-By: from AI tools is explicitly disallowed. Adds privacy guardrail: never include personal contact information in any file. Generated-by: claude-sonnet-4-6 --- .cursorrules | 2 ++ .github/copilot-instructions.md | 3 +++ AGENTS.md | 8 ++++++-- CONTRIBUTING.md | 9 ++++----- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.cursorrules b/.cursorrules index 7c1884a..402fb95 100644 --- a/.cursorrules +++ b/.cursorrules @@ -11,4 +11,6 @@ Highlights: - 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/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3d59e89..861ab02 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -12,3 +12,6 @@ Key reminders for generated code and 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/AGENTS.md b/AGENTS.md index 933093a..1fcd69d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,8 @@ Versioning and releases are **fully automated** by `release-please` from commit ### 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, e.g.: +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 @@ -90,7 +91,7 @@ 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. -Keep any `Co-Authored-By:` line as well if your harness adds one. +The `commit-msg` hook validates the format when the trailer is present. ## Spec-Driven Development (SDD) @@ -118,3 +119,6 @@ Copy the templates into `specs//` for the feature you are workin - 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/CONTRIBUTING.md b/CONTRIBUTING.md index 5e09a89..b816987 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,19 +51,18 @@ The `commit-msg` hook validates this format locally, and CI validates the **PR t ### AI agent attribution (required for agent commits) -If a commit is authored or co-authored by an AI coding agent, it **must** record the model used via -a `Generated-by:` git trailer. This is provider-neutral — it applies to Claude, GPT, Copilot, -Gemini, or any other assistant: +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 -Co-Authored-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 warns if a commit looks agent-authored but omits the trailer. +hook validates the format when the trailer is present. ## Spec-Driven Development (SDD) From 6be4024f14d2ed54487830d44baeaab6a41d60b0 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Fri, 7 Aug 2026 15:19:36 +0200 Subject: [PATCH 03/38] fix(reachability): replace contentsOfDirectory with statfs to stop TCC prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileManager.contentsOfDirectory on a network path triggers the macOS TCC dialog "would like to access files on a network volume" on every call. Since isMountPointAlive runs every 5 seconds via the heartbeat timer, the prompt appeared repeatedly and could not be permanently dismissed. Replace with statfs(2), a kernel syscall that queries filesystem metadata without reading file content — TCC never fires. statfs still blocks on a hung/dead mount and thus correctly times out at 1 s, preserving the dead-mount detection behaviour. Generated-by: claude-sonnet-4-6 --- Mounty/Services/ReachabilityService.swift | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Mounty/Services/ReachabilityService.swift b/Mounty/Services/ReachabilityService.swift index fefe59c..f2a27b8 100644 --- a/Mounty/Services/ReachabilityService.swift +++ b/Mounty/Services/ReachabilityService.swift @@ -1,26 +1,28 @@ +import Darwin import Foundation import Network /// Verifies server and mount point responsiveness. struct ReachabilityService { - /// Validates filesystem responsiveness via I/O. + /// 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. nonisolated static func isMountPointAlive(path: String) -> Bool { let group = DispatchGroup() group.enter() - var isAlive = false + var alive = false DispatchQueue.global(qos: .userInteractive).async { - if (try? FileManager.default.contentsOfDirectory(atPath: path)) - != nil - { - isAlive = true - } + var buf = statfs() // zero-init the struct + alive = statfs(path, &buf) == 0 // C function: 0 = success group.leave() } - let result = group.wait(timeout: .now() + 1.0) - return result == .success && isAlive + return group.wait(timeout: .now() + 1.0) == .success && alive } /// Validates TCP connectivity to SMB port (445). From 3f7596bcace40f8544acc8e6a46fc089255e4810 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Fri, 7 Aug 2026 15:37:35 +0200 Subject: [PATCH 04/38] feat(ui): modernise all views to macOS-native design standards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add IconButtonHover view modifier (in Overlays.swift) — reusable rounded-rect hover highlight for toolbar-style icon buttons - Apply hover feedback to all .plain buttons: HeaderView back/trailing, VolumeRow action row, MainListView search-toggle footer button - Add per-row hover highlight in VolumeRow with contentShape guard - Improve empty-state placeholder: contextual SF Symbol + callout text - Fix duplicate version string in About section (Mounty + Version line) - Replace deprecated .cornerRadius() with .clipShape(RoundedRectangle) across all three overlay types - Replace hardcoded Color.gray border in AddVolumeView with NSColor.separatorColor for automatic dark/light-mode adaptation Generated-by: claude-sonnet-4-6 --- Mounty/Views/AddVolumeView.swift | 4 ++-- Mounty/Views/HeaderView.swift | 9 +++++---- Mounty/Views/MainListView.swift | 12 +++++++++++- Mounty/Views/Overlays.swift | 30 +++++++++++++++++++++++++++--- Mounty/Views/SettingsView.swift | 2 +- Mounty/Views/VolumeRow.swift | 25 ++++++++++++++++++------- 6 files changed, 64 insertions(+), 18 deletions(-) diff --git a/Mounty/Views/AddVolumeView.swift b/Mounty/Views/AddVolumeView.swift index cb25488..0e4250d 100644 --- a/Mounty/Views/AddVolumeView.swift +++ b/Mounty/Views/AddVolumeView.swift @@ -68,10 +68,10 @@ struct AddVolumeView: View { } .padding(8) .background(Color(NSColor.textBackgroundColor)) - .cornerRadius(6) + .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) - .stroke(Color.gray.opacity(0.3), lineWidth: 1) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) ) } .padding(20) diff --git a/Mounty/Views/HeaderView.swift b/Mounty/Views/HeaderView.swift index d883db7..48587e6 100644 --- a/Mounty/Views/HeaderView.swift +++ b/Mounty/Views/HeaderView.swift @@ -16,12 +16,12 @@ 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() @@ -45,10 +45,11 @@ struct HeaderView: View { 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) } } diff --git a/Mounty/Views/MainListView.swift b/Mounty/Views/MainListView.swift index 5543698..b3a83e3 100644 --- a/Mounty/Views/MainListView.swift +++ b/Mounty/Views/MainListView.swift @@ -88,13 +88,21 @@ struct MainListView: View { // List if manager.filteredAndSortedVolumes.isEmpty { - VStack { + 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) @@ -126,11 +134,13 @@ struct MainListView: View { withAnimation { manager.showSearch.toggle() } } label: { Image(systemName: "magnifyingglass") + .font(.system(size: 13)) .foregroundColor( isSearchVisible ? .accentColor : .secondary ) } .buttonStyle(.plain) + .iconButtonHover() .help("Search Volumes (⌘F)") Spacer() diff --git a/Mounty/Views/Overlays.swift b/Mounty/Views/Overlays.swift index 40600c5..7a4f8bb 100644 --- a/Mounty/Views/Overlays.swift +++ b/Mounty/Views/Overlays.swift @@ -1,5 +1,29 @@ import SwiftUI +// MARK: - Icon Button Hover Modifier +struct IconButtonHover: ViewModifier { + @State private var isHovered = false + var cornerRadius: CGFloat = 5 + var padding: CGFloat = 4 + + func body(content: Content) -> some View { + content + .padding(padding) + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(isHovered ? Color.primary.opacity(0.08) : .clear) + .animation(.easeOut(duration: 0.12), value: isHovered) + ) + .onHover { isHovered = $0 } + } +} + +extension View { + func iconButtonHover(cornerRadius: CGFloat = 5, padding: CGFloat = 4) -> some View { + modifier(IconButtonHover(cornerRadius: cornerRadius, padding: padding)) + } +} + // MARK: - Status Alert Overlay /// Displays success (Green) or error (Red) messages non-intrusively. struct AlertOverlay: View { @@ -36,7 +60,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 +102,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)) } @@ -135,7 +159,7 @@ struct InputOverlay: View { .padding(20) .frame(width: 280) .background(.regularMaterial) - .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(radius: 10) .transition(.scale.combined(with: .opacity)) } diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index 3083624..71028dc 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -102,7 +102,7 @@ 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) diff --git a/Mounty/Views/VolumeRow.swift b/Mounty/Views/VolumeRow.swift index a125345..f9c0c10 100644 --- a/Mounty/Views/VolumeRow.swift +++ b/Mounty/Views/VolumeRow.swift @@ -3,6 +3,7 @@ import SwiftUI struct VolumeRow: View { let volume: Volume @ObservedObject var manager: VolumeManager + @State private var isRowHovered = false var isMounted: Bool { manager.mountPaths[volume.id] != nil } var isBusy: Bool { manager.busyVolumes.contains(volume.id) } @@ -12,10 +13,11 @@ struct VolumeRow: 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) .help( isMounted ? "Mounted at: \(currentPath)" @@ -27,7 +29,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 +39,7 @@ struct VolumeRow: View { Spacer() // Actions - HStack(spacing: 8) { + HStack(spacing: 4) { // 1. Automount Toggle Button { @@ -47,13 +49,14 @@ 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" @@ -69,6 +72,7 @@ struct VolumeRow: View { .foregroundColor(.secondary) } .buttonStyle(.plain) + .iconButtonHover(padding: 3) .help("Show in Finder") } @@ -82,6 +86,7 @@ struct VolumeRow: View { .foregroundColor(.secondary) } .buttonStyle(.plain) + .iconButtonHover(padding: 3) .help("Open in Terminal") } @@ -95,20 +100,26 @@ struct VolumeRow: View { } label: { if isBusy { 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) + .iconButtonHover(padding: 3) .disabled(isBusy) .help(isMounted ? "Disconnect" : "Connect") } } .padding(.horizontal, 12) + .background(isRowHovered ? Color.primary.opacity(0.04) : .clear) + .contentShape(Rectangle()) + .animation(.easeOut(duration: 0.1), value: isRowHovered) + .onHover { isRowHovered = $0 } .onTapGesture(count: 2) { if isMounted { manager.openInFinder(volume) } } From bd7e248602f2ffe5c24a94a73d4603571593b480 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Fri, 7 Aug 2026 15:41:57 +0200 Subject: [PATCH 05/38] feat(ui): replace custom icon buttons in Settings with native Form rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three Volumes actions (import/export/clear) were icon-only bordered buttons inside a manually cleared list row — stripping native hover. Replaced with standard Label-based Form rows; grouped Form style handles hover automatically. Clear All uses Button(role: .destructive) for the native red tint with no custom styling needed. Generated-by: claude-sonnet-4-6 --- Mounty/Views/SettingsView.swift | 51 +++++++++++++-------------------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index 71028dc..4983110 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -55,38 +55,27 @@ struct SettingsView: View { } Section(header: Text("Volumes")) { - HStack(spacing: 12) { - Button { - importPath = "" - withAnimation { showImportDialog = true } - } label: { - Image(systemName: "square.and.arrow.up") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .help("Import volumes from JSON") - - Button { - manager.exportToDownloads() - } label: { - Image(systemName: "square.and.arrow.down") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .help("Export volumes to Downloads") - - Button { - withAnimation { showResetConfirmation = true } - } label: { - Image(systemName: "trash") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .tint(.red) - .help("Clear all volumes") + Button { + importPath = "" + withAnimation { showImportDialog = true } + } label: { + Label("Import Volumes", systemImage: "square.and.arrow.up") } - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) + .help("Import volumes from JSON") + + Button { + manager.exportToDownloads() + } label: { + Label("Export to Downloads", systemImage: "square.and.arrow.down") + } + .help("Export volumes to Downloads") + + Button(role: .destructive) { + withAnimation { showResetConfirmation = true } + } label: { + Label("Clear All Volumes", systemImage: "trash") + } + .help("Clear all volumes") } Section(header: Text("Application Info")) { From 16f94d6f213abb8b37679da3f167459f1e8420a9 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Fri, 7 Aug 2026 15:49:10 +0200 Subject: [PATCH 06/38] fix(ui): restore icon-only row layout in Settings with proper hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the Label-based rows back to the original HStack with three icon-only buttons (import/export/trash). Replaces .bordered with .plain + iconButtonHover() — same hover style used throughout the app — so the rounded-rect hover feedback is visible and consistent. Red foreground is applied directly to the trash icon instead of .tint. Generated-by: claude-sonnet-4-6 --- Mounty/Views/SettingsView.swift | 57 +++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index 4983110..06c0f27 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -55,27 +55,44 @@ struct SettingsView: View { } Section(header: Text("Volumes")) { - Button { - importPath = "" - withAnimation { showImportDialog = true } - } label: { - Label("Import Volumes", systemImage: "square.and.arrow.up") - } - .help("Import volumes from JSON") - - Button { - manager.exportToDownloads() - } label: { - Label("Export to Downloads", systemImage: "square.and.arrow.down") - } - .help("Export volumes to Downloads") - - Button(role: .destructive) { - withAnimation { showResetConfirmation = true } - } label: { - Label("Clear All Volumes", systemImage: "trash") + HStack(spacing: 12) { + Button { + importPath = "" + withAnimation { showImportDialog = true } + } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 15)) + .frame(maxWidth: .infinity, minHeight: 26) + } + .buttonStyle(.plain) + .iconButtonHover(cornerRadius: 6, padding: 6) + .help("Import volumes from JSON") + + Button { + manager.exportToDownloads() + } label: { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 15)) + .frame(maxWidth: .infinity, minHeight: 26) + } + .buttonStyle(.plain) + .iconButtonHover(cornerRadius: 6, padding: 6) + .help("Export volumes to Downloads") + + Button { + withAnimation { showResetConfirmation = true } + } label: { + Image(systemName: "trash") + .font(.system(size: 15)) + .foregroundColor(.red) + .frame(maxWidth: .infinity, minHeight: 26) + } + .buttonStyle(.plain) + .iconButtonHover(cornerRadius: 6, padding: 6) + .help("Clear all volumes") } - .help("Clear all volumes") + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) } Section(header: Text("Application Info")) { From 82c28cffbb251a501544951e439c2bc2c152102e Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sat, 8 Aug 2026 02:30:30 +0200 Subject: [PATCH 07/38] fix(ui): reliable animations, drag-to-resize, no main-thread blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI / animation: - RootView: top-aligned ZStack, asymmetric slide transitions, easeOut(0.2) - MainListView: remove hidden shortcut button (caused erratic search toggles); move keyboardShortcut to footer button; declarative .animation on VStack instead of imperative withAnimation so transition never gets dropped; add frame(maxWidth:.infinity) to footer to stop Spacer width from shifting; drag-to-resize handle between list and footer, persisted via AppStorage - SettingsView / AddVolumeView: remove withAnimation from back buttons — RootView's declarative animation handles navigation Threading / main-thread safety: - ReachabilityService.isMountPointAlive: DispatchGroup.wait blocked a cooperative thread for up to 1s per hung mount — rewritten as async with withCheckedContinuation + ResumeGate, zero thread blocking - VolumeManager: detectMounts now awaits the async isMountPointAlive - VolumeManager.importVolumes: Data(contentsOf:) moved off MainActor via Task.detached — was freezing UI on every import - VolumeManager.exportToDownloads: data.write moved off MainActor the same way - VolumeManager.refreshState: remove withAnimation — background animation transactions hijacked concurrent button-press animations; mount-state icon now animates locally in VolumeRow via .animation(_:value:isMounted) - Heartbeat timer: .default RunLoop mode instead of .common so it skips firing during UI event-tracking loops Generated-by: claude-sonnet-4-6 --- Mounty/Services/ReachabilityService.swift | 29 ++-- Mounty/ViewModels/VolumeManager.swift | 102 ++++++------- Mounty/Views/AddVolumeView.swift | 2 +- Mounty/Views/MainListView.swift | 172 +++++++++++++--------- Mounty/Views/RootView.swift | 22 ++- Mounty/Views/SettingsView.swift | 8 +- Mounty/Views/VolumeRow.swift | 1 + 7 files changed, 198 insertions(+), 138 deletions(-) diff --git a/Mounty/Services/ReachabilityService.swift b/Mounty/Services/ReachabilityService.swift index f2a27b8..aefd4e6 100644 --- a/Mounty/Services/ReachabilityService.swift +++ b/Mounty/Services/ReachabilityService.swift @@ -11,18 +11,27 @@ struct ReachabilityService { /// 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. - nonisolated static func isMountPointAlive(path: String) -> Bool { - let group = DispatchGroup() - group.enter() - var alive = false + /// + /// 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 { + await withCheckedContinuation { continuation in + let gate = ResumeGate() - DispatchQueue.global(qos: .userInteractive).async { - var buf = statfs() // zero-init the struct - alive = statfs(path, &buf) == 0 // C function: 0 = success - group.leave() - } + DispatchQueue.global(qos: .userInteractive).async { + var buf = statfs() + let alive = statfs(path, &buf) == 0 + if gate.tryResume() { + continuation.resume(returning: alive) + } + } - return group.wait(timeout: .now() + 1.0) == .success && alive + DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { + if gate.tryResume() { + continuation.resume(returning: false) + } + } + } } /// Validates TCP connectivity to SMB port (445). diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index e577b98..0e4c5a8 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -151,7 +151,8 @@ class VolumeManager: ObservableObject { // 4. Heartbeat Timer (Silent Death Check) // Interval: 5s (Snappy) // Optimization: Gated by Network Status & Lower QoS - Timer.publish(every: 5, on: .main, in: .common) + // .default mode (not .common) so the timer does not fire during UI event tracking. + Timer.publish(every: 5, on: .main, in: .default) .autoconnect() .sink { [weak self] _ in guard let self = self, self.isNetworkUp else { return } @@ -210,10 +211,12 @@ class VolumeManager: ObservableObject { ) }.value + // 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 } } @@ -240,7 +243,7 @@ class VolumeManager: ObservableObject { address: volume.serverAddress ) { // 2. IO Reachability (Catches hung kernel mounts) - if ReachabilityService.isMountPointAlive(path: path) { + if await ReachabilityService.isMountPointAlive(path: path) { return (volume.id, path) } } @@ -364,56 +367,55 @@ class VolumeManager: ObservableObject { 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 + // 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) + var count = 0 + for volume in importedVolumes { + if !self.volumes.contains(where: { + $0.serverAddress == volume.serverAddress + }) { + self.volumes.append(volume) + count += 1 + } } + self.storage.saveVolumes(self.volumes) + await self.refreshState() + self.successMessage = "Imported \(count) volumes successfully." + self.showSuccess = true + } catch { + 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 exportToDownloads() { + let snapshot = volumes + // data.write(to:) is synchronous blocking I/O — run it off the main actor. + Task { + 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(snapshot) + try await Task.detached(priority: .utility) { + try data.write(to: fileURL) + }.value + self.successMessage = "Backup saved to Downloads." + self.showSuccess = true + } catch { + self.lastError = "Export failed: \(error.localizedDescription)" + self.showError = true + } } } } diff --git a/Mounty/Views/AddVolumeView.swift b/Mounty/Views/AddVolumeView.swift index 0e4250d..4e2e6ea 100644 --- a/Mounty/Views/AddVolumeView.swift +++ b/Mounty/Views/AddVolumeView.swift @@ -25,7 +25,7 @@ struct AddVolumeView: View { title: "Add New Volume", backAction: { focusedField = nil - withAnimation { viewMode = .list } + viewMode = .list } ) diff --git a/Mounty/Views/MainListView.swift b/Mounty/Views/MainListView.swift index b3a83e3..d8bfd85 100644 --- a/Mounty/Views/MainListView.swift +++ b/Mounty/Views/MainListView.swift @@ -5,11 +5,17 @@ struct MainListView: View { @Binding var viewMode: AppViewMode private let rowHeight: CGFloat = 50 - private let maxVisibleRows = 5 + 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 } + // Empty state uses the current row cap so the resize handle still works. + if count == 0 { return CGFloat(maxVisibleRows) * rowHeight } return min(CGFloat(count), CGFloat(maxVisibleRows)) * rowHeight } @@ -19,14 +25,7 @@ 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, @@ -36,49 +35,49 @@ struct MainListView: View { ) .transaction { $0.animation = nil } - // Search Bar - Background matches Window Header + // Search bar — declarative animation driven by isSearchVisible. + // No withAnimation in the toggle action; the .animation modifier on + // the VStack handles it, making the transition reliable. if isSearchVisible { - VStack(spacing: 0) { - HStack(alignment: .center) { - TextField("Search...", text: $manager.searchText) - .textFieldStyle(.roundedBorder) - .frame(height: 28) - - Menu { - Picker("Sort By", selection: $manager.sortOrder) { - ForEach( - VolumeManager.SortOrder.allCases, - id: \.self - ) { - Text($0.rawValue).tag($0) - } + HStack(alignment: .center) { + TextField("Search...", text: $manager.searchText) + .textFieldStyle(.roundedBorder) + .frame(height: 28) + + Menu { + Picker("Sort By", selection: $manager.sortOrder) { + ForEach( + VolumeManager.SortOrder.allCases, + id: \.self + ) { + Text($0.rawValue).tag($0) } - } label: { - Image(systemName: "arrow.up.arrow.down.circle") } - .pickerStyle(.inline) - .menuStyle(.borderlessButton) - .frame(width: 28, height: 28) - .help("Sort By") - - Button { - manager.sortDirection = - (manager.sortDirection == .ascending) - ? .descending : .ascending - } label: { - Image( - systemName: manager.sortDirection - == .ascending - ? "arrow.down" : "arrow.up" - ) - } - .buttonStyle(.borderless) - .frame(width: 28, height: 28) - .help("Toggle Sort Direction") + } label: { + Image(systemName: "arrow.up.arrow.down.circle") + } + .pickerStyle(.inline) + .menuStyle(.borderlessButton) + .frame(width: 28, height: 28) + .help("Sort By") + + Button { + manager.sortDirection = + (manager.sortDirection == .ascending) + ? .descending : .ascending + } label: { + Image( + systemName: manager.sortDirection == .ascending + ? "arrow.down" : "arrow.up" + ) } - .padding(.horizontal, 12) - .padding(.vertical, 8) + .buttonStyle(.borderless) + .frame(width: 28, height: 28) + .help("Toggle Sort Direction") } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) .background(Color(NSColor.windowBackgroundColor)) .transition(.move(edge: .top).combined(with: .opacity)) .zIndex(1) @@ -86,7 +85,7 @@ struct MainListView: View { Divider() - // List + // List content if manager.filteredAndSortedVolumes.isEmpty { VStack(spacing: 8) { Spacer() @@ -105,33 +104,65 @@ struct MainListView: View { .font(.callout) .foregroundColor(.secondary) Spacer() - }.frame(height: listHeight) + } + .frame(height: listHeight) } else { - ScrollViewReader { _ in - ScrollView { - VStack(spacing: 0) { - ForEach(manager.filteredAndSortedVolumes) { - volume in - VolumeRow(volume: volume, manager: manager) - .frame(height: rowHeight) - Divider() - } + ScrollView { + VStack(spacing: 0) { + ForEach(manager.filteredAndSortedVolumes) { volume in + VolumeRow(volume: volume, manager: manager) + .frame(height: rowHeight) + Divider() } } - .frame(height: listHeight) - .scrollDisabled( - manager.filteredAndSortedVolumes.count - <= maxVisibleRows - ) } + .frame(height: listHeight) + .scrollDisabled( + manager.filteredAndSortedVolumes.count <= maxVisibleRows + ) } - Divider() + // 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() } + } + ) - // 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() } + manager.showSearch.toggle() } label: { Image(systemName: "magnifyingglass") .font(.system(size: 13)) @@ -141,6 +172,9 @@ struct MainListView: View { } .buttonStyle(.plain) .iconButtonHover() + // Keyboard shortcut lives here — removes the need for the + // hidden zero-size Button that was causing erratic toggles. + .keyboardShortcut("f", modifiers: .command) .help("Search Volumes (⌘F)") Spacer() @@ -155,12 +189,16 @@ struct MainListView: View { .help("Add Volume") } .padding(12) + .frame(maxWidth: .infinity) .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) - // Overlay Layer if manager.showError { AlertOverlay( title: "Error", diff --git a/Mounty/Views/RootView.swift b/Mounty/Views/RootView.swift index b48541e..eb99fd6 100644 --- a/Mounty/Views/RootView.swift +++ b/Mounty/Views/RootView.swift @@ -5,22 +5,34 @@ struct RootView: View { @State private var viewMode: AppViewMode = .list var body: some View { - ZStack { + ZStack(alignment: .top) { Color(NSColor.windowBackgroundColor).ignoresSafeArea() switch viewMode { case .list: MainListView(manager: manager, viewMode: $viewMode) - .transition(.move(edge: .leading)) + .transition( + .asymmetric( + insertion: .move(edge: .leading), + removal: .move(edge: .leading) + )) case .add: AddVolumeView(manager: manager, viewMode: $viewMode) - .transition(.move(edge: .trailing)) + .transition( + .asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .trailing) + )) case .settings: SettingsView(manager: manager, viewMode: $viewMode) - .transition(.move(edge: .trailing)) + .transition( + .asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .trailing) + )) } } - .animation(.default, value: viewMode) + .animation(.easeOut(duration: 0.2), value: viewMode) .frame(width: 420) } } diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index 06c0f27..04245b4 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -24,10 +24,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" ) @@ -144,7 +142,7 @@ struct SettingsView: View { isPresented: $showResetConfirmation ) { manager.clearAllVolumes() - withAnimation { viewMode = .list } + viewMode = .list } } diff --git a/Mounty/Views/VolumeRow.swift b/Mounty/Views/VolumeRow.swift index f9c0c10..cd196c4 100644 --- a/Mounty/Views/VolumeRow.swift +++ b/Mounty/Views/VolumeRow.swift @@ -18,6 +18,7 @@ struct VolumeRow: View { isMounted ? .accentColor : .secondary.opacity(0.4) ) .frame(width: 26) + .animation(.easeOut(duration: 0.2), value: isMounted) .help( isMounted ? "Mounted at: \(currentPath)" From c38807a76f3ea9c75358a6fdb487aaf888bd3669 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sat, 8 Aug 2026 02:48:09 +0200 Subject: [PATCH 08/38] feat(ui): add in-app log viewer and one-shot volume speed test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LogEntry model: ring-buffer struct with level (info/warning/error), timestamp, message, and colour/symbol helpers for the log view - SpeedTestService: nonisolated static async func wrapping all file I/O in DispatchQueue.global so the cooperative thread pool never blocks during multi-second network transfers - VolumeManager: @Published logEntries ring buffer (cap 200); private log() helper mirrors every event to os.Logger and the buffer; new speed-test state (speedTestVolumeId, isRunningSpeedTest, result, error); runSpeedTest(for:)/clearSpeedTest()/clearLogs(); refreshInstalledTerminals deferred to Task(priority:.utility) so init() never blocks the first frame - LogsView: auto-scrolling monospaced log list with level icons, time stamps, per-row textSelection, Clear and Copy-All footer actions; accessible from Settings → Diagnostics → View App Logs - SpeedTestOverlay: write/read MB/s Grid shown over any active view in RootView; launched via right-click → Measure Speed… on a mounted volume - VolumeRow: isTesting computed var; mount/unmount button shows spinner and is disabled while a speed test runs; context menu gains "Measure Speed…" (mounted volumes only, disabled when a test is running) - AppViewMode gains .logs case; RootView handles it with trailing-edge slide transition matching the existing settings navigation Generated-by: claude-sonnet-4-6 --- Mounty/Models/LogEntry.swift | 43 ++++++++ Mounty/Models/Volume.swift | 2 +- Mounty/Services/SpeedTestService.swift | 46 +++++++++ Mounty/ViewModels/VolumeManager.swift | 132 ++++++++++++++++++------- Mounty/Views/LogsView.swift | 110 +++++++++++++++++++++ Mounty/Views/Overlays.swift | 54 ++++++++++ Mounty/Views/RootView.swift | 31 ++++++ Mounty/Views/SettingsView.swift | 8 ++ Mounty/Views/VolumeRow.swift | 20 +++- 9 files changed, 407 insertions(+), 39 deletions(-) create mode 100644 Mounty/Models/LogEntry.swift create mode 100644 Mounty/Services/SpeedTestService.swift create mode 100644 Mounty/Views/LogsView.swift diff --git a/Mounty/Models/LogEntry.swift b/Mounty/Models/LogEntry.swift new file mode 100644 index 0000000..0f431cf --- /dev/null +++ b/Mounty/Models/LogEntry.swift @@ -0,0 +1,43 @@ +import Foundation +import SwiftUI + +struct LogEntry: Identifiable, Sendable { + let id = UUID() + let timestamp: Date + let level: Level + let message: String + + enum Level: Sendable { + case info, warning, error + + var color: Color { + switch self { + case .info: .secondary + case .warning: .orange + case .error: .red + } + } + + var symbol: String { + switch self { + case .info: "circle.fill" + case .warning: "exclamationmark.triangle.fill" + case .error: "xmark.circle.fill" + } + } + + var label: String { + switch self { + case .info: "INFO" + case .warning: "WARN" + case .error: "ERROR" + } + } + } + + // Full-fidelity string used for clipboard export. + var formatted: String { + let ts = timestamp.formatted(.dateTime.year().month().day().hour().minute().second()) + return "[\(ts)] [\(level.label)] \(message)" + } +} diff --git a/Mounty/Models/Volume.swift b/Mounty/Models/Volume.swift index f662b55..7b98434 100644 --- a/Mounty/Models/Volume.swift +++ b/Mounty/Models/Volume.swift @@ -21,4 +21,4 @@ struct Volume: Identifiable, Codable, Equatable, Sendable { } } -enum AppViewMode { case list, add, settings } +enum AppViewMode { case list, add, settings, logs } diff --git a/Mounty/Services/SpeedTestService.swift b/Mounty/Services/SpeedTestService.swift new file mode 100644 index 0000000..6c85926 --- /dev/null +++ b/Mounty/Services/SpeedTestService.swift @@ -0,0 +1,46 @@ +import Foundation + +struct SpeedTestService { + struct Result: Sendable { + let writeSpeed: Double // MB/s + let readSpeed: Double // MB/s + let fileSizeMB: Double + } + + // All file I/O is dispatched to 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 { + let testURL = URL(fileURLWithPath: mountPath) + .appendingPathComponent(".mounty_speed_\(UUID().uuidString)") + let byteCount = Int(fileSizeMB * 1024 * 1024) + + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + defer { try? FileManager.default.removeItem(at: testURL) } + + let data = Data(count: byteCount) + + let writeStart = Date() + try data.write(to: testURL) + let writeDuration = max(Date().timeIntervalSince(writeStart), 0.001) + + let readStart = Date() + _ = try Data(contentsOf: testURL) + 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) + } + } + } + } +} diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index 0e4c5a8..5bcc9cd 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -30,14 +30,24 @@ class VolumeManager: ObservableObject { @Published var successMessage: String? = nil @Published var showSuccess: Bool = false + // In-app log buffer (capped at maxLogEntries) + @Published var logEntries: [LogEntry] = [] + + // Speed test state + @Published var speedTestVolumeId: UUID? = nil + @Published var isRunningSpeedTest = false + @Published var speedTestResult: SpeedTestService.Result? = nil + @Published var speedTestError: String? = nil + private var isNetworkUp: Bool = true + private let maxLogEntries = 200 // Dependencies private let storage = PersistenceService() private let eventMonitor = EventMonitorService() private var cancellables = Set() - // Logger + // Logger (os.Logger for Console.app; log() also feeds the in-app ring buffer) private let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "Mounty", category: "Manager" @@ -88,6 +98,10 @@ class VolumeManager: ObservableObject { return sorted } + var speedTestVolumeName: String { + volumes.first { $0.id == speedTestVolumeId }?.name ?? "Volume" + } + enum SortOrder: String, CaseIterable { case name = "Name" case dateAdded = "Date Added" @@ -97,6 +111,58 @@ class VolumeManager: ObservableObject { case ascending, descending } + // MARK: - Logging + + private func log(_ message: String, level: LogEntry.Level = .info) { + switch level { + case .info: logger.info("\(message, privacy: .public)") + case .warning: logger.warning("\(message, privacy: .public)") + case .error: logger.error("\(message, privacy: .public)") + } + logEntries.append(LogEntry(timestamp: Date(), level: level, message: message)) + if logEntries.count > maxLogEntries { logEntries.removeFirst() } + } + + func clearLogs() { + logEntries.removeAll() + } + + // MARK: - Speed Test + + func runSpeedTest(for volume: Volume) { + guard let path = mountPaths[volume.id] else { return } + speedTestVolumeId = volume.id + isRunningSpeedTest = true + speedTestResult = nil + speedTestError = nil + log("Speed test started for \(volume.name)") + + Task { + do { + let result = try await SpeedTestService.measure(at: path) + self.speedTestResult = result + self.log( + "Speed test (\(volume.name)): " + + "write \(String(format: "%.1f", result.writeSpeed)) MB/s, " + + "read \(String(format: "%.1f", result.readSpeed)) MB/s" + ) + } catch { + self.speedTestError = error.localizedDescription + self.log( + "Speed test failed for \(volume.name): \(error.localizedDescription)", + level: .error + ) + } + self.isRunningSpeedTest = false + } + } + + func clearSpeedTest() { + speedTestVolumeId = nil + speedTestResult = nil + speedTestError = nil + } + // MARK: - Event Pipelines private func setupPipelines() { @@ -106,18 +172,13 @@ class VolumeManager: ObservableObject { .sink { [weak self] status in guard let self else { return } - // Update internal state let wasUp = self.isNetworkUp self.isNetworkUp = (status == .satisfied) if self.isNetworkUp != wasUp { - self.logger.info( - "Global Network Changed: \(self.isNetworkUp ? "UP" : "DOWN")" - ) + self.log("Network: \(self.isNetworkUp ? "UP" : "DOWN")") } - // Trigger refresh immediately on ANY status update. - // Priority: .userInitiated (High) for responsiveness. Task(priority: .userInitiated) { await self.refreshState() if self.isNetworkUp { await self.runAutomount() } @@ -129,10 +190,7 @@ class VolumeManager: ObservableObject { eventMonitor.interfacesChanged .receive(on: RunLoop.main) .sink { [weak self] in - self?.logger.info( - "Interface topology changed. Retrying connections." - ) - // Priority: .userInitiated (High) to catch VPNs quickly + self?.log("Network interface changed — retrying connections") Task(priority: .userInitiated) { await self?.refreshState() await self?.runAutomount() @@ -149,15 +207,11 @@ class VolumeManager: ObservableObject { .store(in: &cancellables) // 4. Heartbeat Timer (Silent Death Check) - // Interval: 5s (Snappy) - // Optimization: Gated by Network Status & Lower QoS // .default mode (not .common) so the timer does not fire during UI event tracking. Timer.publish(every: 5, on: .main, in: .default) .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() } } .store(in: &cancellables) @@ -169,7 +223,6 @@ class VolumeManager: ObservableObject { guard isNetworkUp 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) @@ -177,18 +230,18 @@ class VolumeManager: ObservableObject { address: volume.serverAddress ) - // 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)" - ) + log("Automounting \(volume.name)") if let path = await MountService.mount(url: url) { self.mountPaths[volume.id] = path + log("Automounted \(volume.name) → \(path)") + } else { + log("Automount failed for \(volume.name)", level: .warning) } } busyVolumes.remove(volume.id) @@ -200,7 +253,6 @@ class VolumeManager: ObservableObject { let currentVolumes = self.volumes let networkAvailable = self.isNetworkUp - // Run detection. // NOTE: Task inherits priority from the caller. // Events call this with .userInitiated (Fast). // Timer calls this with .utility (Efficient). @@ -237,8 +289,6 @@ class VolumeManager: ObservableObject { 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( address: volume.serverAddress ) { @@ -260,30 +310,33 @@ class VolumeManager: ObservableObject { // MARK: - Actions + // Runs the 6 NSWorkspace lookups asynchronously so init() returns immediately + // and the first UI frame is not blocked by Launch Services queries. private func refreshInstalledTerminals() { - self.availableTerminals = knownTerminals.filter { (_, bundleID) in - NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) - != nil - } - if !availableTerminals.contains(where: { $0.id == preferredTerminal }) { - preferredTerminal = "com.apple.Terminal" + Task(priority: .utility) { + self.availableTerminals = self.knownTerminals.filter { (_, bundleID) in + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) != nil + } + if !self.availableTerminals.contains(where: { $0.id == self.preferredTerminal }) { + self.preferredTerminal = "com.apple.Terminal" + } } } func mount(_ volume: Volume) { 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)" - ) + self.log("Connected: \(volume.name) → \(path)") } else { self.lastError = "Connection failed. Verify address and keychain credentials." self.showError = true + self.log("Connection failed: \(volume.name)", level: .error) } self.busyVolumes.remove(volume.id) await self.refreshState() @@ -296,10 +349,12 @@ class VolumeManager: ObservableObject { mountPaths.removeValue(forKey: volume.id) busyVolumes.insert(volume.id) + log("Disconnecting \(volume.name)") Task { await MountService.unmount(path: path) self.busyVolumes.remove(volume.id) + self.log("Disconnected: \(volume.name)") await self.refreshState() } } @@ -321,16 +376,21 @@ class VolumeManager: ObservableObject { func addVolume(_ volume: Volume) { volumes.append(volume) storage.saveVolumes(volumes) + log("Added volume: \(volume.name)") Task { await refreshState() } } func removeVolume(_ id: UUID) { + if let v = volumes.first(where: { $0.id == id }) { + log("Removed volume: \(v.name)") + } volumes.removeAll { $0.id == id } storage.saveVolumes(volumes) Task { await refreshState() } } func clearAllVolumes() { + log("Cleared all volumes") volumes.removeAll() storage.saveVolumes(volumes) Task { await refreshState() } @@ -340,7 +400,9 @@ class VolumeManager: ObservableObject { 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() } } } } @@ -385,9 +447,11 @@ class VolumeManager: ObservableObject { } self.storage.saveVolumes(self.volumes) await self.refreshState() + self.log("Imported \(count) volume(s) from backup") self.successMessage = "Imported \(count) volumes successfully." self.showSuccess = true } catch { + self.log("Import failed: \(error.localizedDescription)", level: .error) self.lastError = "Could not import: \(error.localizedDescription)" self.showError = true } @@ -410,9 +474,11 @@ class VolumeManager: ObservableObject { try await Task.detached(priority: .utility) { try data.write(to: fileURL) }.value + self.log("Exported \(snapshot.count) volume(s) to Downloads") self.successMessage = "Backup saved to Downloads." 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/LogsView.swift b/Mounty/Views/LogsView.swift new file mode 100644 index 0000000..0089838 --- /dev/null +++ b/Mounty/Views/LogsView.swift @@ -0,0 +1,110 @@ +import SwiftUI + +struct LogsView: View { + @ObservedObject var manager: VolumeManager + @Binding var viewMode: AppViewMode + + var body: some View { + VStack(spacing: 0) { + HeaderView( + title: "App Logs", + backAction: { viewMode = .settings } + ) + + Divider() + + if manager.logEntries.isEmpty { + VStack(spacing: 8) { + Spacer() + Image(systemName: "doc.text") + .font(.system(size: 28)) + .foregroundColor(.secondary.opacity(0.5)) + Text("No Log Entries") + .font(.callout) + .foregroundColor(.secondary) + Spacer() + } + .frame(height: 200) + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(manager.logEntries) { entry in + LogEntryRow(entry: entry) + } + Color.clear.frame(height: 1).id("logsBottom") + } + .padding(.vertical, 4) + } + .frame(height: 200) + .onChange(of: manager.logEntries.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)) + } + .buttonStyle(.plain) + .foregroundColor(.secondary) + .help("Clear all log entries") + + Spacer() + + Button { + let text = manager.logEntries.map { $0.formatted }.joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } label: { + Label("Copy All", systemImage: "doc.on.doc") + .font(.system(size: 12)) + } + .buttonStyle(.plain) + .foregroundColor(.secondary) + .disabled(manager.logEntries.isEmpty) + .help("Copy all logs to clipboard") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .fixedSize(horizontal: false, vertical: true) + } +} + +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.timestamp.formatted(.dateTime.hour().minute().second())) + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 3) + } +} diff --git a/Mounty/Views/Overlays.swift b/Mounty/Views/Overlays.swift index 7a4f8bb..cb355de 100644 --- a/Mounty/Views/Overlays.swift +++ b/Mounty/Views/Overlays.swift @@ -110,6 +110,60 @@ struct ConfirmationOverlay: View { } } +// MARK: - Speed Test Result Overlay +struct SpeedTestOverlay: View { + let volumeName: String + let result: SpeedTestService.Result + @Binding var isPresented: Bool + + var body: some View { + ZStack { + Color.black.opacity(0.2).ignoresSafeArea() + .onTapGesture { withAnimation { isPresented = false } } + + VStack(spacing: 16) { + 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) + } + GridRow { + Label("Read", systemImage: "arrow.down.circle") + .foregroundColor(.secondary) + Text(String(format: "%.1f MB/s", result.readSpeed)) + .fontWeight(.medium) + .gridColumnAlignment(.trailing) + } + } + .font(.callout) + + Text("Test size: \(String(format: "%.0f", result.fileSizeMB)) MB") + .font(.caption) + .foregroundColor(.secondary) + + Button("Done") { withAnimation { isPresented = false } } + .keyboardShortcut(.defaultAction) + } + .padding(24) + .frame(width: 280) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(radius: 10) + .transition(.scale.combined(with: .opacity)) + } + .zIndex(100) + } +} + // MARK: - Input Overlay /// Modal for text entry (e.g., Import Paths). struct InputOverlay: View { diff --git a/Mounty/Views/RootView.swift b/Mounty/Views/RootView.swift index eb99fd6..4a3d659 100644 --- a/Mounty/Views/RootView.swift +++ b/Mounty/Views/RootView.swift @@ -30,6 +30,37 @@ struct RootView: View { 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) + )) + } + + // Speed test overlays — rendered above any active view. + if let result = manager.speedTestResult { + SpeedTestOverlay( + volumeName: manager.speedTestVolumeName, + result: result, + isPresented: Binding( + get: { manager.speedTestResult != nil }, + set: { if !$0 { manager.clearSpeedTest() } } + ) + ) + } + + if let errMsg = manager.speedTestError { + AlertOverlay( + title: "Speed Test Failed", + message: errMsg, + isPresented: Binding( + get: { manager.speedTestError != nil }, + set: { if !$0 { manager.clearSpeedTest() } } + ), + isError: true + ) } } .animation(.easeOut(duration: 0.2), value: viewMode) diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index 04245b4..f9d0c9f 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -93,6 +93,14 @@ struct SettingsView: View { .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) } + Section(header: Text("Diagnostics")) { + Button { + viewMode = .logs + } label: { + Label("View App Logs", systemImage: "doc.text.magnifyingglass") + } + } + Section(header: Text("Application Info")) { HStack { Spacer() diff --git a/Mounty/Views/VolumeRow.swift b/Mounty/Views/VolumeRow.swift index cd196c4..cb48ab0 100644 --- a/Mounty/Views/VolumeRow.swift +++ b/Mounty/Views/VolumeRow.swift @@ -7,6 +7,7 @@ struct VolumeRow: View { 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 { @@ -91,7 +92,7 @@ struct VolumeRow: View { .help("Open in Terminal") } - // 4. Mount / Unmount + // 4. Mount / Unmount (also shows speed-test progress) Button { if isMounted { manager.unmount(volume) @@ -99,7 +100,7 @@ struct VolumeRow: View { manager.mount(volume) } } label: { - if isBusy { + if isBusy || isTesting { ProgressView().controlSize(.mini).scaleEffect(0.7) .frame(width: 20, height: 20) } else { @@ -112,8 +113,8 @@ struct VolumeRow: View { } .buttonStyle(.plain) .iconButtonHover(padding: 3) - .disabled(isBusy) - .help(isMounted ? "Disconnect" : "Connect") + .disabled(isBusy || isTesting) + .help(isTesting ? "Speed test running…" : (isMounted ? "Disconnect" : "Connect")) } } .padding(.horizontal, 12) @@ -125,10 +126,19 @@ struct VolumeRow: View { if isMounted { manager.openInFinder(volume) } } .contextMenu { + if isMounted { + Button { + manager.runSpeedTest(for: volume) + } label: { + Label("Measure Speed…", systemImage: "speedometer") + } + .disabled(manager.isRunningSpeedTest) + } + Button(role: .destructive) { manager.removeVolume(volume.id) } label: { - Text("Remove Volume") + Label("Remove Volume", systemImage: "trash") } } } From f11cf2afe064c8e4f9a826a6325cd2b07a15ad62 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sat, 8 Aug 2026 09:29:56 +0200 Subject: [PATCH 09/38] fix(ui): logs button in header, reliable hit areas, consistent hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HeaderView gains optional trailingAction2/trailingIcon2/trailingHelp2 for a second trailing button rendered left of the primary one; the leading side mirrors the trailing width to keep the title centred - MainListView header now shows a doc.text logs button to the left of the gear, replacing the Diagnostics Form section in Settings (removed) - IconButtonHover: add .contentShape(Rectangle()) after .padding so the full highlighted region is always the hit target — fixes the Settings back button requiring multiple clicks (macOS Form focus was stealing the first click because the hit zone was only the raw icon pixels) - LogsView footer Clear / Copy All buttons now use .iconButtonHover() and carry their foregroundColor inside the label so hover highlight and disabled-dimming are consistent with the rest of the app Generated-by: claude-sonnet-4-6 --- Mounty/Views/HeaderView.swift | 28 +++++++++++++++++++++------- Mounty/Views/LogsView.swift | 7 +++++-- Mounty/Views/MainListView.swift | 5 ++++- Mounty/Views/Overlays.swift | 3 +++ Mounty/Views/SettingsView.swift | 8 -------- 5 files changed, 33 insertions(+), 18 deletions(-) diff --git a/Mounty/Views/HeaderView.swift b/Mounty/Views/HeaderView.swift index 48587e6..c4456b1 100644 --- a/Mounty/Views/HeaderView.swift +++ b/Mounty/Views/HeaderView.swift @@ -8,6 +8,13 @@ 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 = "" + + // 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 { @@ -28,7 +35,7 @@ struct HeaderView: View { .cornerRadius(4) } } - .frame(width: 32, height: 32, alignment: .leading) + .frame(width: sideWidth, height: 32, alignment: .leading) Spacer() @@ -40,8 +47,18 @@ 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) + } + .buttonStyle(.plain) + .iconButtonHover() + .help(trailingHelp2) + } if let action = trailingAction, let icon = trailingIcon { Button(action: action) { Image(systemName: icon.0) @@ -53,11 +70,8 @@ struct HeaderView: View { .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 index 0089838..1fe6453 100644 --- a/Mounty/Views/LogsView.swift +++ b/Mounty/Views/LogsView.swift @@ -54,9 +54,10 @@ struct LogsView: View { } label: { Label("Clear", systemImage: "trash") .font(.system(size: 12)) + .foregroundColor(.secondary) } .buttonStyle(.plain) - .foregroundColor(.secondary) + .iconButtonHover(cornerRadius: 5, padding: 4) .help("Clear all log entries") Spacer() @@ -68,9 +69,11 @@ struct LogsView: View { } label: { Label("Copy All", systemImage: "doc.on.doc") .font(.system(size: 12)) + .foregroundColor( + manager.logEntries.isEmpty ? .secondary.opacity(0.4) : .secondary) } .buttonStyle(.plain) - .foregroundColor(.secondary) + .iconButtonHover(cornerRadius: 5, padding: 4) .disabled(manager.logEntries.isEmpty) .help("Copy all logs to clipboard") } diff --git a/Mounty/Views/MainListView.swift b/Mounty/Views/MainListView.swift index d8bfd85..d71341a 100644 --- a/Mounty/Views/MainListView.swift +++ b/Mounty/Views/MainListView.swift @@ -31,7 +31,10 @@ struct MainListView: View { showLogo: true, trailingAction: { viewMode = .settings }, trailingIcon: ("gearshape.fill", .secondary), - trailingHelp: "Settings" + trailingHelp: "Settings", + trailingAction2: { viewMode = .logs }, + trailingIcon2: ("doc.text", .secondary), + trailingHelp2: "App Logs" ) .transaction { $0.animation = nil } diff --git a/Mounty/Views/Overlays.swift b/Mounty/Views/Overlays.swift index cb355de..6866022 100644 --- a/Mounty/Views/Overlays.swift +++ b/Mounty/Views/Overlays.swift @@ -9,6 +9,9 @@ struct IconButtonHover: ViewModifier { func body(content: Content) -> some View { content .padding(padding) + // contentShape extends the hit-test area to include the padding so + // the full visible highlight region is always clickable. + .contentShape(Rectangle()) .background( RoundedRectangle(cornerRadius: cornerRadius) .fill(isHovered ? Color.primary.opacity(0.08) : .clear) diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index f9d0c9f..04245b4 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -93,14 +93,6 @@ struct SettingsView: View { .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) } - Section(header: Text("Diagnostics")) { - Button { - viewMode = .logs - } label: { - Label("View App Logs", systemImage: "doc.text.magnifyingglass") - } - } - Section(header: Text("Application Info")) { HStack { Spacer() From ef2a37f83d69788f97df9887a13868f2cf07f448 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sat, 8 Aug 2026 09:37:49 +0200 Subject: [PATCH 10/38] fix(perf): honest speed test measurements and robust cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write: call F_FULLFSYNC (via fcntl) after data.write() so the SMB client is forced to commit buffered bytes to the server before the timer stops. Without this, write() returns the instant the kernel accepts the data locally — often near-instant regardless of link speed. Read: open the test file with F_NOCACHE so every read byte comes from the server rather than the OS unified buffer cache. Without this the just-written file is served from RAM, producing multi-GB/s numbers that have nothing to do with the network. Cleanup: replace the silent try? removal with removeWithRetry() which retries up to 3× (200 ms apart) so a brief network hiccup cannot leave the test file on the server. The UUID suffix already guarantees the file name can never collide with any existing user data. Generated-by: claude-sonnet-4-6 --- Mounty/Services/SpeedTestService.swift | 57 +++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/Mounty/Services/SpeedTestService.swift b/Mounty/Services/SpeedTestService.swift index 6c85926..7c01a39 100644 --- a/Mounty/Services/SpeedTestService.swift +++ b/Mounty/Services/SpeedTestService.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation struct SpeedTestService { @@ -7,29 +8,60 @@ struct SpeedTestService { let fileSizeMB: Double } - // All file I/O is dispatched to a global queue so the Swift cooperative - // thread pool is never blocked during multi-second network transfers. + // 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) return try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { do { - defer { try? FileManager.default.removeItem(at: testURL) } + // defer runs in all exit paths (success, throw, early return) + // so the test file is always removed on the server. + // The only exception is a hard process crash (SIGKILL); in that + // case a single hidden file (.mounty_speed_) is left but + // is harmless — it will not overwrite or shadow any user data. + defer { removeWithRetry(at: testURL) } 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 wfd = Darwin.open(path, O_RDONLY) + if wfd >= 0 { + _ = Darwin.fcntl(wfd, F_FULLFSYNC) + Darwin.close(wfd) + } let writeDuration = max(Date().timeIntervalSince(writeStart), 0.001) - let readStart = Date() - _ = try Data(contentsOf: testURL) - let readDuration = max(Date().timeIntervalSince(readStart), 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. + var readDuration = 0.001 + let rfd = Darwin.open(path, O_RDONLY) + if rfd >= 0 { + _ = Darwin.fcntl(rfd, F_NOCACHE, 1) + let readStart = Date() + var buffer = [UInt8](repeating: 0, count: byteCount) + buffer.withUnsafeMutableBytes { ptr in + _ = Darwin.read(rfd, ptr.baseAddress!, byteCount) + } + readDuration = max(Date().timeIntervalSince(readStart), 0.001) + Darwin.close(rfd) + } continuation.resume( returning: Result( @@ -43,4 +75,17 @@ struct SpeedTestService { } } } + + // 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) } + } + } + } } From 705ee42f8321e67c24a97e136c06525f5583ac14 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sat, 8 Aug 2026 10:40:00 +0200 Subject: [PATCH 11/38] perf(concurrency): parallelize automount and move blocking work off main actor - runAutomount: replace sequential loop with withTaskGroup so all TCP reachability checks and mounts fire concurrently instead of one-at-a-time - refreshInstalledTerminals: switch Task(priority:) to Task.detached so the 6 NSWorkspace Launch Services lookups run off the main actor; the original Task inherited @MainActor and blocked the main thread at startup - toggleLaunchAtLogin: dispatch SMAppService register/unregister to a detached task; the sync calls were blocking the main thread on every toggle in Settings - MountService.toggleLoginItem / isLoginItemEnabled: remove @MainActor (SMAppService is thread-safe) to enable the above detached dispatch Generated-by: claude-sonnet-4-6 --- Mounty/Services/MountService.swift | 7 ++- Mounty/ViewModels/VolumeManager.swift | 77 +++++++++++++++++---------- 2 files changed, 51 insertions(+), 33 deletions(-) diff --git a/Mounty/Services/MountService.swift b/Mounty/Services/MountService.swift index 73d1cc1..b4e3030 100644 --- a/Mounty/Services/MountService.swift +++ b/Mounty/Services/MountService.swift @@ -128,8 +128,8 @@ struct MountService { // 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() @@ -143,8 +143,7 @@ struct MountService { } } - @MainActor - static func isLoginItemEnabled() -> Bool { + nonisolated static func isLoginItemEnabled() -> Bool { return SMAppService.mainApp.status == .enabled } } diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index 5bcc9cd..04e2d14 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -222,29 +222,36 @@ class VolumeManager: ObservableObject { private func runAutomount() async { guard isNetworkUp else { return } - for volume in volumes where volume.isAutomountEnabled { - if mountPaths[volume.id] == nil && !busyVolumes.contains(volume.id) { - busyVolumes.insert(volume.id) - - let isReachable = await ReachabilityService.isServerReachable( - address: volume.serverAddress - ) - - if isReachable && mountPaths[volume.id] == nil { - guard let url = URL(string: volume.serverAddress) else { - busyVolumes.remove(volume.id) - continue - } - log("Automounting \(volume.name)") + let candidates = volumes.filter { + $0.isAutomountEnabled && mountPaths[$0.id] == nil && !busyVolumes.contains($0.id) + } + guard !candidates.isEmpty else { return } + for v in candidates { busyVolumes.insert(v.id) } + + await withTaskGroup(of: (UUID, String?, String).self) { group in + for volume in candidates { + guard let url = URL(string: volume.serverAddress) else { + busyVolumes.remove(volume.id) + continue + } + let addr = volume.serverAddress + let name = volume.name + let id = volume.id + group.addTask { + let isReachable = await ReachabilityService.isServerReachable(address: addr) + guard isReachable else { return (id, nil, name) } + return (id, await MountService.mount(url: url), name) + } + } - if let path = await MountService.mount(url: url) { - self.mountPaths[volume.id] = path - log("Automounted \(volume.name) → \(path)") - } else { - log("Automount failed for \(volume.name)", level: .warning) - } + for await (id, path, name) in group { + if let path { + self.mountPaths[id] = path + log("Automounted \(name) → \(path)") + } else { + log("Automount failed for \(name)", level: .warning) } - busyVolumes.remove(volume.id) + busyVolumes.remove(id) } } } @@ -310,15 +317,22 @@ class VolumeManager: ObservableObject { // MARK: - Actions - // Runs the 6 NSWorkspace lookups asynchronously so init() returns immediately - // and the first UI frame is not blocked by Launch Services queries. + // 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() { - Task(priority: .utility) { - self.availableTerminals = self.knownTerminals.filter { (_, bundleID) in + let known = knownTerminals + Task.detached(priority: .utility) { [weak self] in + let installed = known.filter { (_, bundleID) in NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) != nil } - if !self.availableTerminals.contains(where: { $0.id == self.preferredTerminal }) { - self.preferredTerminal = "com.apple.Terminal" + 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" + } } } } @@ -414,8 +428,13 @@ 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() + await MainActor.run { self?.launchAtLogin = isEnabled } + } } func setPreferredTerminal(_ bundleID: String) { From 46456398bca62c731178835bba30d8bc2938276f Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sat, 8 Aug 2026 10:40:06 +0200 Subject: [PATCH 12/38] fix(ui): eliminate button-click delay in VolumeRow with simultaneousGesture .onTapGesture(count: 2) on a parent view causes SwiftUI to hold single-tap recognition in child Buttons while it waits to determine if a second tap is coming. Replacing it with .simultaneousGesture(TapGesture(count: 2)) lets both recognizers run concurrently so Button clicks register immediately. Generated-by: claude-sonnet-4-6 --- Mounty/Views/VolumeRow.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Mounty/Views/VolumeRow.swift b/Mounty/Views/VolumeRow.swift index cb48ab0..15c5c07 100644 --- a/Mounty/Views/VolumeRow.swift +++ b/Mounty/Views/VolumeRow.swift @@ -122,9 +122,13 @@ struct VolumeRow: View { .contentShape(Rectangle()) .animation(.easeOut(duration: 0.1), value: isRowHovered) .onHover { isRowHovered = $0 } - .onTapGesture(count: 2) { - if isMounted { manager.openInFinder(volume) } - } + // 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 { if isMounted { Button { From 5f79f555ce77fba362cc65a13e41833f37fb530d Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sat, 8 Aug 2026 10:40:11 +0200 Subject: [PATCH 13/38] fix(ui): reduce settings volume buttons to native small control size Replace .plain + iconButtonHover(padding: 6) + minHeight: 26 (~38 px total) with .bordered + .controlSize(.small) (~22 px, Apple native). maxWidth: .infinity is kept on the button itself so all three buttons share equal width. Generated-by: claude-sonnet-4-6 --- Mounty/Views/SettingsView.swift | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index 04245b4..203d91b 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -53,44 +53,41 @@ struct SettingsView: View { } Section(header: Text("Volumes")) { - HStack(spacing: 12) { + HStack(spacing: 8) { Button { importPath = "" withAnimation { showImportDialog = true } } label: { Image(systemName: "square.and.arrow.up") - .font(.system(size: 15)) - .frame(maxWidth: .infinity, minHeight: 26) + .frame(maxWidth: .infinity) } - .buttonStyle(.plain) - .iconButtonHover(cornerRadius: 6, padding: 6) + .buttonStyle(.bordered) + .controlSize(.small) .help("Import volumes from JSON") Button { manager.exportToDownloads() } label: { Image(systemName: "square.and.arrow.down") - .font(.system(size: 15)) - .frame(maxWidth: .infinity, minHeight: 26) + .frame(maxWidth: .infinity) } - .buttonStyle(.plain) - .iconButtonHover(cornerRadius: 6, padding: 6) + .buttonStyle(.bordered) + .controlSize(.small) .help("Export volumes to Downloads") Button { withAnimation { showResetConfirmation = true } } label: { Image(systemName: "trash") - .font(.system(size: 15)) .foregroundColor(.red) - .frame(maxWidth: .infinity, minHeight: 26) + .frame(maxWidth: .infinity) } - .buttonStyle(.plain) - .iconButtonHover(cornerRadius: 6, padding: 6) + .buttonStyle(.bordered) + .controlSize(.small) .help("Clear all volumes") } .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) } Section(header: Text("Application Info")) { From b6e24ec4df9feabf5061f256c0551904e5b4bc2d Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sat, 8 Aug 2026 10:56:51 +0200 Subject: [PATCH 14/38] fix(ui): replace iconButtonHover ViewModifier with ButtonStyle to fix hit areas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old ViewModifier added .padding() outside the Button, leaving the actual hit-test area as only the tiny icon. Hover highlighting worked (tracked on the outer padded wrapper) but clicks often missed, requiring multiple attempts — this was the root cause of the back-button and action-button click failures. ButtonStyle is the correct mechanism: padding added inside makeBody() becomes part of the button's own rendered frame, so the full padded rectangle is the interactive area. Rename to IconHoverButtonStyle; update iconButtonHover() extension to call buttonStyle() instead of modifier(). Remove .buttonStyle(.plain) from all call sites — it would override the new style (innermost environment value wins) and revert to the small hit area. Affected: HeaderView (3 buttons), MainListView (1), VolumeRow (4), LogsView (2). Generated-by: claude-sonnet-4-6 --- Mounty/Views/HeaderView.swift | 3 --- Mounty/Views/LogsView.swift | 2 -- Mounty/Views/MainListView.swift | 1 - Mounty/Views/Overlays.swift | 41 +++++++++++++++++++++++++-------- Mounty/Views/VolumeRow.swift | 4 ---- 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/Mounty/Views/HeaderView.swift b/Mounty/Views/HeaderView.swift index c4456b1..cd78140 100644 --- a/Mounty/Views/HeaderView.swift +++ b/Mounty/Views/HeaderView.swift @@ -26,7 +26,6 @@ struct HeaderView: View { .font(.system(size: 14, weight: .semibold)) .foregroundColor(.accentColor) } - .buttonStyle(.plain) .iconButtonHover() } else if showLogo { Image("Logo") @@ -55,7 +54,6 @@ struct HeaderView: View { .font(.system(size: 15)) .foregroundColor(icon2.1) } - .buttonStyle(.plain) .iconButtonHover() .help(trailingHelp2) } @@ -65,7 +63,6 @@ struct HeaderView: View { .font(.system(size: 15)) .foregroundColor(icon.1) } - .buttonStyle(.plain) .iconButtonHover() .help(trailingHelp) } diff --git a/Mounty/Views/LogsView.swift b/Mounty/Views/LogsView.swift index 1fe6453..e4357a1 100644 --- a/Mounty/Views/LogsView.swift +++ b/Mounty/Views/LogsView.swift @@ -56,7 +56,6 @@ struct LogsView: View { .font(.system(size: 12)) .foregroundColor(.secondary) } - .buttonStyle(.plain) .iconButtonHover(cornerRadius: 5, padding: 4) .help("Clear all log entries") @@ -72,7 +71,6 @@ struct LogsView: View { .foregroundColor( manager.logEntries.isEmpty ? .secondary.opacity(0.4) : .secondary) } - .buttonStyle(.plain) .iconButtonHover(cornerRadius: 5, padding: 4) .disabled(manager.logEntries.isEmpty) .help("Copy all logs to clipboard") diff --git a/Mounty/Views/MainListView.swift b/Mounty/Views/MainListView.swift index d71341a..01a19d9 100644 --- a/Mounty/Views/MainListView.swift +++ b/Mounty/Views/MainListView.swift @@ -173,7 +173,6 @@ struct MainListView: View { isSearchVisible ? .accentColor : .secondary ) } - .buttonStyle(.plain) .iconButtonHover() // Keyboard shortcut lives here — removes the need for the // hidden zero-size Button that was causing erratic toggles. diff --git a/Mounty/Views/Overlays.swift b/Mounty/Views/Overlays.swift index 6866022..e478885 100644 --- a/Mounty/Views/Overlays.swift +++ b/Mounty/Views/Overlays.swift @@ -1,29 +1,50 @@ import SwiftUI -// MARK: - Icon Button Hover Modifier -struct IconButtonHover: ViewModifier { - @State private var isHovered = false +// 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 body(content: Content) -> some View { - content + 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) - // contentShape extends the hit-test area to include the padding so - // the full visible highlight region is always clickable. - .contentShape(Rectangle()) .background( RoundedRectangle(cornerRadius: cornerRadius) - .fill(isHovered ? Color.primary.opacity(0.08) : .clear) + .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 { - modifier(IconButtonHover(cornerRadius: cornerRadius, padding: padding)) + buttonStyle(IconHoverButtonStyle(cornerRadius: cornerRadius, padding: padding)) } } diff --git a/Mounty/Views/VolumeRow.swift b/Mounty/Views/VolumeRow.swift index 15c5c07..26ba3e4 100644 --- a/Mounty/Views/VolumeRow.swift +++ b/Mounty/Views/VolumeRow.swift @@ -57,7 +57,6 @@ struct VolumeRow: View { ? .orange : .secondary.opacity(0.35) ) } - .buttonStyle(.plain) .iconButtonHover(padding: 3) .help( volume.isAutomountEnabled @@ -73,7 +72,6 @@ struct VolumeRow: View { .font(.system(size: 12)) .foregroundColor(.secondary) } - .buttonStyle(.plain) .iconButtonHover(padding: 3) .help("Show in Finder") } @@ -87,7 +85,6 @@ struct VolumeRow: View { .font(.system(size: 12)) .foregroundColor(.secondary) } - .buttonStyle(.plain) .iconButtonHover(padding: 3) .help("Open in Terminal") } @@ -111,7 +108,6 @@ struct VolumeRow: View { .foregroundColor(isMounted ? .red : .primary) } } - .buttonStyle(.plain) .iconButtonHover(padding: 3) .disabled(isBusy || isTesting) .help(isTesting ? "Speed test running…" : (isMounted ? "Disconnect" : "Connect")) From 65d2a9d2aa4594b8945caedfa23b4cdb92c574cd Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sat, 8 Aug 2026 10:57:00 +0200 Subject: [PATCH 15/38] docs(agents): add UI responsiveness rules to AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the four non-negotiable UI-thread rules derived from bugs found and fixed in this sprint: 1. Task(priority:) inherits @MainActor — use Task.detached for off-thread work 2. Padding outside a Button does not extend its hit area — use ButtonStyle 3. onTapGesture(count:) on a parent blocks child Button recognition 4. Heartbeat timers must delegate immediately to detached tasks Generated-by: claude-sonnet-4-6 --- AGENTS.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1fcd69d..8a8b5f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,37 @@ For non-trivial work, write the spec before the code. Templates live in `specs/t Copy the templates into `specs//` for the feature you are working on. +## 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. **Timers that wake the main actor must do minimal synchronous work.** + The 5-second heartbeat timer uses `.default` RunLoop mode (does not fire during event tracking) + and immediately delegates to a `Task.detached` for all detection work. Keep it that way. + ## Guardrails - **Zero warnings policy.** The project must build and test with zero warnings. Before submitting a From d48071ef4ce225a7424b2bc6440cbd11ecf9acca Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sat, 8 Aug 2026 11:06:15 +0200 Subject: [PATCH 16/38] fix(concurrency): resolve Swift 6 actor-isolation warnings to zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReachabilityService: - ResumeGate.init() was implicitly @MainActor (SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor), causing a warning when constructed from the nonisolated isMountPointAlive context. Add explicit nonisolated init() — NSLock and Bool are not actor-isolated so construction is safe from any context. - Replace var buf = statfs() with UnsafeMutablePointer.allocate(capacity:) to avoid calling the @MainActor-isolated statfs struct init in a @Sendable closure; the C statfs(2) syscall overwrites the struct entirely anyway. VolumeManager: - In toggleLaunchAtLogin, the [weak self] capture creates a 'var' optional; accessing it inside MainActor.run{} triggered "captured var in concurrently-executing code". Rebind as 'let ref = self' before the inner closure so MainActor.run captures a constant, not a mutable optional. SettingsView: - Remove .controlSize(.small) from import/export/reset buttons so they use the default regular size — slightly taller and with native hover feedback. - Move .frame(maxWidth: .infinity) from inside the label to the button itself so each button stretches equally without affecting the bordered style sizing. Generated-by: claude-sonnet-4-6 --- Mounty/Services/ReachabilityService.swift | 14 ++++++++++++-- Mounty/ViewModels/VolumeManager.swift | 5 ++++- Mounty/Views/SettingsView.swift | 9 +++------ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/Mounty/Services/ReachabilityService.swift b/Mounty/Services/ReachabilityService.swift index aefd4e6..ce890fa 100644 --- a/Mounty/Services/ReachabilityService.swift +++ b/Mounty/Services/ReachabilityService.swift @@ -19,8 +19,13 @@ struct ReachabilityService { let gate = ResumeGate() DispatchQueue.global(qos: .userInteractive).async { - var buf = statfs() - let alive = statfs(path, &buf) == 0 + // 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 alive = statfs(path, buf) == 0 if gate.tryResume() { continuation.resume(returning: alive) } @@ -84,6 +89,11 @@ private final class ResumeGate: @unchecked Sendable { // thread safety is guaranteed by `lock`. private nonisolated(unsafe) var resumed = false + // Explicit nonisolated init: SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor would make + // the synthesised init() @MainActor, causing a warning when ResumeGate is created + // from nonisolated contexts. NSLock and Bool are not actor-isolated, so this is safe. + nonisolated init() {} + /// Returns `true` the first time it is called; `false` on all subsequent calls. nonisolated func tryResume() -> Bool { lock.withLock { diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index 04e2d14..be873f6 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -433,7 +433,10 @@ class VolumeManager: ObservableObject { Task.detached(priority: .userInitiated) { [weak self] in MountService.toggleLoginItem(enabled: enabled) let isEnabled = MountService.isLoginItemEnabled() - await MainActor.run { self?.launchAtLogin = isEnabled } + // 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 } } } diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index 203d91b..4cbbeb5 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -59,20 +59,18 @@ struct SettingsView: View { withAnimation { showImportDialog = true } } label: { Image(systemName: "square.and.arrow.up") - .frame(maxWidth: .infinity) } .buttonStyle(.bordered) - .controlSize(.small) + .frame(maxWidth: .infinity) .help("Import volumes from JSON") Button { manager.exportToDownloads() } label: { Image(systemName: "square.and.arrow.down") - .frame(maxWidth: .infinity) } .buttonStyle(.bordered) - .controlSize(.small) + .frame(maxWidth: .infinity) .help("Export volumes to Downloads") Button { @@ -80,10 +78,9 @@ struct SettingsView: View { } label: { Image(systemName: "trash") .foregroundColor(.red) - .frame(maxWidth: .infinity) } .buttonStyle(.bordered) - .controlSize(.small) + .frame(maxWidth: .infinity) .help("Clear all volumes") } .listRowBackground(Color.clear) From ace1472ff6e3505de46582925f366443212decf3 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sun, 9 Aug 2026 11:20:24 +0200 Subject: [PATCH 17/38] fix(ui): back button always returns to main list, not settings Generated-by: claude-sonnet-4-6 --- Mounty/Models/Volume.swift | 2 +- Mounty/ViewModels/VolumeManager.swift | 66 ++++++-- Mounty/Views/AddVolumeView.swift | 216 ++++++++++++++++++-------- Mounty/Views/LogsView.swift | 2 +- Mounty/Views/MainListView.swift | 9 +- Mounty/Views/RootView.swift | 7 + Mounty/Views/VolumeRow.swift | 10 ++ 7 files changed, 235 insertions(+), 77 deletions(-) diff --git a/Mounty/Models/Volume.swift b/Mounty/Models/Volume.swift index 7b98434..eba4797 100644 --- a/Mounty/Models/Volume.swift +++ b/Mounty/Models/Volume.swift @@ -21,4 +21,4 @@ struct Volume: Identifiable, Codable, Equatable, Sendable { } } -enum AppViewMode { case list, add, settings, logs } +enum AppViewMode: Equatable { case list, add, settings, logs, edit(Volume) } diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index be873f6..3da9724 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -82,20 +82,32 @@ 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 { @@ -105,6 +117,7 @@ class VolumeManager: ObservableObject { enum SortOrder: String, CaseIterable { case name = "Name" case dateAdded = "Date Added" + case state = "State" } enum SortDirection { @@ -403,6 +416,41 @@ class VolumeManager: ObservableObject { Task { await refreshState() } } + func editVolume(id: UUID, name: String, serverAddress: String) { + guard let idx = volumes.firstIndex(where: { $0.id == id }) else { return } + let old = volumes[idx] + let addressChanged = old.serverAddress != serverAddress + + volumes[idx].name = name + volumes[idx].serverAddress = serverAddress + storage.saveVolumes(volumes) + log("Updated volume: \(name)") + + guard addressChanged, let oldPath = mountPaths[id] else { return } + + // Unmount the old connection by its recorded path, then remount at the new address. + // Using oldPath (not the new address) ensures we disconnect the right kernel mount + // even if the new address points to a different share entirely. + mountPaths.removeValue(forKey: id) + busyVolumes.insert(id) + Task { + await MountService.unmount(path: oldPath) + guard let url = URL(string: serverAddress) else { + self.busyVolumes.remove(id) + return + } + if let newPath = await MountService.mount(url: url) { + 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) + } + } + func clearAllVolumes() { log("Cleared all volumes") volumes.removeAll() diff --git a/Mounty/Views/AddVolumeView.swift b/Mounty/Views/AddVolumeView.swift index 4e2e6ea..4d6dfc7 100644 --- a/Mounty/Views/AddVolumeView.swift +++ b/Mounty/Views/AddVolumeView.swift @@ -1,79 +1,104 @@ import SwiftUI +// MARK: - Shared types + +enum VolumeProtocolType: String, CaseIterable, Identifiable { + case smb = "SMB" + case afp = "AFP" + case nfs = "NFS" + case ftp = "FTP" + var id: String { rawValue } + var scheme: String { rawValue.lowercased() + "://" } +} + +// MARK: - Shared form fields + +/// Reusable form body used by both AddVolumeView and EditVolumeView. +/// Manages its own focus state so callers only need to bind name/address/protocol. +struct VolumeFormFields: View { + @Binding var name: String + @Binding var address: String + @Binding var selectedProtocol: VolumeProtocolType + 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 } + + Picker("Protocol", selection: $selectedProtocol) { + ForEach(VolumeProtocolType.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 { onSubmit() } + .autocorrectionDisabled(true) + .onChange(of: address) { _, newValue in + for proto in VolumeProtocolType.allCases { + if newValue.lowercased().hasPrefix(proto.scheme) { + selectedProtocol = proto + address = String(newValue.dropFirst(proto.scheme.count)) + return + } + } + } + } + .padding(8) + .background(Color(NSColor.textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + focusedField = .name + } + } + } +} + +// MARK: - Add Volume View + struct AddVolumeView: View { @ObservedObject 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() + "://" } - } + @State private var selectedProtocol: VolumeProtocolType = .smb var body: some View { VStack(alignment: .leading, spacing: 0) { HeaderView( title: "Add New Volume", - backAction: { - focusedField = nil - 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)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(NSColor.separatorColor), lineWidth: 1) - ) - } + VolumeFormFields( + name: $name, + address: $address, + selectedProtocol: $selectedProtocol, + onSubmit: save + ) .padding(20) Spacer() @@ -87,19 +112,82 @@ struct AddVolumeView: View { } .padding(20) } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - focusedField = .name + .fixedSize(horizontal: false, vertical: true) + } + + private func save() { + guard !name.isEmpty, !address.isEmpty else { return } + let fullAddress = selectedProtocol.scheme + address + manager.addVolume(Volume(name: name, serverAddress: fullAddress)) + viewMode = .list + } +} + +// MARK: - Edit Volume View + +struct EditVolumeView: View { + let volume: Volume + @ObservedObject var manager: VolumeManager + @Binding var viewMode: AppViewMode + + @State private var name: String + @State private var address: String + @State private var selectedProtocol: VolumeProtocolType + + init(volume: Volume, manager: VolumeManager, viewMode: Binding) { + self.volume = volume + self.manager = manager + self._viewMode = viewMode + + var proto = VolumeProtocolType.smb + var addr = volume.serverAddress + for p in VolumeProtocolType.allCases { + if addr.lowercased().hasPrefix(p.scheme) { + proto = p + addr = String(addr.dropFirst(p.scheme.count)) + break + } + } + self._name = State(initialValue: volume.name) + self._address = State(initialValue: addr) + self._selectedProtocol = State(initialValue: proto) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HeaderView( + title: "Edit Volume", + backAction: { viewMode = .list } + ) + + Divider() + + VolumeFormFields( + name: $name, + address: $address, + selectedProtocol: $selectedProtocol, + 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) } private func save() { guard !name.isEmpty, !address.isEmpty else { return } - focusedField = nil let fullAddress = selectedProtocol.scheme + address - manager.addVolume(Volume(name: name, serverAddress: fullAddress)) + manager.editVolume(id: volume.id, name: name, serverAddress: fullAddress) viewMode = .list } } diff --git a/Mounty/Views/LogsView.swift b/Mounty/Views/LogsView.swift index e4357a1..18503e5 100644 --- a/Mounty/Views/LogsView.swift +++ b/Mounty/Views/LogsView.swift @@ -8,7 +8,7 @@ struct LogsView: View { VStack(spacing: 0) { HeaderView( title: "App Logs", - backAction: { viewMode = .settings } + backAction: { viewMode = .list } ) Divider() diff --git a/Mounty/Views/MainListView.swift b/Mounty/Views/MainListView.swift index 01a19d9..5881f42 100644 --- a/Mounty/Views/MainListView.swift +++ b/Mounty/Views/MainListView.swift @@ -113,8 +113,13 @@ struct MainListView: View { ScrollView { VStack(spacing: 0) { ForEach(manager.filteredAndSortedVolumes) { volume in - VolumeRow(volume: volume, manager: manager) - .frame(height: rowHeight) + VolumeRow( + volume: volume, manager: manager, + onEdit: { + viewMode = .edit(volume) + } + ) + .frame(height: rowHeight) Divider() } } diff --git a/Mounty/Views/RootView.swift b/Mounty/Views/RootView.swift index 4a3d659..62fbb9b 100644 --- a/Mounty/Views/RootView.swift +++ b/Mounty/Views/RootView.swift @@ -37,6 +37,13 @@ struct RootView: View { 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) + )) } // Speed test overlays — rendered above any active view. diff --git a/Mounty/Views/VolumeRow.swift b/Mounty/Views/VolumeRow.swift index 26ba3e4..d73009c 100644 --- a/Mounty/Views/VolumeRow.swift +++ b/Mounty/Views/VolumeRow.swift @@ -3,6 +3,7 @@ import SwiftUI struct VolumeRow: View { let volume: Volume @ObservedObject var manager: VolumeManager + var onEdit: () -> Void = {} @State private var isRowHovered = false var isMounted: Bool { manager.mountPaths[volume.id] != nil } @@ -114,6 +115,7 @@ struct VolumeRow: View { } } .padding(.horizontal, 12) + .frame(maxHeight: .infinity) .background(isRowHovered ? Color.primary.opacity(0.04) : .clear) .contentShape(Rectangle()) .animation(.easeOut(duration: 0.1), value: isRowHovered) @@ -126,6 +128,12 @@ struct VolumeRow: View { } ) .contextMenu { + Button { + onEdit() + } label: { + Label("Edit Volume…", systemImage: "pencil") + } + if isMounted { Button { manager.runSpeedTest(for: volume) @@ -135,6 +143,8 @@ struct VolumeRow: View { .disabled(manager.isRunningSpeedTest) } + Divider() + Button(role: .destructive) { manager.removeVolume(volume.id) } label: { From 68485f47566bd4f47195080990f4c22b83cc775a Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sun, 9 Aug 2026 11:58:53 +0200 Subject: [PATCH 18/38] fix(ui): use icon hover style for settings volume buttons Replaces .bordered buttons with .iconButtonHover() so they match all other icon-only buttons in the app and react to hover. Generated-by: claude-sonnet-4-6 --- Mounty/Views/SettingsView.swift | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index 4cbbeb5..91d0f1f 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -53,38 +53,45 @@ struct SettingsView: View { } Section(header: Text("Volumes")) { - HStack(spacing: 8) { + HStack { + Spacer() Button { importPath = "" withAnimation { showImportDialog = true } } label: { Image(systemName: "square.and.arrow.up") + .font(.system(size: 14)) } - .buttonStyle(.bordered) - .frame(maxWidth: .infinity) + .iconButtonHover(cornerRadius: 6, padding: 6) .help("Import volumes from JSON") + Spacer() + Button { manager.exportToDownloads() } label: { Image(systemName: "square.and.arrow.down") + .font(.system(size: 14)) } - .buttonStyle(.bordered) - .frame(maxWidth: .infinity) + .iconButtonHover(cornerRadius: 6, padding: 6) .help("Export volumes to Downloads") + Spacer() + Button { withAnimation { showResetConfirmation = true } } label: { Image(systemName: "trash") + .font(.system(size: 14)) .foregroundColor(.red) } - .buttonStyle(.bordered) - .frame(maxWidth: .infinity) + .iconButtonHover(cornerRadius: 6, padding: 6) .help("Clear all volumes") + + Spacer() } .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) } Section(header: Text("Application Info")) { From b9f26465f773f1815102181f87ec3f909e61755a Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sun, 9 Aug 2026 12:46:25 +0200 Subject: [PATCH 19/38] fix(ui): full-width hover buttons for settings volume actions Labels with icon+text fill their share of the HStack width; iconButtonHover covers the full area so hover feedback works correctly. Generated-by: claude-sonnet-4-6 --- Mounty/Views/SettingsView.swift | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index 91d0f1f..89ea4b0 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -53,45 +53,41 @@ struct SettingsView: View { } Section(header: Text("Volumes")) { - HStack { - Spacer() + HStack(spacing: 8) { Button { importPath = "" withAnimation { showImportDialog = true } } label: { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 14)) + Label("Import", systemImage: "square.and.arrow.up") + .font(.callout) + .frame(maxWidth: .infinity) } .iconButtonHover(cornerRadius: 6, padding: 6) .help("Import volumes from JSON") - Spacer() - Button { manager.exportToDownloads() } label: { - Image(systemName: "square.and.arrow.down") - .font(.system(size: 14)) + Label("Export", systemImage: "square.and.arrow.down") + .font(.callout) + .frame(maxWidth: .infinity) } .iconButtonHover(cornerRadius: 6, padding: 6) .help("Export volumes to Downloads") - Spacer() - Button { withAnimation { showResetConfirmation = true } } label: { - Image(systemName: "trash") - .font(.system(size: 14)) + Label("Reset", systemImage: "trash") + .font(.callout) + .frame(maxWidth: .infinity) .foregroundColor(.red) } .iconButtonHover(cornerRadius: 6, padding: 6) .help("Clear all volumes") - - Spacer() } .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) } Section(header: Text("Application Info")) { From 82eb8bf7f92301f06685fcffb20e2c0e523a2700 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sun, 9 Aug 2026 18:33:53 +0200 Subject: [PATCH 20/38] fix(automount): restore VolumeManager lifecycle to menu-bar open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move VolumeManager ownership from MountyApp (@State) back to RootView (@State). With @Observable the pattern is @State in the owner; what changed was *which* view owns it. Having the manager in MountyApp meant init() — and its Task { await runAutomount() } — fired at app startup before any window appeared. NetFSMountURLSync with AllowUserInteraction:true can require an auth dialog, but with no window in focus the dialog could not surface, so the call blocked indefinitely and volumes stayed in busyVolumes forever (the "spin forever" regression). RootView is the MenuBarExtra content; it is instantiated once the first time the user opens the menu bar, giving the app focus and a window context for any credential prompts. @State in RootView persists across open/close cycles, so VolumeManager is still created only once. Generated-by: claude-sonnet-4-6 --- Mounty/MountyApp.swift | 3 +- Mounty/Services/EventMonitorService.swift | 72 +++++----- Mounty/ViewModels/VolumeManager.swift | 158 ++++++++++------------ Mounty/Views/RootView.swift | 2 +- Mounty/Views/SettingsView.swift | 67 +++++---- 5 files changed, 155 insertions(+), 147 deletions(-) diff --git a/Mounty/MountyApp.swift b/Mounty/MountyApp.swift index 69d3b09..b3cd2db 100644 --- a/Mounty/MountyApp.swift +++ b/Mounty/MountyApp.swift @@ -3,7 +3,6 @@ import SwiftUI @main @MainActor struct MountyApp: App { - @StateObject var manager = VolumeManager() private static let paddedIcon: NSImage = { guard let image = NSImage(named: "MenuIcon") else { return NSImage() } @@ -20,7 +19,7 @@ struct MountyApp: App { var body: some Scene { MenuBarExtra { - RootView(manager: manager) + RootView() } label: { Image(nsImage: Self.paddedIcon) } diff --git a/Mounty/Services/EventMonitorService.swift b/Mounty/Services/EventMonitorService.swift index c1aef08..7788290 100644 --- a/Mounty/Services/EventMonitorService.swift +++ b/Mounty/Services/EventMonitorService.swift @@ -1,30 +1,39 @@ import AppKit -import Combine import Foundation import Network import os -/// Monitors OS events to trigger application logic. -/// Observes Network Status, Interface Fingerprints (VPN), and Kernel Mount events. +/// Monitors OS events and exposes them as async sequences. class EventMonitorService { - let networkStatus = CurrentValueSubject(.satisfied) - let interfacesChanged = PassthroughSubject() - let fileSystemChanged = PassthroughSubject() + let networkStatusStream: AsyncStream + let interfacesChangedStream: AsyncStream + let fileSystemChangedStream: AsyncStream + + 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 monitorQueue = DispatchQueue(label: "com.mounty.network", qos: .background) + // Written and read exclusively on monitorQueue — nonisolated(unsafe) bypasses the + // implicit @MainActor isolation without requiring an @unchecked Sendable wrapper. + private nonisolated(unsafe) var lastInterfaceFingerprint = "" private let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "Mounty", category: "EventMonitor" ) 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 +41,38 @@ 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() + if currentInterfaces != lastInterfaceFingerprint { + logger.debug("Interface topology changed: \(currentInterfaces, privacy: .public)") + lastInterfaceFingerprint = currentInterfaces + // 1-second debounce: let the interface topology settle before + // triggering a reconnect attempt. + Task { [weak self] in + try? await Task.sleep(for: .seconds(1)) + self?.interfacesChangedContinuation.yield() } } } - 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 + self?.logger.debug("Kernel filesystem event received") + self?.fileSystemChangedContinuation.yield() + } } - .store(in: &cancellables) } } diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index 3da9724..ad3d9c7 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -1,43 +1,42 @@ -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 +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 = [] // 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 + var sortDirection: SortDirection = .ascending + var showSearch = false // Preferences - @Published var launchAtLogin: Bool = MountService.isLoginItemEnabled() - @Published var preferredTerminal: String - @Published var availableTerminals: [(name: String, id: String)] = [] + var launchAtLogin: Bool = MountService.isLoginItemEnabled() + 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? = nil + var showError: Bool = false + var successMessage: String? = nil + var showSuccess: Bool = false // In-app log buffer (capped at maxLogEntries) - @Published var logEntries: [LogEntry] = [] + var logEntries: [LogEntry] = [] // Speed test state - @Published var speedTestVolumeId: UUID? = nil - @Published var isRunningSpeedTest = false - @Published var speedTestResult: SpeedTestService.Result? = nil - @Published var speedTestError: String? = nil + var speedTestVolumeId: UUID? = nil + var isRunningSpeedTest = false + var speedTestResult: SpeedTestService.Result? = nil + var speedTestError: String? = nil private var isNetworkUp: Bool = true private let maxLogEntries = 200 @@ -45,7 +44,6 @@ class VolumeManager: ObservableObject { // Dependencies private let storage = PersistenceService() private let eventMonitor = EventMonitorService() - private var cancellables = Set() // Logger (os.Logger for Console.app; log() also feeds the in-app ring buffer) private let logger = Logger( @@ -66,7 +64,7 @@ class VolumeManager: ObservableObject { self.volumes = storage.loadVolumes() self.preferredTerminal = storage.loadTerminalBundleID() - setupPipelines() + startEventObservation() refreshInstalledTerminals() Task { @@ -176,58 +174,56 @@ class VolumeManager: ObservableObject { speedTestError = nil } - // 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 } - - let wasUp = self.isNetworkUp - self.isNetworkUp = (status == .satisfied) - - if self.isNetworkUp != wasUp { - self.log("Network: \(self.isNetworkUp ? "UP" : "DOWN")") - } - - Task(priority: .userInitiated) { - await self.refreshState() - if self.isNetworkUp { await self.runAutomount() } + // MARK: - Event Observation + + private func startEventObservation() { + // All tasks below inherit @MainActor from this context. They suspend at each + // `for await`, releasing the main actor between events. The actual I/O work is + // dispatched off-actor inside refreshState() and runAutomount() via Task.detached. + + // 1. Network Status — high-priority reaction + Task { [weak self] in + guard let self else { return } + for await status in eventMonitor.networkStatusStream { + let wasUp = isNetworkUp + isNetworkUp = (status == .satisfied) + if isNetworkUp != wasUp { + log("Network: \(isNetworkUp ? "UP" : "DOWN")") } + await refreshState() + if isNetworkUp { await runAutomount() } } - .store(in: &cancellables) - - // 2. Interfaces Changed (VPN Toggles) - High Priority Reaction - eventMonitor.interfacesChanged - .receive(on: RunLoop.main) - .sink { [weak self] in - self?.log("Network interface changed — retrying connections") - Task(priority: .userInitiated) { - await self?.refreshState() - await self?.runAutomount() - } + } + + // 2. Interface changes (VPN) — debounce applied in EventMonitorService + Task { [weak self] in + guard let self else { return } + for await _ in eventMonitor.interfacesChangedStream { + log("Network interface changed — retrying connections") + await refreshState() + await runAutomount() } - .store(in: &cancellables) + } - // 3. File System (Manual Mounts) - eventMonitor.fileSystemChanged - .receive(on: RunLoop.main) - .sink { [weak self] in - Task(priority: .utility) { await self?.refreshState() } + // 3. File system (manual mounts by other apps) + Task { [weak self] in + guard let self else { return } + for await _ in eventMonitor.fileSystemChangedStream { + await refreshState() } - .store(in: &cancellables) - - // 4. Heartbeat Timer (Silent Death Check) - // .default mode (not .common) so the timer does not fire during UI event tracking. - Timer.publish(every: 5, on: .main, in: .default) - .autoconnect() - .sink { [weak self] _ in - guard let self = self, self.isNetworkUp else { return } - Task(priority: .utility) { await self.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() } - .store(in: &cancellables) + } } // MARK: - Logic @@ -493,12 +489,9 @@ class VolumeManager: ObservableObject { storage.saveTerminalBundleID(bundleID) } - // MARK: - Import / Export Logic - - func importVolumes(fromPath pathString: String) { - let expandedPath = (pathString as NSString).expandingTildeInPath - let url = URL(fileURLWithPath: expandedPath) + // MARK: - Import / Export + func importVolumes(fromURL url: URL) { // Data(contentsOf:) is synchronous blocking I/O — run it off the main actor. Task { do { @@ -528,24 +521,17 @@ class VolumeManager: ObservableObject { } } - func exportToDownloads() { + func exportToURL(_ url: URL) { let snapshot = volumes // data.write(to:) is synchronous blocking I/O — run it off the main actor. Task { 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(snapshot) try await Task.detached(priority: .utility) { - try data.write(to: fileURL) + try data.write(to: url) }.value - self.log("Exported \(snapshot.count) volume(s) to Downloads") - self.successMessage = "Backup saved to Downloads." + self.log("Exported \(snapshot.count) volume(s)") + self.successMessage = "Backup saved successfully." self.showSuccess = true } catch { self.log("Export failed: \(error.localizedDescription)", level: .error) diff --git a/Mounty/Views/RootView.swift b/Mounty/Views/RootView.swift index 62fbb9b..7ab95ff 100644 --- a/Mounty/Views/RootView.swift +++ b/Mounty/Views/RootView.swift @@ -1,7 +1,7 @@ import SwiftUI struct RootView: View { - @StateObject var manager = VolumeManager() + @State private var manager = VolumeManager() @State private var viewMode: AppViewMode = .list var body: some View { diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index 89ea4b0..d40b857 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -1,16 +1,14 @@ +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 @@ -55,25 +53,24 @@ struct SettingsView: View { Section(header: Text("Volumes")) { HStack(spacing: 8) { Button { - importPath = "" - withAnimation { showImportDialog = true } + showOpenPanel() } label: { Label("Import", systemImage: "square.and.arrow.up") .font(.callout) .frame(maxWidth: .infinity) } .iconButtonHover(cornerRadius: 6, padding: 6) - .help("Import volumes from JSON") + .help("Import volumes from a JSON backup file") Button { - manager.exportToDownloads() + showSavePanel() } label: { Label("Export", systemImage: "square.and.arrow.down") .font(.callout) .frame(maxWidth: .infinity) } .iconButtonHover(cornerRadius: 6, padding: 6) - .help("Export volumes to Downloads") + .help("Export volumes to a JSON backup file") Button { withAnimation { showResetConfirmation = true } @@ -119,13 +116,11 @@ struct SettingsView: View { .scrollDisabled(true) .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 ) } @@ -154,18 +149,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( @@ -187,4 +170,36 @@ struct SettingsView: View { } .fixedSize(horizontal: false, vertical: true) } + + // 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) + } + } } From 362737f1b832893677990369d2a47067a5f056a6 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sun, 9 Aug 2026 18:34:29 +0200 Subject: [PATCH 21/38] refactor(views): migrate from @ObservedObject to @Observable pattern Replace @ObservedObject with plain var (read-only) or @Bindable (when $property two-way bindings are needed) on all child views that receive VolumeManager. Consistent with the @Observable migration on VolumeManager. Generated-by: claude-sonnet-4-6 --- Mounty/Views/AddVolumeView.swift | 4 ++-- Mounty/Views/LogsView.swift | 2 +- Mounty/Views/MainListView.swift | 2 +- Mounty/Views/VolumeRow.swift | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Mounty/Views/AddVolumeView.swift b/Mounty/Views/AddVolumeView.swift index 4d6dfc7..4a6083c 100644 --- a/Mounty/Views/AddVolumeView.swift +++ b/Mounty/Views/AddVolumeView.swift @@ -77,7 +77,7 @@ struct VolumeFormFields: View { // MARK: - Add Volume View struct AddVolumeView: View { - @ObservedObject var manager: VolumeManager + var manager: VolumeManager @Binding var viewMode: AppViewMode @State private var name = "" @@ -127,7 +127,7 @@ struct AddVolumeView: View { struct EditVolumeView: View { let volume: Volume - @ObservedObject var manager: VolumeManager + var manager: VolumeManager @Binding var viewMode: AppViewMode @State private var name: String diff --git a/Mounty/Views/LogsView.swift b/Mounty/Views/LogsView.swift index 18503e5..fd4bd38 100644 --- a/Mounty/Views/LogsView.swift +++ b/Mounty/Views/LogsView.swift @@ -1,7 +1,7 @@ import SwiftUI struct LogsView: View { - @ObservedObject var manager: VolumeManager + var manager: VolumeManager @Binding var viewMode: AppViewMode var body: some View { diff --git a/Mounty/Views/MainListView.swift b/Mounty/Views/MainListView.swift index 5881f42..f8c5970 100644 --- a/Mounty/Views/MainListView.swift +++ b/Mounty/Views/MainListView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MainListView: View { - @ObservedObject var manager: VolumeManager + @Bindable var manager: VolumeManager @Binding var viewMode: AppViewMode private let rowHeight: CGFloat = 50 diff --git a/Mounty/Views/VolumeRow.swift b/Mounty/Views/VolumeRow.swift index d73009c..bf15684 100644 --- a/Mounty/Views/VolumeRow.swift +++ b/Mounty/Views/VolumeRow.swift @@ -2,7 +2,7 @@ import SwiftUI struct VolumeRow: View { let volume: Volume - @ObservedObject var manager: VolumeManager + var manager: VolumeManager var onEdit: () -> Void = {} @State private var isRowHovered = false From 51caae7c7c4deda7aa17d55202087d739781b1a1 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sun, 9 Aug 2026 18:34:35 +0200 Subject: [PATCH 22/38] fix(mount): replace substring host match with exact extractHost equality The old source.contains(host) check caused a false positive when a host name appeared as a substring of another (e.g. "nas.local" matching a source from "other-nas.local"). extractHost() isolates the actual hostname from SMB kernel-mount sources of the form //[domain;user@]host/share, making the comparison exact. Two new tests cover both the false-positive regression and the Windows-style domain;user@host format. Generated-by: claude-sonnet-4-6 --- Mounty/Services/SystemMountService.swift | 37 ++++++++++++++--------- MountyTests/SystemMountServiceTests.swift | 31 +++++++++++++++++++ 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/Mounty/Services/SystemMountService.swift b/Mounty/Services/SystemMountService.swift index 786b384..5636583 100644 --- a/Mounty/Services/SystemMountService.swift +++ b/Mounty/Services/SystemMountService.swift @@ -47,13 +47,11 @@ struct SystemMountService { let configHost = configUrl.host?.lowercased() ?? "unknown" for mount in mounts { - let source = mount.source.lowercased() - if source.contains(configHost) { - if configPath.count > 1 { - if source.hasSuffix(configPath) { return mount.path } - } else { - return mount.path - } + guard extractHost(from: mount.source) == configHost else { continue } + if configPath.count > 1 { + if mount.source.lowercased().hasSuffix(configPath) { return mount.path } + } else { + return mount.path } } return nil @@ -66,15 +64,26 @@ struct SystemMountService { let path = url.path.lowercased() 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 - } + guard extractHost(from: mount.source) == host else { continue } + if path.count > 1 { + if mount.source.lowercased().hasSuffix(path) { return mount.path } + } else { + return mount.path } } return nil } + + /// 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/MountyTests/SystemMountServiceTests.swift b/MountyTests/SystemMountServiceTests.swift index 5ef9055..ad88af0 100644 --- a/MountyTests/SystemMountServiceTests.swift +++ b/MountyTests/SystemMountServiceTests.swift @@ -82,4 +82,35 @@ struct SystemMountServiceTests { ) #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") + } } From ed678aaa7981c6edd5293c017c29389569ec2c13 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sun, 9 Aug 2026 18:34:40 +0200 Subject: [PATCH 23/38] fix(services): loop read(2) to completion and log force-unmount result SpeedTestService: read(2) on network filesystems may return fewer bytes than requested in one call; loop until all bytes are consumed to get an accurate elapsed-time measurement. MountService: log the errno when Darwin.unmount with MNT_FORCE fails so the outcome is visible in Console and in-app logs. Generated-by: claude-sonnet-4-6 --- Mounty/Services/MountService.swift | 7 ++++++- Mounty/Services/SpeedTestService.swift | 13 ++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Mounty/Services/MountService.swift b/Mounty/Services/MountService.swift index b4e3030..b51e8b1 100644 --- a/Mounty/Services/MountService.swift +++ b/Mounty/Services/MountService.swift @@ -90,7 +90,12 @@ struct MountService { logger.info("Polite unmount successful: \(path)") } catch { logger.warning("Polite unmount failed. Executing MNT_FORCE.") - _ = Darwin.unmount(path, MNT_FORCE) + let forceResult = Darwin.unmount(path, MNT_FORCE) + if forceResult == 0 { + logger.info("Force unmount successful: \(path)") + } else { + logger.error("Force unmount failed: \(path). errno: \(errno)") + } } }.value } diff --git a/Mounty/Services/SpeedTestService.swift b/Mounty/Services/SpeedTestService.swift index 7c01a39..2dd4801 100644 --- a/Mounty/Services/SpeedTestService.swift +++ b/Mounty/Services/SpeedTestService.swift @@ -56,8 +56,19 @@ struct SpeedTestService { _ = Darwin.fcntl(rfd, F_NOCACHE, 1) let readStart = Date() var buffer = [UInt8](repeating: 0, count: byteCount) + // read(2) may return fewer bytes than requested on network + // filesystems; loop until all bytes are consumed or EOF/error. buffer.withUnsafeMutableBytes { ptr in - _ = Darwin.read(rfd, ptr.baseAddress!, byteCount) + var remaining = byteCount + var offset = 0 + while remaining > 0 { + let n = Darwin.read( + rfd, ptr.baseAddress!.advanced(by: offset), remaining + ) + if n <= 0 { break } + offset += n + remaining -= n + } } readDuration = max(Date().timeIntervalSince(readStart), 0.001) Darwin.close(rfd) From a9c03b069fedf8af2f78c1acaecd1b2ff1d807d0 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sun, 9 Aug 2026 18:34:45 +0200 Subject: [PATCH 24/38] docs(agents): update AGENTS.md for Observable and AsyncStream migration Reflect the completed modernization: Combine is fully replaced by AsyncStream, VolumeManager is @Observable (not ObservableObject), the heartbeat uses Task.sleep instead of a Timer, and the Observable pattern rules are now documented in the code-style section. Generated-by: claude-sonnet-4-6 --- AGENTS.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8a8b5f3..7aa23e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,8 @@ Mounty is a macOS **menu-bar app** (SwiftUI) that keeps SMB network shares mount It reacts to network/VPN/reachability changes and re-mounts shares as soon as their server is reachable. -- Language: Swift 5 / Swift Concurrency (`async`/`await`). **Avoid Combine for new code** — prefer - async APIs (existing `EventMonitorService` still uses Combine; do not expand that pattern). +- Language: Swift 5.9+ / 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 @@ -32,7 +32,7 @@ Mounty/Mounty/ │ ├─ EventMonitorService.swift # NWPathMonitor + workspace mount notifications (Combine subjects) │ └─ PersistenceService.swift # UserDefaults-backed storage (injectable defaults) ├─ ViewModels/ -│ └─ VolumeManager.swift # @MainActor ObservableObject; orchestrates detection/automount/state +│ └─ VolumeManager.swift # @MainActor @Observable class; orchestrates detection/automount/state └─ Views/ # SwiftUI views only — no business logic ``` @@ -63,6 +63,10 @@ When running inside an IDE with MCP tools available (e.g. Xcode), prefer `BuildP - 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 @@ -130,9 +134,10 @@ will break the user experience even if the code is otherwise correct. system determines whether a second tap is coming. Use `.simultaneousGesture(TapGesture(count:))` instead so both recognizers run concurrently. -4. **Timers that wake the main actor must do minimal synchronous work.** - The 5-second heartbeat timer uses `.default` RunLoop mode (does not fire during event tracking) - and immediately delegates to a `Task.detached` for all detection work. Keep it that way. +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 From 76800130ead77026335f47ef67d69e1dfa2e7fb0 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 08:17:39 +0200 Subject: [PATCH 25/38] fix(automount): restore reliable mounting and logging Generated-by: github-copilot --- Mounty/Models/LogEntry.swift | 58 +++++- Mounty/MountyApp.swift | 10 +- Mounty/Services/AppLogger.swift | 75 ++++++++ Mounty/Services/EventMonitorService.swift | 18 +- Mounty/Services/MountService.swift | 204 +++++++++++++++------- Mounty/Services/PersistenceService.swift | 10 ++ Mounty/Services/ReachabilityService.swift | 107 ++++++++++-- Mounty/ViewModels/VolumeManager.swift | 160 ++++++++++++----- Mounty/Views/LogsView.swift | 58 ++++-- MountyTests/AppLoggerTests.swift | 23 +++ MountyTests/PersistenceServiceTests.swift | 13 ++ specs/001-mount-reliability/plan.md | 53 ++++++ specs/001-mount-reliability/spec.md | 49 ++++++ specs/001-mount-reliability/tasks.md | 15 ++ 14 files changed, 705 insertions(+), 148 deletions(-) create mode 100644 Mounty/Services/AppLogger.swift create mode 100644 MountyTests/AppLoggerTests.swift create mode 100644 specs/001-mount-reliability/plan.md create mode 100644 specs/001-mount-reliability/spec.md create mode 100644 specs/001-mount-reliability/tasks.md diff --git a/Mounty/Models/LogEntry.swift b/Mounty/Models/LogEntry.swift index 0f431cf..1072c24 100644 --- a/Mounty/Models/LogEntry.swift +++ b/Mounty/Models/LogEntry.swift @@ -2,16 +2,54 @@ import Foundation import SwiftUI struct LogEntry: Identifiable, Sendable { - let id = UUID() - let timestamp: Date - let level: Level - let message: String + nonisolated let id: UUID + nonisolated let timestamp: Date + nonisolated let level: Level + nonisolated let source: Source + nonisolated let message: String - enum Level: Sendable { - case info, warning, error + 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 @@ -20,14 +58,16 @@ struct LogEntry: Identifiable, Sendable { var symbol: String { switch self { + case .debug: "circle" case .info: "circle.fill" case .warning: "exclamationmark.triangle.fill" case .error: "xmark.circle.fill" } } - var label: String { + nonisolated var label: String { switch self { + case .debug: "DEBUG" case .info: "INFO" case .warning: "WARN" case .error: "ERROR" @@ -36,8 +76,8 @@ struct LogEntry: Identifiable, Sendable { } // Full-fidelity string used for clipboard export. - var formatted: String { + nonisolated var formatted: String { let ts = timestamp.formatted(.dateTime.year().month().day().hour().minute().second()) - return "[\(ts)] [\(level.label)] \(message)" + return "[\(ts)] [\(level.label)] [\(source.label)] \(message)" } } diff --git a/Mounty/MountyApp.swift b/Mounty/MountyApp.swift index b3cd2db..0ef36c1 100644 --- a/Mounty/MountyApp.swift +++ b/Mounty/MountyApp.swift @@ -4,6 +4,10 @@ import SwiftUI @MainActor struct MountyApp: App { + private var isRunningTests: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } + private static let paddedIcon: NSImage = { guard let image = NSImage(named: "MenuIcon") else { return NSImage() } @@ -19,7 +23,11 @@ struct MountyApp: App { var body: some Scene { MenuBarExtra { - RootView() + 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..ae8143e --- /dev/null +++ b/Mounty/Services/AppLogger.swift @@ -0,0 +1,75 @@ +import Foundation +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: @unchecked Sendable { + private let lock = NSLock() + private nonisolated(unsafe) var history: [LogEntry] = [] + private nonisolated(unsafe) var subscribers: [UUID: AsyncStream.Continuation] = [:] + + nonisolated func makeStream() -> AsyncStream { + let subscriberID = UUID() + return AsyncStream(bufferingPolicy: .bufferingNewest(500)) { continuation in + lock.withLock { + subscribers[subscriberID] = continuation + for entry in history { + continuation.yield(entry) + } + } + continuation.onTermination = { [weak self] _ in + self?.removeSubscriber(subscriberID) + } + } + } + + nonisolated func emit(_ entry: LogEntry) { + let continuations = lock.withLock { + history.append(entry) + if history.count > 500 { + history.removeFirst(history.count - 500) + } + return Array(subscribers.values) + } + for continuation in continuations { + continuation.yield(entry) + } + } + + nonisolated func clearHistory() { + lock.withLock { history.removeAll() } + } + + nonisolated private func removeSubscriber(_ id: UUID) { + lock.withLock { _ = subscribers.removeValue(forKey: id) } + } +} diff --git a/Mounty/Services/EventMonitorService.swift b/Mounty/Services/EventMonitorService.swift index 7788290..7fe5fd4 100644 --- a/Mounty/Services/EventMonitorService.swift +++ b/Mounty/Services/EventMonitorService.swift @@ -1,7 +1,6 @@ import AppKit import Foundation import Network -import os /// Monitors OS events and exposes them as async sequences. class EventMonitorService { @@ -19,11 +18,6 @@ class EventMonitorService { // Written and read exclusively on monitorQueue — nonisolated(unsafe) bypasses the // implicit @MainActor isolation without requiring an @unchecked Sendable wrapper. private nonisolated(unsafe) var lastInterfaceFingerprint = "" - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "Mounty", - category: "EventMonitor" - ) - init() { (networkStatusStream, networkStatusContinuation) = AsyncStream.makeStream( of: NWPath.Status.self, bufferingPolicy: .bufferingNewest(1) @@ -49,7 +43,11 @@ class EventMonitorService { .joined(separator: ",") if currentInterfaces != lastInterfaceFingerprint { - logger.debug("Interface topology changed: \(currentInterfaces, privacy: .public)") + AppLogger.log( + "Interface topology changed: \(currentInterfaces)", + level: .debug, + source: .eventMonitor + ) lastInterfaceFingerprint = currentInterfaces // 1-second debounce: let the interface topology settle before // triggering a reconnect attempt. @@ -70,7 +68,11 @@ class EventMonitorService { NSWorkspace.didRenameVolumeNotification, ] { center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in - self?.logger.debug("Kernel filesystem event received") + AppLogger.log( + "Kernel filesystem event received: \(name.rawValue)", + level: .debug, + source: .eventMonitor + ) self?.fileSystemChangedContinuation.yield() } } diff --git a/Mounty/Services/MountService.swift b/Mounty/Services/MountService.swift index b51e8b1..d302733 100644 --- a/Mounty/Services/MountService.swift +++ b/Mounty/Services/MountService.swift @@ -3,98 +3,180 @@ import Darwin import Foundation import NetFS import ServiceManagement -import os /// Action Service. struct MountService { - // MARK: - Logger - nonisolated private static let logger = Logger( - subsystem: "Mounty", - category: "MountService" - ) + // MARK: - Mount Result - // MARK: - Mounting + enum MountResult: Sendable { + case success(path: String) + case failed(code: Int32) - /// 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) { + nonisolated var path: String? { + if case .success(let p) = self { return p } + return nil + } - // 1. Check if already mounted - if let existing = SystemMountService.findMountPath(forURL: url) { - logger.info( - "Share already mounted: \(url.absoluteString) -> \(existing)" - ) - return existing + nonisolated var debugDescription: String { + switch self { + case .success(let p): return "success → \(p)" + case .failed(let code): + let detail = + code > 0 + ? String(cString: strerror(code)) + : NSError(domain: NSOSStatusErrorDomain, code: Int(code)).localizedDescription + return "NetFS error \(code): \(detail)" } + } + } - var mountpoints: Unmanaged? = nil - let cfUrl = url as CFURL + // MARK: - Mounting - let openOptions: [String: Any] = [ - "AllowUserInteraction": true, "NoMountOnDir": true, - ] - let mutableOpenOptions = CFDictionaryCreateMutableCopy( - nil, - 0, - openOptions as CFDictionary + /// Mounts a network share with NetFS without blocking MainActor. + nonisolated static func mount(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 .success(path: existing) + } + + AppLogger.log( + "Existing mount is unresponsive: \(existing); unmounting before retry", + level: .warning, + source: .mountService ) + guard await unmount(path: existing) else { + return .failed(code: EBUSY) + } + } + + 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) - let result = NetFSMountURLSync( - cfUrl, - nil, - nil, - nil, - mutableOpenOptions, - nil, - &mountpoints + 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.") + 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 { - logger.info("Force unmount successful: \(path)") + AppLogger.log("Force unmount succeeded: \(path)", source: .mountService) + return true } else { - logger.error("Force unmount failed: \(path). errno: \(errno)") + AppLogger.log( + "Force unmount failed: \(path); errno=\(errno): \(String(cString: strerror(errno)))", + level: .error, + source: .mountService + ) + return false } } }.value @@ -142,8 +224,10 @@ 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 ) } } diff --git a/Mounty/Services/PersistenceService.swift b/Mounty/Services/PersistenceService.swift index f93688b..ce5b299 100644 --- a/Mounty/Services/PersistenceService.swift +++ b/Mounty/Services/PersistenceService.swift @@ -4,6 +4,7 @@ import Foundation struct PersistenceService { private let keyVolumes = "SavedVolumes" private let keyTerminal = "PreferredTerminal" + private let keyMinimumLogLevel = "MinimumLogLevel" private let defaults: UserDefaults /// - Parameter defaults: injectable store; defaults to `.standard` (override in tests). @@ -32,4 +33,13 @@ 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 + } } diff --git a/Mounty/Services/ReachabilityService.swift b/Mounty/Services/ReachabilityService.swift index ce890fa..a477644 100644 --- a/Mounty/Services/ReachabilityService.swift +++ b/Mounty/Services/ReachabilityService.swift @@ -4,6 +4,7 @@ import Network /// Verifies server and mount point responsiveness. struct ReachabilityService { + nonisolated private static let mountProbes = MountProbeRegistry() /// Validates filesystem responsiveness by calling statfs(2) on the mount path. /// @@ -15,32 +16,45 @@ struct ReachabilityService { /// 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 { - await withCheckedContinuation { continuation in - let gate = ResumeGate() + return await withCheckedContinuation { continuation in + guard mountProbes.register(path: path, continuation: continuation) else { return } - DispatchQueue.global(qos: .userInteractive).async { + 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 alive = statfs(path, buf) == 0 - if gate.tryResume() { - continuation.resume(returning: alive) + 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 + ) + } } } DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { - if gate.tryResume() { - continuation.resume(returning: false) + 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 @@ -59,6 +73,11 @@ struct ReachabilityService { 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) } } @@ -70,7 +89,16 @@ struct ReachabilityService { conn.cancel() continuation.resume(returning: true) } - case .failed, .cancelled: + 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) } @@ -82,8 +110,65 @@ struct ReachabilityService { } } +private final class MountProbeRegistry: @unchecked Sendable { + private struct ProbeState { + var result: Bool? + var waiters: [CheckedContinuation] + } + + private let lock = NSLock() + private nonisolated(unsafe) var probes: [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 = lock.withLock { + 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]? = lock.withLock { + 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) { + lock.withLock { _ = probes.removeValue(forKey: path) } + } +} + /// Single-use boolean flag protected by NSLock; safe to share across @Sendable closures. -private final class ResumeGate: @unchecked Sendable { +final class ResumeGate: @unchecked Sendable { private let lock = NSLock() // nonisolated(unsafe): opts out of implicit @MainActor isolation; // thread safety is guaranteed by `lock`. diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index ad3d9c7..1a0d62a 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -1,6 +1,5 @@ import Network import SwiftUI -import os /// ViewModel: Orchestrates detection logic, automounting, state management, and data persistence. @MainActor @@ -31,6 +30,7 @@ class VolumeManager { // In-app log buffer (capped at maxLogEntries) var logEntries: [LogEntry] = [] + var minimumLogLevel: LogEntry.Level // Speed test state var speedTestVolumeId: UUID? = nil @@ -45,12 +45,6 @@ class VolumeManager { private let storage = PersistenceService() private let eventMonitor = EventMonitorService() - // Logger (os.Logger for Console.app; log() also feeds the in-app ring buffer) - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "Mounty", - category: "Manager" - ) - private let knownTerminals = [ ("Terminal", "com.apple.Terminal"), ("iTerm2", "com.googlecode.iterm2"), @@ -63,7 +57,9 @@ class VolumeManager { init() { self.volumes = storage.loadVolumes() self.preferredTerminal = storage.loadTerminalBundleID() + self.minimumLogLevel = storage.loadMinimumLogLevel() + startLogObservation() startEventObservation() refreshInstalledTerminals() @@ -125,17 +121,29 @@ class VolumeManager { // MARK: - Logging private func log(_ message: String, level: LogEntry.Level = .info) { - switch level { - case .info: logger.info("\(message, privacy: .public)") - case .warning: logger.warning("\(message, privacy: .public)") - case .error: logger.error("\(message, privacy: .public)") + AppLogger.log(message, level: level, source: .manager) + } + + 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) + } + } } - logEntries.append(LogEntry(timestamp: Date(), level: level, message: message)) - if logEntries.count > maxLogEntries { logEntries.removeFirst() } } func clearLogs() { logEntries.removeAll() + AppLogger.clearHistory() + } + + func setMinimumLogLevel(_ level: LogEntry.Level) { + minimumLogLevel = level + storage.saveMinimumLogLevel(level) } // MARK: - Speed Test @@ -177,14 +185,14 @@ class VolumeManager { // MARK: - Event Observation private func startEventObservation() { - // All tasks below inherit @MainActor from this context. They suspend at each - // `for await`, releasing the main actor between events. The actual I/O work is - // dispatched off-actor inside refreshState() and runAutomount() via Task.detached. + // 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 - guard let self else { return } - for await status in eventMonitor.networkStatusStream { + for await status in networkStatusStream { + guard let self else { break } let wasUp = isNetworkUp isNetworkUp = (status == .satisfied) if isNetworkUp != wasUp { @@ -196,9 +204,10 @@ class VolumeManager { } // 2. Interface changes (VPN) — debounce applied in EventMonitorService + let interfacesChangedStream = eventMonitor.interfacesChangedStream Task { [weak self] in - guard let self else { return } - for await _ in eventMonitor.interfacesChangedStream { + for await _ in interfacesChangedStream { + guard let self else { break } log("Network interface changed — retrying connections") await refreshState() await runAutomount() @@ -206,9 +215,10 @@ class VolumeManager { } // 3. File system (manual mounts by other apps) + let fileSystemChangedStream = eventMonitor.fileSystemChangedStream Task { [weak self] in - guard let self else { return } - for await _ in eventMonitor.fileSystemChangedStream { + for await _ in fileSystemChangedStream { + guard let self else { break } await refreshState() } } @@ -222,6 +232,7 @@ class VolumeManager { guard let self else { break } guard isNetworkUp else { continue } await refreshState() + await runAutomount() } } } @@ -235,32 +246,53 @@ class VolumeManager { $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) } - await withTaskGroup(of: (UUID, String?, String).self) { group in + let reachableIDs = await withTaskGroup(of: (UUID, Bool).self) { group in for volume in candidates { - guard let url = URL(string: volume.serverAddress) else { - busyVolumes.remove(volume.id) - continue - } - let addr = volume.serverAddress - let name = volume.name let id = volume.id group.addTask { - let isReachable = await ReachabilityService.isServerReachable(address: addr) - guard isReachable else { return (id, nil, name) } - return (id, await MountService.mount(url: url), name) + let reachable = await ReachabilityService.isServerReachable( + address: volume.serverAddress + ) + return (id, reachable) } } - for await (id, path, name) in group { - if let path { - self.mountPaths[id] = path - log("Automounted \(name) → \(path)") - } else { - log("Automount failed for \(name)", level: .warning) - } - busyVolumes.remove(id) + var ids = Set() + for await (id, reachable) in group where reachable { + ids.insert(id) + } + return ids + } + + // 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) } + + 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 + ) } } } @@ -268,6 +300,7 @@ class VolumeManager { func refreshState() async { let currentVolumes = self.volumes let networkAvailable = self.isNetworkUp + let prevPaths = self.mountPaths // NOTE: Task inherits priority from the caller. // Events call this with .userInitiated (Fast). @@ -279,6 +312,12 @@ class VolumeManager { ) }.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.). @@ -304,11 +343,11 @@ class VolumeManager { for: volume, in: systemMounts ) { - // 1. TCP Reachability (Fastest fail for dropped VPNs) + // 1. TCP Reachability (fastest fail for dropped VPNs) if await ReachabilityService.isServerReachable( address: volume.serverAddress ) { - // 2. IO Reachability (Catches hung kernel mounts) + // 2. IO Reachability (catches hung kernel mounts) if await ReachabilityService.isMountPointAlive(path: path) { return (volume.id, path) } @@ -352,14 +391,39 @@ class VolumeManager { log("Connecting \(volume.name)…") Task { - if let path = await MountService.mount(url: url) { + // 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 + ) + 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)") - } else { + 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("Connection failed: \(volume.name)", level: .error) + self.log( + "Failed: \(volume.name); \(result.debugDescription)", + level: .error + ) } self.busyVolumes.remove(volume.id) await self.refreshState() @@ -435,7 +499,9 @@ class VolumeManager { self.busyVolumes.remove(id) return } - if let newPath = await MountService.mount(url: url) { + 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 { diff --git a/Mounty/Views/LogsView.swift b/Mounty/Views/LogsView.swift index fd4bd38..9c5c39a 100644 --- a/Mounty/Views/LogsView.swift +++ b/Mounty/Views/LogsView.swift @@ -4,6 +4,10 @@ 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( @@ -13,13 +17,13 @@ struct LogsView: View { Divider() - if manager.logEntries.isEmpty { + if visibleEntries.isEmpty { VStack(spacing: 8) { Spacer() Image(systemName: "doc.text") .font(.system(size: 28)) .foregroundColor(.secondary.opacity(0.5)) - Text("No Log Entries") + Text(manager.logEntries.isEmpty ? "No Log Entries" : "No Entries at This Level") .font(.callout) .foregroundColor(.secondary) Spacer() @@ -29,7 +33,7 @@ struct LogsView: View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(manager.logEntries) { entry in + ForEach(visibleEntries) { entry in LogEntryRow(entry: entry) } Color.clear.frame(height: 1).id("logsBottom") @@ -37,7 +41,7 @@ struct LogsView: View { .padding(.vertical, 4) } .frame(height: 200) - .onChange(of: manager.logEntries.count) { _, _ in + .onChange(of: visibleEntries.count) { _, _ in proxy.scrollTo("logsBottom", anchor: .bottom) } .onAppear { @@ -61,19 +65,46 @@ struct LogsView: View { 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 = manager.logEntries.map { $0.formatted }.joined(separator: "\n") + let text = visibleEntries.map { $0.formatted }.joined(separator: "\n") NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } label: { - Label("Copy All", systemImage: "doc.on.doc") + Label("Copy", systemImage: "doc.on.doc") .font(.system(size: 12)) .foregroundColor( - manager.logEntries.isEmpty ? .secondary.opacity(0.4) : .secondary) + visibleEntries.isEmpty ? .secondary.opacity(0.4) : .secondary) } .iconButtonHover(cornerRadius: 5, padding: 4) - .disabled(manager.logEntries.isEmpty) - .help("Copy all logs to clipboard") + .disabled(visibleEntries.isEmpty) + .help("Copy visible log entries to clipboard") } .padding(.horizontal, 12) .padding(.vertical, 8) @@ -100,9 +131,12 @@ private struct LogEntryRow: View { .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) - Text(entry.timestamp.formatted(.dateTime.hour().minute().second())) - .font(.system(size: 9)) - .foregroundStyle(.tertiary) + Text( + "\(entry.source.label) · " + + entry.timestamp.formatted(.dateTime.hour().minute().second()) + ) + .font(.system(size: 9)) + .foregroundStyle(.tertiary) } } .padding(.horizontal, 12) 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 index f399bcc..540bed1 100644 --- a/MountyTests/PersistenceServiceTests.swift +++ b/MountyTests/PersistenceServiceTests.swift @@ -30,4 +30,17 @@ struct PersistenceServiceTests { 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) + } } diff --git a/specs/001-mount-reliability/plan.md b/specs/001-mount-reliability/plan.md new file mode 100644 index 0000000..6c045eb --- /dev/null +++ b/specs/001-mount-reliability/plan.md @@ -0,0 +1,53 @@ +# Plan: Mount reliability and unified logging + +- **Spec:** ./spec.md +- **Status:** implemented + +## Approach + +Introduce a thread-safe application log channel that writes every Mounty-owned event to `os.Logger` +and an `AsyncStream` consumed by `VolumeManager`. Replace the timing-out asynchronous NetFS bridge +with the previously working synchronous API, isolated in a detached task and serialized by the +automount workflow. Do not expose a false timeout for a C operation that cannot be cancelled. + +Update the heartbeat to refresh and then automount. Share one in-flight filesystem liveness probe +per path so repeated checks cannot consume an unbounded number of threads. + +## Architecture & data flow + +`AppLogger` in Services emits categorized `LogEntry` values and Unified Logging records. +`VolumeManager` consumes the stream on `MainActor` and owns the capped display buffer. Services log +directly through `AppLogger`. `MountService` performs NetFS work in `Task.detached`; UI state remains +owned by `VolumeManager`. + +## Files to change + +| File | Change | +| ---- | ------ | +| `Mounty/Models/LogEntry.swift` | Add source category and nonisolated construction. | +| `Mounty/Services/AppLogger.swift` | Add unified Console/in-app logging channel. | +| `Mounty/Services/MountService.swift` | Restore reliable off-main synchronous NetFS mounting. | +| `Mounty/Services/EventMonitorService.swift` | Route service diagnostics through `AppLogger`. | +| `Mounty/Services/ReachabilityService.swift` | Bound blocking liveness probes and improve diagnostics. | +| `Mounty/ViewModels/VolumeManager.swift` | Consume logs and retry automount from heartbeat. | +| `Mounty/Views/LogsView.swift` | Display log categories. | +| `MountyTests/AppLoggerTests.swift` | Test categorized in-app stream delivery. | + +## Reused existing code + +Reuse `LogEntry.Level`, `ResumeGate`, `SystemMountService.findMountPath`, the existing automount +candidate workflow, and `PersistenceService` log-level storage. + +## Trade-offs / risks + +- Synchronous NetFS cannot be cancelled once entered. It remains off `MainActor`, and sequential + invocation prevents request storms. TCP preflight avoids entering it for unreachable servers. +- Interactive mounts are intentionally serialized to avoid competing NetAuth dialogs. +- A permanently hung liveness syscall can occupy one dedicated worker, but repeated heartbeats do + not create additional blocked workers. + +## Verification + +Run focused Swift Testing tests, strict `swift-format` lint, and the complete macOS test suite. +Manually verify on a configured SMB share that MountService Debug records appear in the app and the +mount completes without UI stalls. \ No newline at end of file diff --git a/specs/001-mount-reliability/spec.md b/specs/001-mount-reliability/spec.md new file mode 100644 index 0000000..9a1d6d5 --- /dev/null +++ b/specs/001-mount-reliability/spec.md @@ -0,0 +1,49 @@ +# Spec: Mount reliability and unified logging + +- **Status:** implemented +- **Author:** GitHub Copilot +- **Date:** 2026-08-10 + +## Problem / motivation + +Automount and manual mounts stall until the 90-second deadline when using +`NetFSMountURLAsync`, although the earlier synchronous NetFS implementation worked for the same +shares. Mount-service diagnostics are visible in Console but absent from the in-app log because +services and the view model use separate logging paths. Heartbeat detection also removes dead +mounts from state without initiating automount recovery. + +## Goals + +- Reliably mount reachable SMB shares without blocking `MainActor`. +- Show all Mounty-owned service and view-model logs in both Console and the in-app log. +- Retry automount after heartbeat detection finds a lost mount. +- Prevent repeated liveness checks from creating unbounded blocked worker threads. +- Preserve actionable levels, categories, durations, error codes, and credential-safe targets. + +## Non-goals + +- Mirroring macOS framework logs, such as BaseBoard diagnostics, into the app. +- Replacing NetFS or storing SMB credentials. +- Parallel interactive authentication prompts. + +## User-visible behavior + +The Debug filter shows manager, event-monitor, reachability, and mount-service diagnostics. Mounts +are attempted one at a time off the UI actor. A dead automounted share is retried by the heartbeat +when its server remains reachable. Manual mount failures remain visible at Error level with their +underlying NetFS detail. + +## Acceptance criteria + +- [x] `MountService` diagnostics appear in the in-app log and Console with a category. +- [x] No Mounty-owned blocking filesystem, NetFS, or Launch Services call runs on `MainActor`. +- [x] Reachable automount candidates use the proven synchronous NetFS operation sequentially. +- [x] Heartbeat detection invokes automount after refreshing mount state. +- [x] A hung liveness probe cannot create an unbounded number of blocked workers. +- [x] Log-level selection remains persisted. +- [x] The project builds and tests without Swift warnings. + +## Open questions + +- Whether a future macOS release makes parallel `NetFSMountURLAsync` reliable enough to re-enable + bounded concurrent authentication after real-network testing. \ No newline at end of file diff --git a/specs/001-mount-reliability/tasks.md b/specs/001-mount-reliability/tasks.md new file mode 100644 index 0000000..88734a3 --- /dev/null +++ b/specs/001-mount-reliability/tasks.md @@ -0,0 +1,15 @@ +# Tasks: Mount reliability and unified logging + +- **Plan:** ./plan.md + +- [x] **T1** — Add unified categorized application logging _(commit: `feat(logging): unify service logs`)_ +- [x] **T2** — Restore reliable off-main NetFS mounting _(commit: `fix(mount): restore reliable NetFS mounting`)_ +- [x] **T3** — Repair heartbeat retry and bound liveness work _(commit: `fix(automount): retry dead shares safely`)_ +- [x] **T4** — Add focused logging tests _(commit: `test(logging): cover categorized formatting`)_ + +## Definition of done + +- [x] All acceptance criteria in `spec.md` met +- [x] Business logic covered by Swift Testing tests +- [x] `swift-format lint --strict` clean +- [ ] CI green \ No newline at end of file From b8a6b06e89dedaa95a169f0d48eb063e081ead11 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 09:01:47 +0200 Subject: [PATCH 26/38] refactor: modernize Swift implementation Migrate to Swift 6 concurrency patterns, remove stale code and documentation, and harden mount and speed-test behavior. Generated-by: github-copilot --- AGENTS.md | 22 ++++--- Mounty.xcodeproj/project.pbxproj | 12 ++-- Mounty/Models/Volume.swift | 9 ++- Mounty/Services/AppLogger.swift | 32 +++++----- Mounty/Services/EventMonitorService.swift | 38 +++++++---- Mounty/Services/MountService.swift | 37 ++++++++++- Mounty/Services/ReachabilityService.swift | 28 +++------ Mounty/Services/SpeedTestService.swift | 77 +++++++++++++++-------- Mounty/Services/SystemMountService.swift | 47 ++++++-------- Mounty/ViewModels/VolumeManager.swift | 49 +++++++-------- Mounty/Views/AddVolumeView.swift | 53 +++------------- Mounty/Views/Overlays.swift | 62 ------------------ MountyTests/PersistenceServiceTests.swift | 1 + MountyTests/SpeedTestServiceTests.swift | 14 +++++ MountyTests/VolumeTests.swift | 14 ++--- README.md | 8 +-- specs/001-mount-reliability/plan.md | 2 +- specs/001-mount-reliability/tasks.md | 2 +- 18 files changed, 243 insertions(+), 264 deletions(-) create mode 100644 MountyTests/SpeedTestServiceTests.swift diff --git a/AGENTS.md b/AGENTS.md index 7aa23e9..00573bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Mounty is a macOS **menu-bar app** (SwiftUI) that keeps SMB network shares mount It reacts to network/VPN/reachability changes and re-mounts shares as soon as their server is reachable. -- Language: Swift 5.9+ / Swift Concurrency (`async`/`await`). **Avoid Combine entirely** — use +- 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`. @@ -24,12 +24,15 @@ reachable. Mounty/Mounty/ ├─ MountyApp.swift # @main App, MenuBarExtra scene ├─ Models/ -│ └─ Volume.swift # Volume value type (+ AppViewMode enum) -├─ Services/ # Stateless/side-effecting units — the business logic +│ ├─ 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 -│ ├─ EventMonitorService.swift # NWPathMonitor + workspace mount notifications (Combine subjects) +│ ├─ 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 @@ -72,8 +75,9 @@ When running inside an IDE with MCP tools available (e.g. Xcode), prefer `BuildP 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`), URL parsing (`Volume.host`), persistence -round-trips. Use the **Swift Testing** framework (`import Testing`, `@Test`, `#expect`), not XCTest. +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) @@ -148,9 +152,9 @@ will break the user experience even if the code is otherwise correct. `@MainActor` unless explicitly marked `nonisolated`. Common patterns to follow: - Value types shared across actors: add an explicit `nonisolated static func ==` (see `Volume.swift`). - - Classes shared across `@Sendable` closures: mark as `@unchecked Sendable`, protect mutable - state with `NSLock`, and annotate mutable properties `nonisolated(unsafe)` (see - `ReachabilityService.ResumeGate`). + - 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. diff --git a/Mounty.xcodeproj/project.pbxproj b/Mounty.xcodeproj/project.pbxproj index 57d82aa..b8a8968 100644 --- a/Mounty.xcodeproj/project.pbxproj +++ b/Mounty.xcodeproj/project.pbxproj @@ -351,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; }; @@ -384,7 +384,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 = Release; }; @@ -396,7 +396,7 @@ CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 26.1; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = ch.maptic.MountyTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -404,7 +404,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mounty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Mounty"; }; name = Debug; @@ -417,7 +417,7 @@ CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 26.1; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = ch.maptic.MountyTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -425,7 +425,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mounty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Mounty"; }; name = Release; diff --git a/Mounty/Models/Volume.swift b/Mounty/Models/Volume.swift index eba4797..274f5ad 100644 --- a/Mounty/Models/Volume.swift +++ b/Mounty/Models/Volume.swift @@ -7,7 +7,14 @@ 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 diff --git a/Mounty/Services/AppLogger.swift b/Mounty/Services/AppLogger.swift index ae8143e..7209c01 100644 --- a/Mounty/Services/AppLogger.swift +++ b/Mounty/Services/AppLogger.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization import os /// Routes Mounty-owned diagnostics to Unified Logging and the in-app log stream. @@ -32,17 +33,20 @@ struct AppLogger { } } -private final class LogHub: @unchecked Sendable { - private let lock = NSLock() - private nonisolated(unsafe) var history: [LogEntry] = [] - private nonisolated(unsafe) var subscribers: [UUID: AsyncStream.Continuation] = [:] +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 - lock.withLock { - subscribers[subscriberID] = continuation - for entry in history { + state.withLock { state in + state.subscribers[subscriberID] = continuation + for entry in state.history { continuation.yield(entry) } } @@ -53,12 +57,12 @@ private final class LogHub: @unchecked Sendable { } nonisolated func emit(_ entry: LogEntry) { - let continuations = lock.withLock { - history.append(entry) - if history.count > 500 { - history.removeFirst(history.count - 500) + let continuations = state.withLock { state in + state.history.append(entry) + if state.history.count > 500 { + state.history.removeFirst(state.history.count - 500) } - return Array(subscribers.values) + return Array(state.subscribers.values) } for continuation in continuations { continuation.yield(entry) @@ -66,10 +70,10 @@ private final class LogHub: @unchecked Sendable { } nonisolated func clearHistory() { - lock.withLock { history.removeAll() } + state.withLock { $0.history.removeAll() } } nonisolated private func removeSubscriber(_ id: UUID) { - lock.withLock { _ = subscribers.removeValue(forKey: id) } + state.withLock { _ = $0.subscribers.removeValue(forKey: id) } } } diff --git a/Mounty/Services/EventMonitorService.swift b/Mounty/Services/EventMonitorService.swift index 7fe5fd4..09cfd3a 100644 --- a/Mounty/Services/EventMonitorService.swift +++ b/Mounty/Services/EventMonitorService.swift @@ -1,9 +1,15 @@ import AppKit import Foundation import Network +import Synchronization + +private struct InterfaceMonitorState: Sendable { + var fingerprint = "" + var pendingChange: Task? +} /// Monitors OS events and exposes them as async sequences. -class EventMonitorService { +final class EventMonitorService { let networkStatusStream: AsyncStream let interfacesChangedStream: AsyncStream @@ -15,9 +21,8 @@ class EventMonitorService { private let monitor = NWPathMonitor() private let monitorQueue = DispatchQueue(label: "com.mounty.network", qos: .background) - // Written and read exclusively on monitorQueue — nonisolated(unsafe) bypasses the - // implicit @MainActor isolation without requiring an @unchecked Sendable wrapper. - private nonisolated(unsafe) var lastInterfaceFingerprint = "" + nonisolated private let interfaceState = Mutex(InterfaceMonitorState()) + init() { (networkStatusStream, networkStatusContinuation) = AsyncStream.makeStream( of: NWPath.Status.self, bufferingPolicy: .bufferingNewest(1) @@ -42,19 +47,28 @@ class EventMonitorService { .sorted() .joined(separator: ",") - if currentInterfaces != lastInterfaceFingerprint { + 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 ) - lastInterfaceFingerprint = currentInterfaces - // 1-second debounce: let the interface topology settle before - // triggering a reconnect attempt. - Task { [weak self] in - try? await Task.sleep(for: .seconds(1)) - self?.interfacesChangedContinuation.yield() - } } } monitor.start(queue: monitorQueue) diff --git a/Mounty/Services/MountService.swift b/Mounty/Services/MountService.swift index d302733..8a42ea5 100644 --- a/Mounty/Services/MountService.swift +++ b/Mounty/Services/MountService.swift @@ -6,6 +6,7 @@ import ServiceManagement /// Action Service. struct MountService { + nonisolated private static let mountGate = MountGate() // MARK: - Mount Result @@ -14,13 +15,13 @@ struct MountService { case failed(code: Int32) nonisolated var path: String? { - if case .success(let p) = self { return p } + if case .success(let path) = self { return path } return nil } nonisolated var debugDescription: String { switch self { - case .success(let p): return "success → \(p)" + case .success(let path): return "success → \(path)" case .failed(let code): let detail = code > 0 @@ -35,6 +36,13 @@ struct MountService { /// 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 + } + + nonisolated private static func mountExclusively(url: URL) async -> MountResult { if let existing = await Task.detached( priority: .userInitiated, operation: { @@ -233,6 +241,29 @@ struct MountService { } nonisolated static func isLoginItemEnabled() -> Bool { - return SMAppService.mainApp.status == .enabled + 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/ReachabilityService.swift b/Mounty/Services/ReachabilityService.swift index a477644..b84315f 100644 --- a/Mounty/Services/ReachabilityService.swift +++ b/Mounty/Services/ReachabilityService.swift @@ -1,6 +1,7 @@ import Darwin import Foundation import Network +import Synchronization /// Verifies server and mount point responsiveness. struct ReachabilityService { @@ -65,9 +66,6 @@ struct ReachabilityService { using: .tcp ) - // Thread-safe gate: ensures continuation.resume is called exactly once - // even when the timeout and stateUpdateHandler fire concurrently. - // @unchecked Sendable is safe here because NSLock guards the mutation. let gate = ResumeGate() DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) { @@ -110,14 +108,13 @@ struct ReachabilityService { } } -private final class MountProbeRegistry: @unchecked Sendable { +private final class MountProbeRegistry: Sendable { private struct ProbeState { var result: Bool? var waiters: [CheckedContinuation] } - private let lock = NSLock() - private nonisolated(unsafe) var probes: [String: ProbeState] = [:] + private let probes = Mutex([String: ProbeState]()) /// Registers a caller and returns true only when it must start the underlying syscall. nonisolated func register( @@ -125,7 +122,7 @@ private final class MountProbeRegistry: @unchecked Sendable { continuation: CheckedContinuation ) -> Bool { var immediateResult: Bool? - let shouldStart = lock.withLock { + let shouldStart = probes.withLock { probes in guard var state = probes[path] else { probes[path] = ProbeState(result: nil, waiters: [continuation]) return true @@ -147,7 +144,7 @@ private final class MountProbeRegistry: @unchecked Sendable { /// 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]? = lock.withLock { + 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 @@ -163,25 +160,18 @@ private final class MountProbeRegistry: @unchecked Sendable { } nonisolated func finish(path: String) { - lock.withLock { _ = probes.removeValue(forKey: path) } + probes.withLock { _ = $0.removeValue(forKey: path) } } } -/// Single-use boolean flag protected by NSLock; safe to share across @Sendable closures. -final class ResumeGate: @unchecked Sendable { - private let lock = NSLock() - // nonisolated(unsafe): opts out of implicit @MainActor isolation; - // thread safety is guaranteed by `lock`. - private nonisolated(unsafe) var resumed = false +private final class ResumeGate: Sendable { + private let resumed = Mutex(false) - // Explicit nonisolated init: SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor would make - // the synthesised init() @MainActor, causing a warning when ResumeGate is created - // from nonisolated contexts. NSLock and Bool are not actor-isolated, so this is safe. nonisolated init() {} /// Returns `true` the first time it is called; `false` on all subsequent calls. nonisolated func tryResume() -> Bool { - lock.withLock { + 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 index 2dd4801..5a936af 100644 --- a/Mounty/Services/SpeedTestService.swift +++ b/Mounty/Services/SpeedTestService.swift @@ -19,6 +19,7 @@ struct SpeedTestService { .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 { @@ -39,10 +40,11 @@ struct SpeedTestService { // can be near-instant even for slow links. let writeStart = Date() try data.write(to: testURL) - let wfd = Darwin.open(path, O_RDONLY) - if wfd >= 0 { - _ = Darwin.fcntl(wfd, F_FULLFSYNC) - Darwin.close(wfd) + let writeDescriptor = Darwin.open(path, O_RDONLY) + guard writeDescriptor >= 0 else { throw posixError() } + defer { Darwin.close(writeDescriptor) } + guard Darwin.fcntl(writeDescriptor, F_FULLFSYNC) == 0 else { + throw posixError() } let writeDuration = max(Date().timeIntervalSince(writeStart), 0.001) @@ -50,29 +52,36 @@ struct SpeedTestService { // 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. - var readDuration = 0.001 - let rfd = Darwin.open(path, O_RDONLY) - if rfd >= 0 { - _ = Darwin.fcntl(rfd, F_NOCACHE, 1) - let readStart = Date() - var buffer = [UInt8](repeating: 0, count: byteCount) - // read(2) may return fewer bytes than requested on network - // filesystems; loop until all bytes are consumed or EOF/error. - buffer.withUnsafeMutableBytes { ptr in - var remaining = byteCount - var offset = 0 - while remaining > 0 { - let n = Darwin.read( - rfd, ptr.baseAddress!.advanced(by: offset), remaining - ) - if n <= 0 { break } - offset += n - remaining -= n - } + let readDescriptor = Darwin.open(path, O_RDONLY) + guard readDescriptor >= 0 else { throw posixError() } + defer { Darwin.close(readDescriptor) } + guard Darwin.fcntl(readDescriptor, F_NOCACHE, 1) == 0 else { + throw posixError() + } + + 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 } - readDuration = max(Date().timeIntervalSince(readStart), 0.001) - Darwin.close(rfd) + 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( @@ -99,4 +108,22 @@ struct SpeedTestService { } } } + + private nonisolated static func posixError() -> NSError { + NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + + 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 5636583..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 mount.source.lowercased().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 { - guard extractHost(from: mount.source) == host else { continue } - if path.count > 1 { - if mount.source.lowercased().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 diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index 1a0d62a..c4892bd 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -4,7 +4,7 @@ import SwiftUI /// ViewModel: Orchestrates detection logic, automounting, state management, and data persistence. @MainActor @Observable -class VolumeManager { +final class VolumeManager { // MARK: - UI State var volumes: [Volume] = [] @@ -23,22 +23,22 @@ class VolumeManager { var availableTerminals: [(name: String, id: String)] = [] // Feedback & Errors - var lastError: String? = nil - var showError: Bool = false - var successMessage: String? = nil - 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 // Speed test state - var speedTestVolumeId: UUID? = nil + var speedTestVolumeId: UUID? var isRunningSpeedTest = false - var speedTestResult: SpeedTestService.Result? = nil - var speedTestError: String? = nil + var speedTestResult: SpeedTestService.Result? + var speedTestError: String? - private var isNetworkUp: Bool = true + private var isNetworkUp = true private let maxLogEntries = 200 // Dependencies @@ -302,11 +302,8 @@ class VolumeManager { let networkAvailable = self.isNetworkUp let prevPaths = self.mountPaths - // 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 ) @@ -339,21 +336,21 @@ class VolumeManager { 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) - 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 await 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 { diff --git a/Mounty/Views/AddVolumeView.swift b/Mounty/Views/AddVolumeView.swift index 4a6083c..e009b81 100644 --- a/Mounty/Views/AddVolumeView.swift +++ b/Mounty/Views/AddVolumeView.swift @@ -1,24 +1,12 @@ import SwiftUI -// MARK: - Shared types - -enum VolumeProtocolType: String, CaseIterable, Identifiable { - case smb = "SMB" - case afp = "AFP" - case nfs = "NFS" - case ftp = "FTP" - var id: String { rawValue } - var scheme: String { rawValue.lowercased() + "://" } -} - // MARK: - Shared form fields /// Reusable form body used by both AddVolumeView and EditVolumeView. -/// Manages its own focus state so callers only need to bind name/address/protocol. +/// 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 - @Binding var selectedProtocol: VolumeProtocolType var onSubmit: () -> Void = {} @FocusState private var focusedField: Field? @@ -32,13 +20,8 @@ struct VolumeFormFields: View { .submitLabel(.next) .onSubmit { focusedField = .address } - Picker("Protocol", selection: $selectedProtocol) { - ForEach(VolumeProtocolType.allCases) { Text($0.rawValue).tag($0) } - } - .pickerStyle(.segmented) - HStack(spacing: 4) { - Text(selectedProtocol.scheme) + Text("smb://") .font(.body) .foregroundColor(.secondary) @@ -49,13 +32,8 @@ struct VolumeFormFields: View { .onSubmit { onSubmit() } .autocorrectionDisabled(true) .onChange(of: address) { _, newValue in - for proto in VolumeProtocolType.allCases { - if newValue.lowercased().hasPrefix(proto.scheme) { - selectedProtocol = proto - address = String(newValue.dropFirst(proto.scheme.count)) - return - } - } + let normalized = Volume.shareAddress(from: newValue) + if normalized != newValue { address = normalized } } } .padding(8) @@ -67,7 +45,8 @@ struct VolumeFormFields: View { ) } .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + Task { + try? await Task.sleep(for: .milliseconds(500)) focusedField = .name } } @@ -82,7 +61,6 @@ struct AddVolumeView: View { @State private var name = "" @State private var address = "" - @State private var selectedProtocol: VolumeProtocolType = .smb var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -96,7 +74,6 @@ struct AddVolumeView: View { VolumeFormFields( name: $name, address: $address, - selectedProtocol: $selectedProtocol, onSubmit: save ) .padding(20) @@ -117,7 +94,7 @@ struct AddVolumeView: View { private func save() { guard !name.isEmpty, !address.isEmpty else { return } - let fullAddress = selectedProtocol.scheme + address + let fullAddress = Volume.smbServerAddress(from: address) manager.addVolume(Volume(name: name, serverAddress: fullAddress)) viewMode = .list } @@ -132,25 +109,14 @@ struct EditVolumeView: View { @State private var name: String @State private var address: String - @State private var selectedProtocol: VolumeProtocolType init(volume: Volume, manager: VolumeManager, viewMode: Binding) { self.volume = volume self.manager = manager self._viewMode = viewMode - var proto = VolumeProtocolType.smb - var addr = volume.serverAddress - for p in VolumeProtocolType.allCases { - if addr.lowercased().hasPrefix(p.scheme) { - proto = p - addr = String(addr.dropFirst(p.scheme.count)) - break - } - } self._name = State(initialValue: volume.name) - self._address = State(initialValue: addr) - self._selectedProtocol = State(initialValue: proto) + self._address = State(initialValue: Volume.shareAddress(from: volume.serverAddress)) } var body: some View { @@ -165,7 +131,6 @@ struct EditVolumeView: View { VolumeFormFields( name: $name, address: $address, - selectedProtocol: $selectedProtocol, onSubmit: save ) .padding(20) @@ -186,7 +151,7 @@ struct EditVolumeView: View { private func save() { guard !name.isEmpty, !address.isEmpty else { return } - let fullAddress = selectedProtocol.scheme + address + let fullAddress = Volume.smbServerAddress(from: address) manager.editVolume(id: volume.id, name: name, serverAddress: fullAddress) viewMode = .list } diff --git a/Mounty/Views/Overlays.swift b/Mounty/Views/Overlays.swift index e478885..43b1ef8 100644 --- a/Mounty/Views/Overlays.swift +++ b/Mounty/Views/Overlays.swift @@ -187,65 +187,3 @@ struct SpeedTestOverlay: View { .zIndex(100) } } - -// 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 - @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 } - } - - 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 } - } - .keyboardShortcut(.cancelAction) - - Button("Import") { - isFocused = false - withAnimation { isPresented = false } - onConfirm() - } - .buttonStyle(.borderedProminent) - .disabled(inputText.isEmpty) - .keyboardShortcut(.defaultAction) - } - } - .padding(20) - .frame(width: 280) - .background(.regularMaterial) - .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/MountyTests/PersistenceServiceTests.swift b/MountyTests/PersistenceServiceTests.swift index 540bed1..b38eaf4 100644 --- a/MountyTests/PersistenceServiceTests.swift +++ b/MountyTests/PersistenceServiceTests.swift @@ -3,6 +3,7 @@ import Testing @testable import Mounty +@MainActor struct PersistenceServiceTests { /// Round-trips volumes through an isolated UserDefaults suite so real preferences are untouched. diff --git a/MountyTests/SpeedTestServiceTests.swift b/MountyTests/SpeedTestServiceTests.swift new file mode 100644 index 0000000..4119d05 --- /dev/null +++ b/MountyTests/SpeedTestServiceTests.swift @@ -0,0 +1,14 @@ +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) + } + } +} diff --git a/MountyTests/VolumeTests.swift b/MountyTests/VolumeTests.swift index 19aea7c..c960c8a 100644 --- a/MountyTests/VolumeTests.swift +++ b/MountyTests/VolumeTests.swift @@ -1,17 +1,11 @@ -import Foundation import Testing @testable import Mounty struct VolumeTests { - - @Test func hostIsParsedFromServerAddress() { - let volume = Volume(name: "NAS", serverAddress: "smb://nas.local/media") - #expect(volume.host == "nas.local") - } - - @Test func hostIsNilForAddressWithoutHost() { - let volume = Volume(name: "bad", serverAddress: "not-a-url") - #expect(volume.host == nil) + @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 index 7ef3a5a..e1ff0e9 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ brew install --cask maptic/tap/mounty 2. Open the DMG and drag **Mounty** into `Applications`. > [!IMPORTANT] -> **First-launch Gatekeeper note.** Mounty is currently distributed **unsigned** (we do not yet -> have an Apple Developer ID). macOS will refuse to open it on the first try. To allow it: +> **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: @@ -46,8 +47,7 @@ brew install --cask maptic/tap/mounty > xattr -dr com.apple.quarantine /Applications/Mounty.app > ``` > -> This is a one-time step. Once we obtain a Developer ID, releases will be notarized and this step -> will no longer be necessary. +> This is a one-time step and is unnecessary for notarized releases. ## Build from source diff --git a/specs/001-mount-reliability/plan.md b/specs/001-mount-reliability/plan.md index 6c045eb..0bdf44c 100644 --- a/specs/001-mount-reliability/plan.md +++ b/specs/001-mount-reliability/plan.md @@ -8,7 +8,7 @@ Introduce a thread-safe application log channel that writes every Mounty-owned event to `os.Logger` and an `AsyncStream` consumed by `VolumeManager`. Replace the timing-out asynchronous NetFS bridge with the previously working synchronous API, isolated in a detached task and serialized by the -automount workflow. Do not expose a false timeout for a C operation that cannot be cancelled. +`MountService` actor gate. Do not expose a false timeout for a C operation that cannot be cancelled. Update the heartbeat to refresh and then automount. Share one in-flight filesystem liveness probe per path so repeated checks cannot consume an unbounded number of threads. diff --git a/specs/001-mount-reliability/tasks.md b/specs/001-mount-reliability/tasks.md index 88734a3..983e819 100644 --- a/specs/001-mount-reliability/tasks.md +++ b/specs/001-mount-reliability/tasks.md @@ -12,4 +12,4 @@ - [x] All acceptance criteria in `spec.md` met - [x] Business logic covered by Swift Testing tests - [x] `swift-format lint --strict` clean -- [ ] CI green \ No newline at end of file +- [ ] CI green _(pending the remote workflow run for these changes)_ From 68130dcbfecdb04b730eedbd48f320b5bbfbf039 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 09:26:24 +0200 Subject: [PATCH 27/38] fix: keep speed tests and footer responsive Generated-by: github-copilot --- Mounty/Services/MountService.swift | 46 +++--- Mounty/Services/SpeedTestService.swift | 47 ++++-- Mounty/ViewModels/VolumeManager.swift | 47 ++++-- Mounty/Views/HeaderView.swift | 2 + Mounty/Views/LogsView.swift | 5 +- Mounty/Views/MainListView.swift | 191 ++++++++++++------------ Mounty/Views/Overlays.swift | 6 + MountyTests/SpeedTestServiceTests.swift | 13 ++ 8 files changed, 210 insertions(+), 147 deletions(-) diff --git a/Mounty/Services/MountService.swift b/Mounty/Services/MountService.swift index 8a42ea5..9ab571a 100644 --- a/Mounty/Services/MountService.swift +++ b/Mounty/Services/MountService.swift @@ -192,33 +192,35 @@ struct MountService { // 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 diff --git a/Mounty/Services/SpeedTestService.swift b/Mounty/Services/SpeedTestService.swift index 5a936af..cf91e20 100644 --- a/Mounty/Services/SpeedTestService.swift +++ b/Mounty/Services/SpeedTestService.swift @@ -24,13 +24,6 @@ struct SpeedTestService { return try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { do { - // defer runs in all exit paths (success, throw, early return) - // so the test file is always removed on the server. - // The only exception is a hard process crash (SIGKILL); in that - // case a single hidden file (.mounty_speed_) is left but - // is harmless — it will not overwrite or shadow any user data. - defer { removeWithRetry(at: testURL) } - let data = Data(count: byteCount) // --- Write --- @@ -43,9 +36,7 @@ struct SpeedTestService { let writeDescriptor = Darwin.open(path, O_RDONLY) guard writeDescriptor >= 0 else { throw posixError() } defer { Darwin.close(writeDescriptor) } - guard Darwin.fcntl(writeDescriptor, F_FULLFSYNC) == 0 else { - throw posixError() - } + try synchronize(writeDescriptor) let writeDuration = max(Date().timeIntervalSince(writeStart), 0.001) // --- Read --- @@ -55,9 +46,7 @@ struct SpeedTestService { let readDescriptor = Darwin.open(path, O_RDONLY) guard readDescriptor >= 0 else { throw posixError() } defer { Darwin.close(readDescriptor) } - guard Darwin.fcntl(readDescriptor, F_NOCACHE, 1) == 0 else { - throw posixError() - } + try disableCaching(readDescriptor) let readStart = Date() var buffer = [UInt8](repeating: 0, count: byteCount) @@ -92,6 +81,11 @@ struct SpeedTestService { } 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) } } } @@ -109,8 +103,31 @@ struct SpeedTestService { } } - private nonisolated static func posixError() -> NSError { - NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + 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 { diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index c4892bd..0c8316e 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -18,7 +18,7 @@ final class VolumeManager { var showSearch = false // Preferences - var launchAtLogin: Bool = MountService.isLoginItemEnabled() + var launchAtLogin = false var preferredTerminal: String var availableTerminals: [(name: String, id: String)] = [] @@ -62,6 +62,7 @@ final class VolumeManager { startLogObservation() startEventObservation() refreshInstalledTerminals() + refreshLoginItemStatus() Task { await refreshState() @@ -154,25 +155,40 @@ final class VolumeManager { isRunningSpeedTest = true speedTestResult = nil speedTestError = nil - log("Speed test started for \(volume.name)") + let volumeID = volume.id + let volumeName = volume.name - Task { + 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) - self.speedTestResult = result - self.log( - "Speed test (\(volume.name)): " + AppLogger.log( + "Speed test (\(volumeName)): " + "write \(String(format: "%.1f", result.writeSpeed)) MB/s, " - + "read \(String(format: "%.1f", result.readSpeed)) 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 + } } catch { - self.speedTestError = error.localizedDescription - self.log( - "Speed test failed for \(volume.name): \(error.localizedDescription)", - level: .error + let message = error.localizedDescription + AppLogger.log( + "Speed test failed for \(volumeName): \(message)", + level: .error, + source: .manager ) + await MainActor.run { + guard self?.speedTestVolumeId == volumeID else { return } + self?.speedTestError = message + self?.isRunningSpeedTest = false + } } - self.isRunningSpeedTest = false } } @@ -382,6 +398,13 @@ final class VolumeManager { } } + 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 let url = URL(string: volume.serverAddress) else { return } busyVolumes.insert(volume.id) diff --git a/Mounty/Views/HeaderView.swift b/Mounty/Views/HeaderView.swift index cd78140..f7da019 100644 --- a/Mounty/Views/HeaderView.swift +++ b/Mounty/Views/HeaderView.swift @@ -12,6 +12,7 @@ struct HeaderView: View { 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 } @@ -55,6 +56,7 @@ struct HeaderView: View { .foregroundColor(icon2.1) } .iconButtonHover() + .keyboardShortcut(trailingShortcut2) .help(trailingHelp2) } if let action = trailingAction, let icon = trailingIcon { diff --git a/Mounty/Views/LogsView.swift b/Mounty/Views/LogsView.swift index 9c5c39a..aee3c1d 100644 --- a/Mounty/Views/LogsView.swift +++ b/Mounty/Views/LogsView.swift @@ -11,7 +11,7 @@ struct LogsView: View { var body: some View { VStack(spacing: 0) { HeaderView( - title: "App Logs", + title: "Logs", backAction: { viewMode = .list } ) @@ -106,8 +106,7 @@ struct LogsView: View { .disabled(visibleEntries.isEmpty) .help("Copy visible log entries to clipboard") } - .padding(.horizontal, 12) - .padding(.vertical, 8) + .appFooterLayout() } .fixedSize(horizontal: false, vertical: true) } diff --git a/Mounty/Views/MainListView.swift b/Mounty/Views/MainListView.swift index f8c5970..eaf8112 100644 --- a/Mounty/Views/MainListView.swift +++ b/Mounty/Views/MainListView.swift @@ -5,6 +5,7 @@ struct MainListView: View { @Binding var viewMode: AppViewMode private let rowHeight: CGFloat = 50 + private let searchHeight: CGFloat = 44 private let minVisibleRows = 3 private let maxVisibleRowsCap = 12 @AppStorage("mounty.maxVisibleRows") private var maxVisibleRows: Int = 5 @@ -32,103 +33,109 @@ struct MainListView: View { trailingAction: { viewMode = .settings }, trailingIcon: ("gearshape.fill", .secondary), trailingHelp: "Settings", - trailingAction2: { viewMode = .logs }, - trailingIcon2: ("doc.text", .secondary), - trailingHelp2: "App Logs" + trailingAction2: { manager.showSearch.toggle() }, + trailingIcon2: ( + "magnifyingglass", isSearchVisible ? .accentColor : .secondary + ), + trailingHelp2: "Search Volumes (⌘F)", + trailingShortcut2: KeyboardShortcut("f", modifiers: .command) ) .transaction { $0.animation = nil } - // Search bar — declarative animation driven by isSearchVisible. - // No withAnimation in the toggle action; the .animation modifier on - // the VStack handles it, making the transition reliable. - if isSearchVisible { - HStack(alignment: .center) { - TextField("Search...", text: $manager.searchText) - .textFieldStyle(.roundedBorder) - .frame(height: 28) - - Menu { - Picker("Sort By", selection: $manager.sortOrder) { - ForEach( - VolumeManager.SortOrder.allCases, - id: \.self - ) { - Text($0.rawValue).tag($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) { + ForEach( + VolumeManager.SortOrder.allCases, + id: \.self + ) { + Text($0.rawValue).tag($0) + } } + } label: { + Image(systemName: "arrow.up.arrow.down.circle") } - } label: { - Image(systemName: "arrow.up.arrow.down.circle") - } - .pickerStyle(.inline) - .menuStyle(.borderlessButton) - .frame(width: 28, height: 28) - .help("Sort By") - - Button { - manager.sortDirection = - (manager.sortDirection == .ascending) - ? .descending : .ascending - } label: { - Image( - systemName: manager.sortDirection == .ascending - ? "arrow.down" : "arrow.up" - ) + .pickerStyle(.inline) + .menuStyle(.borderlessButton) + .frame(width: 28, height: 28) + .help("Sort By") + + Button { + manager.sortDirection = + (manager.sortDirection == .ascending) + ? .descending : .ascending + } label: { + Image( + systemName: manager.sortDirection == .ascending + ? "arrow.down" : "arrow.up" + ) + } + .buttonStyle(.borderless) + .frame(width: 28, height: 28) + .help("Toggle Sort Direction") } - .buttonStyle(.borderless) - .frame(width: 28, height: 28) - .help("Toggle Sort Direction") + .padding(.horizontal, 12) + .frame(height: searchHeight) + .frame(maxWidth: .infinity) + .background(Color(NSColor.windowBackgroundColor)) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(1) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(Color(NSColor.windowBackgroundColor)) - .transition(.move(edge: .top).combined(with: .opacity)) - .zIndex(1) - } - Divider() + Divider() - // 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) - } else { - ScrollView { - VStack(spacing: 0) { - ForEach(manager.filteredAndSortedVolumes) { volume in - VolumeRow( - volume: volume, manager: manager, - onEdit: { - viewMode = .edit(volume) - } - ) - .frame(height: rowHeight) - Divider() + // 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, + onEdit: { + viewMode = .edit(volume) + } + ) + .frame(height: rowHeight) + Divider() + } } } + .frame(height: listHeight - (isSearchVisible ? searchHeight : 0)) + .scrollDisabled( + manager.filteredAndSortedVolumes.count <= maxVisibleRows + ) } - .frame(height: listHeight) - .scrollDisabled( - 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. @@ -170,19 +177,14 @@ struct MainListView: View { // the same distance regardless of what the list content does. HStack { Button { - manager.showSearch.toggle() + viewMode = .logs } label: { - Image(systemName: "magnifyingglass") + Image(systemName: "doc.text") .font(.system(size: 13)) - .foregroundColor( - isSearchVisible ? .accentColor : .secondary - ) + .foregroundColor(.secondary) } .iconButtonHover() - // Keyboard shortcut lives here — removes the need for the - // hidden zero-size Button that was causing erratic toggles. - .keyboardShortcut("f", modifiers: .command) - .help("Search Volumes (⌘F)") + .help("Logs") Spacer() @@ -195,8 +197,7 @@ struct MainListView: View { .controlSize(.small) .help("Add Volume") } - .padding(12) - .frame(maxWidth: .infinity) + .appFooterLayout() .background(Color(NSColor.windowBackgroundColor)) } // Declarative animation: fires reliably on every isSearchVisible change diff --git a/Mounty/Views/Overlays.swift b/Mounty/Views/Overlays.swift index 43b1ef8..1084b9b 100644 --- a/Mounty/Views/Overlays.swift +++ b/Mounty/Views/Overlays.swift @@ -46,6 +46,12 @@ extension View { 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 diff --git a/MountyTests/SpeedTestServiceTests.swift b/MountyTests/SpeedTestServiceTests.swift index 4119d05..8e41986 100644 --- a/MountyTests/SpeedTestServiceTests.swift +++ b/MountyTests/SpeedTestServiceTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import Mounty @@ -11,4 +12,16 @@ struct SpeedTestServiceTests { 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)) + } } From 755e5b7ec776ccda2e7d5bff39f4edcabd9d440d Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 09:35:21 +0200 Subject: [PATCH 28/38] docs: streamline story workflow Generated-by: gpt-5 --- .agents/skills/implement-story/SKILL.md | 14 +++++ .agents/skills/new-story/SKILL.md | 14 +++++ .agents/skills/review-codebase/SKILL.md | 13 +++++ .github/PULL_REQUEST_TEMPLATE.md | 2 +- AGENTS.md | 17 +++--- CONTRIBUTING.md | 11 ++-- README.md | 4 +- docs/stories/001-open-source-foundation.md | 21 ++++++++ docs/stories/002-reachability-privacy.md | 20 +++++++ docs/stories/003-native-macos-ui.md | 20 +++++++ docs/stories/004-responsive-menu-bar.md | 21 ++++++++ .../005-diagnostics-and-speed-tests.md | 21 ++++++++ docs/stories/006-concurrency-correctness.md | 20 +++++++ .../007-modern-swift-and-mount-state.md | 21 ++++++++ .../008-mount-reliability-and-logging.md | 22 ++++++++ docs/stories/INDEX.md | 14 +++++ docs/stories/README.md | 11 ++++ docs/stories/TEMPLATE.md | 19 +++++++ specs/001-mount-reliability/plan.md | 53 ------------------- specs/001-mount-reliability/spec.md | 49 ----------------- specs/001-mount-reliability/tasks.md | 15 ------ specs/README.md | 38 ------------- specs/templates/plan-template.md | 31 ----------- specs/templates/spec-template.md | 31 ----------- specs/templates/tasks-template.md | 17 ------ 25 files changed, 266 insertions(+), 253 deletions(-) create mode 100644 .agents/skills/implement-story/SKILL.md create mode 100644 .agents/skills/new-story/SKILL.md create mode 100644 .agents/skills/review-codebase/SKILL.md create mode 100644 docs/stories/001-open-source-foundation.md create mode 100644 docs/stories/002-reachability-privacy.md create mode 100644 docs/stories/003-native-macos-ui.md create mode 100644 docs/stories/004-responsive-menu-bar.md create mode 100644 docs/stories/005-diagnostics-and-speed-tests.md create mode 100644 docs/stories/006-concurrency-correctness.md create mode 100644 docs/stories/007-modern-swift-and-mount-state.md create mode 100644 docs/stories/008-mount-reliability-and-logging.md create mode 100644 docs/stories/INDEX.md create mode 100644 docs/stories/README.md create mode 100644 docs/stories/TEMPLATE.md delete mode 100644 specs/001-mount-reliability/plan.md delete mode 100644 specs/001-mount-reliability/spec.md delete mode 100644 specs/001-mount-reliability/tasks.md delete mode 100644 specs/README.md delete mode 100644 specs/templates/plan-template.md delete mode 100644 specs/templates/spec-template.md delete mode 100644 specs/templates/tasks-template.md 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/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index eebb1a1..4f9a786 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,7 +5,7 @@ It becomes the squash-merge commit and drives the automated release. ## What & why - + ## Type of change diff --git a/AGENTS.md b/AGENTS.md index 00573bd..fc360d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,15 +101,14 @@ 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. -## Spec-Driven Development (SDD) - -For non-trivial work, write the spec before the code. Templates live in `specs/templates/`: - -1. **Specify** *what & why* → `specs/templates/spec-template.md` -2. **Plan** *how* → `specs/templates/plan-template.md` -3. **Tasks** breakdown → `specs/templates/tasks-template.md` - -Copy the templates into `specs//` for the feature you are working on. +## 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b816987..7aaad78 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,14 +64,11 @@ 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. -## Spec-Driven Development (SDD) +## Stories -Non-trivial changes start with a short spec, not code. The workflow is provider-neutral and lives -in [`specs/`](./specs/): - -1. **Specify** — write *what* and *why* using [`specs/templates/spec-template.md`](./specs/templates/spec-template.md). -2. **Plan** — write *how* using [`specs/templates/plan-template.md`](./specs/templates/plan-template.md). -3. **Tasks** — break the plan into steps using [`specs/templates/tasks-template.md`](./specs/templates/tasks-template.md). +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). diff --git a/README.md b/README.md index e1ff0e9..f16877b 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ Contributions are welcome! Please read [CONTRIBUTING.md](./CONTRIBUTING.md) firs - 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 a lightweight, provider-neutral **Spec-Driven Development** workflow — see - [`AGENTS.md`](./AGENTS.md) and [`specs/`](./specs/). +- The project uses lightweight, provider-neutral **story records** — see [`AGENTS.md`](./AGENTS.md) + and [`docs/stories/`](./docs/stories/). ## License 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/INDEX.md b/docs/stories/INDEX.md new file mode 100644 index 0000000..c0cf012 --- /dev/null +++ b/docs/stories/INDEX.md @@ -0,0 +1,14 @@ +# Story Index + +Newest stories first. Statuses: `OPEN`, `IN_PROGRESS`, `CLOSED`. + +| ID | Type | Story | Status | Date | +| --- | --- | --- | --- | --- | +| [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/specs/001-mount-reliability/plan.md b/specs/001-mount-reliability/plan.md deleted file mode 100644 index 0bdf44c..0000000 --- a/specs/001-mount-reliability/plan.md +++ /dev/null @@ -1,53 +0,0 @@ -# Plan: Mount reliability and unified logging - -- **Spec:** ./spec.md -- **Status:** implemented - -## Approach - -Introduce a thread-safe application log channel that writes every Mounty-owned event to `os.Logger` -and an `AsyncStream` consumed by `VolumeManager`. Replace the timing-out asynchronous NetFS bridge -with the previously working synchronous API, isolated in a detached task and serialized by the -`MountService` actor gate. Do not expose a false timeout for a C operation that cannot be cancelled. - -Update the heartbeat to refresh and then automount. Share one in-flight filesystem liveness probe -per path so repeated checks cannot consume an unbounded number of threads. - -## Architecture & data flow - -`AppLogger` in Services emits categorized `LogEntry` values and Unified Logging records. -`VolumeManager` consumes the stream on `MainActor` and owns the capped display buffer. Services log -directly through `AppLogger`. `MountService` performs NetFS work in `Task.detached`; UI state remains -owned by `VolumeManager`. - -## Files to change - -| File | Change | -| ---- | ------ | -| `Mounty/Models/LogEntry.swift` | Add source category and nonisolated construction. | -| `Mounty/Services/AppLogger.swift` | Add unified Console/in-app logging channel. | -| `Mounty/Services/MountService.swift` | Restore reliable off-main synchronous NetFS mounting. | -| `Mounty/Services/EventMonitorService.swift` | Route service diagnostics through `AppLogger`. | -| `Mounty/Services/ReachabilityService.swift` | Bound blocking liveness probes and improve diagnostics. | -| `Mounty/ViewModels/VolumeManager.swift` | Consume logs and retry automount from heartbeat. | -| `Mounty/Views/LogsView.swift` | Display log categories. | -| `MountyTests/AppLoggerTests.swift` | Test categorized in-app stream delivery. | - -## Reused existing code - -Reuse `LogEntry.Level`, `ResumeGate`, `SystemMountService.findMountPath`, the existing automount -candidate workflow, and `PersistenceService` log-level storage. - -## Trade-offs / risks - -- Synchronous NetFS cannot be cancelled once entered. It remains off `MainActor`, and sequential - invocation prevents request storms. TCP preflight avoids entering it for unreachable servers. -- Interactive mounts are intentionally serialized to avoid competing NetAuth dialogs. -- A permanently hung liveness syscall can occupy one dedicated worker, but repeated heartbeats do - not create additional blocked workers. - -## Verification - -Run focused Swift Testing tests, strict `swift-format` lint, and the complete macOS test suite. -Manually verify on a configured SMB share that MountService Debug records appear in the app and the -mount completes without UI stalls. \ No newline at end of file diff --git a/specs/001-mount-reliability/spec.md b/specs/001-mount-reliability/spec.md deleted file mode 100644 index 9a1d6d5..0000000 --- a/specs/001-mount-reliability/spec.md +++ /dev/null @@ -1,49 +0,0 @@ -# Spec: Mount reliability and unified logging - -- **Status:** implemented -- **Author:** GitHub Copilot -- **Date:** 2026-08-10 - -## Problem / motivation - -Automount and manual mounts stall until the 90-second deadline when using -`NetFSMountURLAsync`, although the earlier synchronous NetFS implementation worked for the same -shares. Mount-service diagnostics are visible in Console but absent from the in-app log because -services and the view model use separate logging paths. Heartbeat detection also removes dead -mounts from state without initiating automount recovery. - -## Goals - -- Reliably mount reachable SMB shares without blocking `MainActor`. -- Show all Mounty-owned service and view-model logs in both Console and the in-app log. -- Retry automount after heartbeat detection finds a lost mount. -- Prevent repeated liveness checks from creating unbounded blocked worker threads. -- Preserve actionable levels, categories, durations, error codes, and credential-safe targets. - -## Non-goals - -- Mirroring macOS framework logs, such as BaseBoard diagnostics, into the app. -- Replacing NetFS or storing SMB credentials. -- Parallel interactive authentication prompts. - -## User-visible behavior - -The Debug filter shows manager, event-monitor, reachability, and mount-service diagnostics. Mounts -are attempted one at a time off the UI actor. A dead automounted share is retried by the heartbeat -when its server remains reachable. Manual mount failures remain visible at Error level with their -underlying NetFS detail. - -## Acceptance criteria - -- [x] `MountService` diagnostics appear in the in-app log and Console with a category. -- [x] No Mounty-owned blocking filesystem, NetFS, or Launch Services call runs on `MainActor`. -- [x] Reachable automount candidates use the proven synchronous NetFS operation sequentially. -- [x] Heartbeat detection invokes automount after refreshing mount state. -- [x] A hung liveness probe cannot create an unbounded number of blocked workers. -- [x] Log-level selection remains persisted. -- [x] The project builds and tests without Swift warnings. - -## Open questions - -- Whether a future macOS release makes parallel `NetFSMountURLAsync` reliable enough to re-enable - bounded concurrent authentication after real-network testing. \ No newline at end of file diff --git a/specs/001-mount-reliability/tasks.md b/specs/001-mount-reliability/tasks.md deleted file mode 100644 index 983e819..0000000 --- a/specs/001-mount-reliability/tasks.md +++ /dev/null @@ -1,15 +0,0 @@ -# Tasks: Mount reliability and unified logging - -- **Plan:** ./plan.md - -- [x] **T1** — Add unified categorized application logging _(commit: `feat(logging): unify service logs`)_ -- [x] **T2** — Restore reliable off-main NetFS mounting _(commit: `fix(mount): restore reliable NetFS mounting`)_ -- [x] **T3** — Repair heartbeat retry and bound liveness work _(commit: `fix(automount): retry dead shares safely`)_ -- [x] **T4** — Add focused logging tests _(commit: `test(logging): cover categorized formatting`)_ - -## Definition of done - -- [x] All acceptance criteria in `spec.md` met -- [x] Business logic covered by Swift Testing tests -- [x] `swift-format lint --strict` clean -- [ ] CI green _(pending the remote workflow run for these changes)_ diff --git a/specs/README.md b/specs/README.md deleted file mode 100644 index 08caa47..0000000 --- a/specs/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# Specs — Spec-Driven Development - -Non-trivial changes to Mounty start with a spec, not code. This keeps intent explicit and lets any -AI agent (Claude, GPT/Copilot, Cursor, …) or human pick up the work with full context. - -## Workflow - -``` -specify → plan → tasks → implement - (what) (how) (steps) (code + tests) -``` - -1. **Specify** — capture *what* and *why* (the problem, goals, user-visible behavior, acceptance - criteria). Template: [`templates/spec-template.md`](./templates/spec-template.md). -2. **Plan** — capture *how* (architecture, files to touch, data flow, trade-offs). Template: - [`templates/plan-template.md`](./templates/plan-template.md). -3. **Tasks** — break the plan into small, verifiable steps. Template: - [`templates/tasks-template.md`](./templates/tasks-template.md). - -## How to use - -Create a numbered folder per feature and copy the templates into it: - -``` -specs/ -├─ README.md -├─ templates/ -│ ├─ spec-template.md -│ ├─ plan-template.md -│ └─ tasks-template.md -└─ 001-example-feature/ - ├─ spec.md - ├─ plan.md - └─ tasks.md -``` - -This structure is intentionally tool-agnostic — it is plain Markdown, works with any assistant, and -requires no extra tooling. See [`../AGENTS.md`](../AGENTS.md) for the full agent guide. diff --git a/specs/templates/plan-template.md b/specs/templates/plan-template.md deleted file mode 100644 index 738c068..0000000 --- a/specs/templates/plan-template.md +++ /dev/null @@ -1,31 +0,0 @@ -# Plan: - -- **Spec:** ./spec.md -- **Status:** draft | approved - -## Approach - - - -## Architecture & data flow - - - -## Files to change - -| File | Change | -| ---- | ------ | -| `Mounty/Mounty/Services/...` | | -| `MountyTests/...` | | - -## Reused existing code - - - -## Trade-offs / risks - -- - -## Verification - - diff --git a/specs/templates/spec-template.md b/specs/templates/spec-template.md deleted file mode 100644 index bc9108c..0000000 --- a/specs/templates/spec-template.md +++ /dev/null @@ -1,31 +0,0 @@ -# Spec: - -- **Status:** draft | approved | implemented -- **Author:** -- **Date:** - -## Problem / motivation - - - -## Goals - -- -- - -## Non-goals - -- - -## User-visible behavior - - - -## Acceptance criteria - -- [ ] -- [ ] - -## Open questions - -- diff --git a/specs/templates/tasks-template.md b/specs/templates/tasks-template.md deleted file mode 100644 index 2b52bfd..0000000 --- a/specs/templates/tasks-template.md +++ /dev/null @@ -1,17 +0,0 @@ -# Tasks: - -- **Plan:** ./plan.md - -Break the plan into small, independently verifiable steps. Each task should map to a focused commit -with a Conventional-Commit message. - -- [ ] **T1** — _(commit: `feat: ...`)_ -- [ ] **T2** — _(commit: `test: ...`)_ -- [ ] **T3** — _(commit: `docs: ...`)_ - -## Definition of done - -- [ ] All acceptance criteria in `spec.md` met -- [ ] Business logic covered by Swift Testing tests -- [ ] `swift-format lint --strict` clean -- [ ] CI green From 583cc8da57ff2fbb360d718f4f81eba0d4cdf49d Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 09:42:43 +0200 Subject: [PATCH 29/38] fix: handle mount lifecycle cleanup Generated-by: gpt-5 --- Mounty/ViewModels/VolumeManager.swift | 108 +++++++++++++++++--- docs/stories/009-mount-lifecycle-cleanup.md | 23 +++++ docs/stories/INDEX.md | 1 + 3 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 docs/stories/009-mount-lifecycle-cleanup.md diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index 0c8316e..ec01b3d 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -454,12 +454,16 @@ final class VolumeManager { 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() @@ -488,12 +492,26 @@ final class VolumeManager { } func removeVolume(_ id: UUID) { - if let v = volumes.first(where: { $0.id == id }) { - log("Removed volume: \(v.name)") + guard let volume = volumes.first(where: { $0.id == id }) else { return } + guard let path = mountPaths[id] else { + removeVolumeConfiguration(id: id, name: volume.name) + Task { await refreshState() } + return + } + + busyVolumes.insert(id) + log("Removing volume: \(volume.name)") + Task { + 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() } - volumes.removeAll { $0.id == id } - storage.saveVolumes(volumes) - Task { await refreshState() } } func editVolume(id: UUID, name: String, serverAddress: String) { @@ -501,20 +519,40 @@ final class VolumeManager { let old = volumes[idx] let addressChanged = old.serverAddress != serverAddress - volumes[idx].name = name - volumes[idx].serverAddress = serverAddress - storage.saveVolumes(volumes) - log("Updated volume: \(name)") + if !addressChanged { + volumes[idx].name = name + storage.saveVolumes(volumes) + log("Updated volume: \(name)") + return + } - guard addressChanged, let oldPath = mountPaths[id] else { return } + guard let oldPath = mountPaths[id] else { + volumes[idx].name = name + volumes[idx].serverAddress = serverAddress + storage.saveVolumes(volumes) + log("Updated volume: \(name)") + return + } // Unmount the old connection by its recorded path, then remount at the new address. // Using oldPath (not the new address) ensures we disconnect the right kernel mount // even if the new address points to a different share entirely. - mountPaths.removeValue(forKey: id) busyVolumes.insert(id) Task { - await MountService.unmount(path: oldPath) + 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 @@ -530,14 +568,50 @@ final class VolumeManager { self.showError = true } self.busyVolumes.remove(id) + await self.refreshState() } } func clearAllVolumes() { - log("Cleared all volumes") - volumes.removeAll() + let configuredVolumes = volumes + let mountedVolumes = configuredVolumes.compactMap { volume in + mountPaths[volume.id].map { (volume, $0) } + } + for (volume, _) in mountedVolumes { busyVolumes.insert(volume.id) } + + Task { + 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 { !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 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) { 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/INDEX.md b/docs/stories/INDEX.md index c0cf012..a227a63 100644 --- a/docs/stories/INDEX.md +++ b/docs/stories/INDEX.md @@ -4,6 +4,7 @@ Newest stories first. Statuses: `OPEN`, `IN_PROGRESS`, `CLOSED`. | ID | Type | Story | Status | Date | | --- | --- | --- | --- | --- | +| [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 | From 59f38fcad9a570178a6b07c03eab670dcd2992ab Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 09:59:47 +0200 Subject: [PATCH 30/38] fix: harden volume lifecycle and imports Generated-by: github-copilot --- .github/workflows/ci.yml | 2 + .../Services/VolumeConfigurationService.swift | 28 ++++++++ Mounty/ViewModels/VolumeManager.swift | 49 ++++++++++---- Mounty/Views/MainListView.swift | 2 +- Mounty/Views/SettingsView.swift | 1 + Mounty/Views/VolumeRow.swift | 5 +- .../VolumeConfigurationServiceTests.swift | 65 +++++++++++++++++++ docs/stories/010-volume-operation-safety.md | 21 ++++++ docs/stories/011-import-volume-identity.md | 20 ++++++ docs/stories/012-ci-warning-enforcement.md | 19 ++++++ docs/stories/INDEX.md | 3 + 11 files changed, 200 insertions(+), 15 deletions(-) create mode 100644 Mounty/Services/VolumeConfigurationService.swift create mode 100644 MountyTests/VolumeConfigurationServiceTests.swift create mode 100644 docs/stories/010-volume-operation-safety.md create mode 100644 docs/stories/011-import-volume-identity.md create mode 100644 docs/stories/012-ci-warning-enforcement.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bc4562..60aacd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,4 +54,6 @@ jobs: -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/Mounty/Services/VolumeConfigurationService.swift b/Mounty/Services/VolumeConfigurationService.swift new file mode 100644 index 0000000..3634c76 --- /dev/null +++ b/Mounty/Services/VolumeConfigurationService.swift @@ -0,0 +1,28 @@ +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 knownAddresses = Set(existingVolumes.map(\.serverAddress)) + var importedCount = 0 + + for volume in importedVolumes { + guard !knownIDs.contains(volume.id), !knownAddresses.contains(volume.serverAddress) + else { continue } + + knownIDs.insert(volume.id) + knownAddresses.insert(volume.serverAddress) + mergedVolumes.append(volume) + importedCount += 1 + } + + return MergeResult(volumes: mergedVolumes, importedCount: importedCount) + } +} diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index ec01b3d..2562e83 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -10,6 +10,7 @@ final class VolumeManager { var volumes: [Volume] = [] var mountPaths: [UUID: String] = [:] var busyVolumes: Set = [] + private(set) var isClearingVolumes = false // UI Controls var searchText = "" @@ -109,6 +110,10 @@ final class VolumeManager { 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" @@ -150,6 +155,9 @@ final class VolumeManager { // 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 @@ -256,7 +264,7 @@ final class VolumeManager { // MARK: - Logic private func runAutomount() async { - guard isNetworkUp else { return } + guard isNetworkUp, !isClearingVolumes else { return } let candidates = volumes.filter { $0.isAutomountEnabled && mountPaths[$0.id] == nil && !busyVolumes.contains($0.id) @@ -406,6 +414,7 @@ final class VolumeManager { } 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)…") @@ -451,6 +460,7 @@ final class VolumeManager { } func unmount(_ volume: Volume) { + guard !isClearingVolumes, !busyVolumes.contains(volume.id) else { return } disableAutomount(for: volume) guard let path = mountPaths[volume.id] else { return } @@ -485,6 +495,7 @@ final class VolumeManager { // MARK: - Persistence func addVolume(_ volume: Volume) { + guard !isClearingVolumes else { return } volumes.append(volume) storage.saveVolumes(volumes) log("Added volume: \(volume.name)") @@ -492,6 +503,8 @@ final class VolumeManager { } func removeVolume(_ id: UUID) { + guard !isClearingVolumes, !busyVolumes.contains(id) else { return } + guard speedTestVolumeId != id || !isRunningSpeedTest else { return } guard let volume = volumes.first(where: { $0.id == id }) else { return } guard let path = mountPaths[id] else { removeVolumeConfiguration(id: id, name: volume.name) @@ -515,6 +528,8 @@ final class VolumeManager { } func editVolume(id: UUID, name: String, serverAddress: String) { + guard !isClearingVolumes, !busyVolumes.contains(id) else { return } + guard speedTestVolumeId != id || !isRunningSpeedTest else { return } guard let idx = volumes.firstIndex(where: { $0.id == id }) else { return } let old = volumes[idx] let addressChanged = old.serverAddress != serverAddress @@ -573,13 +588,22 @@ final class VolumeManager { } func clearAllVolumes() { + 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)) let mountedVolumes = configuredVolumes.compactMap { volume in mountPaths[volume.id].map { (volume, $0) } } for (volume, _) in mountedVolumes { busyVolumes.insert(volume.id) } Task { + defer { self.isClearingVolumes = false } var retainedIDs = Set() for (volume, path) in mountedVolumes { guard await MountService.unmount(path: path) else { @@ -590,7 +614,9 @@ final class VolumeManager { self.mountPaths.removeValue(forKey: volume.id) self.busyVolumes.remove(volume.id) } - self.volumes.removeAll { !retainedIDs.contains($0.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") @@ -615,6 +641,7 @@ final class VolumeManager { } 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) @@ -659,19 +686,15 @@ final class VolumeManager { try Data(contentsOf: url) }.value let importedVolumes = try JSONDecoder().decode([Volume].self, from: data) - var count = 0 - for volume in importedVolumes { - if !self.volumes.contains(where: { - $0.serverAddress == volume.serverAddress - }) { - self.volumes.append(volume) - count += 1 - } - } + let result = VolumeConfigurationService.merging( + importedVolumes, + into: self.volumes + ) + self.volumes = result.volumes self.storage.saveVolumes(self.volumes) await self.refreshState() - self.log("Imported \(count) volume(s) from backup") - self.successMessage = "Imported \(count) volumes successfully." + 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) diff --git a/Mounty/Views/MainListView.swift b/Mounty/Views/MainListView.swift index eaf8112..6ca1e3e 100644 --- a/Mounty/Views/MainListView.swift +++ b/Mounty/Views/MainListView.swift @@ -205,7 +205,7 @@ struct MainListView: View { // .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) if manager.showError { AlertOverlay( diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index d40b857..ff4c746 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -81,6 +81,7 @@ struct SettingsView: View { .foregroundColor(.red) } .iconButtonHover(cornerRadius: 6, padding: 6) + .disabled(manager.hasActiveVolumeOperations) .help("Clear all volumes") } .listRowBackground(Color.clear) diff --git a/Mounty/Views/VolumeRow.swift b/Mounty/Views/VolumeRow.swift index bf15684..6f4a90a 100644 --- a/Mounty/Views/VolumeRow.swift +++ b/Mounty/Views/VolumeRow.swift @@ -63,6 +63,7 @@ struct VolumeRow: View { volume.isAutomountEnabled ? "Disable Automount" : "Enable Automount" ) + .disabled(isBusy || isTesting || manager.isClearingVolumes) // 2. Open in Finder (Only when mounted) if isMounted { @@ -133,6 +134,7 @@ struct VolumeRow: View { } label: { Label("Edit Volume…", systemImage: "pencil") } + .disabled(isBusy || isTesting || manager.isClearingVolumes) if isMounted { Button { @@ -140,7 +142,7 @@ struct VolumeRow: View { } label: { Label("Measure Speed…", systemImage: "speedometer") } - .disabled(manager.isRunningSpeedTest) + .disabled(isBusy || manager.isRunningSpeedTest || manager.isClearingVolumes) } Divider() @@ -150,6 +152,7 @@ struct VolumeRow: View { } label: { Label("Remove Volume", systemImage: "trash") } + .disabled(isBusy || isTesting || manager.isClearingVolumes) } } } diff --git a/MountyTests/VolumeConfigurationServiceTests.swift b/MountyTests/VolumeConfigurationServiceTests.swift new file mode 100644 index 0000000..91aa9f2 --- /dev/null +++ b/MountyTests/VolumeConfigurationServiceTests.swift @@ -0,0 +1,65 @@ +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]) + } +} 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/INDEX.md b/docs/stories/INDEX.md index a227a63..64728e7 100644 --- a/docs/stories/INDEX.md +++ b/docs/stories/INDEX.md @@ -4,6 +4,9 @@ Newest stories first. Statuses: `OPEN`, `IN_PROGRESS`, `CLOSED`. | ID | Type | Story | Status | Date | | --- | --- | --- | --- | --- | +| [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 | From f0df511c1767274f582707a54dfb34aaf09a65c7 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 10:28:24 +0200 Subject: [PATCH 31/38] fix: preserve SMB volume identity and lifecycle Generated-by: gpt-5 --- .../Services/VolumeConfigurationService.swift | 33 +++++- Mounty/ViewModels/VolumeManager.swift | 104 +++++++++++++----- Mounty/Views/AddVolumeView.swift | 10 +- .../VolumeConfigurationServiceTests.swift | 37 +++++++ docs/stories/013-offline-mount-lifecycle.md | 20 ++++ .../014-volume-identity-enforcement.md | 20 ++++ docs/stories/INDEX.md | 2 + 7 files changed, 194 insertions(+), 32 deletions(-) create mode 100644 docs/stories/013-offline-mount-lifecycle.md create mode 100644 docs/stories/014-volume-identity-enforcement.md diff --git a/Mounty/Services/VolumeConfigurationService.swift b/Mounty/Services/VolumeConfigurationService.swift index 3634c76..0365f30 100644 --- a/Mounty/Services/VolumeConfigurationService.swift +++ b/Mounty/Services/VolumeConfigurationService.swift @@ -1,3 +1,5 @@ +import Foundation + struct VolumeConfigurationService { struct MergeResult: Sendable { let volumes: [Volume] @@ -10,19 +12,44 @@ struct VolumeConfigurationService { ) -> MergeResult { var mergedVolumes = existingVolumes var knownIDs = Set(existingVolumes.map(\.id)) - var knownAddresses = Set(existingVolumes.map(\.serverAddress)) + var knownIdentities = Set(existingVolumes.map { serverIdentity(for: $0.serverAddress) }) var importedCount = 0 for volume in importedVolumes { - guard !knownIDs.contains(volume.id), !knownAddresses.contains(volume.serverAddress) + let identity = serverIdentity(for: volume.serverAddress) + guard !knownIDs.contains(volume.id), !knownIdentities.contains(identity) else { continue } knownIDs.insert(volume.id) - knownAddresses.insert(volume.serverAddress) + 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 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 2562e83..6a7ed7e 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -494,27 +494,39 @@ final class VolumeManager { // MARK: - Persistence - func addVolume(_ volume: Volume) { - guard !isClearingVolumes else { return } + @discardableResult + func addVolume(_ volume: Volume) -> Bool { + guard !isClearingVolumes else { 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) { guard !isClearingVolumes, !busyVolumes.contains(id) else { return } guard speedTestVolumeId != id || !isRunningSpeedTest else { return } guard let volume = volumes.first(where: { $0.id == id }) else { return } - guard let path = mountPaths[id] else { - removeVolumeConfiguration(id: id, name: volume.name) - Task { await refreshState() } - 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() @@ -527,10 +539,21 @@ final class VolumeManager { } } - func editVolume(id: UUID, name: String, serverAddress: String) { - guard !isClearingVolumes, !busyVolumes.contains(id) else { return } - guard speedTestVolumeId != id || !isRunningSpeedTest else { return } - guard let idx = volumes.firstIndex(where: { $0.id == id }) else { return } + @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.hasDuplicateServerIdentity( + for: serverAddress, + in: volumes, + excludingID: id + ) + else { + reportDuplicateVolume() + return false + } let old = volumes[idx] let addressChanged = old.serverAddress != serverAddress @@ -538,22 +561,28 @@ final class VolumeManager { volumes[idx].name = name storage.saveVolumes(volumes) log("Updated volume: \(name)") - return - } - - guard let oldPath = mountPaths[id] else { - volumes[idx].name = name - volumes[idx].serverAddress = serverAddress - storage.saveVolumes(volumes) - log("Updated volume: \(name)") - return + return true } - // Unmount the old connection by its recorded path, then remount at the new address. - // Using oldPath (not the new address) ensures we disconnect the right kernel mount - // even if the new address points to a different share entirely. 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() @@ -585,6 +614,7 @@ final class VolumeManager { self.busyVolumes.remove(id) await self.refreshState() } + return true } func clearAllVolumes() { @@ -597,13 +627,18 @@ final class VolumeManager { isClearingVolumes = true let configuredVolumes = volumes let configuredIDs = Set(configuredVolumes.map(\.id)) - let mountedVolumes = configuredVolumes.compactMap { volume in - mountPaths[volume.id].map { (volume, $0) } - } - for (volume, _) in mountedVolumes { busyVolumes.insert(volume.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 { @@ -633,6 +668,21 @@ final class VolumeManager { 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 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." diff --git a/Mounty/Views/AddVolumeView.swift b/Mounty/Views/AddVolumeView.swift index e009b81..f342e37 100644 --- a/Mounty/Views/AddVolumeView.swift +++ b/Mounty/Views/AddVolumeView.swift @@ -95,7 +95,7 @@ struct AddVolumeView: View { private func save() { guard !name.isEmpty, !address.isEmpty else { return } let fullAddress = Volume.smbServerAddress(from: address) - manager.addVolume(Volume(name: name, serverAddress: fullAddress)) + guard manager.addVolume(Volume(name: name, serverAddress: fullAddress)) else { return } viewMode = .list } } @@ -152,7 +152,13 @@ struct EditVolumeView: View { private func save() { guard !name.isEmpty, !address.isEmpty else { return } let fullAddress = Volume.smbServerAddress(from: address) - manager.editVolume(id: volume.id, name: name, serverAddress: fullAddress) + guard + manager.editVolume( + id: volume.id, + name: name, + serverAddress: fullAddress + ) + else { return } viewMode = .list } } diff --git a/MountyTests/VolumeConfigurationServiceTests.swift b/MountyTests/VolumeConfigurationServiceTests.swift index 91aa9f2..159ccda 100644 --- a/MountyTests/VolumeConfigurationServiceTests.swift +++ b/MountyTests/VolumeConfigurationServiceTests.swift @@ -62,4 +62,41 @@ struct VolumeConfigurationServiceTests { #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 + ) + ) + } } 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/INDEX.md b/docs/stories/INDEX.md index 64728e7..891caf3 100644 --- a/docs/stories/INDEX.md +++ b/docs/stories/INDEX.md @@ -4,6 +4,8 @@ Newest stories first. Statuses: `OPEN`, `IN_PROGRESS`, `CLOSED`. | ID | Type | Story | Status | Date | | --- | --- | --- | --- | --- | +| [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 | From b72326796de39d31aca446227c1fc19d4b1545f6 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 10:51:06 +0200 Subject: [PATCH 32/38] fix: validate SMB endpoints Generated-by: gpt-5 --- .../Services/VolumeConfigurationService.swift | 13 ++++++++++++ Mounty/ViewModels/VolumeManager.swift | 19 +++++++++++++++++- .../VolumeConfigurationServiceTests.swift | 18 +++++++++++++++++ docs/stories/015-smb-endpoint-validation.md | 20 +++++++++++++++++++ docs/stories/INDEX.md | 1 + 5 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 docs/stories/015-smb-endpoint-validation.md diff --git a/Mounty/Services/VolumeConfigurationService.swift b/Mounty/Services/VolumeConfigurationService.swift index 0365f30..176d5fb 100644 --- a/Mounty/Services/VolumeConfigurationService.swift +++ b/Mounty/Services/VolumeConfigurationService.swift @@ -16,6 +16,7 @@ struct VolumeConfigurationService { 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 } @@ -40,6 +41,18 @@ struct VolumeConfigurationService { } } + 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 { diff --git a/Mounty/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index 6a7ed7e..187cc44 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -497,6 +497,10 @@ final class VolumeManager { @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, @@ -544,6 +548,10 @@ final class VolumeManager { 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, @@ -555,10 +563,13 @@ final class VolumeManager { return false } let old = volumes[idx] - let addressChanged = old.serverAddress != serverAddress + 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 @@ -674,6 +685,12 @@ final class VolumeManager { 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( diff --git a/MountyTests/VolumeConfigurationServiceTests.swift b/MountyTests/VolumeConfigurationServiceTests.swift index 159ccda..a4cc644 100644 --- a/MountyTests/VolumeConfigurationServiceTests.swift +++ b/MountyTests/VolumeConfigurationServiceTests.swift @@ -99,4 +99,22 @@ struct VolumeConfigurationServiceTests { ) ) } + + @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/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/INDEX.md b/docs/stories/INDEX.md index 891caf3..1eb2909 100644 --- a/docs/stories/INDEX.md +++ b/docs/stories/INDEX.md @@ -4,6 +4,7 @@ Newest stories first. Statuses: `OPEN`, `IN_PROGRESS`, `CLOSED`. | ID | Type | Story | Status | Date | | --- | --- | --- | --- | --- | +| [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 | From 20de4bb4f2c803a04015533cad765d2d35aef8d6 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 11:18:39 +0200 Subject: [PATCH 33/38] feat: improve menu bar workflows Generated-by: gpt-5 --- Mounty/Services/PersistenceService.swift | 18 ++ Mounty/ViewModels/VolumeManager.swift | 24 ++- Mounty/Views/AddVolumeView.swift | 4 +- Mounty/Views/LogsView.swift | 6 +- Mounty/Views/MainListView.swift | 7 +- Mounty/Views/RootView.swift | 165 +++++++++++------- Mounty/Views/SettingsView.swift | 4 +- Mounty/Views/VolumeRow.swift | 7 + MountyTests/PersistenceServiceTests.swift | 16 ++ ...ar-layout-and-speed-test-responsiveness.md | 26 +++ docs/stories/017-main-list-resize-layout.md | 21 +++ ...tings-scrolling-and-dialog-presentation.md | 21 +++ docs/stories/019-copy-mounted-path.md | 22 +++ docs/stories/INDEX.md | 4 + 14 files changed, 267 insertions(+), 78 deletions(-) create mode 100644 docs/stories/016-menu-bar-layout-and-speed-test-responsiveness.md create mode 100644 docs/stories/017-main-list-resize-layout.md create mode 100644 docs/stories/018-settings-scrolling-and-dialog-presentation.md create mode 100644 docs/stories/019-copy-mounted-path.md diff --git a/Mounty/Services/PersistenceService.swift b/Mounty/Services/PersistenceService.swift index ce5b299..bb1bea5 100644 --- a/Mounty/Services/PersistenceService.swift +++ b/Mounty/Services/PersistenceService.swift @@ -5,6 +5,8 @@ struct PersistenceService { private let keyVolumes = "SavedVolumes" private let keyTerminal = "PreferredTerminal" 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). @@ -42,4 +44,20 @@ struct PersistenceService { 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/ViewModels/VolumeManager.swift b/Mounty/ViewModels/VolumeManager.swift index 187cc44..8ac1cc8 100644 --- a/Mounty/ViewModels/VolumeManager.swift +++ b/Mounty/ViewModels/VolumeManager.swift @@ -14,8 +14,12 @@ final class VolumeManager { // UI Controls var searchText = "" - var sortOrder: SortOrder = .name - var sortDirection: SortDirection = .ascending + var sortOrder: SortOrder = .name { + didSet { storage.saveSortOrder(sortOrder.rawValue) } + } + var sortDirection: SortDirection = .ascending { + didSet { storage.saveSortDirection(sortDirection.rawValue) } + } var showSearch = false // Preferences @@ -38,6 +42,7 @@ final class VolumeManager { var isRunningSpeedTest = false var speedTestResult: SpeedTestService.Result? var speedTestError: String? + private var speedTestTask: Task? private var isNetworkUp = true private let maxLogEntries = 200 @@ -59,6 +64,9 @@ final class VolumeManager { 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 startLogObservation() startEventObservation() @@ -120,8 +128,9 @@ final class VolumeManager { case state = "State" } - enum SortDirection { - case ascending, descending + enum SortDirection: String { + case ascending + case descending } // MARK: - Logging @@ -166,7 +175,7 @@ final class VolumeManager { let volumeID = volume.id let volumeName = volume.name - Task.detached(priority: .userInitiated) { [weak self] in + speedTestTask = Task.detached(priority: .userInitiated) { [weak self] in AppLogger.log( "Speed test started for \(volumeName)", source: .manager @@ -183,6 +192,7 @@ final class VolumeManager { guard self?.speedTestVolumeId == volumeID else { return } self?.speedTestResult = result self?.isRunningSpeedTest = false + self?.speedTestTask = nil } } catch { let message = error.localizedDescription @@ -195,13 +205,17 @@ final class VolumeManager { guard self?.speedTestVolumeId == volumeID else { return } self?.speedTestError = message self?.isRunningSpeedTest = false + self?.speedTestTask = nil } } } } func clearSpeedTest() { + speedTestTask?.cancel() + speedTestTask = nil speedTestVolumeId = nil + isRunningSpeedTest = false speedTestResult = nil speedTestError = nil } diff --git a/Mounty/Views/AddVolumeView.swift b/Mounty/Views/AddVolumeView.swift index f342e37..9624e50 100644 --- a/Mounty/Views/AddVolumeView.swift +++ b/Mounty/Views/AddVolumeView.swift @@ -89,7 +89,7 @@ struct AddVolumeView: View { } .padding(20) } - .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) } private func save() { @@ -146,7 +146,7 @@ struct EditVolumeView: View { } .padding(20) } - .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) } private func save() { diff --git a/Mounty/Views/LogsView.swift b/Mounty/Views/LogsView.swift index aee3c1d..504ba00 100644 --- a/Mounty/Views/LogsView.swift +++ b/Mounty/Views/LogsView.swift @@ -28,7 +28,7 @@ struct LogsView: View { .foregroundColor(.secondary) Spacer() } - .frame(height: 200) + .frame(maxHeight: .infinity) } else { ScrollViewReader { proxy in ScrollView { @@ -40,7 +40,7 @@ struct LogsView: View { } .padding(.vertical, 4) } - .frame(height: 200) + .frame(maxHeight: .infinity) .onChange(of: visibleEntries.count) { _, _ in proxy.scrollTo("logsBottom", anchor: .bottom) } @@ -108,7 +108,7 @@ struct LogsView: View { } .appFooterLayout() } - .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/Mounty/Views/MainListView.swift b/Mounty/Views/MainListView.swift index 6ca1e3e..2b2aa96 100644 --- a/Mounty/Views/MainListView.swift +++ b/Mounty/Views/MainListView.swift @@ -14,10 +14,7 @@ struct MainListView: View { @State private var dragStartRows = 0 private var listHeight: CGFloat { - let count = manager.filteredAndSortedVolumes.count - // Empty state uses the current row cap so the resize handle still works. - if count == 0 { return CGFloat(maxVisibleRows) * rowHeight } - return min(CGFloat(count), CGFloat(maxVisibleRows)) * rowHeight + CGFloat(maxVisibleRows) * rowHeight } private var isSearchVisible: Bool { @@ -216,6 +213,6 @@ struct MainListView: View { ) } } - .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/Mounty/Views/RootView.swift b/Mounty/Views/RootView.swift index 7ab95ff..14fc1c1 100644 --- a/Mounty/Views/RootView.swift +++ b/Mounty/Views/RootView.swift @@ -3,74 +3,117 @@ import SwiftUI struct RootView: View { @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(alignment: .top) { Color(NSColor.windowBackgroundColor).ignoresSafeArea() + activeView + speedTestDialogs + } + .animation(.easeOut(duration: 0.2), value: viewMode) + .frame(width: windowWidth, height: windowHeight) + } - 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 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) + )) + } + } - // Speed test overlays — rendered above any active view. - if let result = manager.speedTestResult { - SpeedTestOverlay( - volumeName: manager.speedTestVolumeName, - result: result, - isPresented: Binding( - get: { manager.speedTestResult != nil }, - set: { if !$0 { manager.clearSpeedTest() } } - ) - ) - } + @ViewBuilder + private var speedTestDialogs: some View { + if let result = manager.speedTestResult { + SpeedTestResultDialog( + volumeName: manager.speedTestVolumeName, + result: result, + onDismiss: manager.clearSpeedTest + ) + } - if let errMsg = manager.speedTestError { - AlertOverlay( - title: "Speed Test Failed", - message: errMsg, - isPresented: Binding( - get: { manager.speedTestError != nil }, - set: { if !$0 { manager.clearSpeedTest() } } - ), - isError: true - ) - } + if let error = manager.speedTestError { + SpeedTestErrorDialog(message: error, onDismiss: manager.clearSpeedTest) + } + } +} + +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(.easeOut(duration: 0.2), value: viewMode) - .frame(width: 420) } } diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index ff4c746..7b1d809 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -114,7 +114,7 @@ struct SettingsView: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) - .scrollDisabled(true) + .scrollIndicators(.automatic) .disabled( showResetConfirmation || showQuitConfirmation || manager.showSuccess || manager.showError @@ -169,7 +169,7 @@ struct SettingsView: View { ) } } - .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - File Panels diff --git a/Mounty/Views/VolumeRow.swift b/Mounty/Views/VolumeRow.swift index 6f4a90a..5ee3257 100644 --- a/Mounty/Views/VolumeRow.swift +++ b/Mounty/Views/VolumeRow.swift @@ -137,6 +137,13 @@ struct VolumeRow: View { .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: { diff --git a/MountyTests/PersistenceServiceTests.swift b/MountyTests/PersistenceServiceTests.swift index b38eaf4..479d5a9 100644 --- a/MountyTests/PersistenceServiceTests.swift +++ b/MountyTests/PersistenceServiceTests.swift @@ -44,4 +44,20 @@ struct PersistenceServiceTests { #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/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/INDEX.md b/docs/stories/INDEX.md index 1eb2909..6add217 100644 --- a/docs/stories/INDEX.md +++ b/docs/stories/INDEX.md @@ -4,6 +4,10 @@ Newest stories first. Statuses: `OPEN`, `IN_PROGRESS`, `CLOSED`. | ID | Type | Story | Status | Date | | --- | --- | --- | --- | --- | +| [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 | From a06292e4b8a0382799c0698fe48f64832a06948a Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 13:46:15 +0200 Subject: [PATCH 34/38] feat: add repository link and asset sources Generated-by: gpt-5 --- Mounty/Views/SettingsView.swift | 10 ++++++++ README.md | 2 +- MenuIcon.svg => assets/source/MenuIcon.svg | 0 logo.png => assets/source/logo.png | Bin logo.svg => assets/source/logo.svg | 0 ...-repository-link-and-asset-organization.md | 22 ++++++++++++++++++ docs/stories/INDEX.md | 1 + 7 files changed, 34 insertions(+), 1 deletion(-) rename MenuIcon.svg => assets/source/MenuIcon.svg (100%) rename logo.png => assets/source/logo.png (100%) rename logo.svg => assets/source/logo.svg (100%) create mode 100644 docs/stories/020-repository-link-and-asset-organization.md diff --git a/Mounty/Views/SettingsView.swift b/Mounty/Views/SettingsView.swift index 7b1d809..a75d304 100644 --- a/Mounty/Views/SettingsView.swift +++ b/Mounty/Views/SettingsView.swift @@ -15,6 +15,7 @@ struct SettingsView: View { ?? "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 { @@ -105,6 +106,15 @@ struct SettingsView: View { 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() } diff --git a/README.md b/README.md index f16877b..79930a5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# Mounty +# Mounty logo Mounty **A tiny macOS menu-bar app that keeps your SMB network shares mounted — automatically.** diff --git a/MenuIcon.svg b/assets/source/MenuIcon.svg similarity index 100% rename from MenuIcon.svg rename to assets/source/MenuIcon.svg diff --git a/logo.png b/assets/source/logo.png similarity index 100% rename from logo.png rename to assets/source/logo.png diff --git a/logo.svg b/assets/source/logo.svg similarity index 100% rename from logo.svg rename to assets/source/logo.svg 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..5a2fa39 --- /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 under a dedicated source-asset directory while Xcode's catalog 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/INDEX.md b/docs/stories/INDEX.md index 6add217..2a31a94 100644 --- a/docs/stories/INDEX.md +++ b/docs/stories/INDEX.md @@ -4,6 +4,7 @@ Newest stories first. Statuses: `OPEN`, `IN_PROGRESS`, `CLOSED`. | ID | Type | Story | Status | Date | | --- | --- | --- | --- | --- | +| [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 | From e677bff62308def23c6a8c9649f2c3d466404859 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 13:51:14 +0200 Subject: [PATCH 35/38] docs: align README logo and assets Generated-by: gpt-5 --- README.md | 2 +- {assets/source => docs/assets}/MenuIcon.svg | 0 {assets/source => docs/assets}/logo.png | Bin {assets/source => docs/assets}/logo.svg | 0 ...-repository-link-and-asset-organization.md | 2 +- docs/stories/021-readme-logo-alignment.md | 20 +++++++++++++++ .../022-documentation-source-artwork.md | 23 ++++++++++++++++++ docs/stories/INDEX.md | 2 ++ 8 files changed, 47 insertions(+), 2 deletions(-) rename {assets/source => docs/assets}/MenuIcon.svg (100%) rename {assets/source => docs/assets}/logo.png (100%) rename {assets/source => docs/assets}/logo.svg (100%) create mode 100644 docs/stories/021-readme-logo-alignment.md create mode 100644 docs/stories/022-documentation-source-artwork.md diff --git a/README.md b/README.md index 79930a5..bbfebb9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# Mounty logo Mounty +

Mounty logo Mounty

**A tiny macOS menu-bar app that keeps your SMB network shares mounted — automatically.** diff --git a/assets/source/MenuIcon.svg b/docs/assets/MenuIcon.svg similarity index 100% rename from assets/source/MenuIcon.svg rename to docs/assets/MenuIcon.svg diff --git a/assets/source/logo.png b/docs/assets/logo.png similarity index 100% rename from assets/source/logo.png rename to docs/assets/logo.png diff --git a/assets/source/logo.svg b/docs/assets/logo.svg similarity index 100% rename from assets/source/logo.svg rename to docs/assets/logo.svg diff --git a/docs/stories/020-repository-link-and-asset-organization.md b/docs/stories/020-repository-link-and-asset-organization.md index 5a2fa39..8daf6fa 100644 --- a/docs/stories/020-repository-link-and-asset-organization.md +++ b/docs/stories/020-repository-link-and-asset-organization.md @@ -13,7 +13,7 @@ Make Mounty's repository easy to find from the About section, show its transpare - [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 under a dedicated source-asset directory while Xcode's catalog layout remains unchanged. +- [x] Root-level source graphics are grouped outside the Xcode catalog while its layout remains unchanged. - [x] Focused validation passes. ## Validation 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/INDEX.md b/docs/stories/INDEX.md index 2a31a94..82dfa1a 100644 --- a/docs/stories/INDEX.md +++ b/docs/stories/INDEX.md @@ -4,6 +4,8 @@ Newest stories first. Statuses: `OPEN`, `IN_PROGRESS`, `CLOSED`. | ID | Type | Story | Status | Date | | --- | --- | --- | --- | --- | +| [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 | From 9f69da8d19b76649b5f98480aafc759d5be2d5e5 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 13:55:23 +0200 Subject: [PATCH 36/38] docs: align README title row Generated-by: gpt-5 --- README.md | 7 ++++++- .../stories/023-readme-title-row-alignment.md | 20 +++++++++++++++++++ docs/stories/INDEX.md | 1 + 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 docs/stories/023-readme-title-row-alignment.md diff --git a/README.md b/README.md index bbfebb9..7b5c96d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@
-

Mounty logo Mounty

+ + + + + +
Mounty logo

Mounty

**A tiny macOS menu-bar app that keeps your SMB network shares mounted — automatically.** diff --git a/docs/stories/023-readme-title-row-alignment.md b/docs/stories/023-readme-title-row-alignment.md new file mode 100644 index 0000000..880ace5 --- /dev/null +++ b/docs/stories/023-readme-title-row-alignment.md @@ -0,0 +1,20 @@ +# STORY-023: README title row alignment + +- Status: CLOSED +- Type: fix +- Date: 2026-08-10 +- Commit: _none_ + +## Intent + +Keep the README logo and title vertically aligned at every rendered viewport size. + +## Acceptance criteria + +- [x] The logo and Mounty title use vertically centered table cells. +- [x] The title row remains centered and compact. +- [x] Documentation validation passes. + +## Validation + +`git diff --check` and README title-row markup and image-path checks passed. \ No newline at end of file diff --git a/docs/stories/INDEX.md b/docs/stories/INDEX.md index 82dfa1a..9c63104 100644 --- a/docs/stories/INDEX.md +++ b/docs/stories/INDEX.md @@ -4,6 +4,7 @@ Newest stories first. Statuses: `OPEN`, `IN_PROGRESS`, `CLOSED`. | ID | Type | Story | Status | Date | | --- | --- | --- | --- | --- | +| [023](./023-readme-title-row-alignment.md) | fix | README title row 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 | From ed5ffc2f2cf5d05663f122d44ed2699370341668 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 13:57:06 +0200 Subject: [PATCH 37/38] revert: align README title row Generated-by: gpt-5 --- README.md | 7 +------ .../stories/023-readme-title-row-alignment.md | 20 ------------------- docs/stories/INDEX.md | 1 - 3 files changed, 1 insertion(+), 27 deletions(-) delete mode 100644 docs/stories/023-readme-title-row-alignment.md diff --git a/README.md b/README.md index 7b5c96d..bbfebb9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,6 @@
- - - - - -
Mounty logo

Mounty

+

Mounty logo Mounty

**A tiny macOS menu-bar app that keeps your SMB network shares mounted — automatically.** diff --git a/docs/stories/023-readme-title-row-alignment.md b/docs/stories/023-readme-title-row-alignment.md deleted file mode 100644 index 880ace5..0000000 --- a/docs/stories/023-readme-title-row-alignment.md +++ /dev/null @@ -1,20 +0,0 @@ -# STORY-023: README title row alignment - -- Status: CLOSED -- Type: fix -- Date: 2026-08-10 -- Commit: _none_ - -## Intent - -Keep the README logo and title vertically aligned at every rendered viewport size. - -## Acceptance criteria - -- [x] The logo and Mounty title use vertically centered table cells. -- [x] The title row remains centered and compact. -- [x] Documentation validation passes. - -## Validation - -`git diff --check` and README title-row markup and image-path checks passed. \ No newline at end of file diff --git a/docs/stories/INDEX.md b/docs/stories/INDEX.md index 9c63104..82dfa1a 100644 --- a/docs/stories/INDEX.md +++ b/docs/stories/INDEX.md @@ -4,7 +4,6 @@ Newest stories first. Statuses: `OPEN`, `IN_PROGRESS`, `CLOSED`. | ID | Type | Story | Status | Date | | --- | --- | --- | --- | --- | -| [023](./023-readme-title-row-alignment.md) | fix | README title row 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 | From dde9985db7551eb2722c8a675ef4a5a2901b4330 Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Mon, 10 Aug 2026 14:08:49 +0200 Subject: [PATCH 38/38] docs: align README logo visually Generated-by: gpt-5 --- README.md | 2 +- docs/assets/logo-readme.svg | 17 +++++++++++++++ .../023-readme-logo-visual-alignment.md | 21 +++++++++++++++++++ docs/stories/INDEX.md | 1 + 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 docs/assets/logo-readme.svg create mode 100644 docs/stories/023-readme-logo-visual-alignment.md diff --git a/README.md b/README.md index bbfebb9..75e2e5c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-

Mounty logo Mounty

+

Mounty logo Mounty

**A tiny macOS menu-bar app that keeps your SMB network shares mounted — automatically.** 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/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 index 82dfa1a..a3b1c9c 100644 --- a/docs/stories/INDEX.md +++ b/docs/stories/INDEX.md @@ -4,6 +4,7 @@ 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 |