Skip to content

Menu bar status widget — all 5 phases (closes #27)#28

Merged
iliyami merged 26 commits into
mainfrom
feat/menubar-widget
Jun 2, 2026
Merged

Menu bar status widget — all 5 phases (closes #27)#28
iliyami merged 26 commits into
mainfrom
feat/menubar-widget

Conversation

@iliyami
Copy link
Copy Markdown
Owner

@iliyami iliyami commented Jun 1, 2026

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:

Phase Capability Status
1 CPU / Memory / Battery / Network / Storage stats with live polling
2 Protection status (shield glyph + last-scan card, color-coded green/yellow/red)
3 Connected devices (external volumes with free-space + external display count)
4 Mac health monitoring + throttled macOS notifications (disk-low, memory pressure, battery health/cycles)
5 Optimization tips ("Trash is 12 GB", "Caches grew to X") with click-to-act + per-tip 30-day dismissal

Diff summary

  • 6 new files: MenuBarLauncher.swift, SharedAppState.swift, SettingsView.swift, ConnectedDevicesCollector.swift, HealthMonitor.swift, TipsEngine.swift
  • 3 updated files: MacCleanApp.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.sh nests MacCleanMenu.app under Library/LoginItems/ with LSUIElement=true
  • 9 new tests: 4 SharedAppState + 5 MenuBarLauncher → 397 tests passing, 0 failures, 4 skipped (unchanged)

Architecture notes

  • Inter-process state: named UserDefaults suite com.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.
  • Polling: fast tick every 3s for cheap reads (stats, protection); slow tick every ~30s for expensive collectors (devices, tips with directory walks). Health monitor evaluates every fast tick but throttles via SharedAppState.recentlyNotified so a sustained low-disk situation still notifies at most once every 15 min.
  • Launch lifecycle (your call: yes): widget keeps running after Mac Clean quits. SMAppService re-registers idempotently on every main app launch so a dropped registration from a macOS upgrade self-heals.
  • Bluetooth devices in Phase 3 intentionally deferredIOBluetooth.framework is deprecated on Sonoma+; CBCentralManager replacement 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 failures
  • swift build — clean
  • Version sync OK at 1.6.0
  • Local DMG install: bash scripts/build-dmg.sh && open .build/dmg/Mac\ Clean.app
    • Leaf + shield glyph appear in the menu bar
    • ⌘, opens Settings with "Show in menu bar" toggle ON
    • Click the menu bar item: stats / Suggestions / Protection / Connected cards render
    • Run a Malware scan in the main app, click the menu bar item again — Protection card shows last scan time + 0 threats
    • Plug in an external drive: Connected card lists it with free-space readout
    • Quit Mac Clean's main window: widget keeps running
  • CI green, Release green, brew cask synced after merge

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.
iliyami added 4 commits June 1, 2026 11:57
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.
@iliyami iliyami marked this pull request as ready for review June 1, 2026 16:58
iliyami added 7 commits June 1, 2026 12:11
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.
@iliyami iliyami changed the title [Draft] Ship menu bar status widget (closes #27) Menu bar status widget — all 5 phases (closes #27) Jun 1, 2026
iliyami added 14 commits June 1, 2026 12:26
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.
@iliyami iliyami merged commit 0b9a9a0 into main Jun 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ship the menu bar status widget so users can see live Mac stats from the top bar

1 participant