From 6e713173d0b837bb458a83c1afdfb35cbe310ac0 Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Tue, 15 Sep 2026 19:54:42 +0000 Subject: [PATCH] feat: direct app name + number switching for Telegram accounts and browser/IDE tabs --- src/StatusBarController.swift | 19 +-- src/VoiceCommandExecutor.swift | 208 +++++++++++++++++++++------------ 2 files changed, 146 insertions(+), 81 deletions(-) diff --git a/src/StatusBarController.swift b/src/StatusBarController.swift index 8e364c6..db67f7c 100644 --- a/src/StatusBarController.swift +++ b/src/StatusBarController.swift @@ -447,14 +447,17 @@ public final class StatusBarController: NSObject, NSMenuDelegate { • /hide OR /هاید -> Hide frontmost app • /quit OR /ببند -> Quit frontmost app - [Browser & Tabs] - • /firefox tab 1 OR /فایرفاکس تب ۱ -> Jump to Firefox Tab 1 - • /tab 1..9 OR /تب ۱..۹ -> Jump to Tab 1..9 in active app - • /new tab OR /تب جدید -> Open new tab (Cmd+T) - • /close tab OR /بستن تب -> Close active tab (Cmd+W) - • /reopen tab OR /ری اپن تب -> Reopen closed tab (Cmd+Shift+T) - • /next tab / /prev tab -> Switch tabs - • /reload OR /رفرش -> Reload web page (Cmd+R) + [Browser & App Tabs / Accounts] + • /telegram 1..3 OR /تلگرام ۱..۳ -> Open Telegram & Switch to Account 1..3 + • /firefox 1..9 OR /فایرفاکس ۱..۹ -> Open Firefox & Switch to Tab 1..9 + • /chrome 1..9 OR /کروم ۱..۹ -> Open Chrome & Switch to Tab 1..9 + • /safari 1..9 OR /سافاری ۱..۹ -> Open Safari & Switch to Tab 1..9 + • /tab 1..9 OR /تب ۱..۹ -> Jump to Tab 1..9 in active app + • /new tab OR /تب جدید -> Open new tab (Cmd+T) + • /close tab OR /بستن تب -> Close active tab (Cmd+W) + • /reopen tab OR /ری اپن تب -> Reopen closed tab (Cmd+Shift+T) + • /next tab / /prev tab -> Switch tabs + • /reload OR /رفرش -> Reload web page (Cmd+R) • /search [query] OR /سرچ [موضوع] -> Search on Google • /youtube [query] OR /یوتیوب [موضوع] -> Search on YouTube diff --git a/src/VoiceCommandExecutor.swift b/src/VoiceCommandExecutor.swift index 94ce0f5..bad5df7 100644 --- a/src/VoiceCommandExecutor.swift +++ b/src/VoiceCommandExecutor.swift @@ -284,23 +284,7 @@ public final class VoiceCommandExecutor { } } - // 2. Specialized Antigravity Matcher (Instant detection of any variation) - if let antiMatch = matchAntigravity(normalized: normalized) { - launchOrActivateApp(named: antiMatch) - playConfirmationSound() - HistoryManager.shared.addEntry("⚡️ [App] Open \(antiMatch)") - VoiceCommandLogger.shared.logCommand( - rawSpeech: rawText, - body: body, - normalized: normalized, - actionCategory: "Application Launch", - actionDetail: "Launched / Focused: \(antiMatch)", - status: "SUCCESS" - ) - return true - } - - // 3. Web Search & Direct Navigation ("سرچ X", "search X", "یوتیوب X", "youtube X", "برو به X") + // 2. Web Search & Direct Navigation ("سرچ X", "search X", "یوتیوب X", "youtube X", "برو به X") if let searchDetail = handleWebSearchAndNavigation(normalized: normalized, original: body) { playConfirmationSound() VoiceCommandLogger.shared.logCommand( @@ -314,7 +298,7 @@ public final class VoiceCommandExecutor { return true } - // 4. Window Tiling & Management (چپ, راست, بالا, پایین, تمام صفحه, وسط, مینیمایز, هاید, بستن) + // 3. Window Tiling & Management (چپ, راست, بالا, پایین, تمام صفحه, وسط, مینیمایز, هاید, بستن) if let tileDetail = handleWindowTiling(normalized: normalized) { playConfirmationSound() VoiceCommandLogger.shared.logCommand( @@ -328,21 +312,21 @@ public final class VoiceCommandExecutor { return true } - // 5. App + Tab combinations (e.g. "فایرفاکس تب ۱", "کروم تب ۲", "firefox tab 1") + // 4. App + Tab/Account combinations (e.g. "تلگرام ۱", "فایرفاکس ۲", "کروم ۳", "سافاری ۱", "telegram 1", "firefox 2") if let appTabDetail = handleAppWithTab(normalized: normalized) { playConfirmationSound() VoiceCommandLogger.shared.logCommand( rawSpeech: rawText, body: body, normalized: normalized, - actionCategory: "Browser App Tab", + actionCategory: "App Account / Tab Switch", actionDetail: appTabDetail, status: "SUCCESS" ) return true } - // 6. Standalone Tab & Navigation commands (e.g. "تب ۱", "تب جدید", "بستن تب", "tab 1", "new tab") + // 5. Standalone Tab & Navigation commands (e.g. "تب ۱", "تب جدید", "بستن تب", "tab 1", "new tab") if let tabDetail = handleStandaloneTab(normalized: normalized) { playConfirmationSound() VoiceCommandLogger.shared.logCommand( @@ -356,7 +340,7 @@ public final class VoiceCommandExecutor { return true } - // 7. System controls (volume, mute, lock, screenshot, dark mode, editing hotkeys) + // 6. System controls (volume, mute, lock, screenshot, dark mode, editing hotkeys) if let sysDetail = handleSystemControls(normalized: normalized) { playConfirmationSound() VoiceCommandLogger.shared.logCommand( @@ -370,6 +354,22 @@ public final class VoiceCommandExecutor { return true } + // 7. Specialized Antigravity Matcher (Instant detection of standalone variation) + if let antiMatch = matchAntigravity(normalized: normalized) { + launchOrActivateApp(named: antiMatch) + playConfirmationSound() + HistoryManager.shared.addEntry("⚡️ [App] Open \(antiMatch)") + VoiceCommandLogger.shared.logCommand( + rawSpeech: rawText, + body: body, + normalized: normalized, + actionCategory: "Application Launch", + actionDetail: "Launched / Focused: \(antiMatch)", + status: "SUCCESS" + ) + return true + } + // 8. Match App from Persian dictionary or exact names if let appName = matchKnownApp(normalized: normalized) { launchOrActivateApp(named: appName) @@ -613,10 +613,101 @@ public final class VoiceCommandExecutor { } } - // MARK: - App + Tab Combinations + // MARK: - App + Tab/Account Combinations + private func extractTrailingOrEmbeddedNumber(from text: String) -> Int? { + let t = text.lowercased() + let words = t.components(separatedBy: .whitespacesAndNewlines) + + // 1. Direct digits (English and Persian) + for i in 1...9 { + let enDig = "\(i)" + let faDigs = ["۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"] + let faDig = faDigs[i - 1] + + for w in words { + let cleanW = w.trimmingCharacters(in: CharacterSet(charactersIn: ".,،؛:!؟?-_")) + if cleanW == enDig || cleanW == faDig { + return i + } + } + } + + // 2. Word numbers (Persian & English cardinal and ordinal) + let numberWords: [(names: [String], num: Int)] = [ + (["یک", "اول", "نخست", "one", "first"], 1), + (["دو", "دوم", "two", "second"], 2), + (["سه", "سوم", "three", "third"], 3), + (["چهار", "چهارم", "four", "fourth"], 4), + (["پنج", "پنجم", "five", "fifth"], 5), + (["شش", "ششم", "six", "sixth"], 6), + (["هفت", "هفتم", "seven", "seventh"], 7), + (["هشت", "هشتم", "eight", "eighth"], 8), + (["نه", "نهم", "اخر", "آخر", "nine", "ninth", "last"], 9) + ] + + for w in words { + let cleanW = w.trimmingCharacters(in: CharacterSet(charactersIn: ".,،؛:!؟?-_")) + for item in numberWords { + if item.names.contains(cleanW) { + return item.num + } + } + } + + return nil + } + + private func parseAppAndNumber(normalized: String) -> (appName: String, number: Int, type: String)? { + var targetApp: String? = nil + var isTelegram = false + + if normalized.contains("تلگرام") || normalized.contains("telegram") { + targetApp = "Telegram" + isTelegram = true + } else if normalized.contains("فایرفاکس") || normalized.contains("firefox") || normalized.contains("فایر فاکس") || normalized.contains("فایرفکس") { + targetApp = "Firefox" + } else if normalized.contains("کروم") || normalized.contains("chrome") { + targetApp = "Google Chrome" + } else if normalized.contains("سافاری") || normalized.contains("safari") { + targetApp = "Safari" + } else if normalized.contains("انتی") && (normalized.contains("گراویتی") || normalized.contains("گرویتی")) { + if normalized.contains("ای دی ای") || normalized.contains("ایدی ای") || normalized.contains("ide") { + targetApp = "Antigravity IDE" + } else { + targetApp = "Antigravity" + } + } else if normalized.contains("وی اس کد") || normalized.contains("vscode") { + targetApp = "Visual Studio Code" + } else if normalized.contains("ترمینال") || normalized.contains("terminal") { + targetApp = "Terminal" + } + + guard let app = targetApp else { return nil } + guard let num = extractTrailingOrEmbeddedNumber(from: normalized) else { return nil } + + let type = isTelegram ? "Account" : "Tab" + return (app, num, type) + } + private func handleAppWithTab(normalized: String) -> String? { - guard normalized.contains("تب") || normalized.contains("tab") else { return nil } + // 1. Direct App + Number (e.g. "تلگرام ۱", "فایرفاکس ۲", "کروم ۳", "سافاری ۱", "telegram 1", "firefox 2") + if let parsed = parseAppAndNumber(normalized: normalized) { + let appName = parsed.appName + let num = parsed.number + let type = parsed.type + + activateApp(named: appName) { + if appName == "Telegram" { + self.switchToAccount(number: num) + } else { + self.switchToTab(number: num) + } + } + HistoryManager.shared.addEntry("⚡️ [\(appName)] \(type) \(num)") + return "\(appName) -> \(type) \(num)" + } + // 2. App + Named actions (e.g. "فایرفاکس تب جدید", "کروم بستن تب") var targetApp: String? = nil if normalized.contains("فایرفاکس") || normalized.contains("firefox") { targetApp = "Firefox" @@ -624,19 +715,15 @@ public final class VoiceCommandExecutor { targetApp = "Google Chrome" } else if normalized.contains("سافاری") || normalized.contains("safari") { targetApp = "Safari" + } else if normalized.contains("تلگرام") || normalized.contains("telegram") { + targetApp = "Telegram" } else if let anti = matchAntigravity(normalized: normalized) { targetApp = anti } guard let appName = targetApp else { return nil } - if let tabNum = extractTabNumber(from: normalized) { - activateApp(named: appName) { - self.switchToTab(number: tabNum) - } - HistoryManager.shared.addEntry("⚡️ [Command] \(appName): Tab \(tabNum)") - return "\(appName) -> Tab \(tabNum)" - } else if normalized.contains("تب جدید") || normalized.contains("new tab") { + if normalized.contains("تب جدید") || normalized.contains("new tab") { activateApp(named: appName) { self.pressHotkey(keyCode: 17, modifiers: .maskCommand) // Cmd+T } @@ -659,53 +746,28 @@ public final class VoiceCommandExecutor { return nil } - private func extractTabNumber(from text: String) -> Int? { - let t = text.lowercased() - if t.contains("تب ۱") || t.contains("تب 1") || t.contains("تب یک") || t.contains("تب اول") || - t.contains("tab 1") || t.contains("tab one") || t.contains("tab first") { - return 1 - } - if t.contains("تب ۲") || t.contains("تب 2") || t.contains("تب دو") || t.contains("تب دوم") || - t.contains("tab 2") || t.contains("tab two") || t.contains("tab second") { - return 2 - } - if t.contains("تب ۳") || t.contains("تب 3") || t.contains("تب سه") || t.contains("تب سوم") || - t.contains("tab 3") || t.contains("tab three") || t.contains("tab third") { - return 3 - } - if t.contains("تب ۴") || t.contains("تب 4") || t.contains("تب چهار") || t.contains("تب چهارم") || - t.contains("tab 4") || t.contains("tab four") { - return 4 - } - if t.contains("تب ۵") || t.contains("تب 5") || t.contains("تب پنج") || t.contains("تب پنجم") || - t.contains("tab 5") || t.contains("tab five") { - return 5 - } - if t.contains("تب ۶") || t.contains("تب 6") || t.contains("تب شش") || t.contains("تب ششم") || - t.contains("tab 6") || t.contains("tab six") { - return 6 - } - if t.contains("تب ۷") || t.contains("تب 7") || t.contains("تب هفت") || t.contains("تب هفتم") || - t.contains("tab 7") || t.contains("tab seven") { - return 7 - } - if t.contains("تب ۸") || t.contains("تب 8") || t.contains("تب هشت") || t.contains("تب هشتم") || - t.contains("tab 8") || t.contains("tab eight") { - return 8 - } - if t.contains("تب ۹") || t.contains("تب 9") || t.contains("تب نه") || t.contains("تب نهم") || t.contains("تب اخر") || - t.contains("tab 9") || t.contains("tab nine") || t.contains("tab last") { - return 9 + public func switchToAccount(number: Int) { + let keyCodes: [Int: CGKeyCode] = [ + 1: 18, 2: 19, 3: 20, 4: 21, 5: 23, + 6: 22, 7: 26, 8: 28, 9: 25 + ] + guard let code = keyCodes[number] else { return } + // For Telegram Desktop: Send Cmd+[number] and Cmd+Shift+[number] (configured via shortcuts-custom.json) + pressHotkey(keyCode: code, modifiers: .maskCommand) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + self.pressHotkey(keyCode: code, modifiers: [.maskCommand, .maskShift]) } - return nil } // MARK: - Standalone Tab Commands private func handleStandaloneTab(normalized: String) -> String? { - if let num = extractTabNumber(from: normalized) { - switchToTab(number: num) - HistoryManager.shared.addEntry("⚡️ [Command] Switch to Tab \(num)") - return "Switch to Tab \(num) (Cmd+\(num))" + if normalized.hasPrefix("تب") || normalized.hasPrefix("tab") || + normalized.contains("تب ") || normalized.contains("tab ") { + if let num = extractTrailingOrEmbeddedNumber(from: normalized) { + switchToTab(number: num) + HistoryManager.shared.addEntry("⚡️ [Command] Switch to Tab \(num)") + return "Switch to Tab \(num) (Cmd+\(num))" + } } if normalized.contains("تب جدید") || normalized.contains("نیو تب") || normalized.contains("new tab") { pressHotkey(keyCode: 17, modifiers: .maskCommand) // Cmd+T