Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
97c4792
Open work on menu bar widget — plan in #27
iliyami Jun 1, 2026
86c9184
Add MenuBarLauncher: SMAppService wrapper for menu widget login item
iliyami Jun 1, 2026
96d2121
Add Settings scene + default-ON menu bar widget on launch
iliyami Jun 1, 2026
cbdc77e
build-dmg: nest MacCleanMenu.app as a LoginItem inside Mac Clean.app
iliyami Jun 1, 2026
2f90f66
Bump 1.5.2 → 1.5.3: Phase 1 of menu bar widget — ship the widget
iliyami Jun 1, 2026
54fcebb
Add SharedAppState: cross-process state between main app and widget
iliyami Jun 1, 2026
ae8762c
Phase 3 — ConnectedDevicesCollector: external volumes + displays
iliyami Jun 1, 2026
5309029
Phase 4 — HealthMonitor: threshold-based UN notifications
iliyami Jun 1, 2026
ef0629a
Phase 5 — TipsEngine: actionable popover suggestions
iliyami Jun 1, 2026
a509f13
Phase 2 — MalwareView writes ProtectionStatus to SharedAppState after…
iliyami Jun 1, 2026
b8042a5
Wire Phases 2-5 into MenuBarExtra UI
iliyami Jun 1, 2026
6152da8
Bump 1.5.3 → 1.6.0: all 5 menu widget phases shipped
iliyami Jun 1, 2026
e477ff6
Fix collapsed popover + crowded label
iliyami Jun 1, 2026
8ced22b
MenuBarLauncher: direct-launch fallback + visible toolbar toggle
iliyami Jun 1, 2026
c828031
Sidebar footer: always-visible Menu Bar Widget toggle
iliyami Jun 1, 2026
3fa03bf
Fix three menu widget UX bugs from local install
iliyami Jun 1, 2026
035e306
Hop UN settings read through nonisolated static to satisfy Swift 6
iliyami Jun 1, 2026
aac8847
Glassmorphism popover redesign + sweeper-themed menu bar icon
iliyami Jun 1, 2026
0987f08
Drop the ScrollView I just re-added — collapsed all the cards again
iliyami Jun 1, 2026
df7da1f
Merge branch 'main' into feat/menubar-widget
iliyami Jun 1, 2026
37a94ac
Vacuum icon + reference-matched glassmorphism popover redesign
iliyami Jun 1, 2026
edb893f
Revert main app icon; menu-bar icon = user vacuum.png on brand purple
iliyami Jun 1, 2026
941eee1
Enlarge menu icon; uniform stat-card height + header padding
iliyami Jun 1, 2026
d44fb5b
Stat-card headers top-aligned at 16px; green Open button, red quit
iliyami Jun 1, 2026
657350c
Center stat-card headers over rings; swap footer (red quit left, gree…
iliyami Jun 1, 2026
56800e1
Fix Swift 6 strict-concurrency: nonisolated(unsafe) on static NSImage
iliyami Jun 2, 2026
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
22 changes: 22 additions & 0 deletions Sources/MacClean/App/MacCleanApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ struct MacCleanApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@State private var appState = AppState()
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@AppStorage("showMenuBarWidget") private var showMenuBarWidget = true
@AppStorage("menuBarFirstLaunchDone") private var menuBarFirstLaunchDone = false
@State private var showOnboarding = false

var body: some Scene {
Expand All @@ -21,10 +23,30 @@ struct MacCleanApp: App {
showOnboarding = true
hasCompletedOnboarding = true
}
syncMenuBarOnLaunch()
}
}
.windowStyle(.titleBar)
.defaultSize(width: 960, height: 620)

Settings {
SettingsView()
}
}

/// First-launch default: ON (per product decision). On every launch
/// we re-sync the SMAppService state with the preference so the
/// truth of "is the helper actually running" matches the toggle —
/// macOS occasionally drops registrations after updates, especially
/// when the helper bundle path changes (which it doesn't here, but
/// re-registering is cheap and idempotent).
private func syncMenuBarOnLaunch() {
if !menuBarFirstLaunchDone {
menuBarFirstLaunchDone = true
// showMenuBarWidget already defaults to true; setEnabled is
// idempotent if already registered.
}
MenuBarLauncher.shared.setEnabled(showMenuBarWidget)
}
}

Expand Down
138 changes: 138 additions & 0 deletions Sources/MacClean/Services/MenuBarLauncher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import Foundation
import AppKit
import ServiceManagement
import MacCleanKit

