From 1190abb698efa87d2b8dc48918764b7c7c084de0 Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Wed, 16 Sep 2026 07:15:00 +0000 Subject: [PATCH] feat: smart per-app window state memory with default full-screen and manual desktop override persistence --- src/StatusBarController.swift | 16 ++++++ src/VoiceCommandExecutor.swift | 87 +++++++++++++++++++++++----- src/WindowStateManager.swift | 101 +++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 13 deletions(-) create mode 100644 src/WindowStateManager.swift diff --git a/src/StatusBarController.swift b/src/StatusBarController.swift index db67f7c..4d5326e 100644 --- a/src/StatusBarController.swift +++ b/src/StatusBarController.swift @@ -222,6 +222,12 @@ public final class StatusBarController: NSObject, NSMenuDelegate { clearLogItem.target = self advancedMenu.addItem(clearLogItem) + advancedMenu.addItem(NSMenuItem.separator()) + + let resetWindowMemItem = NSMenuItem(title: "🖥️ Reset Window Memory (Default Full-Screen)", action: #selector(resetWindowMemoryAction), keyEquivalent: "") + resetWindowMemItem.target = self + advancedMenu.addItem(resetWindowMemItem) + let advancedMenuItem = NSMenuItem(title: "🛠️ Advanced Options", action: nil, keyEquivalent: "") advancedMenuItem.submenu = advancedMenu menu.addItem(advancedMenuItem) @@ -509,6 +515,16 @@ public final class StatusBarController: NSObject, NSMenuDelegate { alert.addButton(withTitle: "OK") alert.runModal() } + + @objc private func resetWindowMemoryAction() { + WindowStateManager.shared.resetAllPreferences() + let alert = NSAlert() + alert.messageText = "Window Memory Reset" + alert.informativeText = "All apps will now open in Full Screen by default unless manually exited." + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } @objc private func toggleLaunchAtLoginAction(_ sender: NSMenuItem) { SonioxSettings.shared.launchAtLogin.toggle() diff --git a/src/VoiceCommandExecutor.swift b/src/VoiceCommandExecutor.swift index a34d113..f48d4b9 100644 --- a/src/VoiceCommandExecutor.swift +++ b/src/VoiceCommandExecutor.swift @@ -558,9 +558,22 @@ public final class VoiceCommandExecutor { } 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") @@ -1046,16 +1059,25 @@ public final class VoiceCommandExecutor { 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]) - ensureWindowFullScreen(app: running) + 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.pollAndMakeFullScreen(app: launched, targetBid: launched.bundleIdentifier, appName: name) + self.pollAndManageWindow( + app: launched, + targetBid: launched.bundleIdentifier, + appName: name, + shouldBeFullScreen: shouldBeFullScreen + ) } } } @@ -1129,6 +1151,8 @@ public final class VoiceCommandExecutor { ] 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: { @@ -1137,9 +1161,11 @@ public final class VoiceCommandExecutor { }) if let running = runningApp { - // Instant path: if the app is ALREADY frontmost (e.g. user is in Telegram switching accounts) + // Instant path: if the app is ALREADY frontmost if running.isActive { - ensureWindowFullScreen(app: running) + if shouldBeFullScreen { + ensureWindowFullScreen(app: running) + } completion?() return } @@ -1161,7 +1187,9 @@ public final class VoiceCommandExecutor { try? task.run() } - ensureWindowFullScreen(app: running) + if shouldBeFullScreen { + ensureWindowFullScreen(app: running) + } if let comp = completion { DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { @@ -1179,7 +1207,7 @@ public final class VoiceCommandExecutor { try? task.run() DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { - self.pollAndMakeFullScreen(app: running, targetBid: targetBid, appName: appName, completion: completion) + self.pollAndManageWindow(app: running, targetBid: targetBid, appName: appName, shouldBeFullScreen: shouldBeFullScreen, completion: completion) } return } @@ -1191,7 +1219,7 @@ public final class VoiceCommandExecutor { conf.activates = true ws.openApplication(at: appURL, configuration: conf) { app, _ in DispatchQueue.main.async { - self.pollAndMakeFullScreen(app: app, targetBid: bid, appName: appName, completion: completion) + self.pollAndManageWindow(app: app, targetBid: bid, appName: appName, shouldBeFullScreen: shouldBeFullScreen, completion: completion) } } return @@ -1204,7 +1232,7 @@ public final class VoiceCommandExecutor { try? task.run() DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.pollAndMakeFullScreen(app: nil, targetBid: targetBid, appName: appName, completion: completion) + self.pollAndManageWindow(app: nil, targetBid: targetBid, appName: appName, shouldBeFullScreen: shouldBeFullScreen, completion: completion) } } @@ -1259,10 +1287,33 @@ public final class VoiceCommandExecutor { return true } - private func pollAndMakeFullScreen( + 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 ) { @@ -1274,8 +1325,17 @@ public final class VoiceCommandExecutor { if let target = target { target.activate(options: [.activateIgnoringOtherApps]) - if ensureWindowFullScreen(app: target) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + 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?() } @@ -1284,11 +1344,12 @@ public final class VoiceCommandExecutor { } if attempt < 18 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.pollAndMakeFullScreen( + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { + self.pollAndManageWindow( app: target, targetBid: targetBid, appName: appName, + shouldBeFullScreen: shouldBeFullScreen, attempt: attempt + 1, completion: completion ) diff --git a/src/WindowStateManager.swift b/src/WindowStateManager.swift new file mode 100644 index 0000000..8875245 --- /dev/null +++ b/src/WindowStateManager.swift @@ -0,0 +1,101 @@ +import Cocoa +import ApplicationServices + +public final class WindowStateManager { + public static let shared = WindowStateManager() + + private let defaults = UserDefaults.standard + private let prefKey = "SonioxAppFullScreenPreferences" + private var observer: NSObjectProtocol? + + private init() { + startObservingAppSwitches() + } + + deinit { + if let obs = observer { + NSWorkspace.shared.notificationCenter.removeObserver(obs) + } + } + + // MARK: - Preference Query + /// Returns true by default unless the user has explicitly exited full screen for this app. + public func prefersFullScreen(for identifier: String) -> Bool { + let prefs = defaults.dictionary(forKey: prefKey) as? [String: Bool] ?? [:] + let cleanId = identifier.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + return prefs[cleanId] ?? true // DEFAULT IS FULL SCREEN! + } + + public func setPrefersFullScreen(_ fullScreen: Bool, for identifier: String) { + let cleanId = identifier.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanId.isEmpty else { return } + + var prefs = defaults.dictionary(forKey: prefKey) as? [String: Bool] ?? [:] + prefs[cleanId] = fullScreen + defaults.set(prefs, forKey: prefKey) + print("WindowStateManager: Saved full screen preference for '\(cleanId)': \(fullScreen)") + } + + public func resetAllPreferences() { + defaults.removeObject(forKey: prefKey) + print("WindowStateManager: Reset all app window preferences to default (Full Screen).") + } + + // MARK: - Automatic Learning via Deactivation + private func startObservingAppSwitches() { + let center = NSWorkspace.shared.notificationCenter + observer = center.addObserver( + forName: NSWorkspace.didDeactivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notif in + guard let self = self, + let app = notif.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.activationPolicy == .regular else { + return + } + + self.captureCurrentWindowState(app: app) + } + } + + public func captureCurrentWindowState(app: NSRunningApplication) { + guard let bid = app.bundleIdentifier ?? app.localizedName, !bid.isEmpty else { return } + + if let isFull = isWindowFullScreen(app: app) { + setPrefersFullScreen(isFull, for: bid) + if let name = app.localizedName { + setPrefersFullScreen(isFull, for: name) + } + } + } + + // MARK: - Window Inspection Helper + public func isWindowFullScreen(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) + + if hasWin, let winElem = windowObj { + var fsObj: CFTypeRef? + if AXUIElementCopyAttributeValue(winElem as! AXUIElement, "AXFullScreen" as CFString, &fsObj) == .success, + let isFull = fsObj as? Bool { + return isFull + } + } + + var winListObj: CFTypeRef? + if AXUIElementCopyAttributeValue(appElem, kAXWindowsAttribute as CFString, &winListObj) == .success, + let wins = winListObj as? [AXUIElement], let firstWin = wins.first { + var fsObj: CFTypeRef? + if AXUIElementCopyAttributeValue(firstWin, "AXFullScreen" as CFString, &fsObj) == .success, + let isFull = fsObj as? Bool { + return isFull + } + } + + return nil + } +}