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 <body> <logoPath> [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]) <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.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{}