From 28f7d0b67612788566c2df93fcd78a83d0619140 Mon Sep 17 00:00:00 2001 From: drew Date: Thu, 23 Apr 2026 16:07:58 +0400 Subject: [PATCH 01/12] feat: mailto support Signed-off-by: drew --- assets/embed.go | 6 ++ cli/integration.go | 193 ++++++++++++++++++++++++++++++++++++++ cli/macos_handler.swift | 30 ++++++ main.go | 52 ++++++++-- notify/macos_notify.swift | 20 ++++ test_icon.go | 12 +++ 6 files changed, 307 insertions(+), 6 deletions(-) create mode 100644 assets/embed.go create mode 100644 cli/integration.go create mode 100644 cli/macos_handler.swift create mode 100644 notify/macos_notify.swift create mode 100644 test_icon.go diff --git a/assets/embed.go b/assets/embed.go new file mode 100644 index 0000000..faf5990 --- /dev/null +++ b/assets/embed.go @@ -0,0 +1,6 @@ +package assets + +import _ "embed" + +//go:embed logo.png +var Logo []byte diff --git a/cli/integration.go b/cli/integration.go new file mode 100644 index 0000000..e3a8c08 --- /dev/null +++ b/cli/integration.go @@ -0,0 +1,193 @@ +package cli + +import ( + _ "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/floatpane/matcha/assets" +) + +//go:embed macos_handler.swift +var macosHandlerSwift string + +// SetupMailto registers matcha as the default handler for mailto: links. +func SetupMailto() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("could not find executable: %w", err) + } + exe, err = filepath.Abs(exe) + if err != nil { + return fmt.Errorf("could not resolve absolute path: %w", err) + } + + switch runtime.GOOS { + case "linux": + return setupMailtoLinux(exe) + case "darwin": + return setupMailtoDarwin(exe) + default: + return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } +} + +func setupMailtoLinux(exe string) error { + desktopContent := fmt.Sprintf(`[Desktop Entry] +Name=Matcha Email +Comment=Terminal-based email client +Exec=%s %%u +Terminal=true +Type=Application +Icon=matcha +Categories=Network;Email; +MimeType=x-scheme-handler/mailto; +`, exe) + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + iconsDir := filepath.Join(home, ".local", "share", "icons", "hicolor", "512x512", "apps") + if err := os.MkdirAll(iconsDir, 0755); err == nil { + iconFile := filepath.Join(iconsDir, "matcha.png") + _ = os.WriteFile(iconFile, assets.Logo, 0644) + _ = exec.Command("gtk-update-icon-cache", filepath.Join(home, ".local", "share", "icons", "hicolor")).Run() + } + + appsDir := filepath.Join(home, ".local", "share", "applications") + if err := os.MkdirAll(appsDir, 0755); err != nil { + return err + } + + desktopFile := filepath.Join(appsDir, "matcha.desktop") + if err := os.WriteFile(desktopFile, []byte(desktopContent), 0644); err != nil { + return err + } + + // Update desktop database + if err := exec.Command("update-desktop-database", appsDir).Run(); err != nil { + // Ignore error if command doesn't exist + } + + // Try to set xdg-mime default + cmd := exec.Command("xdg-mime", "default", "matcha.desktop", "x-scheme-handler/mailto") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to run xdg-mime: %w", err) + } + + fmt.Printf("Successfully registered %s as default mail handler on Linux\n", exe) + return nil +} + +func setupMailtoDarwin(exe string) error { + // For macOS, we need to create a tiny AppleScript/Swift app bundle to handle the URL event, + // because standard terminal programs can't easily register as URL handlers without an app bundle. + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + appDir := filepath.Join(home, "Applications", "MatchaMail.app") + contentsDir := filepath.Join(appDir, "Contents") + macosDir := filepath.Join(contentsDir, "MacOS") + resourcesDir := filepath.Join(contentsDir, "Resources") + + if err := os.MkdirAll(macosDir, 0755); err != nil { + return err + } + if err := os.MkdirAll(resourcesDir, 0755); err != nil { + return err + } + + // Generate .icns from embedded logo + tmpLogo := filepath.Join(os.TempDir(), "matcha_logo.png") + if err := os.WriteFile(tmpLogo, assets.Logo, 0644); err == nil { + icnsPath := filepath.Join(resourcesDir, "MatchaMail.icns") + _ = exec.Command("sips", "-s", "format", "icns", tmpLogo, "--out", icnsPath).Run() + os.Remove(tmpLogo) + } + + infoPlist := ` + + + + CFBundleExecutable + MatchaMail + CFBundleIconFile + MatchaMail.icns + CFBundleIdentifier + com.floatpane.matcha.handler + CFBundleName + MatchaMail + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 10.15 + CFBundleURLTypes + + + CFBundleURLName + Email Address + CFBundleURLSchemes + + mailto + + + + + +` + if err := os.WriteFile(filepath.Join(contentsDir, "Info.plist"), []byte(infoPlist), 0644); err != nil { + return err + } + + // Swift source code to handle URL event and launch Terminal.app running matcha + swiftCode := strings.ReplaceAll(macosHandlerSwift, "{{MATCHA_PATH}}", exe) + + tmpSwiftFile := filepath.Join(os.TempDir(), "matcha_handler.swift") + if err := os.WriteFile(tmpSwiftFile, []byte(swiftCode), 0644); err != nil { + return err + } + defer os.Remove(tmpSwiftFile) + + exeDest := filepath.Join(macosDir, "MatchaMail") + + // Compile the Swift file + cmd := exec.Command("swiftc", tmpSwiftFile, "-o", exeDest) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to compile Swift handler app: %w. Do you have Xcode command line tools installed?", err) + } + + // Register the application + lsregister := "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister" + regCmd := exec.Command(lsregister, "-f", appDir) + if err := regCmd.Run(); err != nil { + return fmt.Errorf("failed to register app with LaunchServices: %w", err) + } + + // Set as default handler + // macOS does not provide a straightforward CLI to change default handler without 3rd party tools (like duti). + // We'll instruct the user on how to do it or try our best. + // Actually, starting from macOS 12, there's no native Apple command for it. But registering it usually makes it show up in Apple Mail -> Preferences -> Default email reader. + + fmt.Printf("Successfully created %s.\n", appDir) + fmt.Println("To complete the setup on macOS:") + fmt.Println("1. Open Apple Mail.") + fmt.Println("2. Go to Mail -> Settings (or Preferences) -> General.") + fmt.Println("3. Select 'MatchaMail.app' from the 'Default email reader' dropdown.") + + return nil +} diff --git a/cli/macos_handler.swift b/cli/macos_handler.swift new file mode 100644 index 0000000..6c1466d --- /dev/null +++ b/cli/macos_handler.swift @@ -0,0 +1,30 @@ +import Cocoa + +class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) // hide from dock initially + } + func application(_ application: NSApplication, open urls: [URL]) { + guard let url = urls.first else { return } + + let matchaPath = "{{MATCHA_PATH}}" + let script = """ + tell application "Terminal" + activate + do script "'\(matchaPath)' '\(url.absoluteString)'" + end tell + """ + + var error: NSDictionary? + if let appleScript = NSAppleScript(source: script) { + appleScript.executeAndReturnError(&error) + } + + NSApplication.shared.terminate(nil) + } +} + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.run() diff --git a/main.go b/main.go index 6fb3f66..c1d2fe6 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "io" "log" "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -110,9 +111,11 @@ type mainModel struct { service daemonclient.Service // Plugin prompt waiting for user input pendingPrompt *plugin.PendingPrompt + // mailto: URL parsed from os.Args + mailtoURL *url.URL } -func newInitialModel(cfg *config.Config) *mainModel { +func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel { idleUpdates := make(chan fetcher.IdleUpdate, 16) initialModel := &mainModel{ emailsByAcct: make(map[string][]fetcher.Email), @@ -120,6 +123,7 @@ func newInitialModel(cfg *config.Config) *mainModel { idleUpdates: idleUpdates, idleWatcher: fetcher.NewIdleWatcher(idleUpdates), providers: make(map[string]backend.Provider), + mailtoURL: mailtoURL, } if cfg == nil || !cfg.HasAccounts() { @@ -129,7 +133,17 @@ func newInitialModel(cfg *config.Config) *mainModel { } initialModel.current = tui.NewLogin(hideTips) } else { - initialModel.current = tui.NewChoice() + if mailtoURL != nil { + to := mailtoURL.Opaque + if to == "" { + to = mailtoURL.Query().Get("to") + } + subject := mailtoURL.Query().Get("subject") + body := mailtoURL.Query().Get("body") + initialModel.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips) + } else { + initialModel.current = tui.NewChoice() + } initialModel.config = cfg } return initialModel @@ -995,7 +1009,17 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = tui.NewLogin(hideTips) } else { m.config = cfg - m.current = tui.NewChoice() + if m.mailtoURL != nil { + to := m.mailtoURL.Opaque + if to == "" { + to = m.mailtoURL.Query().Get("to") + } + subject := m.mailtoURL.Query().Get("subject") + body := m.mailtoURL.Query().Get("body") + m.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips) + } else { + m.current = tui.NewChoice() + } } m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() @@ -3456,6 +3480,15 @@ func main() { os.Exit(0) } + // setup-mailto CLI subcommand: matcha setup-mailto + if len(os.Args) > 1 && os.Args[1] == "setup-mailto" { + if err := matchaCli.SetupMailto(); err != nil { + fmt.Fprintf(os.Stderr, "setup-mailto failed: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } + // Marketplace TUI subcommand: matcha marketplace if len(os.Args) > 1 && os.Args[1] == "marketplace" { mp := tui.NewMarketplace(true) @@ -3475,12 +3508,19 @@ func main() { log.Printf("Failed to initialize i18n: %v", err) } + var mailtoURL *url.URL + if len(os.Args) > 1 && strings.HasPrefix(strings.ToLower(os.Args[1]), "mailto:") { + if u, err := url.Parse(os.Args[1]); err == nil { + mailtoURL = u + } + } + var initialModel *mainModel if config.IsSecureModeEnabled() { // Secure mode: show password prompt before loading config tui.RebuildStyles() - initialModel = newInitialModel(nil) + initialModel = newInitialModel(nil, mailtoURL) initialModel.current = tui.NewPasswordPrompt() } else { cfg, err := config.LoadConfig() @@ -3500,9 +3540,9 @@ func main() { _ = config.EnsurePGPDir() if err != nil { - initialModel = newInitialModel(nil) + initialModel = newInitialModel(nil, mailtoURL) } else { - initialModel = newInitialModel(cfg) + initialModel = newInitialModel(cfg, mailtoURL) } } diff --git a/notify/macos_notify.swift b/notify/macos_notify.swift new file mode 100644 index 0000000..a266016 --- /dev/null +++ b/notify/macos_notify.swift @@ -0,0 +1,20 @@ +import Cocoa + +let args = ProcessInfo.processInfo.arguments +if args.count < 4 { exit(1) } + +let title = args[1] +let body = args[2] +let logoPath = args[3] + +let notification = NSUserNotification() +notification.title = title +notification.informativeText = body +notification.soundName = NSUserNotificationDefaultSoundName + +if let img = NSImage(contentsOfFile: logoPath) { + notification.setValue(img, forKey: "_identityImage") +} + +NSUserNotificationCenter.default.deliver(notification) +RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) diff --git a/test_icon.go b/test_icon.go new file mode 100644 index 0000000..9f1144f --- /dev/null +++ b/test_icon.go @@ -0,0 +1,12 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +func main() { + home, _ := os.UserHomeDir() + fmt.Println(filepath.Join(home, ".local", "share", "icons", "hicolor", "512x512", "apps")) +} From 8c4eca628ae5e489f68998638f2930c89e183c64 Mon Sep 17 00:00:00 2001 From: drew Date: Thu, 23 Apr 2026 16:34:35 +0400 Subject: [PATCH 02/12] finish swift code Signed-off-by: drew --- cli/macos_handler.swift | 27 ++++++++++-- main_test.go | 68 ------------------------------ notify/macos_notify.swift | 57 +++++++++++++++++++++++-- test_icon.go => tests/test_icon.go | 2 +- 4 files changed, 78 insertions(+), 76 deletions(-) delete mode 100644 main_test.go rename test_icon.go => tests/test_icon.go (89%) diff --git a/cli/macos_handler.swift b/cli/macos_handler.swift index 6c1466d..75cba78 100644 --- a/cli/macos_handler.swift +++ b/cli/macos_handler.swift @@ -1,13 +1,29 @@ import Cocoa +import AppleEvents class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { - NSApp.setActivationPolicy(.accessory) // hide from dock initially + NSApp.setActivationPolicy(.accessory) + + // Register for the 'getURL' event + NSAppleEventManager.shared().setEventHandler( + self, + andSelector: #selector(handleGetURLEvent(_:withReplyEvent:)), + forEventClass: AEEventClass(kInternetEventClass), + andEventID: AEEventID(kAEGetURL) + ) } - func application(_ application: NSApplication, open urls: [URL]) { - guard let url = urls.first else { return } + + @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) { + guard let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue, + let url = URL(string: urlString) else { + return + } let matchaPath = "{{MATCHA_PATH}}" + + // Use AppleScript to tell Terminal to run matcha with the URL + // We escape the URL and path for shell safety let script = """ tell application "Terminal" activate @@ -20,7 +36,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { appleScript.executeAndReturnError(&error) } - NSApplication.shared.terminate(nil) + // Exit after handling the event + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + NSApplication.shared.terminate(nil) + } } } diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 5744c10..0000000 --- a/main_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "strings" - "testing" -) - -func TestSanitizeFilename(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - {"normal filename", "document.pdf", "document.pdf"}, - {"filename with spaces", "my report.docx", "my report.docx"}, - {"unix path traversal", "../../../etc/passwd", "passwd"}, - {"windows path traversal", "..\\..\\..\\windows\\system32\\config", "config"}, - {"mixed traversal", "../secret/key.pem", "key.pem"}, - {"hidden file", ".bashrc", "attachment"}, - {"dot only", ".", "attachment"}, - {"dot dot", "..", "_"}, - {"empty string", "", "attachment"}, - {"absolute unix path", "/etc/shadow", "shadow"}, - {"absolute windows path", "C:\\Users\\secret.txt", "secret.txt"}, - {"double dot in middle", "file..name.txt", "file_name.txt"}, - {"multiple slashes", "path/to/file.txt", "file.txt"}, - {"null bytes removed", "file\x00name.txt", "file\x00name.txt"}, - {"unicode filename", "日本語.txt", "日本語.txt"}, - {"long traversal chain", "a/b/c/../../../d/e/f.txt", "f.txt"}, - {"exact 255 chars", strings.Repeat("a", 255), strings.Repeat("a", 255)}, - {"256 chars", strings.Repeat("a", 256), strings.Repeat("a", 255)}, - {"long with extension", strings.Repeat("b", 260) + ".txt", strings.Repeat("b", 251) + ".txt"}, - {"long extension only", "a." + strings.Repeat("c", 260), "." + strings.Repeat("c", 254)}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := sanitizeFilename(tt.input) - if got != tt.expected { - t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.expected) - } - // Verify the sanitized name never allows escaping the download directory - if got == "" { - t.Error("sanitizeFilename returned empty string") - } - if got == "." || got == ".." { - t.Error("sanitizeFilename returned a dangerous name: " + got) - } - }) - } -} - -func TestSanitizeFilenameNoPathSeparators(t *testing.T) { - // Ensure no sanitized output contains path separators - dangerous := []string{ - "a/b", "a\\b", "../a", "..\\a", - "/etc/passwd", "\\Windows\\System32", - "....//....//etc/passwd", - } - for _, input := range dangerous { - got := sanitizeFilename(input) - for _, ch := range got { - if ch == '/' || ch == '\\' { - t.Errorf("sanitizeFilename(%q) = %q contains path separator", input, got) - } - } - } -} diff --git a/notify/macos_notify.swift b/notify/macos_notify.swift index a266016..a99feeb 100644 --- a/notify/macos_notify.swift +++ b/notify/macos_notify.swift @@ -1,20 +1,71 @@ +#!/usr/bin/swift import Cocoa +// Compilation: swiftc macos_notify.swift -o macos_notify +// Usage: ./macos_notify <body> <logoPath> [subtitle] [identifier] [soundName] [contentImagePath] +// Or: ./macos_notify --remove [identifier] + let args = ProcessInfo.processInfo.arguments -if args.count < 4 { exit(1) } + +if args.contains("--remove") { + let center = NSUserNotificationCenter.default + if args.count > 2 { + let identifier = args[2] + center.removeDeliveredNotification(withIdentifier: identifier) + } else { + center.removeAllDeliveredNotifications() + } + exit(0) +} + +guard args.count >= 4 else { + print("Usage: \(args[0]) <title> <body> <logoPath> [subtitle] [identifier] [soundName] [contentImagePath]") + print(" \(args[0]) --remove [identifier]") + exit(1) +} let title = args[1] let body = args[2] let logoPath = args[3] +let subtitle = args.count > 4 ? args[4] : "" +let identifier = args.count > 5 ? args[5] : nil +let soundName = args.count > 6 ? args[6] : NSUserNotificationDefaultSoundName +let contentImagePath = args.count > 7 ? args[7] : "" + +class NotificationDelegate: NSObject, NSUserNotificationCenterDelegate { + func userNotificationCenter(_ center: NSUserNotificationCenter, shouldPresent notification: NSUserNotification) -> Bool { + return true + } +} let notification = NSUserNotification() notification.title = title notification.informativeText = body -notification.soundName = NSUserNotificationDefaultSoundName +notification.subtitle = subtitle +notification.soundName = soundName -if let img = NSImage(contentsOfFile: logoPath) { +if let identifier = identifier { + notification.identifier = identifier +} + +func loadImage(from path: String) -> NSImage? { + if path.isEmpty { return nil } + let expandedPath = (path as NSString).expandingTildeInPath + return NSImage(contentsOfFile: expandedPath) +} + +if let img = loadImage(from: logoPath) { + // _identityImage is a private key that allows showing an image on the left of the notification. notification.setValue(img, forKey: "_identityImage") } +if let contentImg = loadImage(from: contentImagePath) { + notification.contentImage = contentImg +} + +let delegate = NotificationDelegate() +NSUserNotificationCenter.default.delegate = delegate NSUserNotificationCenter.default.deliver(notification) + +// Wait for the notification to be delivered to the system. RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) diff --git a/test_icon.go b/tests/test_icon.go similarity index 89% rename from test_icon.go rename to tests/test_icon.go index 9f1144f..10a4094 100644 --- a/test_icon.go +++ b/tests/test_icon.go @@ -6,7 +6,7 @@ import ( "path/filepath" ) -func main() { +func TestIconPath() { home, _ := os.UserHomeDir() fmt.Println(filepath.Join(home, ".local", "share", "icons", "hicolor", "512x512", "apps")) } From 94a942277849719fe7890c3b2fcc4a5002fb7dbe Mon Sep 17 00:00:00 2001 From: drew <me@andrinoff.com> Date: Thu, 23 Apr 2026 16:51:05 +0400 Subject: [PATCH 03/12] feat: more macos integration Co-authored-by: Lea <lea@floatpane.com> Signed-off-by: drew <me@andrinoff.com> --- cli/contacts_sync.go | 22 ++++++ clib/macos/appearance.go | 61 +++++++++++++++++ clib/macos/appearance.swift | 24 +++++++ clib/macos/auth.swift | 74 ++++++++++++++++++++ clib/macos/badge.go | 53 +++++++++++++++ clib/macos/badge.swift | 24 +++++++ clib/macos/contacts.go | 58 ++++++++++++++++ clib/macos/contacts.swift | 79 ++++++++++++++++++++++ clib/macos/file_picker.go | 60 +++++++++++++++++ clib/macos/file_picker.swift | 33 +++++++++ clib/macos/keychain.swift | 105 +++++++++++++++++++++++++++++ clib/macos/menubar.swift | 126 +++++++++++++++++++++++++++++++++++ clib/macos/spellcheck.swift | 54 +++++++++++++++ clib/macos/spotlight.swift | 61 +++++++++++++++++ config/macos_sync.go | 33 +++++++++ main.go | 70 +++++++++++++++++-- theme/macos.go | 42 ++++++++++++ theme/theme.go | 18 +++++ tui/composer.go | 16 +++-- tui/filepicker.go | 2 +- tui/messages.go | 2 +- 21 files changed, 1004 insertions(+), 13 deletions(-) create mode 100644 cli/contacts_sync.go create mode 100644 clib/macos/appearance.go create mode 100644 clib/macos/appearance.swift create mode 100644 clib/macos/auth.swift create mode 100644 clib/macos/badge.go create mode 100644 clib/macos/badge.swift create mode 100644 clib/macos/contacts.go create mode 100644 clib/macos/contacts.swift create mode 100644 clib/macos/file_picker.go create mode 100644 clib/macos/file_picker.swift create mode 100644 clib/macos/keychain.swift create mode 100644 clib/macos/menubar.swift create mode 100644 clib/macos/spellcheck.swift create mode 100644 clib/macos/spotlight.swift create mode 100644 config/macos_sync.go create mode 100644 theme/macos.go diff --git a/cli/contacts_sync.go b/cli/contacts_sync.go new file mode 100644 index 0000000..0aa8459 --- /dev/null +++ b/cli/contacts_sync.go @@ -0,0 +1,22 @@ +package cli + +import ( + "fmt" + "runtime" + + "github.com/floatpane/matcha/config" +) + +// RunContactsSync handles `matcha contacts sync`. +func RunContactsSync(args []string) error { + if runtime.GOOS != "darwin" { + return fmt.Errorf("contacts sync is only supported on macOS") + } + + fmt.Println("Syncing contacts from macOS Contacts framework...") + if err := config.SyncMacOSContacts(); err != nil { + return err + } + fmt.Println("Successfully synced macOS contacts.") + return nil +} diff --git a/clib/macos/appearance.go b/clib/macos/appearance.go new file mode 100644 index 0000000..3b033df --- /dev/null +++ b/clib/macos/appearance.go @@ -0,0 +1,61 @@ +package macos + +import ( + _ "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +//go:embed appearance.swift +var appearanceSwift string + +type MacOSAppearance struct { + DarkMode bool + AccentColor string +} + +// GetAppearance fetches the current macOS appearance (dark mode and accent color). +func GetAppearance() (*MacOSAppearance, error) { + if runtime.GOOS != "darwin" { + return nil, fmt.Errorf("GetAppearance is only supported on macOS") + } + + tmpDir, err := os.MkdirTemp("", "matcha-appearance") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + swiftFile := filepath.Join(tmpDir, "appearance.swift") + if err := os.WriteFile(swiftFile, []byte(appearanceSwift), 0644); err != nil { + return nil, err + } + + binFile := filepath.Join(tmpDir, "appearance") + + // Compile + cmd := exec.Command("swiftc", swiftFile, "-o", binFile) + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("failed to compile appearance helper: %w\n%s", err, string(out)) + } + + // Run + out, err := exec.Command(binFile).Output() + if err != nil { + return nil, fmt.Errorf("failed to run appearance helper: %w", err) + } + + parts := strings.Fields(string(out)) + if len(parts) < 2 { + return nil, fmt.Errorf("unexpected output from appearance helper: %s", string(out)) + } + + return &MacOSAppearance{ + DarkMode: parts[0] == "true", + AccentColor: parts[1], + }, nil +} diff --git a/clib/macos/appearance.swift b/clib/macos/appearance.swift new file mode 100644 index 0000000..9047d4c --- /dev/null +++ b/clib/macos/appearance.swift @@ -0,0 +1,24 @@ +import Cocoa + +// Compilation: swiftc appearance.swift -o appearance +// Usage: ./appearance + +func getAccentColor() -> String { + if #available(macOS 10.14, *) { + let color = NSColor.controlAccentColor.usingColorSpace(.sRGB) + if let color = color { + let r = Int(color.redComponent * 255) + let g = Int(color.greenComponent * 255) + let b = Int(color.blueComponent * 255) + return String(format: "#%02X%02X%02X", r, g, b) + } + } + return "#007AFF" // Default macOS blue +} + +func isDarkMode() -> Bool { + let mode = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") + return mode == "Dark" +} + +print("\(isDarkMode()) \(getAccentColor())") diff --git a/clib/macos/auth.swift b/clib/macos/auth.swift new file mode 100644 index 0000000..33710f5 --- /dev/null +++ b/clib/macos/auth.swift @@ -0,0 +1,74 @@ +import Foundation +import LocalAuthentication + +// Compilation: swiftc auth.swift -o auth +// Usage: ./auth <reason> + +enum AuthResult: String, Codable { + case success + case failure + case notAvailable + case userCancel + case fallback +} + +struct Response: Codable { + let status: AuthResult + let message: String? +} + +func authenticate(reason: String) { + let context = LAContext() + var error: NSError? + + // Check if biometric authentication is available on the device. + if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { + + context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authenticationError in + + DispatchQueue.main.async { + if success { + sendResponse(status: .success, message: "Successfully authenticated") + } else { + if let error = authenticationError as? LAError { + switch error.code { + case .userCancel: + sendResponse(status: .userCancel, message: "User cancelled") + case .userFallback: + sendResponse(status: .fallback, message: "User chose fallback") + case .biometryNotEnrolled: + sendResponse(status: .notAvailable, message: "Biometry not enrolled") + case .biometryLockout: + sendResponse(status: .failure, message: "Biometry lockout") + default: + sendResponse(status: .failure, message: error.localizedDescription) + } + } else { + sendResponse(status: .failure, message: "Unknown error") + } + } + } + } + } else { + // Biometry is not available on this device + let message = error?.localizedDescription ?? "Biometry not available" + sendResponse(status: .notAvailable, message: message) + } +} + +func sendResponse(status: AuthResult, message: String?) { + let response = Response(status: status, message: message) + if let data = try? JSONEncoder().encode(response), + let jsonString = String(data: data, encoding: .utf8) { + print(jsonString) + } + exit(0) +} + +let args = ProcessInfo.processInfo.arguments +let reason = args.count > 1 ? args[1] : "Authenticate to unlock Matcha" + +authenticate(reason: reason) + +// Keep the process alive for the async evaluation +RunLoop.main.run() diff --git a/clib/macos/badge.go b/clib/macos/badge.go new file mode 100644 index 0000000..e820e1e --- /dev/null +++ b/clib/macos/badge.go @@ -0,0 +1,53 @@ +package macos + +import ( + _ "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" +) + +//go:embed badge.swift +var badgeSwift string + +// SetBadge updates the macOS Dock badge count. +func SetBadge(count int) error { + if runtime.GOOS != "darwin" { + return nil + } + + tmpDir, err := os.MkdirTemp("", "matcha-badge") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + swiftFile := filepath.Join(tmpDir, "badge.swift") + if err := os.WriteFile(swiftFile, []byte(badgeSwift), 0644); err != nil { + return err + } + + binFile := filepath.Join(tmpDir, "badge") + + // Compile + cmd := exec.Command("swiftc", swiftFile, "-o", binFile) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to compile badge helper: %w\n%s", err, string(out)) + } + + // Run + // If we want to target our specific app, we might need a different approach, + // but for now, this will set the badge for the process that runs it. + // To set it for the 'MatchaMail.app', we'd need that app to be running and + // listen for a notification, OR we run this compiled tool *inside* the app bundle context. + + err = exec.Command(binFile, strconv.Itoa(count)).Run() + if err != nil { + return fmt.Errorf("failed to set badge: %w", err) + } + + return nil +} diff --git a/clib/macos/badge.swift b/clib/macos/badge.swift new file mode 100644 index 0000000..33924c1 --- /dev/null +++ b/clib/macos/badge.swift @@ -0,0 +1,24 @@ +import Cocoa + +// Compilation: swiftc badge.swift -o badge +// Usage: ./badge <count> + +let args = ProcessInfo.processInfo.arguments +guard args.count > 1 else { + print("Usage: \(args[0]) <count>") + exit(1) +} + +let countString = args[1] +let label = countString == "0" ? "" : countString + +// Note: This only works if the process has a Dock icon (is a bundled app or has activation policy set) +// For a CLI tool, this would set the badge of the Terminal app if run directly, +// which might not be what's intended. +// However, if we run this from our 'MatchaMail.app' shim, it will work for that app. + +NSApp.dockTile.badgeLabel = label +NSApp.dockTile.display() + +// Give it a tiny bit of time to propagate +RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) diff --git a/clib/macos/contacts.go b/clib/macos/contacts.go new file mode 100644 index 0000000..6c282a0 --- /dev/null +++ b/clib/macos/contacts.go @@ -0,0 +1,58 @@ +package macos + +import ( + _ "embed" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +//go:embed contacts.swift +var contactsSwift string + +type MacOSContact struct { + Name string `json:"name"` + Emails []string `json:"emails"` +} + +// FetchContacts calls the macOS Contacts framework via a compiled Swift helper. +func FetchContacts() ([]MacOSContact, error) { + if runtime.GOOS != "darwin" { + return nil, fmt.Errorf("FetchContacts is only supported on macOS") + } + + tmpDir, err := os.MkdirTemp("", "matcha-macos") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + swiftFile := filepath.Join(tmpDir, "contacts.swift") + if err := os.WriteFile(swiftFile, []byte(contactsSwift), 0644); err != nil { + return nil, err + } + + binFile := filepath.Join(tmpDir, "contacts") + + // Compile the Swift helper + cmd := exec.Command("swiftc", swiftFile, "-o", binFile) + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("failed to compile contacts helper: %w\n%s", err, string(out)) + } + + // Run the helper + out, err := exec.Command(binFile).Output() + if err != nil { + return nil, fmt.Errorf("failed to run contacts helper: %w", err) + } + + var contacts []MacOSContact + if err := json.Unmarshal(out, &contacts); err != nil { + return nil, fmt.Errorf("failed to parse contacts JSON: %w\nOutput: %s", err, string(out)) + } + + return contacts, nil +} diff --git a/clib/macos/contacts.swift b/clib/macos/contacts.swift new file mode 100644 index 0000000..ab773bd --- /dev/null +++ b/clib/macos/contacts.swift @@ -0,0 +1,79 @@ +import Foundation +import Contacts + +// Compilation: swiftc contacts.swift -o contacts +// Usage: ./contacts [searchQuery] + +struct ContactEntry: Codable { + let id: String + let name: String + let emails: [String] + let thumbnail: String? // Base64 encoded image data +} + +func fetchContacts(query: String?) { + let store = CNContactStore() + + store.requestAccess(for: .contacts) { (granted, error) in + guard granted else { + print("[]") + exit(0) + } + + let keys = [ + CNContactGivenNameKey as CNKeyDescriptor, + CNContactFamilyNameKey as CNKeyDescriptor, + CNContactEmailAddressesKey as CNKeyDescriptor, + CNContactThumbnailImageDataKey as CNKeyDescriptor, + CNContactIdentifierKey as CNKeyDescriptor + ] + + var results: [ContactEntry] = [] + let fetchRequest = CNContactFetchRequest(keysToFetch: keys) + + // If a query is provided, we use a predicate to filter contacts by name + if let query = query, !query.isEmpty { + fetchRequest.predicate = CNContact.predicateForContacts(matchingName: query) + } + + do { + try store.enumerateContacts(with: fetchRequest) { (contact, stop) in + let fullName = [contact.givenName, contact.familyName] + .filter { !$0.isEmpty } + .joined(separator: " ") + + let emails = contact.emailAddresses.map { $0.value as String } + + // Only include contacts that actually have an email address + if !emails.isEmpty { + var thumbnailBase64: String? = nil + if let imageData = contact.thumbnailImageData { + thumbnailBase64 = imageData.base64EncodedString() + } + + results.append(ContactEntry( + id: contact.identifier, + name: fullName, + emails: emails, + thumbnail: thumbnailBase64 + )) + } + } + + let encoder = JSONEncoder() + if let data = try? encoder.encode(results), + let jsonString = String(data: data, encoding: .utf8) { + print(jsonString) + } + } catch { + print("[]") + } + exit(0) + } +} + +let args = ProcessInfo.processInfo.arguments +let query = args.count > 1 ? args[1] : nil + +fetchContacts(query: query) +RunLoop.main.run() diff --git a/clib/macos/file_picker.go b/clib/macos/file_picker.go new file mode 100644 index 0000000..7909f4b --- /dev/null +++ b/clib/macos/file_picker.go @@ -0,0 +1,60 @@ +package macos + +import ( + _ "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +//go:embed file_picker.swift +var filePickerSwift string + +// OpenFilePicker launches the native macOS file picker. +// It returns a list of selected absolute file paths. +func OpenFilePicker(initialPath string) ([]string, error) { + if runtime.GOOS != "darwin" { + return nil, fmt.Errorf("OpenFilePicker is only supported on macOS") + } + + tmpDir, err := os.MkdirTemp("", "matcha-filepicker") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + swiftFile := filepath.Join(tmpDir, "file_picker.swift") + if err := os.WriteFile(swiftFile, []byte(filePickerSwift), 0644); err != nil { + return nil, err + } + + binFile := filepath.Join(tmpDir, "file_picker") + + // Compile + cmd := exec.Command("swiftc", swiftFile, "-o", binFile) + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("failed to compile file picker helper: %w\n%s", err, string(out)) + } + + // Run + args := []string{} + if initialPath != "" { + args = append(args, initialPath) + } + out, err := exec.Command(binFile, args...).Output() + if err != nil { + // Exit code 1 usually means user cancelled + return nil, nil + } + + trimmed := strings.TrimSpace(string(out)) + if trimmed == "" { + return nil, nil + } + + paths := strings.Split(trimmed, "\n") + return paths, nil +} diff --git a/clib/macos/file_picker.swift b/clib/macos/file_picker.swift new file mode 100644 index 0000000..1457594 --- /dev/null +++ b/clib/macos/file_picker.swift @@ -0,0 +1,33 @@ +import Cocoa + +// Compilation: swiftc file_picker.swift -o file_picker +// Usage: ./file_picker [initial_path] + +func openFilePicker(initialPath: String?) { + let dialog = NSOpenPanel() + + dialog.title = "Select an attachment" + dialog.showsResizeIndicator = true + dialog.showsHiddenFiles = false + dialog.canChooseDirectories = false + dialog.canCreateDirectories = false + dialog.allowsMultipleSelection = true + + if let initialPath = initialPath { + dialog.directoryURL = URL(fileURLWithPath: (initialPath as NSString).expandingTildeInPath) + } + + // Since this is a CLI helper, we need to force it to the front + NSApp.setActivationPolicy(.accessory) + NSApp.activate(ignoringOtherApps: true) + + if dialog.runModal() == .OK { + let results = dialog.urls.map { $0.path } + print(results.joined(separator: "\n")) + } +} + +let args = ProcessInfo.processInfo.arguments +let initialPath = args.count > 1 ? args[1] : nil + +openFilePicker(initialPath: initialPath) diff --git a/clib/macos/keychain.swift b/clib/macos/keychain.swift new file mode 100644 index 0000000..8fabb25 --- /dev/null +++ b/clib/macos/keychain.swift @@ -0,0 +1,105 @@ +import Foundation +import Security + +// Compilation: swiftc keychain.swift -o keychain +// Usage: ./keychain <get|set|delete> <service> <account> [password] + +enum KeychainError: Error { + case unhandledError(status: OSStatus) +} + +func setPassword(service: String, account: String, password: Data) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: password, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(query as CFDictionary, nil) + + if status == errSecDuplicateItem { + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let attributesToUpdate: [String: Any] = [ + kSecValueData as String: password + ] + let updateStatus = SecItemUpdate(updateQuery as CFDictionary, attributesToUpdate as CFDictionary) + if updateStatus != errSecSuccess { + throw KeychainError.unhandledError(status: updateStatus) + } + } else if status != errSecSuccess { + throw KeychainError.unhandledError(status: status) + } +} + +func getPassword(service: String, account: String) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + if status == errSecItemNotFound { + return nil + } else if status != errSecSuccess { + throw KeychainError.unhandledError(status: status) + } + + return dataTypeRef as? Data +} + +func deletePassword(service: String, account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw KeychainError.unhandledError(status: status) + } +} + +let args = ProcessInfo.processInfo.arguments +guard args.count >= 4 else { + print("Usage: ./keychain <get|set|delete> <service> <account> [password]") + exit(1) +} + +let action = args[1] +let service = args[2] +let account = args[3] + +do { + switch action { + case "set": + guard args.count > 4 else { exit(1) } + let password = args[4].data(using: .utf8)! + try setPassword(service: service, account: account, password: password) + print("Success") + case "get": + if let data = try getPassword(service: service, account: account), + let password = String(data: data, encoding: .utf8) { + print(password) + } + case "delete": + try deletePassword(service: service, account: account) + print("Success") + default: + exit(1) + } +} catch { + print("Error: \(error)") + exit(1) +} diff --git a/clib/macos/menubar.swift b/clib/macos/menubar.swift new file mode 100644 index 0000000..fff8217 --- /dev/null +++ b/clib/macos/menubar.swift @@ -0,0 +1,126 @@ +import Cocoa + +// Compilation: swiftc menubar.swift -o menubar +// Usage: ./menubar <matchaPath> + +class MenubarController: NSObject { + private var statusItem: NSStatusItem + private var matchaPath: String + private var unreadCount: Int = 0 + + init(matchaPath: String) { + self.matchaPath = matchaPath + self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + super.init() + + setupMenu() + updateTitle() + setupNotifications() + } + + private func setupMenu() { + let menu = NSMenu() + + let openItem = NSMenuItem(title: "Open Matcha", action: #selector(openMatcha), keyEquivalent: "o") + openItem.target = self + menu.addItem(openItem) + + let composeItem = NSMenuItem(title: "Compose Message", action: #selector(openCompose), keyEquivalent: "n") + composeItem.target = self + menu.addItem(composeItem) + + menu.addItem(NSMenuItem.separator()) + + let refreshItem = NSMenuItem(title: "Check for Mail", action: #selector(refreshMail), keyEquivalent: "r") + refreshItem.target = self + menu.addItem(refreshItem) + + menu.addItem(NSMenuItem.separator()) + + let quitItem = NSMenuItem(title: "Quit Menubar Helper", action: #selector(terminate), keyEquivalent: "q") + quitItem.target = self + menu.addItem(quitItem) + + statusItem.menu = menu + } + + private func updateTitle() { + if let button = statusItem.button { + // Using a system symbol or a custom string + let icon = unreadCount > 0 ? "✉️ " : "📩 " + button.title = icon + (unreadCount > 0 ? "\(unreadCount)" : "") + } + } + + private func setupNotifications() { + // Listen for updates from the main Matcha Go process + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleUpdateNotification(_:)), + name: NSNotification.Name("com.floatpane.matcha.UpdateUnread"), + object: nil + ) + } + + @objc private func handleUpdateNotification(_ notification: Notification) { + if let userInfo = notification.userInfo, + let countString = userInfo["count"] as? String, + let count = Int(countString) { + self.unreadCount = count + updateTitle() + } + } + + @objc private func openMatcha() { + runTerminalCommand(command: "'\(matchaPath)'") + } + + @objc private func openCompose() { + runTerminalCommand(command: "'\(matchaPath)' send") + } + + @objc private func refreshMail() { + // Trigger a notification that the Go app can listen for, or just run a command + DistributedNotificationCenter.default().postNotificationName( + NSNotification.Name("com.floatpane.matcha.RefreshRequest"), + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } + + private func runTerminalCommand(command: String) { + let script = """ + tell application "Terminal" + activate + if (count of windows) is 0 then + do script "\(command)" + else + do script "\(command)" in window 1 + end if + end tell + """ + + if let appleScript = NSAppleScript(source: script) { + var error: NSDictionary? + appleScript.executeAndReturnError(&error) + } + } + + @objc private func terminate() { + NSApplication.shared.terminate(nil) + } +} + +let args = ProcessInfo.processInfo.arguments +guard args.count > 1 else { + print("Usage: ./menubar <matchaPath>") + exit(1) +} + +let matchaPath = args[1] + +let app = NSApplication.shared +let controller = MenubarController(matchaPath: matchaPath) +app.setActivationPolicy(.prohibited) // Run as a background agent +app.run() diff --git a/clib/macos/spellcheck.swift b/clib/macos/spellcheck.swift new file mode 100644 index 0000000..9b835f3 --- /dev/null +++ b/clib/macos/spellcheck.swift @@ -0,0 +1,54 @@ +import Cocoa + +// Compilation: swiftc spellcheck.swift -o spellcheck +// Usage: ./spellcheck "Your text with typos here" + +struct Misspelling: Codable { + let word: String + let suggestions: [String] + let range: [Int] // [location, length] +} + +func checkSpelling(text: String) { + let spellChecker = NSSpellChecker.shared + let range = NSRange(location: 0, length: text.utf16.count) + var misspellings: [Misspelling] = [] + + var offset = 0 + while offset < range.length { + let currentRange = NSRange(location: offset, length: range.length - offset) + let misspelledRange = spellChecker.checkSpelling(of: text, startingAt: currentRange.location) + + if misspelledRange.location == NSNotFound || misspelledRange.length == 0 { + break + } + + let word = (text as NSString).substring(with: misspelledRange) + let suggestions = spellChecker.guesses(forWordRange: misspelledRange, in: text, language: nil, inSpellDocumentWithTag: 0) ?? [] + + misspellings.append(Misspelling( + word: word, + suggestions: Array(suggestions.prefix(3)), // Top 3 suggestions + range: [misspelledRange.location, misspelledRange.length] + )) + + offset = misspelledRange.location + misspelledRange.length + } + + let encoder = JSONEncoder() + if let data = try? encoder.encode(misspellings), + let jsonString = String(data: data, encoding: .utf8) { + print(jsonString) + } +} + +let args = ProcessInfo.processInfo.arguments +if args.count > 1 { + checkSpelling(text: args[1]) +} else { + // Read from stdin if no arg provided + let input = FileHandle.standardInput.readDataToEndOfFile() + if let text = String(data: input, encoding: .utf8) { + checkSpelling(text: text) + } +} diff --git a/clib/macos/spotlight.swift b/clib/macos/spotlight.swift new file mode 100644 index 0000000..c95d674 --- /dev/null +++ b/clib/macos/spotlight.swift @@ -0,0 +1,61 @@ +import Foundation +import CoreSpotlight +import MobileCoreServices + +// Compilation: swiftc spotlight.swift -o spotlight +// Usage: ./spotlight <json_of_emails> + +struct EmailToIndex: Codable { + let id: String + let subject: String + let sender: String + let body: String + let date: Date +} + +func indexEmails(jsonString: String) { + guard let data = jsonString.data(using: .utf8), + let emails = try? JSONDecoder().decode([EmailToIndex].self, from: data) else { + print("Error: Invalid JSON") + exit(1) + } + + var searchableItems: [CSSearchableItem] = [] + + for email in emails { + let attributeSet = CSSearchableItemAttributeSet(itemContentType: kUTTypeEmailMessage as String) + attributeSet.title = email.subject + attributeSet.contentDescription = email.body + attributeSet.authorNames = [email.sender] + attributeSet.contentCreationDate = email.date + + let item = CSSearchableItem( + uniqueIdentifier: email.id, + domainIdentifier: "com.floatpane.matcha.emails", + attributeSet: attributeSet + ) + searchableItems.append(item) + } + + CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in + if let error = error { + print("Error indexing items: \(error.localizedDescription)") + exit(1) + } else { + print("Successfully indexed \(searchableItems.count) emails") + exit(0) + } + } +} + +let args = ProcessInfo.processInfo.arguments +if args.count > 1 { + indexEmails(jsonString: args[1]) +} else { + let input = FileHandle.standardInput.readDataToEndOfFile() + if let text = String(data: input, encoding: .utf8) { + indexEmails(jsonString: text) + } +} + +RunLoop.main.run() diff --git a/config/macos_sync.go b/config/macos_sync.go new file mode 100644 index 0000000..dd8b9d4 --- /dev/null +++ b/config/macos_sync.go @@ -0,0 +1,33 @@ +package config + +import ( + "fmt" + "runtime" + + "github.com/floatpane/matcha/clib/macos" +) + +// SyncMacOSContacts fetches contacts from the macOS Contacts framework +// and merges them into the local contacts cache. +func SyncMacOSContacts() error { + if runtime.GOOS != "darwin" { + return nil + } + + macContacts, err := macos.FetchContacts() + if err != nil { + return fmt.Errorf("failed to fetch macOS contacts: %w", err) + } + + for _, mc := range macContacts { + for _, email := range mc.Emails { + // AddContact handles deduplication and name updates + if err := AddContact(mc.Name, email); err != nil { + // We continue even if one fails + continue + } + } + } + + return nil +} diff --git a/main.go b/main.go index c1d2fe6..b949a50 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,7 @@ import ( "github.com/floatpane/matcha/calendar" matchaCli "github.com/floatpane/matcha/cli" "github.com/floatpane/matcha/clib" + "github.com/floatpane/matcha/clib/macos" "github.com/floatpane/matcha/config" matchaDaemon "github.com/floatpane/matcha/daemon" "github.com/floatpane/matcha/daemonclient" @@ -179,6 +180,30 @@ func (m *mainModel) Init() tea.Cmd { return tea.Batch(m.current.Init(), checkForUpdatesCmd()) } +func (m *mainModel) syncUnreadBadge() { + if runtime.GOOS != "darwin" { + return + } + count := 0 + // Count unread across all accounts (cached/loaded emails) + for _, emails := range m.emailsByAcct { + for _, e := range emails { + if !e.IsRead { + count++ + } + } + } + // Also check folderEmails for unread status + for _, emails := range m.folderEmails { + for _, e := range emails { + if !e.IsRead { + count++ + } + } + } + _ = macos.SetBadge(count) +} + func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd @@ -775,6 +800,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.emailsByAcct[accID] = refreshed } m.emails = flattenAndSort(m.emailsByAcct) + m.syncUnreadBadge() // Update folder inbox if it exists if m.folderInbox != nil { @@ -786,6 +812,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.AllEmailsFetchedMsg: m.emailsByAcct = msg.EmailsByAccount m.emails = flattenAndSort(msg.EmailsByAccount) + m.syncUnreadBadge() if m.folderInbox != nil { m.folderInbox.SetEmails(m.emails, m.config.Accounts) @@ -799,6 +826,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.emailsByAcct[msg.AccountID] = msg.Emails m.emails = flattenAndSort(m.emailsByAcct) + m.syncUnreadBadge() + if m.folderInbox != nil { m.folderInbox.SetEmails(m.emails, m.config.Accounts) } @@ -832,6 +861,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails) m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...) m.emails = append(m.emails, unique...) + m.syncUnreadBadge() return m, nil case tui.GoToSendMsg: @@ -1284,6 +1314,16 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tui.GoToFilePickerMsg: + if runtime.GOOS == "darwin" { + return m, func() tea.Msg { + wd, _ := os.Getwd() + paths, err := macos.OpenFilePicker(wd) + if err != nil || len(paths) == 0 { + return tui.CancelFilePickerMsg{} + } + return tui.FileSelectedMsg{Paths: paths} + } + } m.previousModel = m.current wd, _ := os.Getwd() m.current = tui.NewFilePicker(wd) @@ -1438,6 +1478,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { log.Printf("Error marking email as read: %v", msg.Err) } + m.syncUnreadBadge() return m, nil case tui.EmailActionDoneMsg: @@ -3471,13 +3512,22 @@ func main() { os.Exit(0) } - // Contacts export CLI subcommand: matcha contacts export [flags] - if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 && os.Args[2] == "export" { - if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil { - fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err) - os.Exit(1) + // Contacts CLI subcommand: matcha contacts <export|sync> [flags] + if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 { + switch os.Args[2] { + case "export": + if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil { + fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err) + os.Exit(1) + } + os.Exit(0) + case "sync": + if err := matchaCli.RunContactsSync(os.Args[3:]); err != nil { + fmt.Fprintf(os.Stderr, "contacts sync failed: %v\n", err) + os.Exit(1) + } + os.Exit(0) } - os.Exit(0) } // setup-mailto CLI subcommand: matcha setup-mailto @@ -3552,6 +3602,14 @@ func main() { initialModel.plugins = plugins plugins.CallHook(plugin.HookStartup) + // Background sync macOS features + if runtime.GOOS == "darwin" && !initialModel.config.DisableNotifications { + go func() { + _ = config.SyncMacOSContacts() + _ = theme.SyncWithMacOS() + }() + } + p := tea.NewProgram(initialModel) if _, err := p.Run(); err != nil { diff --git a/theme/macos.go b/theme/macos.go new file mode 100644 index 0000000..9891a23 --- /dev/null +++ b/theme/macos.go @@ -0,0 +1,42 @@ +package theme + +import ( + "runtime" + + "github.com/floatpane/matcha/clib/macos" + "charm.land/lipgloss/v2" +) + +// SyncWithMacOS updates the 'Native' theme with current macOS system appearance. +func SyncWithMacOS() error { + if runtime.GOOS != "darwin" { + return nil + } + + appearance, err := macos.GetAppearance() + if err != nil { + return err + } + + // Update Native theme + Native.Accent = lipgloss.Color(appearance.AccentColor) + Native.Directory = lipgloss.Color(appearance.AccentColor) + + if appearance.DarkMode { + // Dark mode specifics if needed + Native.AccentText = lipgloss.Color("#FFFDF5") + Native.Contrast = lipgloss.Color("#000000") + } else { + // Light mode specifics + Native.AccentText = lipgloss.Color("#000000") + Native.Contrast = lipgloss.Color("#FFFFFF") + Native.Secondary = lipgloss.Color("240") + } + + // If the active theme is 'Native', update it immediately + if ActiveTheme.Name == "Native" { + ActiveTheme = Native + } + + return nil +} diff --git a/theme/theme.go b/theme/theme.go index 23a3336..f83bb2c 100644 --- a/theme/theme.go +++ b/theme/theme.go @@ -169,9 +169,27 @@ var CatppuccinMocha = Theme{ Contrast: lipgloss.Color("#1E1E2E"), } +var Native = Theme{ + Name: "Native", + Accent: lipgloss.Color("42"), + AccentDark: lipgloss.Color("#25A065"), + AccentText: lipgloss.Color("#FFFDF5"), + Secondary: lipgloss.Color("244"), + SubtleText: lipgloss.Color("245"), + MutedText: lipgloss.Color("247"), + DimText: lipgloss.Color("250"), + Danger: lipgloss.Color("196"), + Warning: lipgloss.Color("208"), + Tip: lipgloss.Color("214"), + Link: lipgloss.Color("#9BC4FF"), + Directory: lipgloss.Color("34"), + Contrast: lipgloss.Color("#000000"), +} + // BuiltinThemes lists all built-in themes in display order. var BuiltinThemes = []Theme{ Matcha, + Native, Rose, Lavender, Ocean, diff --git a/tui/composer.go b/tui/composer.go index c0d22d0..9eafeb4 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -240,13 +240,19 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case FileSelectedMsg: - // Avoid duplicates - for _, p := range m.attachmentPaths { - if p == msg.Path { - return m, nil + // Avoid duplicates and add all selected paths + for _, newPath := range msg.Paths { + exists := false + for _, p := range m.attachmentPaths { + if p == newPath { + exists = true + break + } + } + if !exists { + m.attachmentPaths = append(m.attachmentPaths, newPath) } } - m.attachmentPaths = append(m.attachmentPaths, msg.Path) return m, nil case tea.KeyPressMsg: diff --git a/tui/filepicker.go b/tui/filepicker.go index f1c80b2..883deed 100644 --- a/tui/filepicker.go +++ b/tui/filepicker.go @@ -151,7 +151,7 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.readDir() } else { return m, func() tea.Msg { - return FileSelectedMsg{Path: newPath} + return FileSelectedMsg{Paths: []string{newPath}} } } case "backspace": diff --git a/tui/messages.go b/tui/messages.go index 285706a..8f44368 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -134,7 +134,7 @@ type SetComposerCursorToStartMsg struct{} type GoToFilePickerMsg struct{} type FileSelectedMsg struct { - Path string + Paths []string } type CancelFilePickerMsg struct{} From 21d3fe3c1235f62dc6cfb4728f4bdcaaf4572c40 Mon Sep 17 00:00:00 2001 From: drew <me@andrinoff.com> Date: Thu, 23 Apr 2026 16:55:24 +0400 Subject: [PATCH 04/12] fix: invalid memory address Signed-off-by: drew <me@andrinoff.com> --- main.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index b949a50..a13af9c 100644 --- a/main.go +++ b/main.go @@ -3603,11 +3603,17 @@ func main() { plugins.CallHook(plugin.HookStartup) // Background sync macOS features - if runtime.GOOS == "darwin" && !initialModel.config.DisableNotifications { - go func() { - _ = config.SyncMacOSContacts() - _ = theme.SyncWithMacOS() - }() + if runtime.GOOS == "darwin" { + disableNotifications := false + if initialModel.config != nil { + disableNotifications = initialModel.config.DisableNotifications + } + if !disableNotifications { + go func() { + _ = config.SyncMacOSContacts() + _ = theme.SyncWithMacOS() + }() + } } p := tea.NewProgram(initialModel) From 59493b5aaf17f2378391108bef421d41f2fd8636 Mon Sep 17 00:00:00 2001 From: drew <me@andrinoff.com> Date: Thu, 23 Apr 2026 17:03:07 +0400 Subject: [PATCH 05/12] fix: improve plist and update mailto setup Signed-off-by: drew <me@andrinoff.com> --- cli/integration.go | 10 +++------- cli/macos_handler.swift | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/cli/integration.go b/cli/integration.go index e3a8c08..8cd2f9e 100644 --- a/cli/integration.go +++ b/cli/integration.go @@ -126,16 +126,10 @@ func setupMailtoDarwin(exe string) error { <string>com.floatpane.matcha.handler</string> <key>CFBundleName</key> <string>MatchaMail</string> - <key>CFBundleInfoDictionaryVersion</key> - <string>6.0</string> <key>CFBundlePackageType</key> <string>APPL</string> - <key>CFBundleShortVersionString</key> - <string>1.0</string> - <key>CFBundleVersion</key> + <key>LSBackgroundOnly</key> <string>1</string> - <key>LSMinimumSystemVersion</key> - <string>10.15</string> <key>CFBundleURLTypes</key> <array> <dict> @@ -145,6 +139,8 @@ func setupMailtoDarwin(exe string) error { <array> <string>mailto</string> </array> + <key>LSHandlerRank</key> + <string>Default</string> </dict> </array> </dict> diff --git a/cli/macos_handler.swift b/cli/macos_handler.swift index 75cba78..39ff5cc 100644 --- a/cli/macos_handler.swift +++ b/cli/macos_handler.swift @@ -1,24 +1,30 @@ import Cocoa -import AppleEvents class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) - + + // kInternetEventClass = 'GURL' (0x4755524c) + // kAEGetURL = 'GURL' (0x4755524c) + let eventClass = AEEventClass(0x4755524c) + let eventID = AEEventID(0x4755524c) + // Register for the 'getURL' event NSAppleEventManager.shared().setEventHandler( self, andSelector: #selector(handleGetURLEvent(_:withReplyEvent:)), - forEventClass: AEEventClass(kInternetEventClass), - andEventID: AEEventID(kAEGetURL) + forEventClass: eventClass, + andEventID: eventID ) } - + @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) { - guard let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue, + // keyDirectObject = '----' (0x2d2d2d2d) + guard let urlString = event.paramDescriptor(forKeyword: AEKeyword(0x2d2d2d2d))?.stringValue, let url = URL(string: urlString) else { return } +... let matchaPath = "{{MATCHA_PATH}}" From 5d41490588037fb9ee55b231131a890d9924a9b2 Mon Sep 17 00:00:00 2001 From: drew <me@andrinoff.com> Date: Thu, 23 Apr 2026 17:05:25 +0400 Subject: [PATCH 06/12] fixing macos setup Signed-off-by: drew <me@andrinoff.com> --- cli/integration.go | 14 ++++++++--- cli/macos_handler.swift | 54 ++++++++++++----------------------------- 2 files changed, 27 insertions(+), 41 deletions(-) diff --git a/cli/integration.go b/cli/integration.go index 8cd2f9e..d5d3061 100644 --- a/cli/integration.go +++ b/cli/integration.go @@ -128,8 +128,12 @@ func setupMailtoDarwin(exe string) error { <string>MatchaMail</string> <key>CFBundlePackageType</key> <string>APPL</string> - <key>LSBackgroundOnly</key> + <key>CFBundleShortVersionString</key> + <string>1.0</string> + <key>CFBundleVersion</key> <string>1</string> + <key>LSUIElement</key> + <true/> <key>CFBundleURLTypes</key> <array> <dict> @@ -162,9 +166,13 @@ func setupMailtoDarwin(exe string) error { exeDest := filepath.Join(macosDir, "MatchaMail") // Compile the Swift file - cmd := exec.Command("swiftc", tmpSwiftFile, "-o", exeDest) + cmd := exec.Command("swiftc", "-sdk", "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk", tmpSwiftFile, "-o", exeDest) + // If the above hardcoded path fails (e.g. command line tools only), try simple swiftc if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to compile Swift handler app: %w. Do you have Xcode command line tools installed?", err) + cmd = exec.Command("swiftc", tmpSwiftFile, "-o", exeDest) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to compile Swift handler app: %w. Do you have Xcode command line tools installed?", err) + } } // Register the application diff --git a/cli/macos_handler.swift b/cli/macos_handler.swift index 39ff5cc..dcbfb11 100644 --- a/cli/macos_handler.swift +++ b/cli/macos_handler.swift @@ -2,54 +2,32 @@ import Cocoa class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { - NSApp.setActivationPolicy(.accessory) - - // kInternetEventClass = 'GURL' (0x4755524c) - // kAEGetURL = 'GURL' (0x4755524c) - let eventClass = AEEventClass(0x4755524c) - let eventID = AEEventID(0x4755524c) - - // Register for the 'getURL' event + // 1196711500 = 'GURL' NSAppleEventManager.shared().setEventHandler( self, andSelector: #selector(handleGetURLEvent(_:withReplyEvent:)), - forEventClass: eventClass, - andEventID: eventID + forEventClass: AEEventClass(1196711500), + andEventID: AEEventID(1196711500) ) } - + @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) { - // keyDirectObject = '----' (0x2d2d2d2d) - guard let urlString = event.paramDescriptor(forKeyword: AEKeyword(0x2d2d2d2d))?.stringValue, - let url = URL(string: urlString) else { - return - } -... - - let matchaPath = "{{MATCHA_PATH}}" - - // Use AppleScript to tell Terminal to run matcha with the URL - // We escape the URL and path for shell safety - let script = """ - tell application "Terminal" - activate - do script "'\(matchaPath)' '\(url.absoluteString)'" - end tell - """ - - var error: NSDictionary? - if let appleScript = NSAppleScript(source: script) { - appleScript.executeAndReturnError(&error) + // 757935405 = '----' (keyDirectObject) + if let urlString = event.paramDescriptor(forKeyword: AEKeyword(757935405))?.stringValue { + let matchaPath = "{{MATCHA_PATH}}" + let script = "tell application \"Terminal\" to do script \"'\(matchaPath)' '\(urlString)'\"" + + if let appleScript = NSAppleScript(source: script) { + appleScript.executeAndReturnError(nil) + } } - // Exit after handling the event - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - NSApplication.shared.terminate(nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + NSApp.terminate(nil) } } } -let app = NSApplication.shared let delegate = AppDelegate() -app.delegate = delegate -app.run() +NSApplication.shared.delegate = delegate +NSApplication.shared.run() From 8eee062d1b840379ddc724361378e5fa51802d24 Mon Sep 17 00:00:00 2001 From: drew <me@andrinoff.com> Date: Thu, 23 Apr 2026 17:10:42 +0400 Subject: [PATCH 07/12] fix: increase timer for the AppleScript Signed-off-by: drew <me@andrinoff.com> --- cli/macos_handler.swift | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/cli/macos_handler.swift b/cli/macos_handler.swift index dcbfb11..1eba94b 100644 --- a/cli/macos_handler.swift +++ b/cli/macos_handler.swift @@ -15,14 +15,27 @@ class AppDelegate: NSObject, NSApplicationDelegate { // 757935405 = '----' (keyDirectObject) if let urlString = event.paramDescriptor(forKeyword: AEKeyword(757935405))?.stringValue { let matchaPath = "{{MATCHA_PATH}}" - let script = "tell application \"Terminal\" to do script \"'\(matchaPath)' '\(urlString)'\"" - if let appleScript = NSAppleScript(source: script) { - appleScript.executeAndReturnError(nil) + // Expanded AppleScript for better reliability + let scriptSource = """ + tell application "Terminal" + activate + do script "'\(matchaPath)' '\(urlString)'" + end tell + """ + + if let appleScript = NSAppleScript(source: scriptSource) { + var error: NSDictionary? + appleScript.executeAndReturnError(&error) + if let err = error { + // Log to console if there's an error (can be seen in Console.app) + NSLog("MatchaMail AppleScript Error: \(err)") + } } } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // Increased delay slightly to ensure the Apple Event is sent successfully + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { NSApp.terminate(nil) } } From 932aceadf2a4cf4d5a9749bfd5038046a61f6de9 Mon Sep 17 00:00:00 2001 From: drew <me@andrinoff.com> Date: Thu, 23 Apr 2026 17:13:17 +0400 Subject: [PATCH 08/12] fix: use .command Signed-off-by: drew <me@andrinoff.com> --- cli/macos_handler.swift | 55 +++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/cli/macos_handler.swift b/cli/macos_handler.swift index 1eba94b..cbf42e6 100644 --- a/cli/macos_handler.swift +++ b/cli/macos_handler.swift @@ -2,7 +2,7 @@ import Cocoa class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { - // 1196711500 = 'GURL' + // Register for the 'getURL' event (GURL = 1196711500) NSAppleEventManager.shared().setEventHandler( self, andSelector: #selector(handleGetURLEvent(_:withReplyEvent:)), @@ -12,30 +12,43 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) { - // 757935405 = '----' (keyDirectObject) - if let urlString = event.paramDescriptor(forKeyword: AEKeyword(757935405))?.stringValue { - let matchaPath = "{{MATCHA_PATH}}" + // keyDirectObject = 757935405 + guard let urlString = event.paramDescriptor(forKeyword: AEKeyword(757935405))?.stringValue else { + NSLog("MatchaMail: Failed to extract URL from event") + return + } + + let matchaPath = "{{MATCHA_PATH}}" + + // Create a temporary .command file. macOS automatically opens these in Terminal. + let tempDir = NSTemporaryDirectory() + let commandFileUrl = URL(fileURLWithPath: tempDir).appendingPathComponent("matcha-open.command") + + let scriptContent = """ + #!/bin/bash + # This script was generated by MatchaMail handler + '\(matchaPath)' '\(urlString)' + # Remove ourselves after execution + rm -- "$0" + """ + + do { + try scriptContent.write(to: commandFileUrl, atomically: true, encoding: .utf8) + + // Make the file executable + let attributes = [FileAttributeKey.posixPermissions: 0o755] + try FileManager.default.setAttributes(attributes, ofItemAtPath: commandFileUrl.path) - // Expanded AppleScript for better reliability - let scriptSource = """ - tell application "Terminal" - activate - do script "'\(matchaPath)' '\(urlString)'" - end tell - """ + // Open the file with the default handler (which is Terminal.app for .command files) + NSWorkspace.shared.open(commandFileUrl) + NSLog("MatchaMail: Successfully launched Terminal with command file") - if let appleScript = NSAppleScript(source: scriptSource) { - var error: NSDictionary? - appleScript.executeAndReturnError(&error) - if let err = error { - // Log to console if there's an error (can be seen in Console.app) - NSLog("MatchaMail AppleScript Error: \(err)") - } - } + } catch { + NSLog("MatchaMail: Error creating or opening command file: \(error.localizedDescription)") } - // Increased delay slightly to ensure the Apple Event is sent successfully - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + // Exit quickly + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { NSApp.terminate(nil) } } From b2c7778d4868eda01f9f2dd9eea675f1f1ff93f9 Mon Sep 17 00:00:00 2001 From: drew <me@andrinoff.com> Date: Thu, 23 Apr 2026 17:16:24 +0400 Subject: [PATCH 09/12] fix: rewrite Signed-off-by: drew <me@andrinoff.com> --- cli/integration.go | 24 +++++----- cli/macos_handler.swift | 103 +++++++++++++++++++++++++++------------- 2 files changed, 81 insertions(+), 46 deletions(-) diff --git a/cli/integration.go b/cli/integration.go index d5d3061..b27a627 100644 --- a/cli/integration.go +++ b/cli/integration.go @@ -95,6 +95,9 @@ func setupMailtoDarwin(exe string) error { } appDir := filepath.Join(home, "Applications", "MatchaMail.app") + // Cleanup old version to avoid conflicts + os.RemoveAll(appDir) + contentsDir := filepath.Join(appDir, "Contents") macosDir := filepath.Join(contentsDir, "MacOS") resourcesDir := filepath.Join(contentsDir, "Resources") @@ -123,13 +126,13 @@ func setupMailtoDarwin(exe string) error { <key>CFBundleIconFile</key> <string>MatchaMail.icns</string> <key>CFBundleIdentifier</key> - <string>com.floatpane.matcha.handler</string> + <string>com.floatpane.matcha.mailto-handler</string> <key>CFBundleName</key> <string>MatchaMail</string> <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> - <string>1.0</string> + <string>1.1</string> <key>CFBundleVersion</key> <string>1</string> <key>LSUIElement</key> @@ -144,7 +147,7 @@ func setupMailtoDarwin(exe string) error { <string>mailto</string> </array> <key>LSHandlerRank</key> - <string>Default</string> + <string>Owner</string> </dict> </array> </dict> @@ -166,21 +169,16 @@ func setupMailtoDarwin(exe string) error { exeDest := filepath.Join(macosDir, "MatchaMail") // Compile the Swift file - cmd := exec.Command("swiftc", "-sdk", "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk", tmpSwiftFile, "-o", exeDest) - // If the above hardcoded path fails (e.g. command line tools only), try simple swiftc + cmd := exec.Command("swiftc", "-O", tmpSwiftFile, "-o", exeDest) if err := cmd.Run(); err != nil { - cmd = exec.Command("swiftc", tmpSwiftFile, "-o", exeDest) - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to compile Swift handler app: %w. Do you have Xcode command line tools installed?", err) - } + return fmt.Errorf("failed to compile Swift handler app: %w", err) } // Register the application lsregister := "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister" - regCmd := exec.Command(lsregister, "-f", appDir) - if err := regCmd.Run(); err != nil { - return fmt.Errorf("failed to register app with LaunchServices: %w", err) - } + _ = exec.Command(lsregister, "-f", appDir).Run() + + fmt.Printf("Successfully created %s.\n", appDir) // Set as default handler // macOS does not provide a straightforward CLI to change default handler without 3rd party tools (like duti). diff --git a/cli/macos_handler.swift b/cli/macos_handler.swift index cbf42e6..efefd10 100644 --- a/cli/macos_handler.swift +++ b/cli/macos_handler.swift @@ -1,59 +1,96 @@ import Cocoa class AppDelegate: NSObject, NSApplicationDelegate { + var handled = false + func applicationDidFinishLaunching(_ notification: Notification) { - // Register for the 'getURL' event (GURL = 1196711500) + log("MatchaMail handler started") + + // Register for legacy Apple Events (GURL = 1196711500) NSAppleEventManager.shared().setEventHandler( self, andSelector: #selector(handleGetURLEvent(_:withReplyEvent:)), forEventClass: AEEventClass(1196711500), andEventID: AEEventID(1196711500) ) + + // If nothing handled in 2 seconds, assume failure/no event and quit + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + if !self.handled { + self.log("No URL event received within 2s, terminating.") + NSApp.terminate(nil) + } + } } + // Modern URL handling + func application(_ application: NSApplication, open urls: [URL]) { + if let url = urls.first { + log("Modern API received URL: \(url.absoluteString)") + launchMatcha(with: url.absoluteString) + } + } + + // Legacy Apple Event handling @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) { // keyDirectObject = 757935405 - guard let urlString = event.paramDescriptor(forKeyword: AEKeyword(757935405))?.stringValue else { - NSLog("MatchaMail: Failed to extract URL from event") - return + if let urlString = event.paramDescriptor(forKeyword: AEKeyword(757935405))?.stringValue { + log("Legacy API received URL: \(urlString)") + launchMatcha(with: urlString) } - - let matchaPath = "{{MATCHA_PATH}}" + } + + func launchMatcha(with url: String) { + guard !handled else { return } + handled = true - // Create a temporary .command file. macOS automatically opens these in Terminal. - let tempDir = NSTemporaryDirectory() - let commandFileUrl = URL(fileURLWithPath: tempDir).appendingPathComponent("matcha-open.command") + let matchaPath = "{{MATCHA_PATH}}" + log("Launching Matcha at \(matchaPath) with URL \(url)") - let scriptContent = """ - #!/bin/bash - # This script was generated by MatchaMail handler - '\(matchaPath)' '\(urlString)' - # Remove ourselves after execution - rm -- "$0" + // Use AppleScript to tell Terminal to run matcha + // We use a more robust script that ensures Terminal is active + let scriptSource = """ + tell application "Terminal" + activate + do script "'\(matchaPath)' '\(url)'" + end tell """ - do { - try scriptContent.write(to: commandFileUrl, atomically: true, encoding: .utf8) - - // Make the file executable - let attributes = [FileAttributeKey.posixPermissions: 0o755] - try FileManager.default.setAttributes(attributes, ofItemAtPath: commandFileUrl.path) - - // Open the file with the default handler (which is Terminal.app for .command files) - NSWorkspace.shared.open(commandFileUrl) - NSLog("MatchaMail: Successfully launched Terminal with command file") - - } catch { - NSLog("MatchaMail: Error creating or opening command file: \(error.localizedDescription)") + if let appleScript = NSAppleScript(source: scriptSource) { + var error: NSDictionary? + appleScript.executeAndReturnError(&error) + if let err = error { + log("AppleScript Error: \(err)") + } else { + log("AppleScript executed successfully") + } } - - // Exit quickly - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + + // Small delay to ensure handoff + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { NSApp.terminate(nil) } } + + func log(_ message: String) { + let logPath = "/tmp/matcha-handler.log" + let timestamp = Date().description + let line = "[\(timestamp)] \(message)\n" + + if let data = line.data(using: .utf8) { + if let fileHandle = FileHandle(forWritingAtPath: logPath) { + fileHandle.seekToEndOfFile() + fileHandle.write(data) + fileHandle.closeFile() + } else { + try? data.write(to: URL(fileURLWithPath: logPath)) + } + } + NSLog(message) + } } +let app = NSApplication.shared let delegate = AppDelegate() -NSApplication.shared.delegate = delegate -NSApplication.shared.run() +app.delegate = delegate +app.run() From d28337d343616fd074e57fcd2efdad50a6e77bbf Mon Sep 17 00:00:00 2001 From: drew <me@andrinoff.com> Date: Thu, 23 Apr 2026 17:20:23 +0400 Subject: [PATCH 10/12] fix: default terminal Signed-off-by: drew <me@andrinoff.com> --- cli/macos_handler.swift | 49 ++++++++++++++++++++++++----------------- main.go | 8 +++++++ 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/cli/macos_handler.swift b/cli/macos_handler.swift index efefd10..db1e8bc 100644 --- a/cli/macos_handler.swift +++ b/cli/macos_handler.swift @@ -14,7 +14,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { andEventID: AEEventID(1196711500) ) - // If nothing handled in 2 seconds, assume failure/no event and quit + // Timeout DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { if !self.handled { self.log("No URL event received within 2s, terminating.") @@ -33,7 +33,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Legacy Apple Event handling @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) { - // keyDirectObject = 757935405 if let urlString = event.paramDescriptor(forKeyword: AEKeyword(757935405))?.stringValue { log("Legacy API received URL: \(urlString)") launchMatcha(with: urlString) @@ -45,28 +44,39 @@ class AppDelegate: NSObject, NSApplicationDelegate { handled = true let matchaPath = "{{MATCHA_PATH}}" - log("Launching Matcha at \(matchaPath) with URL \(url)") + log("Launching Matcha via .command file at \(matchaPath) with URL \(url)") - // Use AppleScript to tell Terminal to run matcha - // We use a more robust script that ensures Terminal is active - let scriptSource = """ - tell application "Terminal" - activate - do script "'\(matchaPath)' '\(url)'" - end tell + // Use a .command file to open in the DEFAULT terminal + let tempDir = NSTemporaryDirectory() + let commandFileName = "matcha-mailto-\(UUID().uuidString).command" + let commandFileUrl = URL(fileURLWithPath: tempDir).appendingPathComponent(commandFileName) + + // We use a bash script that opens matcha and then removes itself + let scriptContent = """ + #!/bin/bash + '\(matchaPath)' '\(url)' + # Clean up this temporary script + rm -- "$0" + exit """ - if let appleScript = NSAppleScript(source: scriptSource) { - var error: NSDictionary? - appleScript.executeAndReturnError(&error) - if let err = error { - log("AppleScript Error: \(err)") - } else { - log("AppleScript executed successfully") - } + do { + try scriptContent.write(to: commandFileUrl, atomically: true, encoding: .utf8) + + // Make the file executable + let attributes = [FileAttributeKey.posixPermissions: 0o755] + try FileManager.default.setAttributes(attributes, ofItemAtPath: commandFileUrl.path) + + // Open the file with NSWorkspace. + // Since it's a .command file, macOS will open it in the default terminal. + NSWorkspace.shared.open(commandFileUrl) + log("Successfully requested macOS to open .command file") + + } catch { + log("Failed to create/open .command file: \(error.localizedDescription)") } - // Small delay to ensure handoff + // Small delay to ensure launch DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { NSApp.terminate(nil) } @@ -76,7 +86,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { let logPath = "/tmp/matcha-handler.log" let timestamp = Date().description let line = "[\(timestamp)] \(message)\n" - if let data = line.data(using: .utf8) { if let fileHandle = FileHandle(forWritingAtPath: logPath) { fileHandle.seekToEndOfFile() diff --git a/main.go b/main.go index a13af9c..0d4d618 100644 --- a/main.go +++ b/main.go @@ -135,7 +135,11 @@ func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel { initialModel.current = tui.NewLogin(hideTips) } else { if mailtoURL != nil { + // mailto:addr@example.com?subject=test to := mailtoURL.Opaque + if to == "" { + to = mailtoURL.Path + } if to == "" { to = mailtoURL.Query().Get("to") } @@ -143,6 +147,7 @@ func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel { body := mailtoURL.Query().Get("body") initialModel.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips) } else { + initialModel.current = tui.NewChoice() } initialModel.config = cfg @@ -1041,6 +1046,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.config = cfg if m.mailtoURL != nil { to := m.mailtoURL.Opaque + if to == "" { + to = m.mailtoURL.Path + } if to == "" { to = m.mailtoURL.Query().Get("to") } From 36774f3323b1a4d851a2138d6add26c649d7ea5a Mon Sep 17 00:00:00 2001 From: drew <me@andrinoff.com> Date: Thu, 23 Apr 2026 17:26:01 +0400 Subject: [PATCH 11/12] remove tests Signed-off-by: drew <me@andrinoff.com> --- tests/test_icon.go | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 tests/test_icon.go diff --git a/tests/test_icon.go b/tests/test_icon.go deleted file mode 100644 index 10a4094..0000000 --- a/tests/test_icon.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import ( - "fmt" - "os" - "path/filepath" -) - -func TestIconPath() { - home, _ := os.UserHomeDir() - fmt.Println(filepath.Join(home, ".local", "share", "icons", "hicolor", "512x512", "apps")) -} From 9d96bacd0fb7e5e419c16771b0591af9cbbd8da6 Mon Sep 17 00:00:00 2001 From: drew <me@andrinoff.com> Date: Thu, 23 Apr 2026 17:27:05 +0400 Subject: [PATCH 12/12] fix: lint Signed-off-by: drew <me@andrinoff.com> --- clib/macos/appearance.go | 2 +- clib/macos/badge.go | 6 +++--- clib/macos/contacts.go | 2 +- clib/macos/file_picker.go | 2 +- theme/macos.go | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/clib/macos/appearance.go b/clib/macos/appearance.go index 3b033df..880d742 100644 --- a/clib/macos/appearance.go +++ b/clib/macos/appearance.go @@ -36,7 +36,7 @@ func GetAppearance() (*MacOSAppearance, error) { } binFile := filepath.Join(tmpDir, "appearance") - + // Compile cmd := exec.Command("swiftc", swiftFile, "-o", binFile) if out, err := cmd.CombinedOutput(); err != nil { diff --git a/clib/macos/badge.go b/clib/macos/badge.go index e820e1e..0e623b5 100644 --- a/clib/macos/badge.go +++ b/clib/macos/badge.go @@ -31,7 +31,7 @@ func SetBadge(count int) error { } binFile := filepath.Join(tmpDir, "badge") - + // Compile cmd := exec.Command("swiftc", swiftFile, "-o", binFile) if out, err := cmd.CombinedOutput(); err != nil { @@ -41,9 +41,9 @@ func SetBadge(count int) error { // Run // If we want to target our specific app, we might need a different approach, // but for now, this will set the badge for the process that runs it. - // To set it for the 'MatchaMail.app', we'd need that app to be running and + // To set it for the 'MatchaMail.app', we'd need that app to be running and // listen for a notification, OR we run this compiled tool *inside* the app bundle context. - + err = exec.Command(binFile, strconv.Itoa(count)).Run() if err != nil { return fmt.Errorf("failed to set badge: %w", err) diff --git a/clib/macos/contacts.go b/clib/macos/contacts.go index 6c282a0..147b9ae 100644 --- a/clib/macos/contacts.go +++ b/clib/macos/contacts.go @@ -36,7 +36,7 @@ func FetchContacts() ([]MacOSContact, error) { } binFile := filepath.Join(tmpDir, "contacts") - + // Compile the Swift helper cmd := exec.Command("swiftc", swiftFile, "-o", binFile) if out, err := cmd.CombinedOutput(); err != nil { diff --git a/clib/macos/file_picker.go b/clib/macos/file_picker.go index 7909f4b..b0a4764 100644 --- a/clib/macos/file_picker.go +++ b/clib/macos/file_picker.go @@ -32,7 +32,7 @@ func OpenFilePicker(initialPath string) ([]string, error) { } binFile := filepath.Join(tmpDir, "file_picker") - + // Compile cmd := exec.Command("swiftc", swiftFile, "-o", binFile) if out, err := cmd.CombinedOutput(); err != nil { diff --git a/theme/macos.go b/theme/macos.go index 9891a23..9aeae98 100644 --- a/theme/macos.go +++ b/theme/macos.go @@ -3,8 +3,8 @@ package theme import ( "runtime" - "github.com/floatpane/matcha/clib/macos" "charm.land/lipgloss/v2" + "github.com/floatpane/matcha/clib/macos" ) // SyncWithMacOS updates the 'Native' theme with current macOS system appearance. @@ -21,7 +21,7 @@ func SyncWithMacOS() error { // Update Native theme Native.Accent = lipgloss.Color(appearance.AccentColor) Native.Directory = lipgloss.Color(appearance.AccentColor) - + if appearance.DarkMode { // Dark mode specifics if needed Native.AccentText = lipgloss.Color("#FFFDF5")