Skip to content

Commit f351471

Browse files
committed
refactor: protocol-back NavigationGraph for pluggable graph implementations
1 parent 6154aaa commit f351471

7 files changed

Lines changed: 170 additions & 11 deletions

File tree

Sources/mirroir-mcp/BFSExplorer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import HelperLib
1212
/// Session Accumulator pattern with NSLock protection.
1313
final class BFSExplorer: @unchecked Sendable {
1414

15-
let graph: NavigationGraph
15+
let graph: any NavigationGraphing
1616
let session: ExplorationSession
1717
let budget: ExplorationBudget
1818
let windowSize: CGSize

Sources/mirroir-mcp/DFSExplorer.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ enum ExploreStepResult: Sendable {
2525
/// Follows the Session Accumulator pattern with NSLock protection.
2626
final class DFSExplorer: @unchecked Sendable {
2727

28-
let graph: NavigationGraph
28+
let graph: any NavigationGraphing
2929
let session: ExplorationSession
3030
let budget: ExplorationBudget
3131
let windowSize: CGSize
@@ -405,8 +405,8 @@ final class DFSExplorer: @unchecked Sendable {
405405
_ = graph.recordTransition(
406406
elements: afterResult.elements, icons: afterResult.icons,
407407
hints: afterResult.hints, screenshot: afterResult.screenshotBase64,
408-
actionType: "tap", elementText: target.text, screenType: screenType,
409-
edgeType: scoutEdgeType
408+
actionType: "tap", elementText: target.text, displayLabel: nil,
409+
screenType: screenType, edgeType: scoutEdgeType
410410
)
411411
// Record in session for flat screen list.
412412
// Skip graph transition since the explorer manages the graph directly above.

Sources/mirroir-mcp/ExplorationGuidanceHelper.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ enum ExplorationGuidanceHelper {
4242
/// Dispatch strategy-based guidance analysis using the session's detected strategy.
4343
static func analyzeWithDetectedStrategy(
4444
session: ExplorationSession,
45-
graph: NavigationGraph,
45+
graph: any NavigationGraphing,
4646
elements: [TapPoint],
4747
icons: [IconDetector.DetectedIcon],
4848
hints: [String]

Sources/mirroir-mcp/ExplorationGuide.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ enum ExplorationGuide {
263263
/// Produces guidance from graph state + strategy instead of keyword matching.
264264
static func analyzeWithStrategy<S: ExplorationStrategy>(
265265
strategy: S.Type,
266-
graph: NavigationGraph,
266+
graph: any NavigationGraphing,
267267
elements: [TapPoint],
268268
icons: [IconDetector.DetectedIcon],
269269
hints: [String],

Sources/mirroir-mcp/ExplorationSession.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ final class ExplorationSession: @unchecked Sendable {
151151
elements: elements, icons: icons, hints: hints,
152152
screenshot: screenshotBase64, actionType: actionType,
153153
elementText: arrivedVia, displayLabel: displayLabel,
154-
screenType: screenType
154+
screenType: screenType, edgeType: .push
155155
)
156156
}
157157
}
@@ -298,7 +298,7 @@ final class ExplorationSession: @unchecked Sendable {
298298
}
299299

300300
/// The navigation graph for the current exploration session.
301-
var currentGraph: NavigationGraph {
301+
var currentGraph: any NavigationGraphing {
302302
lock.lock()
303303
defer { lock.unlock() }
304304
return graph

Sources/mirroir-mcp/FrontierPlanner.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ enum FrontierPlanner {
4646
/// - screenHeight: Height of the target window for element scoring.
4747
/// - Returns: The highest-scoring frontier target, or nil if no unvisited elements remain.
4848
static func bestTarget(
49-
graph: NavigationGraph,
49+
graph: any NavigationGraphing,
5050
backtrackStack: [String],
5151
screenHeight: Double
5252
) -> FrontierTarget? {

Sources/mirroir-mcp/Protocols.swift

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2026 jfarcand@apache.org
22
// Licensed under the Apache License, Version 2.0
33
//
4-
// ABOUTME: Protocol abstractions for system boundaries (mirroring bridge, input, capture, recording, OCR).
4+
// ABOUTME: Protocol abstractions for system boundaries (mirroring bridge, input, capture, recording, OCR, navigation graph).
55
// ABOUTME: Enables dependency injection for testing without requiring real macOS system APIs.
66

77
import AppKit
@@ -210,7 +210,7 @@ protocol Exploring: AnyObject, Sendable {
210210
var stats: (nodeCount: Int, edgeCount: Int, actionCount: Int, elapsedSeconds: Int) { get }
211211

212212
/// The navigation graph tracking screen transitions and visited elements.
213-
var graph: NavigationGraph { get }
213+
var graph: any NavigationGraphing { get }
214214

215215
/// Generate the final skill bundle from the exploration session.
216216
func generateBundle() -> SkillBundle
@@ -219,6 +219,163 @@ protocol Exploring: AnyObject, Sendable {
219219
func generateReport() -> String
220220
}
221221

222+
/// Abstracts the navigation graph used during app exploration.
223+
/// Enables swappable graph implementations for testing and alternative strategies.
224+
protocol NavigationGraphing: AnyObject, Sendable {
225+
226+
// MARK: - Lifecycle
227+
228+
/// Initialize the graph with the root screen, resetting all state.
229+
func start(
230+
rootElements: [TapPoint],
231+
icons: [IconDetector.DetectedIcon],
232+
hints: [String],
233+
screenshot: String,
234+
screenType: ScreenType
235+
)
236+
237+
/// Record a navigation transition. Returns whether the screen is new, revisited, or duplicate.
238+
func recordTransition(
239+
elements: [TapPoint],
240+
icons: [IconDetector.DetectedIcon],
241+
hints: [String],
242+
screenshot: String,
243+
actionType: String,
244+
elementText: String,
245+
displayLabel: String?,
246+
screenType: ScreenType,
247+
edgeType: EdgeType
248+
) -> TransitionResult
249+
250+
/// Export an immutable snapshot of the current graph state.
251+
func finalize() -> GraphSnapshot
252+
253+
// MARK: - Properties
254+
255+
/// Number of distinct screens discovered.
256+
var nodeCount: Int { get }
257+
258+
/// Number of navigation edges recorded.
259+
var edgeCount: Int { get }
260+
261+
/// Fingerprint of the current screen.
262+
var currentFingerprint: String { get }
263+
264+
/// Fingerprint of the root (first) screen.
265+
var rootFingerprint: String { get }
266+
267+
/// Whether the graph has been initialized with a root screen.
268+
var started: Bool { get }
269+
270+
/// The set of labels marked globally visited (breadth_navigation items).
271+
var globalVisitedLabels: Set<String> { get }
272+
273+
// MARK: - Node Access
274+
275+
/// Get the node for a given fingerprint.
276+
func node(for fingerprint: String) -> ScreenNode?
277+
278+
/// Get the most recent incoming edge that led to a given screen fingerprint.
279+
func incomingEdge(to fingerprint: String) -> NavigationEdge?
280+
281+
/// Find a node with similar structural elements using title-aware similarity.
282+
func findMatchingNode(elements: [TapPoint]) -> String?
283+
284+
/// Find a node matching the viewport using both Jaccard similarity and containment.
285+
func findMatchingNodeWithContainment(elements: [TapPoint]) -> String?
286+
287+
// MARK: - Visited State
288+
289+
/// Mark an element as visited on the specified screen.
290+
func markElementVisited(fingerprint: String, elementText: String)
291+
292+
/// Update the current fingerprint after backtracking to sync graph state.
293+
func setCurrentFingerprint(_ fingerprint: String)
294+
295+
// MARK: - Screen Plans
296+
297+
/// Store a ranked exploration plan for a screen.
298+
func setScreenPlan(for fingerprint: String, plan: [RankedElement])
299+
300+
/// Get the exploration plan for a screen, if one has been built.
301+
func screenPlan(for fingerprint: String) -> [RankedElement]?
302+
303+
/// Get the next unvisited plan element, skipping per-screen and global visited sets.
304+
func nextPlannedElement(for fingerprint: String) -> RankedElement?
305+
306+
/// Clear the exploration plan for a screen, forcing a rebuild on next access.
307+
func clearScreenPlan(for fingerprint: String)
308+
309+
// MARK: - Scout Phase
310+
311+
/// Record the result of scouting an element on a screen.
312+
func recordScoutResult(fingerprint: String, elementText: String, result: ScoutResult)
313+
314+
/// Get all scout results for a screen.
315+
func scoutResults(for fingerprint: String) -> [String: ScoutResult]
316+
317+
/// Get the current traversal phase for a screen.
318+
func traversalPhase(for fingerprint: String) -> TraversalPhase
319+
320+
/// Set the traversal phase for a screen.
321+
func setTraversalPhase(for fingerprint: String, phase: TraversalPhase)
322+
323+
// MARK: - Breadth Navigation
324+
325+
/// Register breadth_navigation labels (e.g. tab bar items) for global tracking.
326+
func registerBreadthLabels(_ labels: Set<String>)
327+
328+
/// Check if a displayLabel belongs to a breadth_navigation component.
329+
func isBreadthLabel(_ label: String) -> Bool
330+
331+
/// Mark a breadth_navigation component as globally visited across all screens.
332+
func markGloballyVisited(label: String)
333+
334+
// MARK: - Tap Area Cache
335+
336+
/// Record a tap at the given coordinates on a screen.
337+
func recordTap(fingerprint: String, x: Double, y: Double)
338+
339+
/// Check whether a point was already tapped on a screen (within proximity radius).
340+
func wasAlreadyTapped(fingerprint: String, x: Double, y: Double) -> Bool
341+
342+
/// Number of tapped areas recorded for a screen.
343+
func tapCount(for fingerprint: String) -> Int
344+
345+
// MARK: - Dead Edge Tracking
346+
347+
/// Mark an edge as dead (tap had no effect on the screen).
348+
func markEdgeDead(fromFingerprint: String, elementText: String)
349+
350+
// MARK: - Recovery Events
351+
352+
/// Append a recovery event for post-hoc diagnosis.
353+
func appendRecoveryEvent(_ event: RecoveryEvent)
354+
355+
// MARK: - Scroll Support
356+
357+
/// Merge scrolled elements into a screen node. Returns novel count.
358+
func mergeScrolledElements(fingerprint: String, newElements: [TapPoint]) -> Int
359+
360+
/// Get the number of scroll actions performed on a screen.
361+
func scrollCount(for fingerprint: String) -> Int
362+
363+
/// Increment the scroll count for a screen.
364+
func incrementScrollCount(for fingerprint: String)
365+
366+
/// Mark a screen as having infinite scroll (content never exhausts).
367+
func markInfiniteScroll(fingerprint: String)
368+
369+
/// Mark a screen as scroll-exhausted (all content has been revealed).
370+
func markScrollExhausted(fingerprint: String)
371+
372+
/// Check if a screen has been marked as having infinite scroll.
373+
func isInfiniteScroll(fingerprint: String) -> Bool
374+
375+
/// Check if a screen has been marked as scroll-exhausted.
376+
func isScrollExhausted(fingerprint: String) -> Bool
377+
}
378+
222379
// MARK: - Conformances
223380

224381
extension MirroringBridge: MenuActionCapable {}
@@ -242,3 +399,5 @@ extension VisionScreenDescriber: ScreenDescribing {}
242399
extension BFSExplorer: Exploring {}
243400

244401
extension DFSExplorer: Exploring {}
402+
403+
extension NavigationGraph: NavigationGraphing {}

0 commit comments

Comments
 (0)