Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Input Source Pro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@
D58168D528957D5500E84058 /* QuestionMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58168D428957D5500E84058 /* QuestionMark.swift */; };
D58C184428A6ABBC00F52845 /* NSRunningApplication+FocusedElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58C184328A6ABBC00F52845 /* NSRunningApplication+FocusedElement.swift */; };
D58C184628A6B2DE00F52845 /* ISPLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58C184528A6B2DE00F52845 /* ISPLogger.swift */; };
D6A100112F2000000000A001 /* ISPFileLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A100012F2000000000A001 /* ISPFileLog.swift */; };
D6A100122F2000000000A002 /* LaunchpadOverlayDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A100022F2000000000A002 /* LaunchpadOverlayDetector.swift */; };
D6A100132F2000000000A003 /* SystemChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A100032F2000000000A003 /* SystemChrome.swift */; };
D58C184828A808F000F52845 /* KeyboardRestoreStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58C184728A808F000F52845 /* KeyboardRestoreStrategy.swift */; };
D5CC44942A33F143007FF839 /* TroubleshootingSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CC44932A33F143007FF839 /* TroubleshootingSettingsView.swift */; };
D5CC44962A33F485007FF839 /* EnhancedModeRequiredBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CC44952A33F485007FF839 /* EnhancedModeRequiredBadge.swift */; };
Expand Down Expand Up @@ -342,6 +345,9 @@
D58168D428957D5500E84058 /* QuestionMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionMark.swift; sourceTree = "<group>"; };
D58C184328A6ABBC00F52845 /* NSRunningApplication+FocusedElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRunningApplication+FocusedElement.swift"; sourceTree = "<group>"; };
D58C184528A6B2DE00F52845 /* ISPLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ISPLogger.swift; sourceTree = "<group>"; };
D6A100012F2000000000A001 /* ISPFileLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ISPFileLog.swift; sourceTree = "<group>"; };
D6A100022F2000000000A002 /* LaunchpadOverlayDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchpadOverlayDetector.swift; sourceTree = "<group>"; };
D6A100032F2000000000A003 /* SystemChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemChrome.swift; sourceTree = "<group>"; };
D58C184728A808F000F52845 /* KeyboardRestoreStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardRestoreStrategy.swift; sourceTree = "<group>"; };
D5CC44932A33F143007FF839 /* TroubleshootingSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TroubleshootingSettingsView.swift; sourceTree = "<group>"; };
D5CC44952A33F485007FF839 /* EnhancedModeRequiredBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedModeRequiredBadge.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -688,6 +694,8 @@
D579258F2A1AB1DA003B83EA /* NSRunningApplication+FloatingApp.swift */,
D579258B2A1AB0A4003B83EA /* NSRunningApplication+WindowInfo.swift */,
D58C184328A6ABBC00F52845 /* NSRunningApplication+FocusedElement.swift */,
D6A100022F2000000000A002 /* LaunchpadOverlayDetector.swift */,
D6A100032F2000000000A003 /* SystemChrome.swift */,
4ADDC325281AC4DD0075528A /* NSApplication.swift */,
4A742D0928229A5400DCB392 /* NSWorkspace.swift */,
4AC450B1281BE00A00DA0329 /* CGRect.swift */,
Expand Down Expand Up @@ -731,6 +739,7 @@
isa = PBXGroup;
children = (
D58C184528A6B2DE00F52845 /* ISPLogger.swift */,
D6A100012F2000000000A001 /* ISPFileLog.swift */,
);
path = Log;
sourceTree = "<group>";
Expand Down Expand Up @@ -1047,6 +1056,9 @@
D5F4A1CA2F0B1234001B2C3D /* InputSourceSwitcher.swift in Sources */,
D5115A1E2A33D57A00D94E51 /* ItemSection.swift in Sources */,
D58C184628A6B2DE00F52845 /* ISPLogger.swift in Sources */,
D6A100112F2000000000A001 /* ISPFileLog.swift in Sources */,
D6A100122F2000000000A002 /* LaunchpadOverlayDetector.swift in Sources */,
D6A100132F2000000000A003 /* SystemChrome.swift in Sources */,
4A8E47692F08814500424D11 /* SwiftUI+FlexibleButtonSizing.swift in Sources */,
4ADDC326281AC4DD0075528A /* NSApplication.swift in Sources */,
4AC2615328786EAB0032AAFE /* NSCFRange.swift in Sources */,
Expand Down
10 changes: 10 additions & 0 deletions Input Source Pro/Controllers/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ class StatusItemController {
changelogMenu,
checkUpdatesMenu,
settingsMenu,
NSMenuItem(
title: "Open Log…",
target: self,
action: #selector(openLog),
keyEquivalent: ""
),
NSMenuItem.separator(),
NSMenuItem(
title: "Quit".i18n(),
Expand Down Expand Up @@ -232,6 +238,10 @@ class StatusItemController {
}
}

@objc func openLog() {
ISPFileLog.openInFinder()
}

@objc func checkForUpdates() {
preferencesVM.checkUpdates()
}
Expand Down
248 changes: 242 additions & 6 deletions Input Source Pro/Models/ApplicationVM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ final class ApplicationVM: ObservableObject {
let cancelBag = CancelBag()
let preferencesVM: PreferencesVM

private var launchpadSessionActive = false
private var launchpadSettleWork: DispatchWorkItem?
private var launchpadReturnLayout: (bundleId: String, inputSourceId: String)?

private let launchpadCloseSettleMilliseconds = 220

lazy var windowAXNotificationPublisher = ApplicationVM
.createWindowAXNotificationPublisher(preferencesVM: preferencesVM)

Expand All @@ -23,6 +29,7 @@ final class ApplicationVM: ObservableObject {

activateAccessibilitiesForCurrentApp()
watchApplicationChange()
watchLaunchpadOverlay()
watchRuntimeRuleChange()
watchAppsDiffChange()
}
Expand All @@ -48,15 +55,14 @@ extension ApplicationVM {
activeSpaceDidChangeNotification.eraseToAnyPublisher()
])
.compactMap { [weak self] _ -> NSRunningApplication? in
guard self?.preferencesVM.preferences.isEnhancedModeEnabled == true,
let elm: UIElement = try? systemWideElement.attribute(.focusedApplication),
let pid = try? elm.pid()
else { return NSWorkspace.shared.frontmostApplication }
return NSRunningApplication(processIdentifier: pid)
self?.resolveFocusApplication()
}
.filter { app in
!InputSourceSwitcher.isTemporaryInputWindowApplicationActivation(app)
}
.compactMap { [weak self] app -> NSRunningApplication? in
self?.normalizeFocusApplication(app)
}
.removeDuplicates()
.flatMapLatest { [weak self] (app: NSRunningApplication) -> AnyPublisher<AppKind, Never> in
guard let preferencesVM = self?.preferencesVM
Expand Down Expand Up @@ -84,9 +90,239 @@ extension ApplicationVM {
.eraseToAnyPublisher()
}
.removeDuplicates(by: { $0.isSameAppOrWebsite(with: $1, detectAddressBar: true) })
.sink { [weak self] in self?.appKind = $0 }
.sink { [weak self] in
ISPFileLog.event(
"focus",
"appKind=\($0.getApp().bundleIdentifier ?? "nil") \($0.getApp().localizedName ?? "")"
)
self?.appKind = $0
}
.store(in: cancelBag)
}

private func resolveFocusApplication() -> NSRunningApplication? {
guard preferencesVM.preferences.isEnhancedModeEnabled == true,
let elm: UIElement = try? systemWideElement.attribute(.focusedApplication),
let pid = try? elm.pid()
else { return NSWorkspace.shared.frontmostApplication }
return NSRunningApplication(processIdentifier: pid)
}

private func normalizeFocusApplication(_ app: NSRunningApplication) -> NSRunningApplication? {
let launchpadVisible = LaunchpadOverlayDetector.isLaunchpadVisible()

if launchpadVisible {
if let dock = SystemChrome.dockRunningApplication() {
return dock
}
return app
}

if launchpadSessionActive {
ISPFileLog.event(
"focus-skip",
"settle in progress, ignore \(app.bundleIdentifier ?? "nil")",
includeSnapshot: false
)
return nil
}

if SystemChrome.isPointerChrome(app.bundleIdentifier) {
ISPFileLog.event(
"focus-skip",
"pointer-chrome \(app.bundleIdentifier ?? "nil")",
includeSnapshot: false
)
return nil
}

return app
}

private func watchLaunchpadOverlay() {
Timer.interval(seconds: 0.3)
.prepend(Date())
.sink { [weak self] _ in
self?.pollLaunchpadOverlay()
}
.store(in: cancelBag)
}

private func pollLaunchpadOverlay() {
let visible = LaunchpadOverlayDetector.isLaunchpadVisible()

if visible {
launchpadSettleWork?.cancel()
launchpadSettleWork = nil

if !launchpadSessionActive {
launchpadSessionActive = true
captureTypingLayoutBeforeLaunchpad()
ISPFileLog.event("launchpad", "OPENED")
applyLaunchpadFocus()
}
return
}

guard launchpadSessionActive else { return }

scheduleLaunchpadCloseSettle()
}

private func scheduleLaunchpadCloseSettle() {
launchpadSettleWork?.cancel()
ISPFileLog.event(
"launchpad",
"close detected, settle \(launchpadCloseSettleMilliseconds)ms",
includeSnapshot: false
)
let work = DispatchWorkItem { [weak self] in
self?.finishLaunchpadClose()
}
launchpadSettleWork = work
DispatchQueue.main.asyncAfter(
deadline: .now() + .milliseconds(launchpadCloseSettleMilliseconds),
execute: work
)
}

private func finishLaunchpadClose() {
launchpadSettleWork = nil

if LaunchpadOverlayDetector.isLaunchpadVisible() {
ISPFileLog.event("launchpad", "reappeared during settle — abort close")
applyLaunchpadFocus()
return
}

launchpadSessionActive = false

let app = resolvePostLaunchpadApplication()
ISPFileLog.event(
"launchpad",
"CLOSED → \(app?.localizedName ?? "?")[\(app?.bundleIdentifier ?? "nil")]"
)

guard let app else {
launchpadReturnLayout = nil
return
}

if let pending = launchpadReturnLayout,
app.bundleIdentifier != pending.bundleId
{
ISPFileLog.event(
"launchpad-restore-drop",
"pending=\(pending.bundleId) actual=\(app.bundleIdentifier ?? "nil")",
includeSnapshot: false
)
launchpadReturnLayout = nil
}

appKind = .from(app, preferencesVM: preferencesVM)
}

private func applyLaunchpadFocus() {
guard let dock = SystemChrome.dockRunningApplication() else {
ISPFileLog.event("launchpad", "OPENED but Dock process missing")
return
}
appKind = .from(dock, preferencesVM: preferencesVM)
}

private func captureTypingLayoutBeforeLaunchpad() {
let candidate = typingApplicationForLaunchpadCapture()
guard let candidate,
let bundleId = candidate.bundleIdentifier,
!SystemChrome.isPointerChrome(bundleId),
!SystemChrome.isLaunchpadRelated(bundleId)
else {
launchpadReturnLayout = nil
ISPFileLog.event("launchpad-capture", "skipped — no typing app", includeSnapshot: false)
return
}

let layout = InputSource.getCurrentInputSource()
launchpadReturnLayout = (bundleId, layout.persistentIdentifier)
ISPFileLog.event(
"launchpad-capture",
"\(bundleId) → \(layout.persistentIdentifier)"
)
}

private func typingApplicationForLaunchpadCapture() -> NSRunningApplication? {
if let current = appKind?.getApp(),
let id = current.bundleIdentifier,
!SystemChrome.isPointerChrome(id),
!SystemChrome.isLaunchpadRelated(id)
{
return current
}

if let front = NSWorkspace.shared.frontmostApplication,
front.activationPolicy == .regular,
let id = front.bundleIdentifier,
!SystemChrome.isPointerChrome(id)
{
return front
}

return nil
}

func consumeLaunchpadLayoutRestore(for appKind: AppKind) -> InputSource? {
guard let pending = launchpadReturnLayout else { return nil }

let bundleId = appKind.getApp().bundleIdentifier
guard bundleId == pending.bundleId else { return nil }

launchpadReturnLayout = nil

if preferencesVM.getAppCustomization(app: appKind.getApp())?.forcedKeyboard != nil {
ISPFileLog.event(
"launchpad-restore-skip",
"forced keyboard rule for \(bundleId ?? "?")",
includeSnapshot: false
)
return nil
}

guard let source = InputSource.resolvePersistedIdentifier(pending.inputSourceId) else {
ISPFileLog.event(
"launchpad-restore-miss",
"unresolved \(pending.inputSourceId)",
includeSnapshot: false
)
return nil
}

ISPFileLog.event(
"launchpad-restore",
"\(bundleId ?? "?") → \(source.persistentIdentifier)"
)
return source
}

private func resolvePostLaunchpadApplication() -> NSRunningApplication? {
if let app = NSWorkspace.shared.frontmostApplication,
app.activationPolicy == .regular,
!SystemChrome.isPointerChrome(app.bundleIdentifier)
{
return app
}

if preferencesVM.preferences.isEnhancedModeEnabled,
let elm: UIElement = try? systemWideElement.attribute(.focusedApplication),
let pid = try? elm.pid(),
let app = NSRunningApplication(processIdentifier: pid),
app.activationPolicy == .regular,
!SystemChrome.isPointerChrome(app.bundleIdentifier)
{
return app
}

return NSWorkspace.shared.frontmostApplication
}
}

extension ApplicationVM {
Expand Down
Loading