Skip to content
Merged
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 MousePortal/Models/WindowLayoutSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ struct WindowDisplaySnapshot: Codable, Equatable {
}
}

/// 只用于核验同一系统会话中仍然存活的窗口,不是跨应用重启的永久 ID。
struct WindowRuntimeIdentity: Codable, Equatable {
let bundleIdentifier: String
let processIdentifier: Int32
let processLaunchDate: Date
let sessionIdentifier: String
let windowID: CGWindowID
}

/// 单个普通窗口在其所属显示器内的相对位置。
struct WindowPlacement: Codable, Equatable {
let bundleIdentifier: String
Expand All @@ -53,6 +62,9 @@ struct WindowPlacement: Codable, Equatable {
let relativeY: Double
let width: Double
let height: Double
var runtimeIdentity: WindowRuntimeIdentity? = nil
// CG 捕获无法确认 AX 类型;这类记录只能通过精确身份恢复,不能猜测标题或序号。
var requiresExactIdentity: Bool? = nil
}

enum WindowLayoutSnapshotKind: String, Codable, Equatable {
Expand Down
61 changes: 61 additions & 0 deletions MousePortal/Services/WindowIdentityResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import AppKit
import ApplicationServices
import Darwin

enum WindowIdentityResolver {
private typealias GetWindowID = @convention(c) (AXUIElement, UnsafeMutablePointer<CGWindowID>) -> AXError

// 私有接口:用于精确关联 AX 对象与 WindowServer 窗口,不能用标题/尺寸推测。
// 动态加载失败时禁用这一层身份核验,继续原有匹配流程。
private static let getWindowID: GetWindowID? = {
guard let handle = dlopen(
"/System/Library/Frameworks/ApplicationServices.framework/Frameworks/HIServices.framework/HIServices",
RTLD_LAZY | RTLD_LOCAL
) else { return nil }
guard let symbol = dlsym(handle, "_AXUIElementGetWindow") else {
dlclose(handle)
return nil
}
// 保留库句柄,确保函数指针在进程存续期间有效。
return unsafeBitCast(symbol, to: GetWindowID.self)
}()

static func windowID(for element: AXUIElement) -> CGWindowID? {
guard let getWindowID else { return nil }
var windowID = kCGNullWindowID
guard getWindowID(element, &windowID) == .success,
windowID != kCGNullWindowID else { return nil }
return windowID
}

static func currentSessionIdentifier() -> String? {
var length = 0
guard sysctlbyname("kern.bootsessionuuid", nil, &length, nil, 0) == 0,
length > 0 else { return nil }
var bootSession = [CChar](repeating: 0, count: length)
guard sysctlbyname("kern.bootsessionuuid", &bootSession, &length, nil, 0) == 0 else { return nil }
var auditInfo = auditinfo_addr_t()
guard getaudit_addr(&auditInfo, Int32(MemoryLayout<auditinfo_addr_t>.size)) == 0 else { return nil }
return "\(String(cString: bootSession)):\(geteuid()):\(auditInfo.ai_asid)"
}

static func identity(
for application: NSRunningApplication,
windowID: CGWindowID?,
sessionIdentifier: String?
) -> WindowRuntimeIdentity? {
guard let windowID, windowID != kCGNullWindowID,
let sessionIdentifier, !sessionIdentifier.isEmpty,
let bundleIdentifier = application.bundleIdentifier,
let launchDate = application.launchDate,
application.processIdentifier > 0,
!application.isTerminated else { return nil }
return WindowRuntimeIdentity(
bundleIdentifier: bundleIdentifier,
processIdentifier: application.processIdentifier,
processLaunchDate: launchDate,
sessionIdentifier: sessionIdentifier,
windowID: windowID
)
}
}
171 changes: 146 additions & 25 deletions MousePortal/Services/WindowLayoutEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct WindowMatchCandidate: Equatable {
let subrole: String
let windowIndex: Int
let frame: CGRect
var runtimeIdentity: WindowRuntimeIdentity? = nil
}

enum WindowLayoutEngine {
Expand Down Expand Up @@ -66,30 +67,153 @@ enum WindowLayoutEngine {
abs(actual.height - target.height) <= tolerance
}

static func bestMatchIndex(
for placement: WindowPlacement,
static func matchWindowIndices(
for placements: [WindowPlacement],
candidates: [WindowMatchCandidate],
excluding usedIndices: Set<Int>,
preferredIndex: Int? = nil
) -> Int? {
if let preferredIndex,
candidates.indices.contains(preferredIndex),
!usedIndices.contains(preferredIndex),
candidates[preferredIndex].role == placement.role,
candidates[preferredIndex].subrole == placement.subrole {
return preferredIndex
preferredIndices: [Int?]
) -> [Int?] {
var matches = [Int?](repeating: nil, count: placements.count)
var usedIndices = Set<Int>()

for placementIndex in placements.indices {
guard let preferredIndex = preferredIndices[placementIndex],
candidates.indices.contains(preferredIndex),
!usedIndices.contains(preferredIndex),
exactWindowTypeMatches(placements[placementIndex], candidates[preferredIndex]) else {
continue
}

matches[placementIndex] = preferredIndex
usedIndices.insert(preferredIndex)
}

return candidates.indices
.filter { !usedIndices.contains($0) }
.filter {
candidates[$0].role == placement.role &&
candidates[$0].subrole == placement.subrole &&
hasMeaningfulIdentityMatch(placement, candidates[$0])
// MousePortal 重启后重新枚举 AX 对象,只接受同应用、同进程启动、同会话的窗口 ID。
for placementIndex in placements.indices where matches[placementIndex] == nil {
let placement = placements[placementIndex]
guard let identity = placement.runtimeIdentity,
identity.bundleIdentifier == placement.bundleIdentifier else { continue }
let identityMatches = candidates.indices.filter {
candidates[$0].runtimeIdentity == identity && exactWindowTypeMatches(placement, candidates[$0])
}
.max { lhs, rhs in
matchScore(placement, candidates[lhs]) < matchScore(placement, candidates[rhs])
guard identityMatches.count == 1,
let matchIndex = identityMatches.first,
!usedIndices.contains(matchIndex) else { continue }
matches[placementIndex] = matchIndex
usedIndices.insert(matchIndex)
Comment thread
ai-eks marked this conversation as resolved.
}

// 这些字段只是相似度线索;真实窗口身份已在前两轮优先匹配。
// 精确身份先保留;其余先最大化有效配对数量,再最大化整体相似度。
let remainingPlacements = placements.indices.filter {
matches[$0] == nil && placements[$0].requiresExactIdentity != true
}
let remainingCandidates = candidates.indices.filter { !usedIndices.contains($0) }
let scores = remainingPlacements.map { placementIndex in
let placement = placements[placementIndex]
return remainingCandidates.map { candidateIndex -> Double in
let candidate = candidates[candidateIndex]
guard candidate.role == placement.role,
candidate.subrole == placement.subrole,
hasMeaningfulIdentityMatch(placement, candidate) else { return 0 }
return matchScore(placement, candidate)
}
}
for (row, column) in maximumMetadataAssignment(scores).enumerated() {
guard let column else { continue }
let candidateIndex = remainingCandidates[column]
matches[remainingPlacements[row]] = candidateIndex
usedIndices.insert(candidateIndex)
}

for placementIndex in placements.indices where matches[placementIndex] == nil {
let placement = placements[placementIndex]
guard placement.requiresExactIdentity != true else { continue }
guard let matchIndex = candidates.indices.first(where: { candidateIndex in
let candidate = candidates[candidateIndex]
return !usedIndices.contains(candidateIndex) &&
candidate.role == placement.role &&
candidate.subrole == placement.subrole &&
candidate.windowIndex == placement.windowIndex
}) else {
continue
}

matches[placementIndex] = matchIndex
usedIndices.insert(matchIndex)
}

return matches
}

private static func exactWindowTypeMatches(_ placement: WindowPlacement, _ candidate: WindowMatchCandidate) -> Bool {
if placement.requiresExactIdentity == true {
// CG 阶段未知的类型,直到重新枚举 AX 且精确身份命中后才能确认。
return candidate.role == "AXWindow" && candidate.subrole == "AXStandardWindow"
}
return candidate.role == placement.role && candidate.subrole == placement.subrole
}

// 匈牙利算法;每行附加一个虚拟候选,允许没有有效线索的窗口保持未匹配。
private static func maximumMetadataAssignment(_ scores: [[Double]]) -> [Int?] {
let rows = scores.count
let realColumns = scores.first?.count ?? 0
guard rows > 0, realColumns > 0 else { return [Int?](repeating: nil, count: rows) }
let columns = realColumns + rows
let cardinalityBonus = (scores.flatMap { $0 }.max() ?? 0) * Double(min(rows, realColumns)) + 1
var rowPotential = [Double](repeating: 0, count: rows + 1)
var columnPotential = [Double](repeating: 0, count: columns + 1)
var rowAtColumn = [Int](repeating: 0, count: columns + 1)
var previousColumn = [Int](repeating: 0, count: columns + 1)

for row in 1...rows {
rowAtColumn[0] = row
var column = 0
var distances = [Double](repeating: .infinity, count: columns + 1)
var visited = [Bool](repeating: false, count: columns + 1)
repeat {
visited[column] = true
let currentRow = rowAtColumn[column]
var delta = Double.infinity
var nextColumn = 0
for next in 1...columns where !visited[next] {
let score = next <= realColumns ? scores[currentRow - 1][next - 1] : 0
let cost = score > 0 ? -(cardinalityBonus + score) : 0
let distance = cost - rowPotential[currentRow] - columnPotential[next]
if distance < distances[next] {
distances[next] = distance
previousColumn[next] = column
}
if distances[next] < delta {
delta = distances[next]
nextColumn = next
}
}
for next in 0...columns {
if visited[next] {
rowPotential[rowAtColumn[next]] += delta
columnPotential[next] -= delta
} else {
distances[next] -= delta
}
}
column = nextColumn
} while rowAtColumn[column] != 0

repeat {
let previous = previousColumn[column]
rowAtColumn[column] = rowAtColumn[previous]
column = previous
} while column != 0
}

var matches = [Int?](repeating: nil, count: rows)
for column in 1...realColumns {
let row = rowAtColumn[column]
if row > 0, scores[row - 1][column - 1] > 0 {
matches[row - 1] = column - 1
}
}
return matches
}

private static func hasMeaningfulIdentityMatch(
Expand All @@ -112,18 +236,15 @@ enum WindowLayoutEngine {
private static func matchScore(_ placement: WindowPlacement, _ candidate: WindowMatchCandidate) -> Double {
var score = 0.0

if let documentURL = placement.documentURL,
documentURL == candidate.documentURL {
if exactNonEmptyMatch(placement.documentURL, candidate.documentURL) {
score += 1_000
}

if let identifier = placement.windowIdentifier,
identifier == candidate.windowIdentifier {
if exactNonEmptyMatch(placement.windowIdentifier, candidate.windowIdentifier) {
score += 600
}

if let title = placement.windowTitle,
title == candidate.windowTitle {
if exactNonEmptyMatch(placement.windowTitle, candidate.windowTitle) {
score += 300
}

Expand Down
Loading
Loading