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/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/cli/integration.go b/cli/integration.go
new file mode 100644
index 0000000..b27a627
--- /dev/null
+++ b/cli/integration.go
@@ -0,0 +1,195 @@
+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")
+ // Cleanup old version to avoid conflicts
+ os.RemoveAll(appDir)
+
+ 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.mailto-handler
+ CFBundleName
+ MatchaMail
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.1
+ CFBundleVersion
+ 1
+ LSUIElement
+
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ Email Address
+ CFBundleURLSchemes
+
+ mailto
+
+ LSHandlerRank
+ Owner
+
+
+
+
+`
+ 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", "-O", tmpSwiftFile, "-o", exeDest)
+ if err := cmd.Run(); err != nil {
+ 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"
+ _ = 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).
+ // 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..db1e8bc
--- /dev/null
+++ b/cli/macos_handler.swift
@@ -0,0 +1,105 @@
+import Cocoa
+
+class AppDelegate: NSObject, NSApplicationDelegate {
+ var handled = false
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ 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)
+ )
+
+ // Timeout
+ 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) {
+ if let urlString = event.paramDescriptor(forKeyword: AEKeyword(757935405))?.stringValue {
+ log("Legacy API received URL: \(urlString)")
+ launchMatcha(with: urlString)
+ }
+ }
+
+ func launchMatcha(with url: String) {
+ guard !handled else { return }
+ handled = true
+
+ let matchaPath = "{{MATCHA_PATH}}"
+ log("Launching Matcha via .command file at \(matchaPath) with URL \(url)")
+
+ // 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
+ """
+
+ 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 launch
+ 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()
+app.delegate = delegate
+app.run()
diff --git a/clib/macos/appearance.go b/clib/macos/appearance.go
new file mode 100644
index 0000000..880d742
--- /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
+
+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..0e623b5
--- /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
+
+let args = ProcessInfo.processInfo.arguments
+guard args.count > 1 else {
+ print("Usage: \(args[0]) ")
+ 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..147b9ae
--- /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..b0a4764
--- /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 [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 [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
+
+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 ")
+ 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
+
+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 6fb3f66..0d4d618 100644
--- a/main.go
+++ b/main.go
@@ -12,6 +12,7 @@ import (
"io"
"log"
"net/http"
+ "net/url"
"os"
"os/exec"
"path/filepath"
@@ -30,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"
@@ -110,9 +112,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 +124,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 +134,22 @@ func newInitialModel(cfg *config.Config) *mainModel {
}
initialModel.current = tui.NewLogin(hideTips)
} else {
- initialModel.current = tui.NewChoice()
+ if mailtoURL != nil {
+ // mailto:addr@example.com?subject=test
+ to := mailtoURL.Opaque
+ if to == "" {
+ to = mailtoURL.Path
+ }
+ 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
@@ -165,6 +185,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
@@ -761,6 +805,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 {
@@ -772,6 +817,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)
@@ -785,6 +831,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)
}
@@ -818,6 +866,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:
@@ -995,7 +1044,20 @@ 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.Path
+ }
+ 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()
@@ -1260,6 +1322,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)
@@ -1414,6 +1486,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:
@@ -3447,10 +3520,28 @@ 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)
+ // Contacts CLI subcommand: matcha contacts [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)
+ }
+ }
+
+ // 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)
@@ -3475,12 +3566,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 +3598,9 @@ func main() {
_ = config.EnsurePGPDir()
if err != nil {
- initialModel = newInitialModel(nil)
+ initialModel = newInitialModel(nil, mailtoURL)
} else {
- initialModel = newInitialModel(cfg)
+ initialModel = newInitialModel(cfg, mailtoURL)
}
}
@@ -3512,6 +3610,20 @@ func main() {
initialModel.plugins = plugins
plugins.CallHook(plugin.HookStartup)
+ // Background sync macOS features
+ 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)
if _, err := p.Run(); err != 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
new file mode 100644
index 0000000..a99feeb
--- /dev/null
+++ b/notify/macos_notify.swift
@@ -0,0 +1,71 @@
+#!/usr/bin/swift
+import Cocoa
+
+// Compilation: swiftc macos_notify.swift -o macos_notify
+// Usage: ./macos_notify [subtitle] [identifier] [soundName] [contentImagePath]
+// Or: ./macos_notify --remove [identifier]
+
+let args = ProcessInfo.processInfo.arguments
+
+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]) [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.subtitle = subtitle
+notification.soundName = soundName
+
+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/theme/macos.go b/theme/macos.go
new file mode 100644
index 0000000..9aeae98
--- /dev/null
+++ b/theme/macos.go
@@ -0,0 +1,42 @@
+package theme
+
+import (
+ "runtime"
+
+ "charm.land/lipgloss/v2"
+ "github.com/floatpane/matcha/clib/macos"
+)
+
+// 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{}