Skip to content

feat: Menu Bar Foundation - Phase 1 Task 1#1

Open
jsonify wants to merge 3 commits intomainfrom
menubar-foundation
Open

feat: Menu Bar Foundation - Phase 1 Task 1#1
jsonify wants to merge 3 commits intomainfrom
menubar-foundation

Conversation

@jsonify
Copy link
Copy Markdown
Owner

@jsonify jsonify commented Nov 27, 2025

Summary

Implements Phase 1 Task 1 of the Menu Bar Foundation spec: App Group and Shared State Infrastructure.

This establishes the foundation for state sharing between the main Pearcleaner app and the menu bar component that will be built in subsequent tasks.

Changes Made

1. App Group Configuration

  • Verified existing group.com.alienator88.Pearcleaner entitlement in Pearcleaner.entitlements
  • Ready for shared UserDefaults communication between app components

2. MenuBarUpdatePublisher Class

Location: Pearcleaner/Logic/MenuBar/MenuBarUpdatePublisher.swift

  • Singleton pattern for centralized shared state publishing
  • Methods:
    • publishUpdateCount(_:) - Writes update count to shared storage
    • publishLastCheckDate(_:) - Writes last check timestamp to shared storage
    • publishUpdate(count:lastCheck:) - Atomic write of both values
    • readUpdateCount() / readLastCheckDate() - Read methods for testing
  • Error handling with graceful degradation when App Group unavailable
  • Debug logging via printOS() for troubleshooting
  • Returns Bool to indicate success/failure

3. AppState Extension

Location: Pearcleaner/Logic/AppState.swift

  • Added @Published var showMenuBar: Bool property
  • Persists to UserDefaults key: "settings.interface.showMenuBar"
  • Auto-saves on change via didSet observer
  • Defaults to false (menu bar hidden initially)
  • Reactive property for SwiftUI binding in Settings UI (Task 8)

4. Shared Storage Keys

  • menubar.updateCount - Total pending updates
  • menubar.lastCheck - Last update check Date
  • App Group: group.com.alienator88.Pearcleaner

Testing

Manual test verification document created at:
.agent-os/specs/2025-11-26-menubar-foundation/test-verification/task-1-verification.md

Test Checklist:

  • App Group entitlement configured
  • MenuBarUpdatePublisher class created with all methods
  • Error handling for nil shared storage
  • AppState.showMenuBar property with persistence
  • Manual runtime testing (pending Xcode build)

Product Documentation

This PR also includes initial Agent OS product documentation:

  • Product mission and vision
  • Technical architecture decisions
  • 5-phase development roadmap
  • Decision log with rationale
  • CLAUDE.md for future AI agents working on this codebase

Next Steps

Task 2 will build MenuBarManager to read from this shared state and manage menu bar UI state.

🤖 Generated with Claude Code

Implements Phase 1 Task 1: App Group and Shared State Infrastructure

## Changes Made

- **App Group Setup**: Verified existing `group.com.alienator88.Pearcleaner` entitlement
- **MenuBarUpdatePublisher**: Created new class for publishing update counts and timestamps to shared UserDefaults
  - Singleton pattern for centralized access
  - Methods: `publishUpdateCount(_:)`, `publishLastCheckDate(_:)`, `publishUpdate(count:lastCheck:)`
  - Error handling for missing App Group with graceful degradation
  - Read methods for testing and verification
- **AppState Extension**: Added `@Published var showMenuBar: Bool` property
  - Persists to UserDefaults key `"settings.interface.showMenuBar"`
  - Auto-saves on property change via didSet observer
  - Defaults to false (menu bar hidden initially)
- **Test Documentation**: Created comprehensive test verification document

## Technical Details

- Location: `Pearcleaner/Logic/MenuBar/MenuBarUpdatePublisher.swift`
- Shared storage keys:
  - `menubar.updateCount`: Current update count
  - `menubar.lastCheck`: Last update check timestamp
