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
2 changes: 2 additions & 0 deletions LookInside.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@
GestureDebug/LKGestureDebugWindowController.swift,
GestureDebug/LKGestureNodeDetailView.swift,
GestureDebug/LKNativeInteractionDetailView.swift,
GestureDebug/LKSuggestions.swift,
GestureDebug/LKSuggestionsView.swift,
MCPBridge/LKMCPBridgeAttributeValueDecoder.swift,
MCPBridge/LKMCPBridgeConnection.swift,
MCPBridge/LKMCPBridgeDetailsService.swift,
Expand Down
1 change: 1 addition & 0 deletions LookInside/Base/LookinClient-Bridging-Header.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
#import "LookinDisplayItem+LookinClient.h"
#import "LookinObject.h"
#import "LookinAppInfo.h"
#import "LookinHitTargetSize.h"
#import "LookinHierarchyInfo.h"
#import "LookinHierarchyFile.h"
#import "LookinArchiveDocument.h"
Expand Down
3 changes: 3 additions & 0 deletions LookInside/GestureDebug/LKGestureCaptureModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct LKGestureCaptureBatch: Codable {
var pollDurationMS: Double
var overlayStatus: LKGestureOverlayStatus?
var nativeRegions: [LKNativeInteractionRegion]?
var interactions: LKInteractionSnapshot?
}