/// Registers / unregisters the menu bar widget as a login item via
/// `SMAppService.loginItem(identifier:)`. The identifier is the bundle id
/// of the helper app embedded at
/// `Mac Clean.app/Contents/Library/LoginItems/MacCleanMenu.app/`. macOS
/// looks at that exact path to find the helper, so the bundling in
/// `scripts/build-dmg.sh` must match.
///
/// On registration the system launches the helper immediately (no need
/// to call NSWorkspace.open). On unregister the helper is also stopped.
/// State is queryable via `status` so the Settings toggle can reflect
/// the truth of "is the widget actually running right now."
@MainActor
@Observable
public final class MenuBarLauncher {
public enum LauncherError: Error, LocalizedError {
case registrationFailed(String)
case unregisterFailed(String)

public var errorDescription: String? {
switch self {
case .registrationFailed(let msg):
return "Couldn't enable the menu bar widget: \(msg)"
case .unregisterFailed(let msg):
return "Couldn't disable the menu bar widget: \(msg)"
}
}
}

public static let shared = MenuBarLauncher()

public private(set) var lastError: LauncherError?

private let service = SMAppService.loginItem(identifier: MCConstants.menuBundleIdentifier)

public var isRegistered: Bool {
service.status == .enabled
}

public var status: SMAppService.Status {
service.status
}

private init() {}

public func register() throws {
do {
try service.register()
lastError = nil
} catch {
let wrapped = LauncherError.registrationFailed(error.localizedDescription)
lastError = wrapped
throw wrapped
}
}

public func unregister() throws {
do {
try service.unregister()
lastError = nil
} catch {
let wrapped = LauncherError.unregisterFailed(error.localizedDescription)
lastError = wrapped
throw wrapped
}
}

/// Best-effort enable; swallows errors so app launch can't be
/// blocked by a Settings-level "show in menu bar" preference flip
/// going sideways. The error surfaces via `lastError` and the
/// Settings UI can prompt the user to retry.
///
/// Two-step on enable:
/// 1. SMAppService.register() — auto-start at login (the "real"
/// reason for the API).
/// 2. NSWorkspace.openApplication() — launch the helper NOW.
///
/// Step 2 exists because SMAppService is finicky with ad-hoc
/// signed builds (the path Homebrew users get). It can return
/// `.enabled` from `register()` without macOS actually launching
/// the helper — the system intends to launch it at next login
/// but won't kick it off in the current session. We want the
/// widget visible the moment the toggle flips, so we kick it
/// directly via NSWorkspace. Idempotent: skips if already running.
public func setEnabled(_ enabled: Bool) {
if enabled {
try? register()
launchHelperIfNotRunning()
} else {
try? unregister()
terminateRunningHelper()
}
}

/// Path to the bundled `MacCleanMenu.app` helper. Returns `nil`
/// when running under `swift run` (no .app wrapper around us),
/// which is fine — dev workflow is `swift run MacCleanMenu`
/// directly.
public func helperAppURL() -> URL? {
let helper = Bundle.main.bundleURL
.appending(path: "Contents")
.appending(path: "Library")
.appending(path: "LoginItems")
.appending(path: "MacCleanMenu.app")
guard FileManager.default.fileExists(atPath: helper.path) else { return nil }
return helper
}

private func isHelperRunning() -> Bool {
NSWorkspace.shared.runningApplications.contains {
$0.bundleIdentifier == MCConstants.menuBundleIdentifier
}
}

private func launchHelperIfNotRunning() {
guard !isHelperRunning(), let url = helperAppURL() else { return }
let config = NSWorkspace.OpenConfiguration()
config.activates = false // Don't steal focus from the main app
config.hides = false
NSWorkspace.shared.openApplication(at: url, configuration: config) { [weak self] _, error in
guard let self, let error else { return }
Task { @MainActor in
self.lastError = .registrationFailed(error.localizedDescription)
}
}
}

private func terminateRunningHelper() {
for app in NSWorkspace.shared.runningApplications
where app.bundleIdentifier == MCConstants.menuBundleIdentifier {
app.terminate()
}
}
}
16 changes: 16 additions & 0 deletions Sources/MacClean/Views/Protection/MalwareView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ struct MalwareView: View {
for r in results { selectedItems.formUnion(r.items.map(\.url)) }
isScanning = false
scanComplete = true

// Surface scan outcome to the menu bar widget. The widget
// reads SharedAppState every poll and shows the shield
// glyph color (green = clean recent, yellow = stale,
// red = threats) plus a "Last scan / X threats" card.
let depthLabel: String
switch scanDepth {
case .quick: depthLabel = "quick"
case .balanced: depthLabel = "balanced"
case .deep: depthLabel = "deep"
}
SharedAppState.protectionStatus = SharedAppState.ProtectionStatus(
lastScanDate: Date(),
threatsFound: results.reduce(0) { $0 + $1.items.count },
scanDepth: depthLabel
)
}
}

Expand Down
82 changes: 82 additions & 0 deletions Sources/MacClean/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import SwiftUI
import ServiceManagement
import MacCleanKit

