You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1396 lines
64 KiB
1396 lines
64 KiB
import Cocoa
|
|
import Carbon
|
|
import ApplicationServices
|
|
|
|
public struct InstalledAppInfo {
|
|
public let name: String
|
|
public let normalizedName: String
|
|
public let url: URL
|
|
}
|
|
|
|
public enum WindowTileAction {
|
|
case left
|
|
case right
|
|
case top
|
|
case bottom
|
|
case maximize
|
|
case center
|
|
}
|
|
|
|
public final class VoiceCommandExecutor {
|
|
public static let shared = VoiceCommandExecutor()
|
|
|
|
private var cachedInstalledApps: [InstalledAppInfo] = []
|
|
private var lastAppsScanTime: TimeInterval = 0
|
|
|
|
// Canonical Persian to English application names
|
|
private let persianAppDictionary: [String: String] = [
|
|
// Antigravity Suite (All phonetic variations with and without alef)
|
|
"انتی گراویتی ای دی ای": "Antigravity IDE",
|
|
"انتی گراویتی ایدی ای": "Antigravity IDE",
|
|
"انتی گراویتی ایدیه": "Antigravity IDE",
|
|
"انتی گراویتی ادیتور": "Antigravity IDE",
|
|
"انتی گراویتی کد": "Antigravity IDE",
|
|
"انتی گراویتی ide": "Antigravity IDE",
|
|
"انتی گرویتی ای دی ای": "Antigravity IDE",
|
|
"انتی گرویتی ایدی ای": "Antigravity IDE",
|
|
"انتی گرویتی ادیتور": "Antigravity IDE",
|
|
"انتیگراویتی ایدی ای": "Antigravity IDE",
|
|
"انتیگراویتی ای دی ای": "Antigravity IDE",
|
|
"انتیگرویتی ایدی ای": "Antigravity IDE",
|
|
"antigravity ide": "Antigravity IDE",
|
|
"anti gravity ide": "Antigravity IDE",
|
|
|
|
"انتی گراویتی": "Antigravity",
|
|
"انتی گرویتی": "Antigravity",
|
|
"انتیگراویتی": "Antigravity",
|
|
"انتیگرویتی": "Antigravity",
|
|
"antigravity": "Antigravity",
|
|
"anti gravity": "Antigravity",
|
|
|
|
// System Core Apps
|
|
"ستینگ": "System Settings",
|
|
"ستینگز": "System Settings",
|
|
"تنظیمات": "System Settings",
|
|
"تنظیمات سیستم": "System Settings",
|
|
"سیستم ستینگ": "System Settings",
|
|
"settings": "System Settings",
|
|
"system settings": "System Settings",
|
|
|
|
"فایندر": "Finder",
|
|
"finder": "Finder",
|
|
"ترمینال": "Terminal",
|
|
"terminal": "Terminal",
|
|
"سافاری": "Safari",
|
|
"safari": "Safari",
|
|
"ماشین حساب": "Calculator",
|
|
"کلکولیتور": "Calculator",
|
|
"calculator": "Calculator",
|
|
"تقویم": "Calendar",
|
|
"کلندر": "Calendar",
|
|
"calendar": "Calendar",
|
|
"یادداشت": "Notes",
|
|
"یادداشت ها": "Notes",
|
|
"نوت": "Notes",
|
|
"نوتس": "Notes",
|
|
"notes": "Notes",
|
|
"اپ استور": "App Store",
|
|
"app store": "App Store",
|
|
"فوتوز": "Photos",
|
|
"عکس": "Photos",
|
|
"photos": "Photos",
|
|
"موزیک": "Music",
|
|
"اهنگ": "Music",
|
|
"music": "Music",
|
|
"پادکست": "Podcasts",
|
|
"podcasts": "Podcasts",
|
|
"مخاطبین": "Contacts",
|
|
"contacts": "Contacts",
|
|
"پیام ها": "Messages",
|
|
"مسج": "Messages",
|
|
"messages": "Messages",
|
|
"ایمیل": "Mail",
|
|
"میل": "Mail",
|
|
"mail": "Mail",
|
|
"شورتکات": "Shortcuts",
|
|
"میانبرها": "Shortcuts",
|
|
"shortcuts": "Shortcuts",
|
|
"فری فرم": "Freeform",
|
|
"freeform": "Freeform",
|
|
"پریویو": "Preview",
|
|
"preview": "Preview",
|
|
"اکتیویتی مانیتور": "Activity Monitor",
|
|
"activity monitor": "Activity Monitor",
|
|
"دیسک یوتیلیتی": "Disk Utility",
|
|
"disk utility": "Disk Utility",
|
|
"پسورد": "Passwords",
|
|
"passwords": "Passwords",
|
|
|
|
// Third-party & Productivity
|
|
"فایرفاکس": "Firefox",
|
|
"فایر فاکس": "Firefox",
|
|
"فایرفکس": "Firefox",
|
|
"firefox": "Firefox",
|
|
|
|
"کروم": "Google Chrome",
|
|
"گوگل کروم": "Google Chrome",
|
|
"chrome": "Google Chrome",
|
|
"google chrome": "Google Chrome",
|
|
|
|
"تلگرام": "Telegram",
|
|
"تلگرم": "Telegram",
|
|
"telegram": "Telegram",
|
|
|
|
"تیم بی": "TeamBy",
|
|
"تیم بای": "TeamBy",
|
|
"تیمبی": "TeamBy",
|
|
"تیمبای": "TeamBy",
|
|
"teamby": "TeamBy",
|
|
|
|
"فیگما": "Figma",
|
|
"figma": "Figma",
|
|
|
|
"وی تو باکس": "V2Box",
|
|
"وی۲باکس": "V2Box",
|
|
"ویتوباکس": "V2Box",
|
|
"v2box": "V2Box",
|
|
|
|
"وی اس کد": "Visual Studio Code",
|
|
"ویژوال استودیو کد": "Visual Studio Code",
|
|
"ویژوال استودیو": "Visual Studio Code",
|
|
"کد": "Visual Studio Code",
|
|
"vscode": "Visual Studio Code",
|
|
"vs code": "Visual Studio Code",
|
|
"code": "Visual Studio Code",
|
|
|
|
"سانی اوکس": "Soniox Voice",
|
|
"soniox": "Soniox Voice",
|
|
|
|
"روزگار": "Roozegar",
|
|
"roozegar": "Roozegar",
|
|
"شمسی کال": "ShamsiCal",
|
|
"shamsical": "ShamsiCal",
|
|
"چند": "Chand",
|
|
"chand": "Chand",
|
|
"میدل کلیک": "MiddleClick",
|
|
"middleclick": "MiddleClick"
|
|
]
|
|
|
|
private init() {
|
|
refreshInstalledApps()
|
|
}
|
|
|
|
// MARK: - Normalization Helpers
|
|
public static func normalizePersianText(_ text: String) -> String {
|
|
var s = text.lowercased()
|
|
|
|
// 1. ZWNJ, ZWJ, and non-standard spaces
|
|
s = s.replacingOccurrences(of: "\u{200C}", with: " ")
|
|
s = s.replacingOccurrences(of: "\u{200D}", with: " ")
|
|
s = s.replacingOccurrences(of: "\u{FEFF}", with: "")
|
|
s = s.replacingOccurrences(of: "\u{00A0}", with: " ")
|
|
|
|
// 2. Arabic to Persian letter unification
|
|
s = s.replacingOccurrences(of: "ي", with: "ی")
|
|
s = s.replacingOccurrences(of: "ى", with: "ی")
|
|
s = s.replacingOccurrences(of: "ك", with: "ک")
|
|
s = s.replacingOccurrences(of: "آ", with: "ا")
|
|
s = s.replacingOccurrences(of: "أ", with: "ا")
|
|
s = s.replacingOccurrences(of: "إ", with: "ا")
|
|
s = s.replacingOccurrences(of: "ؤ", with: "و")
|
|
s = s.replacingOccurrences(of: "ة", with: "ه")
|
|
|
|
// 3. Remove vowels and diacritics
|
|
let diacritics = ["\u{064B}", "\u{064C}", "\u{064D}", "\u{064E}", "\u{064F}", "\u{0650}", "\u{0651}", "\u{0652}"]
|
|
for d in diacritics {
|
|
s = s.replacingOccurrences(of: d, with: "")
|
|
}
|
|
|
|
// 4. Punctuation to space
|
|
let punct = CharacterSet(charactersIn: ".,،؛:!؟?\"'«»()[]{}—-_/\\")
|
|
s = s.components(separatedBy: punct).joined(separator: " ")
|
|
|
|
// 5. Clean whitespace
|
|
let parts = s.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
|
|
return parts.joined(separator: " ")
|
|
}
|
|
|
|
public func isSlashCommand(text: String) -> Bool {
|
|
return getSlashWordPosition(text: text) != nil
|
|
}
|
|
|
|
public func getSlashWordPosition(text: String) -> Int? {
|
|
let cleanText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !cleanText.isEmpty else { return nil }
|
|
|
|
// Starting with slash symbol, e.g. "/settings"
|
|
if cleanText.hasPrefix("/") || cleanText.hasPrefix("\\") {
|
|
return 0
|
|
}
|
|
|
|
let punct = CharacterSet(charactersIn: ".,،؛:!؟?\"'«»()[]{}—-_")
|
|
let rawWords = cleanText.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
|
|
guard !rawWords.isEmpty else { return nil }
|
|
|
|
let slashTriggers = ["اسلش", "اصلش", "slash", "slsh", "/", "\\"]
|
|
|
|
// Word 1: Direct slash command (e.g. "اسلش آنتیگراویتی")
|
|
let firstWordClean = rawWords[0].trimmingCharacters(in: punct).lowercased()
|
|
if slashTriggers.contains(firstWordClean) || firstWordClean.hasPrefix("/") || firstWordClean.hasPrefix("\\") {
|
|
return 0
|
|
}
|
|
|
|
// Word 2: Slash after a single filler word (e.g. "امم اسلش فایرفاکس" or "خب، اسلش چپ")
|
|
if rawWords.count >= 2 {
|
|
let secondWordClean = rawWords[1].trimmingCharacters(in: punct).lowercased()
|
|
if slashTriggers.contains(secondWordClean) || secondWordClean.hasPrefix("/") || secondWordClean.hasPrefix("\\") {
|
|
return 1
|
|
}
|
|
}
|
|
|
|
// STRICT RULE: If "slash" occurs at word 3 or later (middle of sentence), it is NOT a command!
|
|
return nil
|
|
}
|
|
|
|
public func extractCommandBody(rawText: String) -> String {
|
|
guard let slashIndex = getSlashWordPosition(text: rawText) else { return "" }
|
|
|
|
let rawWords = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.components(separatedBy: .whitespacesAndNewlines)
|
|
.filter { !$0.isEmpty }
|
|
|
|
guard rawWords.count > slashIndex + 1 else {
|
|
// Case where command is attached to first symbol, e.g. "/settings"
|
|
if slashIndex == 0, let first = rawWords.first, first.hasPrefix("/") || first.hasPrefix("\\") {
|
|
let bodyFromFirst = String(first.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let rest = rawWords.dropFirst().joined(separator: " ")
|
|
return ([bodyFromFirst, rest].filter { !$0.isEmpty }).joined(separator: " ")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
let bodyWords = rawWords[(slashIndex + 1)...]
|
|
var body = bodyWords.joined(separator: " ")
|
|
let leadingPunct = CharacterSet(charactersIn: " :.,،؛!؟?-_—\"'«»()[]{}")
|
|
body = body.trimmingCharacters(in: leadingPunct)
|
|
return body
|
|
}
|
|
|
|
// MARK: - Main Execution Dispatcher
|
|
@discardableResult
|
|
public func execute(rawText: String) -> Bool {
|
|
let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard isSlashCommand(text: trimmed) else { return false }
|
|
|
|
let body = extractCommandBody(rawText: trimmed)
|
|
let normalized = Self.normalizePersianText(body)
|
|
|
|
print("VoiceCommandExecutor: Executing command body: '\(body)' | Normalized: '\(normalized)'")
|
|
|
|
// 1. User-Customized Commands (from custom_commands.json)
|
|
if let customCmd = CustomCommandManager.shared.findMatchingCommand(query: body) ??
|
|
CustomCommandManager.shared.findMatchingCommand(query: normalized) {
|
|
if CustomCommandManager.shared.executeCommand(customCmd) {
|
|
playConfirmationSound()
|
|
VoiceCommandLogger.shared.logCommand(
|
|
rawSpeech: rawText,
|
|
body: body,
|
|
normalized: normalized,
|
|
actionCategory: "Custom User Command",
|
|
actionDetail: customCmd.title ?? "\(customCmd.action): \(customCmd.value)",
|
|
status: "SUCCESS"
|
|
)
|
|
return true
|
|
}
|
|
}
|
|
|
|
// 2. Web Search & Direct Navigation ("سرچ X", "search X", "یوتیوب X", "youtube X", "برو به X")
|
|
if let searchDetail = handleWebSearchAndNavigation(normalized: normalized, original: body) {
|
|
playConfirmationSound()
|
|
VoiceCommandLogger.shared.logCommand(
|
|
rawSpeech: rawText,
|
|
body: body,
|
|
normalized: normalized,
|
|
actionCategory: "Web Search / Navigation",
|
|
actionDetail: searchDetail,
|
|
status: "SUCCESS"
|
|
)
|
|
return true
|
|
}
|
|
|
|
// 3. Window Tiling & Management (چپ, راست, بالا, پایین, تمام صفحه, وسط, مینیمایز, هاید, بستن)
|
|
if let tileDetail = handleWindowTiling(normalized: normalized) {
|
|
playConfirmationSound()
|
|
VoiceCommandLogger.shared.logCommand(
|
|
rawSpeech: rawText,
|
|
body: body,
|
|
normalized: normalized,
|
|
actionCategory: "Window Management",
|
|
actionDetail: tileDetail,
|
|
status: "SUCCESS"
|
|
)
|
|
return true
|
|
}
|
|
|
|
// 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: "App Account / Tab Switch",
|
|
actionDetail: appTabDetail,
|
|
status: "SUCCESS"
|
|
)
|
|
return true
|
|
}
|
|
|
|
// 5. Standalone Tab & Navigation commands (e.g. "تب ۱", "تب جدید", "بستن تب", "tab 1", "new tab")
|
|
if let tabDetail = handleStandaloneTab(normalized: normalized) {
|
|
playConfirmationSound()
|
|
VoiceCommandLogger.shared.logCommand(
|
|
rawSpeech: rawText,
|
|
body: body,
|
|
normalized: normalized,
|
|
actionCategory: "Tab Navigation",
|
|
actionDetail: tabDetail,
|
|
status: "SUCCESS"
|
|
)
|
|
return true
|
|
}
|
|
|
|
// 6. System controls (volume, mute, lock, screenshot, dark mode, editing hotkeys)
|
|
if let sysDetail = handleSystemControls(normalized: normalized) {
|
|
playConfirmationSound()
|
|
VoiceCommandLogger.shared.logCommand(
|
|
rawSpeech: rawText,
|
|
body: body,
|
|
normalized: normalized,
|
|
actionCategory: "System Control",
|
|
actionDetail: sysDetail,
|
|
status: "SUCCESS"
|
|
)
|
|
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)
|
|
playConfirmationSound()
|
|
HistoryManager.shared.addEntry("⚡️ [App] Open \(appName)")
|
|
VoiceCommandLogger.shared.logCommand(
|
|
rawSpeech: rawText,
|
|
body: body,
|
|
normalized: normalized,
|
|
actionCategory: "Application Launch",
|
|
actionDetail: "Launched / Focused: \(appName)",
|
|
status: "SUCCESS"
|
|
)
|
|
return true
|
|
}
|
|
|
|
// 9. Universal fuzzy resolver across all installed apps in /Applications and /System/Applications
|
|
if let universalMatch = handleUniversalInstalledApp(query: body, normalized: normalized) {
|
|
playConfirmationSound()
|
|
VoiceCommandLogger.shared.logCommand(
|
|
rawSpeech: rawText,
|
|
body: body,
|
|
normalized: normalized,
|
|
actionCategory: "Universal App Match",
|
|
actionDetail: "Launched / Focused: \(universalMatch)",
|
|
status: "SUCCESS"
|
|
)
|
|
return true
|
|
}
|
|
|
|
// Unrecognized Slash Command
|
|
print("VoiceCommandExecutor: Unrecognized slash command body: '\(body)'")
|
|
HistoryManager.shared.addEntry("⚡️ [Command] Unrecognized: \(body)")
|
|
VoiceCommandLogger.shared.logCommand(
|
|
rawSpeech: rawText,
|
|
body: body,
|
|
normalized: normalized,
|
|
actionCategory: "Unrecognized",
|
|
actionDetail: "No matching application, shortcut, or search trigger found",
|
|
status: "UNRECOGNIZED"
|
|
)
|
|
playWarningSound()
|
|
return false
|
|
}
|
|
|
|
// MARK: - Specialized Antigravity Matcher
|
|
private func matchAntigravity(normalized: String) -> String? {
|
|
let hasAnti = normalized.contains("انتی") || normalized.contains("anti") ||
|
|
normalized.contains("on the") || normalized.contains("in the") || normalized.contains("and the")
|
|
|
|
let hasGravity = normalized.contains("گراویتی") ||
|
|
normalized.contains("گرویتی") ||
|
|
normalized.contains("گریویتی") ||
|
|
normalized.contains("گریتی") ||
|
|
normalized.contains("گرایتی") ||
|
|
normalized.contains("گروتی") ||
|
|
normalized.contains("گویتی") ||
|
|
normalized.contains("gravity") ||
|
|
normalized.contains("graphity") ||
|
|
normalized.contains("graviti") ||
|
|
normalized.contains("gravty")
|
|
|
|
let isFusedAntiGravity = normalized.contains("antigravity") ||
|
|
normalized.contains("انتیگراویتی") ||
|
|
normalized.contains("انتیگرویتی") ||
|
|
normalized.contains("انتیگریویتی") ||
|
|
normalized.contains("انتیگریتی") ||
|
|
normalized.contains("انتیگرایتی")
|
|
|
|
guard (hasAnti && hasGravity) || isFusedAntiGravity else {
|
|
return nil
|
|
}
|
|
|
|
// Strict explicit check for IDE / Editor (Black Icon App)
|
|
let isIDE = normalized.contains("ای دی ای") ||
|
|
normalized.contains("ایدی ای") ||
|
|
normalized.contains("ای دی") ||
|
|
normalized.contains("ایدی") ||
|
|
normalized.contains("ایدیای") ||
|
|
normalized.contains("ایدیا") ||
|
|
normalized.contains("آیدی ای") ||
|
|
normalized.contains("آی دی ای") ||
|
|
normalized.contains("آیدی") ||
|
|
normalized.contains("آی دی") ||
|
|
normalized.contains("ide") ||
|
|
normalized.contains("ادیتور") ||
|
|
normalized.contains("کد")
|
|
|
|
// If IDE is mentioned -> "Antigravity IDE" (Black icon)
|
|
// If standalone / "2" -> "Antigravity" (White icon app, Antigravity 2)
|
|
return isIDE ? "Antigravity IDE" : "Antigravity"
|
|
}
|
|
|
|
// MARK: - Web Search & Direct Navigation
|
|
private func handleWebSearchAndNavigation(normalized: String, original: String) -> String? {
|
|
// Google Search
|
|
for p in ["سرچ", "جستجو", "search", "google"] {
|
|
if normalized.hasPrefix(p) {
|
|
var q = original
|
|
// Clean original string from search keyword and punctuations
|
|
let lowerOrig = q.lowercased()
|
|
if let r = lowerOrig.range(of: p) {
|
|
q = String(q[r.upperBound...])
|
|
}
|
|
let trimSet = CharacterSet(charactersIn: " :.,،؛!؟?-_—\"'«»()[]{}")
|
|
q = q.trimmingCharacters(in: trimSet)
|
|
|
|
if !q.isEmpty, let enc = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
|
let url = URL(string: "https://www.google.com/search?q=\(enc)") {
|
|
NSWorkspace.shared.open(url)
|
|
HistoryManager.shared.addEntry("⚡️ [Search] Google: \(q)")
|
|
return "Google Search: \(q)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// YouTube Search
|
|
for p in ["یوتیوب", "youtube"] {
|
|
if normalized.hasPrefix(p) {
|
|
var q = original
|
|
let lowerOrig = q.lowercased()
|
|
if let r = lowerOrig.range(of: p) {
|
|
q = String(q[r.upperBound...])
|
|
}
|
|
let trimSet = CharacterSet(charactersIn: " :.,،؛!؟?-_—\"'«»()[]{}")
|
|
q = q.trimmingCharacters(in: trimSet)
|
|
|
|
if !q.isEmpty, let enc = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
|
let url = URL(string: "https://www.youtube.com/results?search_query=\(enc)") {
|
|
NSWorkspace.shared.open(url)
|
|
HistoryManager.shared.addEntry("⚡️ [Search] YouTube: \(q)")
|
|
return "YouTube Search: \(q)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Direct URL navigation ("برو به X" / "go to X")
|
|
for p in ["برو به", "برو تو", "برو", "goto", "go to"] {
|
|
if normalized.hasPrefix(p) {
|
|
var target = original
|
|
let lowerOrig = target.lowercased()
|
|
if let r = lowerOrig.range(of: p) {
|
|
target = String(target[r.upperBound...])
|
|
}
|
|
let trimSet = CharacterSet(charactersIn: " :.,،؛!؟?-_—\"'«»()[]{}")
|
|
target = target.trimmingCharacters(in: trimSet)
|
|
|
|
if !target.isEmpty {
|
|
var urlStr = target
|
|
if !urlStr.contains("://") {
|
|
urlStr = "https://" + urlStr
|
|
}
|
|
if let url = URL(string: urlStr) {
|
|
NSWorkspace.shared.open(url)
|
|
HistoryManager.shared.addEntry("⚡️ [Nav] Go to: \(target)")
|
|
return "Direct Navigation: \(urlStr)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Window Tiling & Management
|
|
private func handleWindowTiling(normalized: String) -> String? {
|
|
if normalized == "چپ" || normalized.contains("پنجره چپ") || normalized == "left" || normalized == "window left" {
|
|
tileFrontmostWindow(action: .left)
|
|
HistoryManager.shared.addEntry("⚡️ [Window] Tile Left Half")
|
|
return "Tile Left Half"
|
|
}
|
|
if normalized == "راست" || normalized.contains("پنجره راست") || normalized == "right" || normalized == "window right" {
|
|
tileFrontmostWindow(action: .right)
|
|
HistoryManager.shared.addEntry("⚡️ [Window] Tile Right Half")
|
|
return "Tile Right Half"
|
|
}
|
|
if normalized == "بالا" || normalized.contains("پنجره بالا") || normalized == "top" || normalized == "window top" {
|
|
tileFrontmostWindow(action: .top)
|
|
HistoryManager.shared.addEntry("⚡️ [Window] Tile Top Half")
|
|
return "Tile Top Half"
|
|
}
|
|
if normalized == "پایین" || normalized.contains("پنجره پایین") || normalized == "bottom" || normalized == "window bottom" {
|
|
tileFrontmostWindow(action: .bottom)
|
|
HistoryManager.shared.addEntry("⚡️ [Window] Tile Bottom Half")
|
|
return "Tile Bottom Half"
|
|
}
|
|
if normalized.contains("تمام صفحه") || normalized.contains("فول اسکرین") || normalized.contains("ماکزیمایز") || normalized == "maximize" || normalized == "full screen" {
|
|
tileFrontmostWindow(action: .maximize)
|
|
if let front = NSWorkspace.shared.frontmostApplication {
|
|
if let bid = front.bundleIdentifier { WindowStateManager.shared.setPrefersFullScreen(true, for: bid) }
|
|
if let name = front.localizedName { WindowStateManager.shared.setPrefersFullScreen(true, for: name) }
|
|
}
|
|
HistoryManager.shared.addEntry("⚡️ [Window] Maximize Window")
|
|
return "Maximize Window"
|
|
}
|
|
if normalized.contains("خروج از تمام صفحه") || normalized.contains("پنجره عادی") || normalized == "دسکتاپ" || normalized == "پنجره" || normalized == "exit full screen" || normalized == "restore window" {
|
|
exitFrontmostWindowFullScreen()
|
|
if let front = NSWorkspace.shared.frontmostApplication {
|
|
if let bid = front.bundleIdentifier { WindowStateManager.shared.setPrefersFullScreen(false, for: bid) }
|
|
if let name = front.localizedName { WindowStateManager.shared.setPrefersFullScreen(false, for: name) }
|
|
}
|
|
HistoryManager.shared.addEntry("⚡️ [Window] Exit Full Screen (Desktop Mode)")
|
|
return "Exit Full Screen"
|
|
}
|
|
if normalized == "وسط" || normalized == "مرکز" || normalized.contains("پنجره وسط") || normalized == "center" || normalized == "window center" {
|
|
tileFrontmostWindow(action: .center)
|
|
HistoryManager.shared.addEntry("⚡️ [Window] Center Window")
|
|
return "Center Window"
|
|
}
|
|
if normalized == "مینیمایز" || normalized.contains("کوچک کردن") || normalized == "minimize" {
|
|
pressHotkey(keyCode: 46, modifiers: .maskCommand) // Cmd+M
|
|
HistoryManager.shared.addEntry("⚡️ [Window] Minimize")
|
|
return "Minimize Window (Cmd+M)"
|
|
}
|
|
if normalized == "هاید" || normalized == "مخفی" || normalized.contains("مخفی کن") || normalized == "hide" {
|
|
pressHotkey(keyCode: 4, modifiers: .maskCommand) // Cmd+H
|
|
HistoryManager.shared.addEntry("⚡️ [Window] Hide App")
|
|
return "Hide Frontmost App (Cmd+H)"
|
|
}
|
|
if normalized.contains("هاید بقیه") || normalized.contains("مخفی کردن بقیه") || normalized == "hide others" {
|
|
pressHotkey(keyCode: 4, modifiers: [.maskCommand, .maskAlternate]) // Cmd+Option+H
|
|
HistoryManager.shared.addEntry("⚡️ [Window] Hide Other Apps")
|
|
return "Hide Other Apps (Cmd+Option+H)"
|
|
}
|
|
if normalized == "بستن پنجره" || normalized == "کلوز پنجره" || normalized == "close window" {
|
|
pressHotkey(keyCode: 13, modifiers: .maskCommand) // Cmd+W
|
|
HistoryManager.shared.addEntry("⚡️ [Window] Close Window")
|
|
return "Close Window (Cmd+W)"
|
|
}
|
|
if normalized == "ببند" || normalized.contains("خروج از برنامه") || normalized.contains("کویت") || normalized == "quit" || normalized == "quit app" {
|
|
pressHotkey(keyCode: 12, modifiers: .maskCommand) // Cmd+Q
|
|
HistoryManager.shared.addEntry("⚡️ [Window] Quit App")
|
|
return "Quit Application (Cmd+Q)"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func tileFrontmostWindow(action: WindowTileAction) {
|
|
guard let frontApp = NSWorkspace.shared.frontmostApplication,
|
|
let screen = NSScreen.main else { return }
|
|
|
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
|
|
var windowObj: CFTypeRef?
|
|
guard AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &windowObj) == .success,
|
|
let winElem = windowObj else { return }
|
|
|
|
let axWin = winElem as! AXUIElement
|
|
let screenFrame = screen.visibleFrame
|
|
let fullH = NSScreen.screens.first?.frame.height ?? screen.frame.height
|
|
let axY = fullH - (screenFrame.origin.y + screenFrame.size.height)
|
|
|
|
var newPos = CGPoint.zero
|
|
var newSize = CGSize.zero
|
|
|
|
switch action {
|
|
case .left:
|
|
newPos = CGPoint(x: screenFrame.origin.x, y: axY)
|
|
newSize = CGSize(width: screenFrame.width / 2, height: screenFrame.height)
|
|
case .right:
|
|
newPos = CGPoint(x: screenFrame.origin.x + screenFrame.width / 2, y: axY)
|
|
newSize = CGSize(width: screenFrame.width / 2, height: screenFrame.height)
|
|
case .top:
|
|
newPos = CGPoint(x: screenFrame.origin.x, y: axY)
|
|
newSize = CGSize(width: screenFrame.width, height: screenFrame.height / 2)
|
|
case .bottom:
|
|
newPos = CGPoint(x: screenFrame.origin.x, y: axY + screenFrame.height / 2)
|
|
newSize = CGSize(width: screenFrame.width, height: screenFrame.height / 2)
|
|
case .maximize:
|
|
newPos = CGPoint(x: screenFrame.origin.x, y: axY)
|
|
newSize = CGSize(width: screenFrame.width, height: screenFrame.height)
|
|
case .center:
|
|
let w = min(1100, screenFrame.width * 0.85)
|
|
let h = min(750, screenFrame.height * 0.85)
|
|
newPos = CGPoint(x: screenFrame.origin.x + (screenFrame.width - w) / 2, y: axY + (screenFrame.height - h) / 2)
|
|
newSize = CGSize(width: w, height: h)
|
|
}
|
|
|
|
var size = newSize
|
|
var pos = newPos
|
|
if let sVal = AXValueCreate(.cgSize, &size) {
|
|
AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sVal)
|
|
}
|
|
if let pVal = AXValueCreate(.cgPoint, &pos) {
|
|
AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, pVal)
|
|
}
|
|
}
|
|
|
|
// 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) anywhere inside words (e.g. "تلگرام ۱", "تلگرام1", "اکانت۱")
|
|
for w in words {
|
|
let cleanW = w.trimmingCharacters(in: CharacterSet(charactersIn: ".,،؛:!؟?-_/\"'()[]{}«»—–"))
|
|
for ch in cleanW {
|
|
if let d = Int(String(ch)), d >= 1 && d <= 9 {
|
|
return d
|
|
}
|
|
let faDigits: [Character: Int] = [
|
|
"۱": 1, "۲": 2, "۳": 3, "۴": 4, "۵": 5, "۶": 6, "۷": 7, "۸": 8, "۹": 9,
|
|
"١": 1, "٢": 2, "٣": 3, "٤": 4, "٥": 5, "٦": 6, "٧": 7, "٨": 8, "٩": 9
|
|
]
|
|
if let d = faDigits[ch] {
|
|
return d
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Comprehensive Word numbers (Persian & English cardinal, ordinal, colloquial)
|
|
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
|
|
var isAntigravity = false
|
|
|
|
if normalized.contains("تلگرام") || normalized.contains("telegram") ||
|
|
normalized.contains("اکانت") || normalized.contains("account") || normalized.contains("حساب") {
|
|
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("گرویتی")) {
|
|
isAntigravity = true
|
|
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 }
|
|
|
|
// For Antigravity: "آنتی گرویتی ۲" / "antigravity 2" without explicit "تب"/"tab" refers to Antigravity.app (the white icon app)!
|
|
if isAntigravity && !normalized.contains("تب") && !normalized.contains("tab") {
|
|
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? {
|
|
// 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"
|
|
} 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("telegram") {
|
|
targetApp = "Telegram"
|
|
} else if let anti = matchAntigravity(normalized: normalized) {
|
|
targetApp = anti
|
|
}
|
|
|
|
guard let appName = targetApp else { return nil }
|
|
|
|
if normalized.contains("تب جدید") || normalized.contains("new tab") {
|
|
activateApp(named: appName) {
|
|
self.pressHotkey(keyCode: 17, modifiers: .maskCommand) // Cmd+T
|
|
}
|
|
HistoryManager.shared.addEntry("⚡️ [Command] \(appName): New Tab")
|
|
return "\(appName) -> New Tab (Cmd+T)"
|
|
} else if normalized.contains("بستن تب") || normalized.contains("close tab") {
|
|
activateApp(named: appName) {
|
|
self.pressHotkey(keyCode: 13, modifiers: .maskCommand) // Cmd+W
|
|
}
|
|
HistoryManager.shared.addEntry("⚡️ [Command] \(appName): Close Tab")
|
|
return "\(appName) -> Close Tab (Cmd+W)"
|
|
} else if normalized.contains("ری اپن") || normalized.contains("reopen tab") || normalized.contains("باز کردن مجدد تب") {
|
|
activateApp(named: appName) {
|
|
self.pressHotkey(keyCode: 17, modifiers: [.maskCommand, .maskShift]) // Cmd+Shift+T
|
|
}
|
|
HistoryManager.shared.addEntry("⚡️ [Command] \(appName): Reopen Tab")
|
|
return "\(appName) -> Reopen Tab (Cmd+Shift+T)"
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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], Cmd+Shift+[number], and Ctrl combinations (configured via shortcuts-custom.json)
|
|
pressHotkey(keyCode: code, modifiers: .maskCommand)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
|
|
self.pressHotkey(keyCode: code, modifiers: [.maskCommand, .maskShift])
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
self.pressHotkey(keyCode: code, modifiers: .maskControl)
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
|
|
self.pressHotkey(keyCode: code, modifiers: [.maskControl, .maskShift])
|
|
}
|
|
}
|
|
|
|
// MARK: - Standalone Tab Commands
|
|
private func handleStandaloneTab(normalized: String) -> String? {
|
|
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
|
|
HistoryManager.shared.addEntry("⚡️ [Command] New Tab")
|
|
return "New Tab (Cmd+T)"
|
|
}
|
|
if normalized.contains("بستن تب") || normalized.contains("کلوز تب") || normalized.contains("close tab") {
|
|
pressHotkey(keyCode: 13, modifiers: .maskCommand) // Cmd+W
|
|
HistoryManager.shared.addEntry("⚡️ [Command] Close Tab")
|
|
return "Close Tab (Cmd+W)"
|
|
}
|
|
if normalized.contains("ری اپن") || normalized.contains("reopen tab") || normalized.contains("باز کردن مجدد تب") {
|
|
pressHotkey(keyCode: 17, modifiers: [.maskCommand, .maskShift]) // Cmd+Shift+T
|
|
HistoryManager.shared.addEntry("⚡️ [Command] Reopen Closed Tab")
|
|
return "Reopen Closed Tab (Cmd+Shift+T)"
|
|
}
|
|
if normalized.contains("تب بعد") || normalized.contains("تب بعدی") || normalized.contains("next tab") {
|
|
pressHotkey(keyCode: 48, modifiers: .maskControl) // Ctrl+Tab
|
|
HistoryManager.shared.addEntry("⚡️ [Command] Next Tab")
|
|
return "Next Tab (Ctrl+Tab)"
|
|
}
|
|
if normalized.contains("تب قبل") || normalized.contains("تب قبلی") || normalized.contains("prev tab") || normalized.contains("previous tab") {
|
|
pressHotkey(keyCode: 48, modifiers: [.maskControl, .maskShift]) // Ctrl+Shift+Tab
|
|
HistoryManager.shared.addEntry("⚡️ [Command] Previous Tab")
|
|
return "Previous Tab (Ctrl+Shift+Tab)"
|
|
}
|
|
if normalized.contains("رفرش") || normalized.contains("ریلود") || normalized.contains("reload") || normalized.contains("refresh") {
|
|
pressHotkey(keyCode: 15, modifiers: .maskCommand) // Cmd+R
|
|
HistoryManager.shared.addEntry("⚡️ [Command] Refresh Page")
|
|
return "Reload Page (Cmd+R)"
|
|
}
|
|
if normalized.contains("هارد رفرش") || normalized.contains("hard refresh") {
|
|
pressHotkey(keyCode: 15, modifiers: [.maskCommand, .maskShift]) // Cmd+Shift+R
|
|
HistoryManager.shared.addEntry("⚡️ [Command] Hard Refresh Page")
|
|
return "Hard Refresh Page (Cmd+Shift+R)"
|
|
}
|
|
if normalized.contains("ادرس بار") || normalized.contains("address bar") {
|
|
pressHotkey(keyCode: 37, modifiers: .maskCommand) // Cmd+L
|
|
HistoryManager.shared.addEntry("⚡️ [Command] Focus Address Bar")
|
|
return "Focus Address Bar (Cmd+L)"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - System Controls
|
|
private func handleSystemControls(normalized: String) -> String? {
|
|
// Volume exact levels
|
|
if normalized.contains("صدا ۱۰۰") || normalized.contains("صدا 100") || normalized == "volume 100" || normalized == "max volume" {
|
|
runAppleScript("set volume output volume 100")
|
|
HistoryManager.shared.addEntry("⚡️ [Audio] Volume 100%")
|
|
return "Set Output Volume to 100%"
|
|
}
|
|
if normalized.contains("صدا ۵۰") || normalized.contains("صدا 50") || normalized == "volume 50" {
|
|
runAppleScript("set volume output volume 50")
|
|
HistoryManager.shared.addEntry("⚡️ [Audio] Volume 50%")
|
|
return "Set Output Volume to 50%"
|
|
}
|
|
if normalized.contains("صدا صفر") || normalized.contains("صدا 0") || normalized == "volume 0" {
|
|
runAppleScript("set volume output volume 0")
|
|
HistoryManager.shared.addEntry("⚡️ [Audio] Volume 0%")
|
|
return "Set Output Volume to 0%"
|
|
}
|
|
// Volume up / down
|
|
if normalized.contains("صدا زیاد") || normalized.contains("افزایش صدا") || normalized.contains("volume up") {
|
|
runAppleScript("set volume output volume ((output volume of (get volume settings)) + 15)")
|
|
HistoryManager.shared.addEntry("⚡️ [Audio] Volume Up")
|
|
return "Volume Up (+15%)"
|
|
}
|
|
if normalized.contains("صدا کم") || normalized.contains("کاهش صدا") || normalized.contains("volume down") {
|
|
runAppleScript("set volume output volume ((output volume of (get volume settings)) - 15)")
|
|
HistoryManager.shared.addEntry("⚡️ [Audio] Volume Down")
|
|
return "Volume Down (-15%)"
|
|
}
|
|
if normalized.contains("میوت") || normalized.contains("بی صدا") || normalized == "mute" {
|
|
runAppleScript("set volume output muted not (output muted of (get volume settings))")
|
|
HistoryManager.shared.addEntry("⚡️ [Audio] Toggle Mute")
|
|
return "Toggle Audio Mute"
|
|
}
|
|
|
|
// Media Play / Pause
|
|
if normalized == "پلی" || normalized == "پاز" || normalized == "توقف" || normalized == "play" || normalized == "pause" {
|
|
runAppleScript("tell application \"System Events\" to key code 16 using {}")
|
|
HistoryManager.shared.addEntry("⚡️ [Media] Play / Pause")
|
|
return "Media Play / Pause"
|
|
}
|
|
if normalized.contains("اهنگ بعد") || normalized == "next track" {
|
|
runAppleScript("tell application \"System Events\" to key code 19 using {}")
|
|
HistoryManager.shared.addEntry("⚡️ [Media] Next Track")
|
|
return "Media Next Track"
|
|
}
|
|
if normalized.contains("اهنگ قبل") || normalized == "prev track" {
|
|
runAppleScript("tell application \"System Events\" to key code 20 using {}")
|
|
HistoryManager.shared.addEntry("⚡️ [Media] Previous Track")
|
|
return "Media Previous Track"
|
|
}
|
|
|
|
// Lock Screen
|
|
if normalized.contains("قفل") || normalized.contains("لاک") || normalized.contains("lock screen") || normalized == "lock" {
|
|
let task = Process()
|
|
task.executableURL = URL(fileURLWithPath: "/usr/bin/pmset")
|
|
task.arguments = ["displaysleepnow"]
|
|
try? task.run()
|
|
HistoryManager.shared.addEntry("⚡️ [System] Lock Screen")
|
|
return "Display Sleep / Lock Screen"
|
|
}
|
|
|
|
// Sleep
|
|
if normalized == "اسلیپ" || normalized == "sleep" {
|
|
let task = Process()
|
|
task.executableURL = URL(fileURLWithPath: "/usr/bin/pmset")
|
|
task.arguments = ["sleepnow"]
|
|
try? task.run()
|
|
HistoryManager.shared.addEntry("⚡️ [System] Sleep Mac")
|
|
return "System Sleep (pmset sleepnow)"
|
|
}
|
|
|
|
// Screenshot (Interactive)
|
|
if normalized.contains("اسکرین شات") || normalized.contains("عکس صفحه") || normalized == "screenshot" {
|
|
let task = Process()
|
|
task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
|
|
task.arguments = ["-i", "-c"]
|
|
try? task.run()
|
|
HistoryManager.shared.addEntry("⚡️ [System] Interactive Screenshot")
|
|
return "Interactive Screenshot to Clipboard"
|
|
}
|
|
|
|
// Dark Mode toggle
|
|
if normalized.contains("دارک مود") || normalized.contains("لایت مود") || normalized.contains("حالت شب") || normalized.contains("حالت روز") || normalized.contains("dark mode") {
|
|
runAppleScript("tell application \"System Events\" to tell appearance preferences to set dark mode to not dark mode")
|
|
HistoryManager.shared.addEntry("⚡️ [System] Toggle Dark Mode")
|
|
return "Toggle Dark Mode Appearance"
|
|
}
|
|
|
|
// Editing hotkeys
|
|
if normalized == "کپی" || normalized == "copy" {
|
|
pressHotkey(keyCode: 8, modifiers: .maskCommand) // Cmd+C
|
|
HistoryManager.shared.addEntry("⚡️ [Edit] Copy")
|
|
return "Copy (Cmd+C)"
|
|
}
|
|
if normalized == "پیست" || normalized == "paste" {
|
|
pressHotkey(keyCode: 9, modifiers: .maskCommand) // Cmd+V
|
|
HistoryManager.shared.addEntry("⚡️ [Edit] Paste")
|
|
return "Paste (Cmd+V)"
|
|
}
|
|
if normalized == "کات" || normalized == "cut" {
|
|
pressHotkey(keyCode: 7, modifiers: .maskCommand) // Cmd+X
|
|
HistoryManager.shared.addEntry("⚡️ [Edit] Cut")
|
|
return "Cut (Cmd+X)"
|
|
}
|
|
if normalized.contains("انتخاب همه") || normalized.contains("سلکت ال") || normalized == "select all" {
|
|
pressHotkey(keyCode: 0, modifiers: .maskCommand) // Cmd+A
|
|
HistoryManager.shared.addEntry("⚡️ [Edit] Select All")
|
|
return "Select All (Cmd+A)"
|
|
}
|
|
if normalized == "اندو" || normalized == "برگرد" || normalized == "undo" {
|
|
pressHotkey(keyCode: 6, modifiers: .maskCommand) // Cmd+Z
|
|
HistoryManager.shared.addEntry("⚡️ [Edit] Undo")
|
|
return "Undo (Cmd+Z)"
|
|
}
|
|
if normalized == "ریدو" || normalized == "redo" {
|
|
pressHotkey(keyCode: 6, modifiers: [.maskCommand, .maskShift]) // Cmd+Shift+Z
|
|
HistoryManager.shared.addEntry("⚡️ [Edit] Redo")
|
|
return "Redo (Cmd+Shift+Z)"
|
|
}
|
|
if normalized == "سیو" || normalized == "ذخیره" || normalized == "save" {
|
|
pressHotkey(keyCode: 1, modifiers: .maskCommand) // Cmd+S
|
|
HistoryManager.shared.addEntry("⚡️ [Edit] Save")
|
|
return "Save (Cmd+S)"
|
|
}
|
|
if normalized == "فایند" || normalized == "پیدا کن" || normalized == "find" {
|
|
pressHotkey(keyCode: 3, modifiers: .maskCommand) // Cmd+F
|
|
HistoryManager.shared.addEntry("⚡️ [Edit] Find")
|
|
return "Find (Cmd+F)"
|
|
}
|
|
if normalized == "اینتر" || normalized == "enter" {
|
|
pressHotkey(keyCode: 36, modifiers: []) // Return
|
|
HistoryManager.shared.addEntry("⚡️ [Edit] Enter")
|
|
return "Press Enter Key"
|
|
}
|
|
if normalized.contains("پاک کردن خط") || normalized == "delete line" {
|
|
pressHotkey(keyCode: 51, modifiers: .maskCommand) // Cmd+Backspace
|
|
HistoryManager.shared.addEntry("⚡️ [Edit] Delete Line")
|
|
return "Delete Line (Cmd+Backspace)"
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Known Apps Matching
|
|
private func matchKnownApp(normalized: String) -> String? {
|
|
var query = normalized
|
|
for prefix in ["باز کن ", "بازکن ", "برو به ", "برو تو ", "اجرا کن ", "open ", "launch ", "goto "] {
|
|
if query.hasPrefix(prefix) {
|
|
query = String(query.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
break
|
|
}
|
|
}
|
|
|
|
for (persianKey, englishAppName) in persianAppDictionary {
|
|
if query == persianKey || query.contains(persianKey) {
|
|
return englishAppName
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Universal Installed App Scanner
|
|
private func handleUniversalInstalledApp(query: String, normalized: String) -> String? {
|
|
var cleanQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
for prefix in ["باز کن ", "بازکن ", "open ", "launch "] {
|
|
if cleanQuery.lowercased().hasPrefix(prefix) {
|
|
cleanQuery = String(cleanQuery.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
break
|
|
}
|
|
}
|
|
guard !cleanQuery.isEmpty else { return nil }
|
|
|
|
let normalizedQuery = normalizeForMatching(cleanQuery)
|
|
refreshInstalledApps()
|
|
|
|
if let match = cachedInstalledApps.first(where: { $0.normalizedName == normalizedQuery }) {
|
|
launchApp(at: match.url, name: match.name)
|
|
return match.name
|
|
}
|
|
if let match = cachedInstalledApps.first(where: { $0.normalizedName.hasPrefix(normalizedQuery) }) {
|
|
launchApp(at: match.url, name: match.name)
|
|
return match.name
|
|
}
|
|
if let match = cachedInstalledApps.first(where: { $0.normalizedName.contains(normalizedQuery) || normalizedQuery.contains($0.normalizedName) }) {
|
|
launchApp(at: match.url, name: match.name)
|
|
return match.name
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func launchApp(at url: URL, name: String) {
|
|
let ws = NSWorkspace.shared
|
|
let shouldBeFullScreen = WindowStateManager.shared.prefersFullScreen(for: name)
|
|
|
|
if let running = ws.runningApplications.first(where: { $0.bundleURL == url }) {
|
|
running.activate(options: [.activateIgnoringOtherApps])
|
|
if shouldBeFullScreen {
|
|
ensureWindowFullScreen(app: running)
|
|
}
|
|
} else {
|
|
let conf = NSWorkspace.OpenConfiguration()
|
|
conf.activates = true
|
|
ws.openApplication(at: url, configuration: conf) { app, _ in
|
|
if let launched = app {
|
|
DispatchQueue.main.async {
|
|
self.pollAndManageWindow(
|
|
app: launched,
|
|
targetBid: launched.bundleIdentifier,
|
|
appName: name,
|
|
shouldBeFullScreen: shouldBeFullScreen
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
HistoryManager.shared.addEntry("⚡️ [App] Open \(name)")
|
|
}
|
|
|
|
private func normalizeForMatching(_ text: String) -> String {
|
|
return text.lowercased()
|
|
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
|
.joined()
|
|
}
|
|
|
|
private func refreshInstalledApps() {
|
|
let now = Date().timeIntervalSince1970
|
|
guard (now - lastAppsScanTime) > 60 || cachedInstalledApps.isEmpty else { return }
|
|
lastAppsScanTime = now
|
|
|
|
var apps: [InstalledAppInfo] = []
|
|
let searchDirs = [
|
|
URL(fileURLWithPath: "/Applications"),
|
|
URL(fileURLWithPath: "/Applications/Utilities"),
|
|
URL(fileURLWithPath: "/System/Applications"),
|
|
URL(fileURLWithPath: "/System/Applications/Utilities"),
|
|
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications")
|
|
]
|
|
|
|
let fm = FileManager.default
|
|
for dir in searchDirs {
|
|
guard let contents = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { continue }
|
|
for item in contents where item.pathExtension == "app" {
|
|
let name = item.deletingPathExtension().lastPathComponent
|
|
let normalized = normalizeForMatching(name)
|
|
apps.append(InstalledAppInfo(name: name, normalizedName: normalized, url: item))
|
|
}
|
|
}
|
|
cachedInstalledApps = apps
|
|
}
|
|
|
|
public func switchToTab(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 }
|
|
pressHotkey(keyCode: code, modifiers: .maskCommand)
|
|
}
|
|
|
|
private func pressHotkey(keyCode: CGKeyCode, modifiers: CGEventFlags) {
|
|
let source = CGEventSource(stateID: .combinedSessionState)
|
|
if let down = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
|
|
let up = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) {
|
|
down.flags = modifiers
|
|
up.flags = modifiers
|
|
down.post(tap: .cghidEventTap)
|
|
up.post(tap: .cghidEventTap)
|
|
}
|
|
}
|
|
|
|
private func launchOrActivateApp(named appName: String, completion: (() -> Void)? = nil) {
|
|
let ws = NSWorkspace.shared
|
|
|
|
// 1. Precise bundle identifier map
|
|
let bundleIDMap: [String: String] = [
|
|
"Antigravity": "com.google.antigravity",
|
|
"Antigravity IDE": "com.google.antigravity-ide",
|
|
"Telegram": "com.tdesktop.Telegram",
|
|
"Firefox": "org.mozilla.firefox",
|
|
"Google Chrome": "com.google.Chrome",
|
|
"Safari": "com.apple.Safari"
|
|
]
|
|
|
|
let targetBid = bundleIDMap[appName]
|
|
let appPrefKey = targetBid ?? appName
|
|
let shouldBeFullScreen = WindowStateManager.shared.prefersFullScreen(for: appPrefKey)
|
|
|
|
// Check if currently running
|
|
let runningApp = ws.runningApplications.first(where: {
|
|
if let bid = targetBid, $0.bundleIdentifier == bid { return true }
|
|
return $0.localizedName?.caseInsensitiveCompare(appName) == .orderedSame
|
|
})
|
|
|
|
if let running = runningApp {
|
|
// Instant path: if the app is ALREADY frontmost
|
|
if running.isActive {
|
|
if shouldBeFullScreen {
|
|
ensureWindowFullScreen(app: running)
|
|
}
|
|
completion?()
|
|
return
|
|
}
|
|
|
|
// Check if app has any open windows
|
|
let appElem = AXUIElementCreateApplication(running.processIdentifier)
|
|
var winListObj: CFTypeRef?
|
|
let hasWindows = (AXUIElementCopyAttributeValue(appElem, kAXWindowsAttribute as CFString, &winListObj) == .success) &&
|
|
((winListObj as? [AXUIElement])?.isEmpty == false)
|
|
|
|
if hasWindows {
|
|
running.activate(options: [.activateIgnoringOtherApps])
|
|
|
|
// Force macOS WindowServer to switch Spaces asynchronously
|
|
DispatchQueue.global(qos: .userInteractive).async {
|
|
let task = Process()
|
|
task.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
|
task.arguments = ["-a", appName]
|
|
try? task.run()
|
|
}
|
|
|
|
if shouldBeFullScreen {
|
|
ensureWindowFullScreen(app: running)
|
|
}
|
|
|
|
if let comp = completion {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
|
|
comp()
|
|
}
|
|
}
|
|
return
|
|
} else {
|
|
// Process is lingering with 0 windows! Re-open window via AppleScript & open -a
|
|
runAppleScript("tell application \"\(appName)\" to reopen")
|
|
runAppleScript("tell application \"\(appName)\" to activate")
|
|
let task = Process()
|
|
task.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
|
task.arguments = ["-a", appName]
|
|
try? task.run()
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
|
|
self.pollAndManageWindow(app: running, targetBid: targetBid, appName: appName, shouldBeFullScreen: shouldBeFullScreen, completion: completion)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Cold launch if app was closed
|
|
if let bid = targetBid, let appURL = ws.urlForApplication(withBundleIdentifier: bid) {
|
|
let conf = NSWorkspace.OpenConfiguration()
|
|
conf.activates = true
|
|
ws.openApplication(at: appURL, configuration: conf) { app, _ in
|
|
DispatchQueue.main.async {
|
|
self.pollAndManageWindow(app: app, targetBid: bid, appName: appName, shouldBeFullScreen: shouldBeFullScreen, completion: completion)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Fallback: launch via /usr/bin/open -a
|
|
let task = Process()
|
|
task.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
|
task.arguments = ["-a", appName]
|
|
try? task.run()
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
self.pollAndManageWindow(app: nil, targetBid: targetBid, appName: appName, shouldBeFullScreen: shouldBeFullScreen, completion: completion)
|
|
}
|
|
}
|
|
|
|
private func activateApp(named appName: String, completion: @escaping () -> Void) {
|
|
launchOrActivateApp(named: appName, completion: completion)
|
|
}
|
|
|
|
@discardableResult
|
|
private func ensureWindowFullScreen(app: NSRunningApplication) -> Bool {
|
|
let appElem = AXUIElementCreateApplication(app.processIdentifier)
|
|
|
|
var windowObj: CFTypeRef?
|
|
let hasWin = (AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &windowObj) == .success && windowObj != nil) ||
|
|
(AXUIElementCopyAttributeValue(appElem, kAXMainWindowAttribute as CFString, &windowObj) == .success && windowObj != nil)
|
|
|
|
guard hasWin, let winElem = windowObj else {
|
|
var winListObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(appElem, kAXWindowsAttribute as CFString, &winListObj) == .success,
|
|
let winList = winListObj as? [AXUIElement], let firstWin = winList.first {
|
|
return applyFullScreen(to: firstWin, app: app)
|
|
}
|
|
return false
|
|
}
|
|
|
|
return applyFullScreen(to: winElem as! AXUIElement, app: app)
|
|
}
|
|
|
|
private func applyFullScreen(to axWin: AXUIElement, app: NSRunningApplication) -> Bool {
|
|
// 1. Check if already full screen
|
|
var isFullObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(axWin, "AXFullScreen" as CFString, &isFullObj) == .success,
|
|
let isFull = isFullObj as? Bool, isFull {
|
|
return true
|
|
}
|
|
|
|
// 2. Set native macOS full-screen space attribute
|
|
let setRes = AXUIElementSetAttributeValue(axWin, "AXFullScreen" as CFString, kCFBooleanTrue)
|
|
if setRes == .success {
|
|
return true
|
|
}
|
|
|
|
// 3. Fallback: Zoom button (kAXFullScreenButton)
|
|
var zoomObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(axWin, "AXFullScreenButton" as CFString, &zoomObj) == .success, let btn = zoomObj {
|
|
AXUIElementPerformAction(btn as! AXUIElement, kAXPressAction as CFString)
|
|
return true
|
|
}
|
|
|
|
// 4. Fallback: Cmd+Ctrl+F shortcut
|
|
app.activate(options: [.activateIgnoringOtherApps])
|
|
pressHotkey(keyCode: 3, modifiers: [.maskCommand, .maskControl]) // 'F' + Cmd + Ctrl
|
|
return true
|
|
}
|
|
|
|
private func exitFrontmostWindowFullScreen() {
|
|
guard let front = NSWorkspace.shared.frontmostApplication else { return }
|
|
let appElem = AXUIElementCreateApplication(front.processIdentifier)
|
|
var windowObj: CFTypeRef?
|
|
let hasWin = (AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &windowObj) == .success && windowObj != nil) ||
|
|
(AXUIElementCopyAttributeValue(appElem, kAXMainWindowAttribute as CFString, &windowObj) == .success && windowObj != nil)
|
|
|
|
if hasWin, let winElem = windowObj {
|
|
let axWin = winElem as! AXUIElement
|
|
var isFullObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(axWin, "AXFullScreen" as CFString, &isFullObj) == .success,
|
|
let isFull = isFullObj as? Bool, isFull {
|
|
let res = AXUIElementSetAttributeValue(axWin, "AXFullScreen" as CFString, kCFBooleanFalse)
|
|
if res != .success {
|
|
pressHotkey(keyCode: 3, modifiers: [.maskCommand, .maskControl])
|
|
}
|
|
return
|
|
}
|
|
}
|
|
pressHotkey(keyCode: 3, modifiers: [.maskCommand, .maskControl])
|
|
}
|
|
|
|
private func pollAndManageWindow(
|
|
app: NSRunningApplication?,
|
|
targetBid: String?,
|
|
appName: String,
|
|
shouldBeFullScreen: Bool,
|
|
attempt: Int = 1,
|
|
completion: (() -> Void)? = nil
|
|
) {
|
|
let ws = NSWorkspace.shared
|
|
let target = app ?? ws.runningApplications.first(where: {
|
|
if let bid = targetBid, $0.bundleIdentifier == bid { return true }
|
|
return $0.localizedName?.caseInsensitiveCompare(appName) == .orderedSame
|
|
})
|
|
|
|
if let target = target {
|
|
target.activate(options: [.activateIgnoringOtherApps])
|
|
if shouldBeFullScreen {
|
|
if ensureWindowFullScreen(app: target) {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
target.activate(options: [.activateIgnoringOtherApps])
|
|
completion?()
|
|
}
|
|
return
|
|
}
|
|
} else {
|
|
// Desktop mode preferred
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
target.activate(options: [.activateIgnoringOtherApps])
|
|
completion?()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
if attempt < 18 {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
|
|
self.pollAndManageWindow(
|
|
app: target,
|
|
targetBid: targetBid,
|
|
appName: appName,
|
|
shouldBeFullScreen: shouldBeFullScreen,
|
|
attempt: attempt + 1,
|
|
completion: completion
|
|
)
|
|
}
|
|
} else {
|
|
target?.activate(options: [.activateIgnoringOtherApps])
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
private func runAppleScript(_ script: String) {
|
|
let full = "osascript -e '" + script.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
let task = Process()
|
|
task.executableURL = URL(fileURLWithPath: "/bin/sh")
|
|
task.arguments = ["-c", full]
|
|
try? task.run()
|
|
}
|
|
|
|
private func playConfirmationSound() {
|
|
if SonioxSettings.shared.playSounds {
|
|
if !(NSSound(named: NSSound.Name("Hero"))?.play() ?? false) {
|
|
NSSound(named: NSSound.Name("Glass"))?.play()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func playWarningSound() {
|
|
if SonioxSettings.shared.playSounds {
|
|
if !(NSSound(named: NSSound.Name("Basso"))?.play() ?? false) {
|
|
NSSound(named: NSSound.Name("Pop"))?.play()
|
|
}
|
|
}
|
|
}
|
|
}
|