COORD: 44.21.90
OFFSET: +12.5°
SYS.READY
BUFFER: 99%
FOCAL_PT
BACK TO DEVLOG
POCKET

PocketMac Menu Bar UI and Distribution Journey

PocketMac v2 onboarding was complete from a previous session — Welcome → Explanation → System Check → Pairing → Success flow. The app worked but had a minimal menu bar (just \"Setup Assistant...\" and \"

2026-01-21 // RAW LEARNING CAPTURE
PROJECTPOCKET

Starting Point

PocketMac v2 onboarding was complete from a previous session — Welcome → Explanation → System Check → Pairing → Success flow. The app worked but had a minimal menu bar (just "Setup Assistant..." and "Quit"). Goal: build a proper menu bar experience and get to TestFlight.

Built the Menu Bar UI

Created PocketMac/PocketMac/MenuBar/MenuBarView.swift with:

  • Server toggle (Tailscale-style switch instead of Start/Stop buttons)
  • Inline pairing mode with 6-digit code display
  • Countdown timer when pairing
  • Hover states on menu rows
  • Settings and Setup Assistant links

Key design decisions from user feedback:

  • No keyboard shortcuts — user said "we don't need shortcuts right now"
  • Toggle instead of red "Stop" button — "Stop" color was "too saturated red, looks like an error"
  • Normal button for pairing — original was orange/branded, user wanted it plain

ForEach Duplicate ID Bug

Hit this warning twice:

ForEach<Array<Character>, Character, ...>: the ID 5 occurs multiple times within the collection

Cause: Using ForEach(Array(code), id: \.self) on a pairing code like "195655" — two 5s means duplicate IDs.

Fix: Use enumerated index:

ForEach(Array(code.enumerated()), id: \.offset) { _, digit in
    Text(String(digit))
    // ...
}

Fixed in both MenuBarView.swift and PairingStep.swift.

Port Number Formatting Bug

Port showed as "2,022" instead of "2022":

Text("Port \(port)")  // Uses locale number formatting

Fix:

Text("Port \(String(port))")  // Explicit string conversion

SwiftUI Animation Didn't Work

The iPhone→Mac connection animation in ExplanationStep.swift was static. Original code:

private struct ConnectionArrow: View {
    @State private var phase: CGFloat = 0

    var body: some View {
        HStack(spacing: 6) {
            ForEach(0..<4, id: \.self) { i in
                Circle()
                    .opacity(opacity(for: i))  // Computed from phase
            }
        }
        .onAppear {
            withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) {
                phase = 4
            }
        }
    }

    private func opacity(for index: Int) -> Double {
        let position = (phase + Double(index)).truncatingRemainder(dividingBy: 4)
        return position < 1 ? 1.0 : 0.3
    }
}

Why it didn't animate: SwiftUI doesn't re-evaluate opacity(for:) on each animation frame because it's a computed function, not a direct dependency on the animated @State.

Fix: Each element needs its own animation modifier:

private struct ConnectionArrow: View {
    @State private var isAnimating = false

    var body: some View {
        HStack(spacing: 6) {
            ForEach(0..<4, id: \.self) { i in
                Circle()
                    .fill(Color.pocketAccent)
                    .frame(width: 6, height: 6)
                    .opacity(isAnimating ? 1.0 : 0.3)
                    .animation(
                        .easeInOut(duration: 0.4)
                            .repeatForever(autoreverses: true)
                            .delay(Double(i) * 0.15),
                        value: isAnimating
                    )
            }
        }
        .onAppear {
            isAnimating = true
        }
    }
}

The key insight: animation is tied to actual state change (isAnimating), and each dot has its own .animation() modifier with staggered .delay().

Window Focus Issue

Windows opened from menu bar appeared behind other apps because the app is LSUIElement = true (no dock icon).

Fix: Add NSApp.activate() after opening:

MenuRowButton(title: "Settings...") {
    openWindow(id: "settings")
    NSApp.activate(ignoringOtherApps: true)
}

Onboarding Reset

Opening Setup Assistant showed the last step from previous run.

Fix: Reset to welcome on appear:

.onAppear {
    step = .welcome
}

Commit

93e90f2 feat: Add menu bar UI and settings window for PocketMac

TestFlight Attempt #1: App Store Connect

Tried uploading to App Store for TestFlight. Hit multiple validation errors:

Invalid Code Signing Entitlements. key 'com.apple.security.files.home-relative-path.read-write' is not supported.

The Info.plist must contain a LSApplicationCategoryType key.

App sandbox not enabled. The following executables must include the "com.apple.security.app-sandbox" entitlement:
- PocketMac.app/Contents/MacOS/PocketMac
- PocketMac.app/Contents/Resources/etserver_dist/etserver

