Menuet

repository·master·Indexed 43 days ago

https://github.com/caseymrm/menuet

A Golang library for creating macOS menubar applications in the NSStatusBar. It provides high-level abstractions for managing menu states, handling clicks, and building native macOS UI components such as submenus, separators, notifications, and search fields. Includes support for building .app bundles via a provided Makefile and an OAuth redirect trampoline service at auth.menuet.app.

Tokens
8.7K
Snippets
38
Records
44
Agent score
54%

What's inside Menuet

  1. Implement toggle-style menu items with checkmarks

    master

    Since macOS menus dismiss immediately upon clicking an item, use the State field on a menuet.Regular item to show a checkmark for toggles. When the item is clicked, update your application state, and ensure the next time the menu is rendered, the State reflects the new value.

    menuet.Regular{
        Text:    "Notifications enabled",
        State:   prefs.NotificationsEnabled,
        Clicked: func() { prefs.NotificationsEnabled = !prefs.NotificationsEnabled },
    }
  2. Deploy the auth.menuet.app trampoline

    master

    You can deploy the OAuth redirect trampoline to Cloudflare Pages using the Wrangler CLI. This service is intended to be deployed to the menuet-auth project on the main branch.

    npx wrangler pages deploy web/auth --project-name menuet-auth --branch main
  3. Use auth.menuet.app for OAuth redirects

    master

    The auth.menuet.app service acts as a generic OAuth redirect trampoline for Menuet applications. It is designed to handle OAuth providers that require an https redirect URI, which is a common requirement for many modern identity providers.

    How it works

    When an OAuth provider redirects to https://auth.menuet.app/<app>, the service forwards the query string to your application's local loopback listener using a top-level https $\rightarrow$ http navigation. Browsers permit this specific transition for 127.0.0.1 addresses.

    Registering a new application

    To add a new application to the trampoline:

    1. Add a new entry for your application in the apps map within the index.html file of the web/auth directory.
    2. Register the URI https://auth.menuet.app/<app> (replacing <app> with your application's identifier) with your OAuth provider.
  4. Build and run Menuet apps as macOS .app bundles

    master

    While go run works for development, certain features like Notifications, Start at Login, and Auto-update require the binary to be inside a proper macOS .app bundle.

    To automate this, use the provided menuet.mk Makefile. Create a Makefile in your app directory with the following structure:

    APP=My App
    IDENTIFIER=com.example.myapp
    include $(GOPATH)/src/github.com/caseymrm/menuet/menuet.mk

    Commands

    • make run: Builds the binary into My App.app/Contents/MacOS/myapp, generates the Info.plist using your CFBundleIdentifier, and launches it.
    • make sign: Signs the bundle using a Developer ID Application certificate from your Keychain (set the IDENTITY variable to your certificate name).
  5. Generate application snapshots via environment variables

    master

    You can capture a JSON representation of your application's menu state by setting specific environment variables before running your application. This snapshot can be used by HTML renderers to create mockups for websites, README screenshots, or regression tests.

    To generate a snapshot, set the following environment variables:

    • MENUET_SNAPSHOT_PATH: The file path where the JSON snapshot should be written.
    • MENUET_SNAPSHOT_DELAY: (Optional) The amount of time to wait before capturing the snapshot to allow startup goroutines to populate state. Defaults to 2s. Accepts any valid Go time.Duration string (e.g., 5s, 500ms).

    When MENUET_SNAPSHOT_PATH is provided, the application will write the snapshot and then short-circuit startup (it will not initialize AppKit, Carbon, or start goroutines).

    # Example: Capture a snapshot after a 5-second delay
    MENUET_SNAPSHOT_DELAY=5s MENUET_SNAPSHOT_PATH=./menu_snapshot.json ./your_app
  6. Define menu items using the MenuItem interface

    master

    In Menuet, all items within a menu must implement the MenuItem interface. The primary concrete types you will use are Regular for standard rows, Separator for dividers, Search for in-menu search fields, and Image for large picture rows. These items are typically returned by the Children callback of a Regular item or the root application.

    type MenuItem interface {
    	menuItem()
    }
  7. Define global keyboard shortcuts with Shortcut

    master

    A Shortcut represents a global keyboard shortcut. When assigned to a Regular menu item, it performs two actions:

    1. Displays the shortcut in the menu (e.g., ⌘N).
    2. Fires the item's Clicked callback when the keys are pressed system-wide, even if the application is not in the foreground.

    Note: Identical KeyCode and Modifiers combinations cannot be used for multiple menu items. The first item registered with a specific shortcut will receive the trigger; subsequent registrations of the same shortcut will be silently ignored. It is recommended to use distinct shortcuts for every action.

    // Example of defining a shortcut
    shortcut := menuet.Shortcut{
    	KeyCode:   menuet.KeyN,
    	Modifiers: menuet.ModCmd,
    }
  8. Understand the Snapshot data structure

    master

    A Snapshot is a self-contained, JSON-serializable record of an application's menu. It is designed to be safe to render even if the file came from a third party, as it contains no live code paths or references to the application logic.

    Snapshot Schema

    Every snapshot includes a schema field. The current version is menuet-snapshot/v1 (defined by SnapshotSchema).

    Snapshot Types

    SnapshotItem represents the individual elements in the menu. The Type field determines how the item is rendered:

    • regular: A standard menu item with text, subtitles, shortcuts, and styling.
    • separator: A visual divider.
    • search: A search field. The snapshot captures the results of the search with an empty query.
    • image: An image item. Note that actual image bytes are not captured to keep the file size small; instead, the snapshot records the imageWidth, imageHeight, and imageSource ("data" or "path").
  9. Initialize and run a Menuet application

    master

    To create a macOS menu bar application, use the App() singleton to configure your application's properties and then call RunApplication().

    Note that RunApplication() is a blocking call that enters the AppKit run loop. If the environment variable MENUET_SNAPSHOT_PATH is set, the application will instead write a JSON snapshot of the current MenuState and return immediately without starting the UI loop.

    package main
    
    import "auth.menuet/menuet"
    
    func main() {
    	app := menuet.App()
    	app.Name = "My App"
    	app.Label = "com.example.myapp"
    	app.Children = func() []menuet.MenuItem {
    		return []menuet.MenuItem{
    			menuet.Regular{Label: "Hello"},
    		}
    	}
    
    	app.RunApplication()
    }
  10. Manage application lifecycle and graceful shutdown

    master

    Menuet provides GracefulShutdownHandles() to help you manage long-running goroutines when the user terminates the application. It returns a *sync.WaitGroup and a context.Context.

    1. Use the WaitGroup to track all background goroutines your app starts.
    2. Use the Context to signal those goroutines to stop when the app is closing.
    3. The underlying C layer will call shutdownWait() which triggers the context cancellation and waits for the WaitGroup to finish before the process exits.
    app := menuet.App()
    wg, ctx := app.GracefulShutdownHandles()
    
    go func() {
    	wg.Add(1)
    	defer wg.Done()
    	for {
    		select {
    		case <-ctx.Done():
    			return
    		// do work
    		}
    	}
    }()
    
    app.RunApplication()
  11. Mac App Store compatibility for menuet.Search

    master

    If your application is intended for distribution via the Mac App Store, you should not use menuet.Search.

    Implementation details for menuet.Search rely on the private selector setKeyOverride: on NSPopupMenuWindow to enable text cursor focus and non-modal editing within an NSMenu. While this provides a seamless search experience for direct-distribution apps (like whyawake, notafan, or traytter), it violates App Store policies regarding private API usage.