struct SettingsView: View {
@AppStorage("showMenuBarWidget") private var showMenuBarWidget = true
@State private var launcher = MenuBarLauncher.shared
@State private var refreshTick = 0

var body: some View {
Form {
Section {
Toggle(isOn: $showMenuBarWidget) {
VStack(alignment: .leading, spacing: 2) {
Text("Show Mac Clean in the menu bar")
Text("Live CPU, memory, disk, battery, and network at the top of your screen. Click to expand the popover.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.onChange(of: showMenuBarWidget) { _, newValue in
launcher.setEnabled(newValue)
refreshTick &+= 1
}

statusRow
if let err = launcher.lastError {
Label(err.localizedDescription, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.caption)
}
} header: {
Text("Menu Bar")
}
}
.formStyle(.grouped)
.frame(width: 480, height: 240)
.id(refreshTick)
}

@ViewBuilder
private var statusRow: some View {
HStack {
Image(systemName: statusGlyph)
.foregroundStyle(statusColor)
Text("Widget status:")
.foregroundStyle(.secondary)
Text(statusText)
.font(.system(.body, design: .monospaced))
Spacer()
}
.font(.caption)
}

private var statusGlyph: String {
switch launcher.status {
case .enabled: return "checkmark.circle.fill"
case .notRegistered: return "minus.circle"
case .notFound: return "questionmark.circle"
case .requiresApproval: return "exclamationmark.triangle.fill"
@unknown default: return "questionmark.circle"
}
}

private var statusColor: Color {
switch launcher.status {
case .enabled: return .green
case .requiresApproval: return .orange
default: return .secondary
}
}

private var statusText: String {
switch launcher.status {
case .enabled: return "running"
case .notRegistered: return "not registered"
case .notFound: return "helper not found in bundle"
case .requiresApproval: return "needs approval in System Settings → Login Items"
@unknown default: return "unknown"
}
}
}
7 changes: 6 additions & 1 deletion Sources/MacClean/Views/Shared/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ struct OnboardingView: View {
}
.padding(24)
}
.frame(width: 600, height: 450)
// Was 600x450; the FDA step's full content (icon + title + body
// + 4 numbered steps + "Open System Settings" button) exceeded
// that and clipped the Back/Next buttons at the bottom of the
// sheet on real installs. 620 height gives every step room
// without becoming an empty-looking sheet on shorter steps.
.frame(width: 600, height: 620)
}

private var welcomeStep: some View {
Expand Down
57 changes: 48 additions & 9 deletions Sources/MacClean/Views/Sidebar/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,31 +88,70 @@ public enum SidebarSection: String, CaseIterable, Identifiable {

public struct SidebarView: View {
@Binding var selection: SidebarItem?
@AppStorage("showMenuBarWidget") private var showMenuBarWidget = true
@State private var launcher = MenuBarLauncher.shared

public init(selection: Binding<SidebarItem?>) {
self._selection = selection
}

public var body: some View {
List(selection: $selection) {
ForEach(SidebarSection.allCases) { section in
if section == .main {
ForEach(section.items) { item in
sidebarRow(item)
}
} else {
Section(section.rawValue) {
VStack(spacing: 0) {
List(selection: $selection) {
ForEach(SidebarSection.allCases) { section in
if section == .main {
ForEach(section.items) { item in
sidebarRow(item)
}
} else {
Section(section.rawValue) {
ForEach(section.items) { item in
sidebarRow(item)
}
}
}
}
}
.listStyle(.sidebar)

Divider().opacity(0.4)

menuBarFooter
}
.listStyle(.sidebar)
.frame(minWidth: 180, idealWidth: 200)
}

/// Always-visible footer at the bottom of the sidebar with the
/// menu bar widget toggle. ⌘, Settings has the same control plus
/// a status diagnostic row; this one is the discoverable entry
/// point for users who haven't learned the keyboard shortcut.
private var menuBarFooter: some View {
HStack(spacing: 8) {
Image(systemName: showMenuBarWidget
? "menubar.dock.rectangle.badge.record"
: "menubar.dock.rectangle")
.foregroundStyle(showMenuBarWidget ? .green : .secondary)
.font(.system(size: 14))
VStack(alignment: .leading, spacing: 0) {
Text("Menu Bar Widget")
.font(.system(size: 11, weight: .medium))
Text(showMenuBarWidget ? "Running" : "Off")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $showMenuBarWidget)
.toggleStyle(.switch)
.controlSize(.mini)
.labelsHidden()
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.onChange(of: showMenuBarWidget) { _, newValue in
launcher.setEnabled(newValue)
}
}

private func sidebarRow(_ item: SidebarItem) -> some View {
Label {
Text(item.rawValue)
Expand Down
2 changes: 1 addition & 1 deletion Sources/MacCleanKit/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,5 +159,5 @@ public enum MCConstants {
// plugin was tried (commit history) but doesn't work under multi-arch
// `swift build --arch arm64 --arch x86_64` because xcbuild doesn't
// execute plugins.
public static let appVersion = "1.5.3"
public static let appVersion = "1.6.0"
}
Loading
Loading