- Uses `printOS()` for debug logging
- All methods return Bool for success/failure indication

## Testing

See `.agent-os/specs/2025-11-26-menubar-foundation/test-verification/task-1-verification.md` for manual test checklist.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello @jsonify, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request lays the groundwork for integrating a persistent menu bar component into Pearcleaner. It focuses on establishing the necessary infrastructure for shared state management between the main application and the upcoming menu bar feature, ensuring seamless data synchronization and user preference persistence. The changes are part of Phase 1 Task 1 of the Menu Bar Foundation, setting up the core communication channels and initial configuration without yet implementing the menu bar UI itself.

Highlights

  • App Group and Shared State Infrastructure: This PR establishes the foundation for state sharing between the main Pearcleaner app and a future menu bar component. It includes verifying the existing App Group entitlement group.com.alienator88.Pearcleaner for shared UserDefaults communication.
  • MenuBarUpdatePublisher Class: A new singleton class, MenuBarUpdatePublisher, has been added to Pearcleaner/Logic/MenuBar/MenuBarUpdatePublisher.swift. This class is responsible for publishing update counts and last check timestamps to the shared App Group storage, featuring error handling for unavailable App Groups and debug logging.
  • AppState Extension for Menu Bar Visibility: The AppState.swift file has been extended with a new @Published var showMenuBar: Bool property. This property manages the visibility of the menu bar, persists its state to UserDefaults under the key settings.interface.showMenuBar, and defaults to false.
  • Comprehensive Documentation Added: Extensive new documentation has been added, including product mission, decisions, roadmap, technical stack, and detailed specifications (spec, technical-spec, tests, tasks) for the Menu Bar Foundation feature. A CLAUDE.md file was also added to guide AI agents.
  • Initial Test Verification: An initial manual test verification document (.agent-os/specs/2025-11-26-menubar-foundation/test-verification/task-1-verification.md) has been created, outlining steps and expected behaviors for verifying the App Group and shared state infrastructure, with several checks already marked as complete.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request lays a solid foundation for the new menu bar feature by setting up the App Group and shared state infrastructure. The code is well-structured and accompanied by thorough documentation, which is great to see. I've provided a couple of suggestions to improve maintainability and align with modern Swift practices, mainly concerning the use of UserDefaults.