struct LKNativeInteractionRegion: Codable, Identifiable {
Expand Down Expand Up @@ -123,6 +124,8 @@ struct LKGestureCaptureArchive: Codable {
var snapshots: [LKGestureCaptureSnapshot]
var records: [LKGestureCaptureRecord]
var nativeRegions: [LKNativeInteractionRegion]?
var interactions: LKInteractionSnapshot?
var suggestionPlatform: LKSuggestionPlatform?
}

struct LKGestureTreeItem: Identifiable {
Expand Down
23 changes: 21 additions & 2 deletions LookInside/GestureDebug/LKGestureDebugSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ final class LKGestureDebugSession: ObservableObject {
@Published private(set) var overlayStatus: LKGestureOverlayStatus?
@Published private(set) var nativeRegions: [LKNativeInteractionRegion] = []
@Published private var lastNativeRegions: [LKNativeInteractionRegion] = []
@Published private(set) var suggestions = LKSuggestionReport()
@Published private(set) var suggestionPlatform = LKSuggestionPlatform.unsupported
private var lastInteractions: LKInteractionSnapshot?
@Published private(set) var appName = "No target"
@Published private(set) var supported = false

Expand All @@ -38,7 +41,7 @@ final class LKGestureDebugSession: ObservableObject {
}

var canExport: Bool {
!records.isEmpty || !snapshots.isEmpty || !lastNativeRegions.isEmpty
!records.isEmpty || !snapshots.isEmpty || !lastNativeRegions.isEmpty || lastInteractions?.targets.isEmpty == false
}

init() {
Expand Down Expand Up @@ -72,6 +75,13 @@ final class LKGestureDebugSession: ObservableObject {
self.app = app
channel = app?.channel
appName = app?.appInfo?.appName ?? "No target"
if let info = app?.appInfo {
suggestionPlatform = .resolve(deviceType: info.deviceType.rawValue, model: info.deviceModelIdentifier ?? "",
deviceName: info.deviceDescription ?? "", os: info.osDescription ?? "")
} else {
suggestionPlatform = .unsupported
}
lastInteractions = nil
supported = (app?.appInfo?.gestureDebugProtocolVersion ?? 0) >= 1
snapshots.removeAll()
records.removeAll()
Expand All @@ -97,6 +107,8 @@ final class LKGestureDebugSession: ObservableObject {
nativeRegions.removeAll()
lastNativeRegions.removeAll()
snapshots.removeAll()
lastInteractions = nil
suggestions.replace(with: nil, platform: suggestionPlatform, isCapturing: true)
records.removeAll()
selectedSnapshotID = nil
selectedNodeID = nil
Expand Down Expand Up @@ -133,6 +145,7 @@ final class LKGestureDebugSession: ObservableObject {
overlayStatus = nil
nativeRegions.removeAll()
state = "stopped"
suggestions.replace(with: nil, platform: suggestionPlatform, isCapturing: false)
if token != nil {
message = "Capture stopped. Recorded events remain available."
}
Expand Down Expand Up @@ -189,7 +202,8 @@ final class LKGestureDebugSession: ObservableObject {
func archiveData() throws -> Data {
let archive = LKGestureCaptureArchive(
appName: appName, bundleIdentifier: app?.appInfo?.appBundleIdentifier ?? "",
sessionID: lastSessionID, snapshots: snapshots, records: records, nativeRegions: lastNativeRegions
sessionID: lastSessionID, snapshots: snapshots, records: records, nativeRegions: lastNativeRegions,
interactions: lastInteractions, suggestionPlatform: suggestionPlatform
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
Expand Down Expand Up @@ -224,6 +238,10 @@ final class LKGestureDebugSession: ObservableObject {
state = batch.state
isStarting = state == "starting"
isRunning = state == "waiting" || state == "capturing" || state == "redacted"
suggestions.replace(with: batch.interactions, platform: suggestionPlatform, isCapturing: isRunning || isStarting)
if isRunning || isStarting {
lastInteractions = batch.interactions
}
message = gap ? "A capture batch was lost. Some event details may be incomplete." : batch.message
if batch.state == "stopped" || batch.state == "error" {
isRunning = false
Expand All @@ -248,6 +266,7 @@ final class LKGestureDebugSession: ObservableObject {
selectedNodeID = nil
}
} catch {
suggestions.replace(with: nil, platform: suggestionPlatform, isCapturing: isRunning || isStarting)
message = "Could not decode the target's gesture capture: \(error.localizedDescription)"
state = "error"
}
Expand Down
16 changes: 14 additions & 2 deletions LookInside/GestureDebug/LKGestureDebugView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct LKGestureDebugView: View {
case gestures = "Gestures"
case bindings = "Bindings"
case native = "Native"
case suggestions = "Suggestions"
case hitTest = "Hit Test"
case raw = "Raw Log"
}
Expand All @@ -29,7 +30,9 @@ struct LKGestureDebugView: View {
}
.pickerStyle(.segmented)
.padding(12)
if section == .native {
if section == .suggestions {
LKSuggestionsView(report: session.suggestions, platform: session.suggestionPlatform)
} else if section == .native {
LKNativeInteractionDetailView(regions: session.nativeRegions, isRunning: session.isRunning, overlayEnabled: session.overlayEnabled)
} else if let snapshot = session.selectedSnapshot {
snapshotContent(snapshot)
Expand Down Expand Up @@ -100,6 +103,15 @@ struct LKGestureDebugView: View {
Text("\(session.nativeRegions.count) native controls / list rows · See Native for handler details.")
.font(.caption).foregroundStyle(.secondary)
}
if session.isRunning {
Button { section = .suggestions } label: {
Label("Suggestions · \(session.suggestions.warnings.count) warnings · \(session.suggestions.inspectedCount) regions inspected",
systemImage: "exclamationmark.triangle")
}
.buttonStyle(.link).font(.caption)
.foregroundStyle(session.suggestions.warnings.isEmpty ? Color.secondary : .orange)
.accessibilityIdentifier("gesture.suggestions.summary")
}
if session.state == "redacted" {
DisclosureGroup("Target Debug build configuration") {
Text("""
Expand Down Expand Up @@ -181,7 +193,7 @@ struct LKGestureDebugView: View {
.padding(.horizontal, 14).padding(.bottom, 8)
}
switch section {
case .native: EmptyView()
case .native, .suggestions: EmptyView()
case .responders: tree(snapshot.responders, snapshot: snapshot)
case .gestures: tree(snapshot.gestures, snapshot: snapshot)
case .bindings:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ struct LKNativeInteractionDetailView: View {
.font(.caption).foregroundStyle(.secondary).padding(.horizontal, 12).padding(.bottom, 12)
Divider()
if regions.isEmpty {
Text(isRunning && overlayEnabled ? "No supported native interactions on this page. Native collection currently supports UIKit." : "Start capture with Gesture borders enabled to inspect native regions.")
Text(isRunning ? "No supported native interactions on this page. Native collection currently supports UIKit." : "Start capture to inspect native regions.")
.foregroundStyle(.secondary).padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Expand Down
123 changes: 123 additions & 0 deletions LookInside/GestureDebug/LKSuggestions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import Foundation

struct LKInteractionSnapshot: Codable {
var targets: [LKInteractionTarget]
var swiftUIStatus: String
var nativeStatus: String
var truncated: Bool
}

struct LKInteractionTarget: Codable, Identifiable {
var id: String
var source: String
var name: String
var targetAddress: String
var hostAddress: String?
var windowAddress: String?
var geometry: LKGestureCaptureGeometry
var geometryKind: String
var shapeKind: String?

var usesEstimatedBounds: Bool {
geometryKind != "contentShapeBounds"
}
}

/// Product rules in logical points, selected from the inspected device, not the Mac host.
enum LKSuggestionPlatform: String, Codable {
case iPhone, mac, unsupported

var minimumTargetSize: Double? {
switch self {
case .iPhone: LookinMinimumIPhoneTargetSize
case .mac: LookinMinimumMacTargetSize
case .unsupported: nil
}
}

static func resolve(deviceType: Int, model: String, deviceName: String, os: String) -> Self {
if deviceType == 3 || deviceType == 4 {
return .mac
}
let model = model.lowercased()
if model.hasPrefix("iphone") {
return .iPhone
}
if deviceType == 1 || model.hasPrefix("ipad") {
return .unsupported
}
if deviceType == 0 {
return deviceName.lowercased().hasPrefix("iphone") ? .iPhone : .unsupported
}
if os.lowercased().hasPrefix("macos") {
return .mac
}
// The legacy Others device enum represents a physical iPhone.
return deviceType == 2 && (model.isEmpty || model.hasPrefix("iphone")) ? .iPhone : .unsupported
}
}

struct LKSuggestion: Identifiable {
let ruleID = "interaction.minimum-target-size"
let target: LKInteractionTarget
let minimumSize: Double
var id: String {
"\(ruleID):\(target.id)"
}

var title: String {
target.usesEstimatedBounds ? "Potentially small hit target" : "Small hit target"
}
}

enum LKHitTargetSuggestionRule {
static func evaluate(_ targets: [LKInteractionTarget], platform: LKSuggestionPlatform) -> [LKSuggestion] {
guard let minimum = platform.minimumTargetSize else { return [] }
var seen: Set<String> = []
return targets.compactMap { target in
guard let width = target.geometry.width, let height = target.geometry.height,
LookinHitTargetSizeDeficit(width, height, minimum) > 0,
seen.insert(target.id).inserted else { return nil }
return LKSuggestion(target: target, minimumSize: minimum)
}.sorted { $0.id < $1.id }
}
}

/// Replaced by each current-page sample. Historical events never revive warnings.
struct LKSuggestionReport {
private(set) var warnings: [LKSuggestion] = []
private(set) var inspectedCount = 0
private(set) var coverageMessage = "Start capture to inspect the current page."

mutating func replace(with snapshot: LKInteractionSnapshot?, platform: LKSuggestionPlatform, isCapturing: Bool) {
warnings = []
inspectedCount = 0
guard isCapturing else {
coverageMessage = "Start capture to inspect the current page."
return
}
guard platform != .unsupported else {
coverageMessage = "Target-size suggestions currently support iPhone and Mac targets."
return
}
guard let snapshot else {
coverageMessage = "Waiting for current interaction regions. Older Servers need an update to provide Suggestions."
return
}
warnings = LKHitTargetSuggestionRule.evaluate(snapshot.targets, platform: platform)
inspectedCount = snapshot.targets.count
var notes = ["Checks reported interaction regions; custom hit testing and occlusion may change the effective target."]
if snapshot.swiftUIStatus != "available" {
notes.append(snapshot.swiftUIStatus == "partial"
? "Some SwiftUI hosts could not be inspected."
: "SwiftUI whole-page regions are unavailable on this runtime.")
}
if snapshot.nativeStatus != "available" {
notes.append("Native controls are not inspected on this platform.")
}
if snapshot.truncated {
notes.append("The region limit was reached; this page is only partially inspected.")
}
coverageMessage = notes.joined(separator: " ")
}
}
92 changes: 92 additions & 0 deletions LookInside/GestureDebug/LKSuggestionsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import SwiftUI

struct LKSuggestionsView: View {
let report: LKSuggestionReport
let platform: LKSuggestionPlatform
@State private var selectedID: String?

var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Label("\(report.warnings.count) warnings", systemImage: "exclamationmark.triangle")
.foregroundStyle(report.warnings.isEmpty ? Color.secondary : .orange)
.font(.headline)
Spacer()
if let minimum = platform.minimumTargetSize {
Text("\(platform == .iPhone ? "iPhone" : "Mac") · \(minimum, specifier: "%.0f") × \(minimum, specifier: "%.0f") pt minimum")
.font(.callout)
}
}
Text("\(report.inspectedCount) current interaction regions inspected. \(report.coverageMessage)")
.font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
if report.warnings.isEmpty {
Text(report.inspectedCount == 0
? "No interaction regions are currently available for this check."
: "No small targets found in the inspected regions.")
.foregroundStyle(.secondary).frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
HSplitView {
List(report.warnings, selection: $selectedID) { warning in
VStack(alignment: .leading, spacing: 4) {
Label(warning.title, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange).font(.callout)
Text(warning.target.name).lineLimit(2).font(.caption)
size(warning.target).font(.caption).monospacedDigit()
}
.padding(.vertical, 4).tag(warning.id)
}
.accessibilityIdentifier("gesture.suggestions.list")
.frame(minWidth: 240, idealWidth: 300)
if let warning = report.warnings.first(where: { $0.id == selectedID }) {
detail(warning).frame(minWidth: 250)
} else {
Text("Select a warning to inspect its target.")
.foregroundStyle(.secondary).frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
}
.padding(14)
.onChange(of: report.warnings.map(\.id)) { ids in
if let selectedID, !ids.contains(selectedID) {
self.selectedID = nil
}
}
}

private func size(_ target: LKInteractionTarget) -> some View {
Text("\(target.geometry.width ?? 0, specifier: "%.1f") × \(target.geometry.height ?? 0, specifier: "%.1f") pt")
}

private func detail(_ warning: LKSuggestion) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text(warning.title).font(.headline)
Text(warning.target.name).font(.system(.callout, design: .monospaced))
size(warning.target).font(.title2).monospacedDigit()
Text("The width or height is below \(warning.minimumSize, specifier: "%.0f") pt. Increase the interactive area to at least \(warning.minimumSize, specifier: "%.0f") × \(warning.minimumSize, specifier: "%.0f") pt.")
if warning.target.usesEstimatedBounds {
Label("Estimated bounds", systemImage: "info.circle")
Text("These bounds may differ from the effective hit target. Check custom hit testing before changing the control.")
.foregroundStyle(.secondary)
} else {
Text("Measured from the reported \(warning.target.shapeKind ?? "interaction") content shape and its bounds.")
.foregroundStyle(.secondary)
}
Text("For SwiftUI, apply padding before the interaction contentShape / gesture. Padding outside a Button may only change layout.")
Divider()
Text("Target: \(warning.target.targetAddress)")
if let host = warning.target.hostAddress {
Text("Host: \(host)")
}
if let window = warning.target.windowAddress {
Text("Window: \(window)")
}
Text("Source: \(warning.target.source) · \(warning.target.geometryKind)")
Text("Origin: (\(warning.target.geometry.x ?? 0, specifier: "%.1f"), \(warning.target.geometry.y ?? 0, specifier: "%.1f")) pt · \(warning.target.geometry.coordinateSpace)")
}
.font(.callout).textSelection(.enabled).padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
4 changes: 3 additions & 1 deletion Scripts/test-gesture-models.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
GESTURE_TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/lookinside-gesture-models.XXXXXX")"
trap 'rm -rf "$GESTURE_TEST_DIR"' EXIT
swiftc -parse-as-library "$ROOT/LookInside/GestureDebug/LKGestureCaptureModels.swift" \
swiftc -parse-as-library -import-objc-header "$ROOT/Sources/LookinCore/LookinHitTargetSize.h" \
"$ROOT/LookInside/GestureDebug/LKGestureCaptureModels.swift" \
"$ROOT/LookInside/GestureDebug/LKSuggestions.swift" \
"$ROOT/Tests/GestureDebug/LKGestureCaptureModelsTests.swift" -o "$GESTURE_TEST_DIR/gesture-models"
"$GESTURE_TEST_DIR/gesture-models"
Loading
Loading