Menu bar status widget — all 5 phases (closes #27)#28
Merged
Conversation
Branch placeholder so the draft PR has a starting point. Actual implementation commits will follow once the plan in the PR description is reviewed and approved.
Thin @mainactor @observable service around SMAppService.loginItem(identifier:) using MCConstants.menuBundleIdentifier ('com.macclean.menu'). The identifier must match the Info.plist of the nested helper at Mac Clean.app/Contents/Library/LoginItems/MacCleanMenu.app/; a regression test pins the constant. register() launches the helper immediately; unregister() stops it. setEnabled(bool) is the idempotent toggle the Settings UI calls — it swallows errors so a Settings flip can't block app launch, and surfaces failure via lastError for the UI to display. 5 read-only tests cover identity, initial state, status enum readability, and the bundle-id constant. We DO NOT call register or unregister from tests — those mutate the real launchd database and would plant login items on every test run.
SwiftUI Settings scene (opens via ⌘,) with a 'Show Mac Clean in the menu bar' toggle bound to @AppStorage('showMenuBarWidget'). On change, calls MenuBarLauncher.setEnabled. Status row reflects the live SMAppService state (running / not registered / needs approval / not found) with color-coded glyph + failure message when registration fails — users see WHY the widget isn't appearing instead of a silent toggle that does nothing. Default state is ON per product decision: new users get the widget immediately on first launch. MacCleanApp.syncMenuBarOnLaunch re-syncs SMAppService with the @AppStorage truth on every launch (idempotent), catching the edge case where macOS drops the registration after upgrades.
The SwiftPM build already produces MacCleanMenu alongside MacClean
(multi-arch xcbuild builds all executable products by default). New
Step 2.5 assembles a nested .app bundle at the canonical SMAppService
location:
Mac Clean.app/Contents/Library/LoginItems/MacCleanMenu.app/
with an Info.plist that pins:
- LSUIElement=true (no Dock icon, menu bar only)
- bundle id = com.macclean.menu (matches MenuBarLauncher constant
and the SMAppService.loginItem(identifier:) call)
Same AppIcon, same version string as the main app, signed by the
outer codesign --deep pass. Distribution-ready: a fresh DMG install
gives MenuBarLauncher.register() a real helper to find at the path
launchd expects.
Patch bump per the decided release cadence for this multi-phase feature: each phase ships as a patch; the 1.6.0 minor bump is reserved for when all 5 phases (existing stats, Protection, Connected devices, Health notifications, Optimization tips) have landed. Phase 1 delivers what was already coded but not shipping: the existing menu bar widget with CPU/Memory/Battery/Network/Storage, now bundled into the DMG, registered as a login item by default, and toggleable via the new Settings scene.
Named UserDefaults suite ('com.macclean.shared'); both processes
open the same plist by name. App Groups would be canonical but
require notarized + signed-with-team-id builds; named suites work
under ad-hoc signing and are good enough for the small state we
need to share (protection status, tip dismissals, notification
throttle log).
Exposes:
- ProtectionStatus codable record (lastScanDate, threatsFound,
scanDepth) with isStale convenience (>7 days)
- tipDismissals dict and isTipDismissed predicate (30-day window)
- notificationLog dict and recentlyNotified predicate
(throttle window varies per kind: disk-low 15min,
battery-health 7d)
4 spec tests on the pure logic. UserDefaults-coupled paths are not
unit-tested at this layer to avoid leaking test state into the
real shared suite — they get exercised by integration.
Async actor enumerates mounted volumes via mountedVolumeURLs + URLResourceKey prefetch, filtering for removable OR not-internal (catches USB sticks, externally-attached SSDs, network shares). Drops the root volume by construction so 'Connected' truly means external. External display count = NSScreen.screens.count - 1 read on the MainActor (NSScreen is main-only). Returns a Sendable ConnectedDevices struct with hasAny convenience so the popover can hide the section entirely when nothing is attached. Bluetooth devices intentionally deferred — IOBluetooth.framework is deprecated on Sonoma+ and reliable replacement APIs require CBCentralManager with permission prompts. Add in a follow-up if user demand exists.
Runs on every fast poll tick (3s) but the heavy lifting is SharedAppState.recentlyNotified throttling — disk-low fires at most once every 15 min, memory-pressure every 30 min, battery health/ cycle alerts once per week. Memory pressure is gated on a sample streak (5 consecutive samples ≥ 0.85) so a momentary spike doesn't notify. Authorization is requested lazily on first notification attempt (not eagerly on widget launch) so users who never cross a threshold never see a permissions prompt. Battery health/cycle thresholds are wired but currently return nil from SystemStatsCollector because SMC access isn't implemented; the code paths exist so the moment SMC lands, alerts fire without further wiring.
Builds a short tip list with per-tip 'don't show again' dismissal
(30-day suppression via SharedAppState). MVP catalog:
- trash_large: Trash > 1 GB → click opens Mac Clean
- caches_large: ~/Library/Caches > 2 GB → click opens Mac Clean
Sorted by estimatedSavings descending so the biggest payoff is
on top. directorySize walks the tree via FileManager.enumerator
with a hard 50k-file cap (yields every 5k to avoid actor
starvation) — a runaway directory can't stall the popover.
Click-to-act is bundle-id-based 'open main app' for MVP. Future
phase: macclean:// URL scheme for deep-linking to specific
modules ('macclean://module/system-junk').
… scan Right after scanComplete = true the view records the just-completed scan's outcome (last date, threat count from result.items, scan depth as 'quick'/'balanced'/'deep') so the menu bar widget can render the shield glyph + 'Last scan: X ago' card on its next poll tick (cheap read every 3s, no IPC needed).
Menu bar label: shield glyph next to the leaf icon, color-coded
green (clean recent) / yellow (stale, >7d since scan) / red (threats
found). Falls back to a hollow shield when no scan has run yet.
Popover gains four new cards beneath the existing stats:
- Suggestions (TipsEngine) — title + body + Open + Dismiss
- Protection (SharedAppState) — last scan + threat count
- Connected (ConnectedDevicesCollector) — external volumes
with free-space readout, external display count
Polling loop split into fast (every 3s) and slow (~30s) ticks:
- Fast: stats, network, protection-status read (cheap memory read)
- Slow: connected devices enumeration, tips engine (involves
directory walks)
HealthMonitor.evaluate runs every fast tick; its own throttling is
the rate limiter for notifications.
Whole popover wrapped in a ScrollView with maxHeight 600 so a
dense system (many external drives, many tips) doesn't blow past
the screen edge.
Minor bump per the decided cadence — held until every phase of the
menu widget vision landed. This release introduces, in one app
update, the menu bar widget with:
Phase 1 — CPU / Memory / Disk / Battery / Network bundled and
launching by default
Phase 2 — Protection status (shield glyph, last-scan card)
Phase 3 — Connected external devices (volumes + displays)
Phase 4 — Mac health monitoring with throttled OS notifications
Phase 5 — Optimization tips with per-tip dismissal
Settings → 'Show Mac Clean in the menu bar' toggle, defaults on.
Widget persists across main-app quit; auto-launches at login via
SMAppService.
Two bugs from the Phase 2-5 wiring: 1) Popover collapsed to ~0 height. The wrapping ScrollView had only .frame(maxHeight: 600) — no min and no fixed dimension. Inside MenuBarExtra(.window) the popover sizes to its content's idealSize, and a ScrollView's idealSize is 0 (it's expected to scroll, after all). So the popover opened but with effectively no visible area. Reverted to the original plain-VStack layout sized by content via .frame(width: 320). Adding the ScrollView back later (if a power user ever overflows the screen) needs explicit width AND height. 2) Menu bar label cramped. Showing shield + leaf + 7.38 GB text pushed the layout past the menu bar's tolerable width on typical setups, hiding the leaf. Drop the leaf from the label (kept in the popover header instead) — the colored shield is already the visual identifier, and the disk-free text is the informative payload.
SMAppService.register() on ad-hoc-signed builds (what Homebrew users
get — we don't notarize) is unreliable: it can return success without
macOS actually launching the helper in the current session. The
helper is queued for next login, but the user sees no widget right
now. Fixes:
1. setEnabled(true) still calls register() for the next-login path,
then ALSO launches the helper directly via NSWorkspace using the
bundled .app at Contents/Library/LoginItems/MacCleanMenu.app.
Guarded against double-launch by checking
NSWorkspace.runningApplications. setEnabled(false) symmetrically
unregisters AND terminates the running helper.
2. ContentView gains a primary-action toolbar toggle ('Menu Bar')
bound to the same @AppStorage('showMenuBarWidget') key as the
Settings sheet — clicking it in the main app window is now the
obvious path; ⌘, Settings is still there for the detail view
with status diagnostics.
3. helperAppURL() exposes the bundled helper path for the launch
step and any future code that wants to find it (e.g. an Open in
Finder option, log-redirect, etc.). Returns nil under 'swift run'
where no .app wrapper exists.
The toolbar variant in ContentView doesn't render reliably under .toolbarBackground(.hidden, for: .windowToolbar) + .navigationSubtitle combo — visible on some macOS versions, completely hidden on others, and a user just reported they couldn't find any toggle in the app. Move the same @AppStorage('showMenuBarWidget') binding into the bottom of the sidebar, with the system-glyph + 'Menu Bar Widget' label + 'Running' / 'Off' status and a switch toggle. Sidebar is always visible (NavigationSplitView), so this is the discoverable entry point. ⌘, Settings still has the same toggle plus the SMAppService status diagnostic row.
1. Onboarding sheet clipped the Back/Next buttons. The FDA step's content (icon + title + body + 4 numbered steps + 'Open System Settings' button) overflowed the 450 height, pushing the navigation HStack below the sheet edge. Bumped to 620. 2. Two widgets in the menu bar. SMAppService.register() AND our NSWorkspace.openApplication fallback both fire on app launch, and macOS doesn't auto-deduplicate LSUIElement apps by bundle id the way it does for regular apps — so both successfully spawned a MacCleanMenu, producing two shields. Single-instance check at the helper's @main init: if another process with our bundle id is already running, exit(0) immediately. Whichever instance got there first stays; the second self-terminates. Works regardless of which launcher path fired. 3. Two toggles in the main app. The toolbar-item version added in ContentView didn't render under .toolbarBackground(.hidden) on most setups, so I added the sidebar-footer toggle as the guaranteed-visible alternative. Now both render and the user has duplicate UI — remove the toolbar one; the sidebar footer is the canonical control going forward.
CI macos-15 Swift 6 strict-concurrency rejected: await UNUserNotificationCenter.current().notificationSettings() inside the HealthMonitor actor because UNNotificationSettings is non-Sendable and can't cross the actor boundary. Local Swift 6.3.2 let it through (consistent with earlier ScanCoordinatorTests case); the strict toolchain catches it. Fix: extract the auth check into a static (actor-unisolated) helper. The non-Sendable settings object is created and consumed in the nonisolated context; only the Bool result crosses back to the actor on await.
UI redesign of the menu bar widget popover. Replaces the flat
divider-stacked layout with a card-based glassmorphism look:
- Header: tinted leaf glyph in a colored circle + 'Mac Clean'
title + 'Live system stats' subtitle
- Stat cards (CPU, Memory, Disk, Battery): translucent
ultraThinMaterial fill with hairline white 0.08-opacity border,
12pt continuous rounded corners. Icon sits in a 22pt tint-colored
circle (blue/purple/orange/green per stat). Progress bars are
capsule-shaped with linear-gradient fill on a 18% secondary track.
- Info strip: network up/down + uptime + swap in a single horizontal
glass card with evenly-spaced cells, monospaced 10pt
- Suggestions: glass card with section header + per-tip row (icon
+ title + body + Open button + circular x dismiss in a hairline
pill)
- Protection: glass card, status text + relative-time + scan depth
- Connected: glass card, per-volume name + free-space, external
display count
- Footer: full-width green 'Open Mac Clean' borderedProminent
button + small power-icon Quit (compact, no full-text label)
ScrollView wraps the card stack so a power user with many tips +
devices doesn't blow past 520pt; the popover is fixed-width 340pt.
The shared glassCard view modifier (private extension) bakes the
look so every card stays consistent: ultraThinMaterial fill + 0.5pt
hairline white border at 8% opacity, 12pt continuous radius.
Menu bar label icon: shield to sparkles. Sweeper/cleaner glyph
matches the app's purpose (cleaning the Mac, not protecting it),
which is what the user explicitly asked for. Threats still escalate
to exclamationmark.triangle.fill (red) so an actual problem is
impossible to miss. Color still encodes status (green clean,
yellow stale, red threats).
Same bug I fixed earlier. ScrollView inside MenuBarExtra(.window) sizes to its idealSize, which is 0 for a ScrollView (it expects to scroll). frame(maxHeight:) doesn't fix it — only frame(height:) or frame(idealHeight:) would. Result: every stat / info / tips / protection / connected card disappeared, leaving only header + footer. Bug visible to the user the moment they opened the popover. Same fix as before: plain VStack, no ScrollView. Comments in code now spell out why this is a deliberate choice and what to do if overflow ever becomes a real problem.
# Conflicts: # Sources/MacCleanKit/Constants.swift # VERSION
Icon: replace the leaf AppIcon.icns with the user-selected Canva vacuum
design (cordless stick vacuum on a violet squircle). Embedded a 256px
copy as base64 in VacuumAsset.swift so the menu bar / popover load it
identically under `swift run` and inside the DMG, with no SwiftPM
resource-bundle plumbing to break. Menu-bar label now shows the vacuum
(color, 18px) + disk-free text.
Popover: full redesign to match the user's reference (CleanMyMac-style
menu widget):
- Deep violet gradient background (bgTop→bgBottom)
- Translucent glass cards (dark-violet fill + ultraThin + hairline)
- Teal accent + yellow CTA palette (MenuPalette)
- 2-column ring-gauge grid for CPU / Memory / Disk / Battery (circular
trim gauges with % in the center, color-graded teal→yellow→red)
- Network/uptime/swap strip with divider cells
- Recommendations cards with yellow action buttons (Empty Trash /
Free Up Space) + per-tip dismiss
- Protection + Connected-devices glass cards
- Footer: yellow "Open Mac Clean" + power quit
Also guard the single-instance check so it only fires when a real
bundle id exists (the bare `swift run` executable has none and was
exiting itself immediately in dev).
Mistake fix: a prior commit wrongly replaced the MAIN app icon (Resources/AppIcon.icns). Restored the original brand sweeper icon — the app icon must not change. Menu-bar widget icon only: use the user-supplied Downloads/vacuum.png, rendered WHITE on the brand purple-gradient squircle (matching the app icon's background, per request). The dark original was invisible on purple; white reads cleanly at 18px. 128px source kept small so the base64 literal compiles fast.
- Menu icon: crop vacuum.png to its content bbox (it was only 33% of its own 500px canvas) and scale to ~82% of the purple tile, so it no longer looks tiny in the menu bar. - Stat grid: every card now has a fixed 150pt height and always renders a subtitle line (a space when empty), so CPU/Battery (no sub) align with Memory/Disk (with sub) — the 2×2 grid and its rings line up. Added 14pt horizontal padding so the icon+label headers aren't flush against the card's left edge.
- Stat cards: drop the fixed-height center alignment (which floated content to the middle and pushed headers down). Cards are equal height naturally via the always-present subtitle line; header now sits 16pt from the top-left via uniform .padding(16) + top alignment. - Footer: "Open Mac Clean" button is now green (white leaf + label), the quit/power button is now red. Dropped the purple vacuum tile from the green button (it clashed) for a white leaf glyph.
CI's full `swift build` emit-module step rejected the static `image: NSImage` (NSImage isn't Sendable). Built once and only read, so nonisolated(unsafe) is correct. Verified locally with the full CI gate (check-version-sync + swift build + swift test) before pushing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #27 in full. All 5 phases now in one PR.
What ships
The menu bar widget your users have been asking for, with every capability listed in #27:
Diff summary
MenuBarLauncher.swift,SharedAppState.swift,SettingsView.swift,ConnectedDevicesCollector.swift,HealthMonitor.swift,TipsEngine.swiftMacCleanApp.swift(Settings scene + default-on first launch),MacCleanMenuApp.swift(new cards + slow/fast tick split),MalwareView.swift(writes ProtectionStatus after every scan)scripts/build-dmg.shnestsMacCleanMenu.appunderLibrary/LoginItems/withLSUIElement=trueArchitecture notes
UserDefaultssuitecom.macclean.shared(no App Groups — those need signed/notarized + matching team-id, we ship ad-hoc). Plist lives at~/Library/Preferences/com.macclean.shared.plist. Main app writes ProtectionStatus after every scan; widget reads every 3s.SharedAppState.recentlyNotifiedso a sustained low-disk situation still notifies at most once every 15 min.IOBluetooth.frameworkis deprecated on Sonoma+;CBCentralManagerreplacement needs a permissions prompt and a longer design conversation. Add in a follow-up if user demand exists.Version
1.5.2 → 1.6.0 (minor — your call: hold the minor bump until all phases land in one release).
Test plan
swift test— 397 tests, 4 skipped, 0 failuresswift build— cleanbash scripts/build-dmg.sh && open .build/dmg/Mac\ Clean.app