Reality check: App Store requires full sandboxing. PocketMac needs to:

  • Write to ~/.ssh/authorized_keys
  • Run bundled etserver on port 2022
  • Access files outside sandbox

This is why iTerm2, Docker Desktop, Homebrew are NOT on Mac App Store.

Pivot to Direct Distribution

Switched to Developer ID + notarization for direct distribution (like most developer tools).

First attempt:

"etserver" must be rebuilt with support for the Hardened Runtime.

The bundled etserver binary wasn't signed with hardened runtime.

Getting Developer ID Certificate

Only had "Apple Development" certificate:

$ security find-identity -v -p codesigning
1) E01277AC6FC4E9A751ECD241D9EDAB3E438EDB79 "Apple Development: pk.joel@gmail.com (FUJG77L97Z)"

Needed "Developer ID Application" for notarized distribution.

Process:

  1. Apple Developer portal → Certificates → + → Developer ID Application
  2. Choose "G2 Sub-CA (Xcode 11.4.1 or later)" — modern option
  3. Create CSR in Keychain Access (CA Email can be left blank)
  4. Upload CSR, download cert, double-click to install

Got error -25294 on import — this means private key mismatch. User had to redo the CSR on the same machine where they'd import the cert.

After retry:

$ security find-identity -v -p codesigning
1) E01277AC6FC4E9A751ECD241D9EDAB3E438EDB79 "Apple Development: pk.joel@gmail.com (FUJG77L97Z)"
2) 142F087DFF15B5EA14B4F52F97DA1CB5E93C18E3 "Developer ID Application: Joel Brubaker (UQ27DB7N8K)"

Signing etserver and dylibs

etserver was only ad-hoc signed:

$ codesign -dv --verbose=4 etserver_dist/etserver 2>&1 | grep Signature
Signature=adhoc

Signed everything with Developer ID + Hardened Runtime:

IDENTITY="Developer ID Application: Joel Brubaker (UQ27DB7N8K)"
ETSERVER_DIR="/Users/joel/Code/pocket/PocketMac/etserver_dist"

# Sign all dylibs first
for lib in "$ETSERVER_DIR"/libs/*.dylib; do
    codesign --force --options runtime --timestamp --sign "$IDENTITY" "$lib"
done

# Sign etserver
codesign --force --options runtime --timestamp --sign "$IDENTITY" "$ETSERVER_DIR/etserver"

83 files signed. Verified:

$ codesign -dv etserver 2>&1 | grep -E "(Identifier|TeamIdentifier|Runtime)"
Identifier=etserver
TeamIdentifier=UQ27DB7N8K
Runtime Version=15.2.0

Runtime Version=15.2.0 confirms Hardened Runtime is enabled.

Notarization Submitted

After clean build + archive, notarization was submitted via Xcode Organizer → Distribute → Developer ID → Upload.

Waiting for Apple (typically 5-15 minutes).

Distribution Site Setup

While waiting, set up download infrastructure.

Created /Users/joel/Code/pocket-site/public/downloads/ for hosting the ZIP.

Created download page at src/app/(main)/download/page.tsx with:

  • iOS TestFlight section (placeholder link)
  • Mac direct download section (points to /downloads/PocketMac.zip)
  • Setup instructions

Updated Hero and CallToAction buttons to point to /download.

Build verified:

$ pnpm build
Route (app)                                 Size  First Load JS
 /download                              162 B         106 kB

Where We Landed

Menu bar UI: Complete with toggle, inline pairing, hover states Settings window: General tab (auto-start, status) + Logs tab Onboarding: Resets properly, animations work Distribution: Notarization pending, download page ready

Commits:

  • 93e90f2 - Menu bar UI and settings window

Once notarization completes:

  1. Export from Xcode Organizer
  2. zip -r PocketMac.zip PocketMac.app
  3. Copy to pocket-site/public/downloads/
  4. Update TestFlight link in download page
  5. vercel --prod

Takeaways

SwiftUI animation gotcha: Animations based on computed properties from @State don't work. Each view element needs its own .animation() modifier tied directly to the state value.

Mac App Store vs Direct Distribution: If your app needs to write outside its sandbox (like ~/.ssh/), run helper binaries, or listen on privileged ports — App Store isn't an option. Most developer tools use notarized direct distribution.

Hardened Runtime for bundled binaries: Any executable in your app bundle must be signed with --options runtime for notarization. Sign dylibs first, then the main binary.

Developer ID vs Apple Development: Different certificates for different purposes. Apple Development = TestFlight/device testing. Developer ID Application = notarized direct distribution.

LOG.ENTRY_END
ref:pocket
RAW