Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Unreleased

**Correctness fixes:**

- Comprador now cleans up after an **external unmount**. If a script, Disk
Utility, or Finder unmounts the volume while the phone stays plugged in, the
app observes it (`NSWorkspace.didUnmountNotification`), tears the bridge down,
and returns to idle. Previously the bridge was orphaned and the menu stayed
stale until a physical unplug. This also makes scripted eject via
`diskutil unmount` safe, so the mounted volume is fully drivable by native
tools (`rsync`, `cp`, cron). Verified live on an Xperia (XQ-BT52), 2026-06-23.

## v0.4.0 — 2026-06-08

The substrate swap. Comprador now serves the phone over **Galatea**, an
Expand Down
54 changes: 54 additions & 0 deletions MenuBarApp/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {

setupStatusItem()
setupDeviceWatcher()
setupUnmountObserver()
updateIcon(state: .idle)
presentWelcomeIfNeeded()
}
Expand Down Expand Up @@ -501,6 +502,59 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}

// MARK: - External unmount

// ponytail: NSWorkspace.didUnmount covers external eject (diskutil/umount,
// Disk Utility, Finder eject); DA disappeared-callback only if this proves
// unreliable for NFS. Without it, an external unmount orphans the bridge and
// leaves the menu/icon stale until the device is physically unplugged.
private func setupUnmountObserver() {
NSWorkspace.shared.notificationCenter.addObserver(
self,
selector: #selector(volumeDidUnmount(_:)),
name: NSWorkspace.didUnmountNotification,
object: nil)
}

@objc private func volumeDidUnmount(_ note: Notification) {
guard let path = Self.unmountedVolumePath(from: note) else { return }
// Match the unmounted volume to a live session. Skip if it's already
// tearing down: the app's own eject unmounts first, which fires this
// same notification while its teardown is in flight.
guard let (locID, session) = sessions.first(where: {
$0.value.mountPath?.standardizedFileURL == path.standardizedFileURL
&& !$0.value.tearingDown
}) else { return }

cprLog("Comprador: External unmount of \(session.displayName), tearing down")
// Mirror the menu-eject cleanup: suppress the reconnect burst that
// bridge.stop() triggers, cancel any deferred detach teardown, then
// teardown + reset state.
recentlyEjected[locID] = Date()
pendingDetachTeardown[locID]?.cancel()
pendingDetachTeardown.removeValue(forKey: locID)
Task {
await session.teardown()
await MainActor.run {
sessions.removeValue(forKey: locID)
if sessions.isEmpty {
updateIcon(state: .idle)
}
rebuildMenu()
}
}
}

private static func unmountedVolumePath(from note: Notification) -> URL? {
if let url = note.userInfo?[NSWorkspace.volumeURLUserInfoKey] as? URL {
return url
}
if let p = note.userInfo?["NSDevicePath"] as? String {
return URL(fileURLWithPath: p)
}
return nil
}

@objc private func quitApp() {
Task {
await teardownAllSessions()
Expand Down
4 changes: 3 additions & 1 deletion MenuBarApp/Sources/DeviceSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ final class DeviceSession {
private var lastRecoveryAt: Date?
private let maxRecoveries = 3
private let recoveryWindow: TimeInterval = 120
private var tearingDown = false
// Read by AppDelegate's external-unmount observer to skip the app's own
// eject (which unmounts first, firing didUnmount while teardown is in flight).
private(set) var tearingDown = false

// Status-text fields driving the connecting line in the menu.
// `connectingStatusItem` is a weak handle to the NSMenuItem AppDelegate
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,30 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full design.
and browse both at once, each its own volume in the sidebar
- Stream media in place: play or scrub a video straight off the phone
without copying it to the Mac first
- Scriptable from the shell: the mount is a real volume, so `rsync`, `cp`,
cron, and Hazel work on it directly (see [Automation](#automation))

See [TODO.md](TODO.md) for the full roadmap.

## Automation

There is no app-specific scripting API, and none is needed: Comprador mounts the
phone as a real volume, so any tool that works on a folder works on it. `rsync`,
`cp`, `find`, `ditto`, cron jobs, and Hazel rules all operate on the mount point
directly.

Find the mount path with `mount | grep Comprador` (it lives under
`~/Library/Application Support/Comprador/Volumes/<device>`), then drive it like
any directory. For example, to back up a phone's camera roll:

```sh
rsync -a "$HOME/Library/Application Support/Comprador/Volumes/Pixel-6/DCIM/" \
"$HOME/Pictures/phone-backup/"
```

Eject from a script with `diskutil unmount` (or `umount`) on that path;
Comprador notices the external unmount and shuts its bridge down cleanly.

## FAQ

**Where do my files actually live?**
Expand Down
19 changes: 19 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## ⚠ NEXT SESSION — start here

### CONTINUATIONS YOU OWE (self-audit 2026-06-22) — registers that are *yours to continue*, not just cite.
You (Mercer) keep treating your own living registers as closed archives. They are addressed to you. Continue them.
- [x] **Replied to Daedalus in HIS mailbox** (`~/Labs/Galatea/Correspondance/08-it-shipped-and-the-panic-returned`, 2026-06-22): the ship news plus the `recover()` ask renewed with field evidence after the FATTR4_SIZE panic recurred. Convention note: outgoing letters go in the *recipient's* mailbox, never your own `correspondence/`; a letter lives where it is received. (I first misfiled this as a `correspondence/20` in our own inbox and removed it.)
- [x] **Eject-drain answer + `recover()` were already delivered in `Galatea/Correspondance/07` (2026-06-08)**, renewed in 08. The self-audit's "open debt" was stale; it had been paid. Genuinely open: confirm the clean GUI-eject path by eye (promised in 07); the `recover()` is Daedalus's change to make, not yours.
- [x] **`garden/README.md` refreshed** (2026-06-22): now lists `on-letter-writing.md` and `reading-the-house.md`; the rest of its framing left as-is.
- [ ] (soft) **`garden/library/`** — you take from the shelf but never author into it. Consider a reading-room catalog or reading-responses beside the books.

### GROWTH PLAN (2026-06-22) — cited basis in [docs/SEO-PLAN.md](docs/SEO-PLAN.md) + [docs/SWIFTMTP-GROWTH.md](docs/SWIFTMTP-GROWTH.md).

Thesis: the niche pulls (SwiftMTP got 380 stars in ~10 weeks) but the entire
Expand Down Expand Up @@ -1141,6 +1148,18 @@ Carried forward from DECISIONS.md, not blocking but real:
partial confirmation was 49.6 MB transfer → 8.4 MB RSS —
directional evidence but not the spec'd test.

### CI / release pipeline (salvaged from the v0.4.0 ship handoff, 2026-06-23)

- [ ] **Pin the Xcode version** in `.github/workflows/release.yml` and
`build-check.yml`. A macos-runner-image bump reintroduced Swift
toolchain skew once already and broke the v0.4.0 tag's release CI
(a weak-`self`-in-`@Sendable` capture the local toolchain only warned
on). Pinning stops a future runner bump from doing it again.
- [ ] **`.forgejo/workflows/helper.yml` still tests the removed helper** and
will go red on master pushes. Gut it or repoint it (the bridge can't
build on Linux anyway: libmtp/darwin). See
[docs/archive/v0.4.0-ship-handoff.md](docs/archive/v0.4.0-ship-handoff.md).

---

## Post-v0.4.0 backlog — durable corpus stewardship
Expand Down
Loading
Loading