Comment on lines +53 to +58
// Menu bar visibility (persisted to UserDefaults)
@Published var showMenuBar: Bool = UserDefaults.standard.bool(forKey: "settings.interface.showMenuBar") {
didSet {
UserDefaults.standard.set(showMenuBar, forKey: "settings.interface.showMenuBar")
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The UserDefaults key "settings.interface.showMenuBar" is hardcoded and duplicated. It's a good practice to define it as a constant to avoid typos and improve maintainability. This also makes it easier to find all usages of this key.

Suggested change
// Menu bar visibility (persisted to UserDefaults)
@Published var showMenuBar: Bool = UserDefaults.standard.bool(forKey: "settings.interface.showMenuBar") {
didSet {
UserDefaults.standard.set(showMenuBar, forKey: "settings.interface.showMenuBar")
}
}
// Menu bar visibility (persisted to UserDefaults)
private static let showMenuBarKey = "settings.interface.showMenuBar"
@Published var showMenuBar: Bool = UserDefaults.standard.bool(forKey: AppState.showMenuBarKey) {
didSet {
UserDefaults.standard.set(showMenuBar, forKey: AppState.showMenuBarKey)
}
}

Comment on lines +12 to +109
class MenuBarUpdatePublisher {
static let shared = MenuBarUpdatePublisher()

// MARK: - Shared UserDefaults Keys
private let updateCountKey = "menubar.updateCount"
private let lastCheckKey = "menubar.lastCheck"
private let appGroupIdentifier = "group.com.alienator88.Pearcleaner"

// MARK: - Shared Storage Access

/// Shared UserDefaults container for App Group communication
/// Returns nil if App Group is not configured
private var sharedDefaults: UserDefaults? {
return UserDefaults(suiteName: appGroupIdentifier)
}

// MARK: - Public Methods

/// Publishes update count to shared storage
/// - Parameter count: Total number of pending updates
/// - Returns: Boolean indicating success or failure
@discardableResult
func publishUpdateCount(_ count: Int) -> Bool {
guard let defaults = sharedDefaults else {
printOS("MenuBarUpdatePublisher: App Group not available, cannot publish update count")
return false
}

defaults.set(count, forKey: updateCountKey)
defaults.synchronize()

printOS("MenuBarUpdatePublisher: Published update count: \(count)")
return true
}

/// Publishes last check timestamp to shared storage
/// - Parameter date: Date when update check completed
/// - Returns: Boolean indicating success or failure
@discardableResult
func publishLastCheckDate(_ date: Date) -> Bool {
guard let defaults = sharedDefaults else {
printOS("MenuBarUpdatePublisher: App Group not available, cannot publish last check date")
return false
}

defaults.set(date, forKey: lastCheckKey)
defaults.synchronize()

printOS("MenuBarUpdatePublisher: Published last check date: \(date)")
return true
}

/// Publishes both update count and timestamp atomically
/// - Parameters:
/// - count: Total number of pending updates
/// - date: Date when update check completed
/// - Returns: Boolean indicating success or failure
@discardableResult
func publishUpdate(count: Int, lastCheck date: Date) -> Bool {
guard let defaults = sharedDefaults else {
printOS("MenuBarUpdatePublisher: App Group not available, cannot publish update")
return false
}

defaults.set(count, forKey: updateCountKey)
defaults.set(date, forKey: lastCheckKey)
defaults.synchronize()

printOS("MenuBarUpdatePublisher: Published count: \(count), date: \(date)")
return true
}

// MARK: - Read Methods (for testing/debugging)

/// Reads current update count from shared storage
/// Returns nil if App Group not available or key doesn't exist
func readUpdateCount() -> Int? {
guard let defaults = sharedDefaults else {
return nil
}

guard defaults.object(forKey: updateCountKey) != nil else {
return nil
}

return defaults.integer(forKey: updateCountKey)
}

/// Reads last check date from shared storage
/// Returns nil if App Group not available or key doesn't exist
func readLastCheckDate() -> Date? {
guard let defaults = sharedDefaults else {
return nil
}

return defaults.object(forKey: lastCheckKey) as? Date
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This is a solid implementation for publishing state. I have a couple of suggestions to improve it further:

  1. Use Static Constants for Keys: The UserDefaults keys and app group identifier are defined as private instance properties. Making them static constants is more idiomatic and allows other components (like the future menu bar reader) to safely access them (e.g., MenuBarUpdatePublisher.appGroupIdentifier), preventing stringly-typed errors.
  2. Remove synchronize() Calls: The calls to defaults.synchronize() are no longer necessary. UserDefaults automatically saves data at appropriate times, and explicitly calling synchronize() can be inefficient. Removing these calls is the modern best practice.

I've combined these changes into a single suggestion for the whole class.

class MenuBarUpdatePublisher {
    static let shared = MenuBarUpdatePublisher()

    // MARK: - Shared UserDefaults Keys
    static let updateCountKey = "menubar.updateCount"
    static let lastCheckKey = "menubar.lastCheck"
    static let appGroupIdentifier = "group.com.alienator88.Pearcleaner"

    // MARK: - Shared Storage Access

    /// Shared UserDefaults container for App Group communication
    /// Returns nil if App Group is not configured
    private var sharedDefaults: UserDefaults? {
        return UserDefaults(suiteName: Self.appGroupIdentifier)
    }

    // MARK: - Public Methods

    /// Publishes update count to shared storage
    /// - Parameter count: Total number of pending updates
    /// - Returns: Boolean indicating success or failure
    @discardableResult
    func publishUpdateCount(_ count: Int) -> Bool {
        guard let defaults = sharedDefaults else {
            printOS("MenuBarUpdatePublisher: App Group not available, cannot publish update count")
            return false
        }

        defaults.set(count, forKey: Self.updateCountKey)

        printOS("MenuBarUpdatePublisher: Published update count: \(count)")
        return true
    }

    /// Publishes last check timestamp to shared storage
    /// - Parameter date: Date when update check completed
    /// - Returns: Boolean indicating success or failure
    @discardableResult
    func publishLastCheckDate(_ date: Date) -> Bool {
        guard let defaults = sharedDefaults else {
            printOS("MenuBarUpdatePublisher: App Group not available, cannot publish last check date")
            return false
        }

        defaults.set(date, forKey: Self.lastCheckKey)

        printOS("MenuBarUpdatePublisher: Published last check date: \(date)")
        return true
    }

    /// Publishes both update count and timestamp atomically
    /// - Parameters:
    ///   - count: Total number of pending updates
    ///   - date: Date when update check completed
    /// - Returns: Boolean indicating success or failure
    @discardableResult
    func publishUpdate(count: Int, lastCheck date: Date) -> Bool {
        guard let defaults = sharedDefaults else {
            printOS("MenuBarUpdatePublisher: App Group not available, cannot publish update")
            return false
        }

        defaults.set(count, forKey: Self.updateCountKey)
        defaults.set(date, forKey: Self.lastCheckKey)

        printOS("MenuBarUpdatePublisher: Published count: \(count), date: \(date)")
        return true
    }

    // MARK: - Read Methods (for testing/debugging)

    /// Reads current update count from shared storage
    /// Returns nil if App Group not available or key doesn't exist
    func readUpdateCount() -> Int? {
        guard let defaults = sharedDefaults else {
            return nil
        }

        guard defaults.object(forKey: Self.updateCountKey) != nil else {
            return nil
        }

        return defaults.integer(forKey: Self.updateCountKey)
    }

    /// Reads last check date from shared storage
    /// Returns nil if App Group not available or key doesn't exist
    func readLastCheckDate() -> Date? {
        guard let defaults = sharedDefaults else {
            return nil
        }

        return defaults.object(forKey: Self.lastCheckKey) as? Date
    }
}

jsonify and others added 2 commits November 26, 2025 23:07
This commit implements the foundational menu bar components for Phase 1:

**Task 2: MenuBarManager Core Logic**
- Created MenuBarManager singleton with @mainactor isolation
- Implements reactive state management via Combine publishers
- Observes UpdateManager for real-time update count and checking state
- Provides updateFromSharedDefaults() for App Group synchronization
- Implements openMainApp() for main window activation and navigation
- Includes error handling and fallback states for missing App Group

**Task 3: UpdateManager Integration**
- Extended UpdateManager with publishToMenuBar() method
- Publishes update count and timestamp after each scan completion
- Aggregates updates across all sources (App Store, Homebrew, Sparkle)
- Uses MenuBarUpdatePublisher for atomic shared storage writes
- Maintains backward compatibility with existing update flow

**Task 4: Menu Bar Icon and Visual States**
- Created MenuBarIconView with dynamic state rendering
- Implements MenuBarIconState enum (idle, checking, updatesAvailable)
- Badge overlay with red capsule showing update count (99+ support)
- 360° rotation animation during update checks
- Tooltip with relative timestamp ("Last checked 2 hours ago")

**Bonus: Menu Bar Dropdown Content (Task 5 partial)**
- Created MenuBarContent view with update summary and actions
- "View Updates" / "Open Pearcleaner" button with smart navigation
- "Quit Pearcleaner" option following macOS patterns
- Custom MenuButtonStyle for native macOS appearance
- Progress indicator during update checks

**Implementation Details:**
- All code follows existing Pearcleaner patterns and conventions
- Uses Combine for reactive state observation
- Proper @mainactor isolation for thread safety
- Error handling with printOS() logging
- RelativeDateTimeFormatter for human-readable timestamps
- Preview providers for Xcode Canvas support

**Files Modified:**
- Pearcleaner/Logic/AppsUpdater/UpdateManager.swift

**Files Created:**
- Pearcleaner/Logic/MenuBar/MenuBarManager.swift
- Pearcleaner/Views/MenuBar/MenuBarIconView.swift
- Pearcleaner/Views/MenuBar/MenuBarContent.swift
- .agent-os/specs/2025-11-26-menubar-foundation/IMPLEMENTATION_SUMMARY.md
- .agent-os/specs/2025-11-26-menubar-foundation/TODO.md

**Next Steps:**
- Task 6: MenuBarExtra integration in PearcleanerApp.swift
- Task 8: Settings UI toggle for menu bar visibility
- Manual testing and build verification in Xcode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit completes the menu bar integration and user-facing controls:

**Task 6: MenuBarExtra Integration**
- Added MenuBarExtra scene to PearcleanerApp.swift
- Conditional rendering based on AppState.showMenuBar
- Uses MenuBarIconView for label, MenuBarContent for dropdown
- Native macOS window style (.menuBarExtraStyle(.window))
- Updated AppDelegate to keep app running when menu bar enabled
  - Returns !AppState.shared.showMenuBar from applicationShouldTerminateAfterLastWindowClosed
  - Preserves original behavior (quit on window close) when disabled

**Task 7: Main App Launcher Action**
- Already implemented in Task 2 (MenuBarManager.openMainApp)
- Integrated with MenuBarContent "View Updates" button
- Smart navigation: goes to Updater only when updates available
- Handles app activation, window unhiding, and navigation

**Task 8: Settings UI Integration**
- Added menu bar toggle in Settings → Interface
- Positioned after "Badge notification overlays" setting
- Dynamic icon: menubar.dock.rectangle (enabled) / menubar.dock.rectangle.badge.record (disabled)
- Primary label reflects current state
- Secondary label: "Show update count and quick actions in menu bar"
- Two-way binding: Toggle ↔ AppState.showMenuBar ↔ UserDefaults
- Follows existing settings pattern exactly

**Key Features:**
- No app restart required for menu bar visibility changes
- Menu bar appears/disappears immediately when toggled
- App lifecycle adapts dynamically (stay running vs. quit on close)
- Persistent user preference across app launches
- Native macOS appearance and behavior

**User Experience:**
1. Enable menu bar in Settings → Interface
2. Menu bar icon appears immediately with update count badge
3. Click icon to see update summary and actions
4. Click "Open Pearcleaner" to activate main window
5. Close window → app stays running (accessible via menu bar)
6. Disable menu bar → icon disappears, app quits when window closes

**Implementation Details:**
- SwiftUI conditional Scene rendering (native approach)
- Reactive state management via @Published/@ObservedObject
- No manual view lifecycle management
- Proper Scene hierarchy (WindowGroup + MenuBarExtra as siblings)
- MenuBarManager singleton shared across all views

**Files Modified:**
- Pearcleaner/PearcleanerApp.swift
- Pearcleaner/Views/Settings/Interface.swift

**Documentation Created:**
- .agent-os/specs/2025-11-26-menubar-foundation/IMPLEMENTATION_SUMMARY_TASKS_6_8.md
- .agent-os/specs/2025-11-26-menubar-foundation/TODO.md (updated)

**Phase 1 Progress: 70% complete (7 of 10 tasks)**

**Next Steps:**
- Task 9: Should-Have Features (optional "Check for Updates Now" button)
- Task 10: Integration Testing and Polish (requires Xcode build verification)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
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.

1 participant