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
32 changes: 32 additions & 0 deletions .github/workflows/apple-compile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Apple native host compile

on:
pull_request:
paths:
- 'apple/**'
- '.github/workflows/apple-compile.yml'

permissions:
contents: read

concurrency:
group: apple-compile-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
ios-xctest-compile:
name: iOS XCTest build-for-testing
runs-on: macos-26
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Generate standalone UI-testing project
run: python3 apple/generate-project.py --app-bundle-id dev.lui.components
- name: Compile iOS runner without executing UI tests
run: >-
xcodebuild
-project apple/.generated/DriveApple.xcodeproj
-scheme DriveApple -configuration Debug
-destination 'generic/platform=iOS Simulator'
-derivedDataPath apple/.generated/DerivedData
CODE_SIGNING_ALLOWED=NO build-for-testing
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Three ways to drive:
loads, e.g. `libmeng_editor.so` built via `ocamlopt
-output-complete-obj`). Patches stream back as wire JSON and are
replayed into the same `Model`.
- **Live** (`Drive.Live`): attach over Unix socket or WebSocket. Use
- **Live** (`Drive.Live`): attach over Unix socket, loopback TCP or WebSocket. Use
`--input semantic` (default) for injected LUI events, or `--input native`
for capability-checked, acknowledged host input. Native input never
falls back to semantic injection.
Expand Down Expand Up @@ -92,6 +92,29 @@ Because the scenario drives the *live* process, the real UI visibly
responds, but semantic events do not exercise native hit testing,
gesture recognition, or focus handling.

## Loopback TCP attach (`--tcp`)

Native test hosts can expose the same newline-delimited JSON protocol on a
loopback TCP listener. This also works across an explicit device port forward:

```sh
adb -s "$DEVICE" forward tcp:49377 tcp:49377
drive --tcp 127.0.0.1:49377 --input native scenario.drive
adb -s "$DEVICE" forward --remove tcp:49377
```

Start a compatible test host on the device first; port forwarding alone does
not provide native input. `--tcp` accepts only `127.0.0.1`, `localhost`
(mapped directly to IPv4 loopback), or `[::1]`, with a port from 1 to 65535.
No DNS lookup or external interface is allowed. It is mutually exclusive with
`--lib`, `--socket` and `--ws-listen`. There is no reconnect/retry: a timed-out
action may already have run. Close the host and start a new test session.

Embedding callers use `Drive.Live.connect_tcp ~host:"127.0.0.1" ~port:49377`,
then `Drive.Live.driver ~timeout:5.0` and `Drive.Live.close`, just as for Unix
socket attach. No native capability is implied by the transport: the host's
v1 handshake remains mandatory.

## Coordinate taps (`tap x y`)

In semantic mode, selector events target a node id directly; `tap x y`
Expand Down Expand Up @@ -185,7 +208,23 @@ Native scenarios stop at the first failure.
| In-process / FFI | Yes | No |
| `web/lui-drive.js` | Synthetic DOM events | No |
| `web/cdp-host.mjs` | No | Chromium mouse, text, keys, wheel, pointer drag, PNG |
| Apple / Android / MyGo | Existing host-specific semantic attach | Requires a v1 native adapter |
| Android | Existing host-specific semantic attach | Optional LUI debug instrumentation host, API 30+; device input unverified |
| iOS | Existing host-specific semantic attach | [Separate XCTest runner](apple/README.md) + DEBUG LUI read-only probe; runner compiles, device input unverified |
| MyGo | Existing host-specific semantic attach | Unsupported: the pinned SDK has no public live-window input dispatcher |

Android's optional host lives in [LUI](https://github.com/logseq/lui), under
`platform/android/drive`; its sample launcher explicitly preserves/restores a
test-device IME and TCP forwarding. Text requires deliberately selecting the
test IME; the SDK never changes it automatically. Compilation and protocol
tests are not evidence of actual device input. Unsupported geometry/input fails
instead of falling back to semantic events.

The iOS runner is a separate UI-testing target, not app code. It uses public
XCTest input and reads the app through an explicitly enabled DEBUG probe;
stable native accessibility identifiers are required. The standalone generated
project compiles the runner only, not the app under test. Follow the
[Apple guide](apple/README.md) for build/install steps and public XCTest limits.
Neither mobile adapter implies MyGo support or universal animation/network idle.

CDP currently targets tagged light DOM, not iframes or shadow roots.
Drag is a pointer gesture, not a promised HTML5 drag-and-drop operation;
Expand Down
2 changes: 2 additions & 0 deletions apple/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.generated/
__pycache__/
207 changes: 207 additions & 0 deletions apple/DriveTCP.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
#if os(iOS)
import Foundation
import Network

// All mutable state crossing the Network callback queue is protected by condition.
final class DriveTCPChannel: @unchecked Sendable {
private let connection: NWConnection
private let queue = DispatchQueue(label: "dev.lui.drive.tcp")
private let condition = NSCondition()
private var buffer = Data()
private var closed: DriveFailure?
private var deadlineToken = UUID()
private let maximumLine: Int

init(connection: NWConnection, maximumLine: Int = 1_048_576) {
self.connection = connection
self.maximumLine = maximumLine
connection.stateUpdateHandler = { [weak self] state in
switch state {
case let .failed(error): self?.close(.transport(String(describing: error)))
case .cancelled: self?.close(.transport("connection cancelled"))
default: break
}
}
connection.start(queue: queue)
receive()
}

convenience init(loopbackPort: UInt16) {
self.init(connection: NWConnection(
host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: loopbackPort)!, using: .tcp
), maximumLine: 33_554_432)
}

deinit { connection.cancel() }

func close(_ reason: DriveFailure = .transport("disconnected")) {
condition.lock()
if closed == nil { closed = reason }
buffer.removeAll(keepingCapacity: false)
condition.broadcast()
condition.unlock()
connection.cancel()
}

func check(deadline: TimeInterval) throws {
condition.lock()
let failure = closed
condition.unlock()
if let failure { throw failure }
if ProcessInfo.processInfo.systemUptime >= deadline {
close(.deadline)
throw DriveFailure.deadline
}
}

func arm(deadline: TimeInterval) {
let token = UUID()
condition.lock()
deadlineToken = token
condition.unlock()
queue.asyncAfter(deadline: .now() + max(0, deadline - ProcessInfo.processInfo.systemUptime)) { [weak self] in
guard let self else { return }
self.condition.lock()
let expired = self.deadlineToken == token && self.closed == nil
if expired {
self.closed = .deadline
self.buffer.removeAll(keepingCapacity: false)
self.condition.broadcast()
}
self.condition.unlock()
if expired { self.connection.cancel() }
}
}

func disarm() {
condition.lock()
deadlineToken = UUID()
condition.unlock()
}

func readLine(until deadline: TimeInterval) throws -> Data? {
condition.lock()
defer { condition.unlock() }
while true {
if let closed { throw closed }
if let index = buffer.firstIndex(of: 10) {
let line = Data(buffer.prefix(upTo: index))
buffer.removeSubrange(...index)
guard !line.isEmpty else { throw DriveFailure.protocolViolation("empty JSON line") }
return line
}
let remaining = deadline - ProcessInfo.processInfo.systemUptime
guard remaining > 0 else { return nil }
_ = condition.wait(until: Date(timeIntervalSinceNow: remaining))
}
}

private final class Receipt: @unchecked Sendable {
let condition = NSCondition()
var complete = false
var error: String?

func finish(_ error: NWError?) {
condition.lock()
self.error = error.map { String(describing: $0) }
complete = true
condition.broadcast()
condition.unlock()
}
}

func send(_ value: DriveJSON, deadline: TimeInterval) throws {
try check(deadline: deadline)
var data = try value.data()
data.append(10)
let receipt = Receipt()
connection.send(content: data, completion: .contentProcessed { receipt.finish($0) })
receipt.condition.lock()
while !receipt.complete {
let remaining = deadline - ProcessInfo.processInfo.systemUptime
if remaining <= 0 { break }
_ = receipt.condition.wait(until: Date(timeIntervalSinceNow: remaining))
}
let complete = receipt.complete
let error = receipt.error
receipt.condition.unlock()
guard complete else { close(.deadline); throw DriveFailure.deadline }
if let error { close(.transport(error)); throw DriveFailure.transport(error) }
try check(deadline: deadline)
}

private func receive() {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, done, error in
guard let self else { return }
self.condition.lock()
if let data, self.closed == nil { self.buffer.append(data) }
let overflow = self.buffer.count > self.maximumLine
self.condition.broadcast()
self.condition.unlock()
if overflow { self.close(.protocolViolation("JSON line buffer limit exceeded")) }
else if let error { self.close(.transport(String(describing: error))) }
else if done { self.close() }
else { self.receive() }
}
}
}

final class DriveTCPServer: @unchecked Sendable {
private let listener: NWListener
private let condition = NSCondition()
private var channel: DriveTCPChannel?
private var accepted = false
private var failure: String?

init(port: UInt16) throws {
guard port >= 1024 else { throw DriveFailure.rejected("runner port must be 1024...65535") }
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: port)!)
listener = try NWListener(using: parameters)
listener.newConnectionHandler = { [weak self] connection in
guard let self, case let .hostPort(host, _) = connection.endpoint,
host == .ipv4(.loopback) else { connection.cancel(); return }
self.condition.lock()
if self.accepted { self.condition.unlock(); connection.cancel(); return }
self.accepted = true
self.channel = DriveTCPChannel(connection: connection)
self.condition.broadcast()
self.condition.unlock()
}
listener.stateUpdateHandler = { [weak self] state in
guard let self, case let .failed(error) = state else { return }
self.condition.lock()
self.failure = String(describing: error)
self.condition.broadcast()
self.condition.unlock()
}
listener.start(queue: DispatchQueue(label: "dev.lui.drive.listener"))
}

func accept(deadline: TimeInterval) throws -> DriveTCPChannel {
condition.lock()
defer { condition.unlock() }
while channel == nil && failure == nil {
let remaining = deadline - ProcessInfo.processInfo.systemUptime
guard remaining > 0 else { throw DriveFailure.deadline }
_ = condition.wait(until: Date(timeIntervalSinceNow: remaining))
}
if let failure { throw DriveFailure.transport(failure) }
guard let channel else { throw DriveFailure.transport("listener closed") }
return channel
}

func stop() {
listener.cancel()
condition.lock()
accepted = true
failure = "listener stopped"
let channel = channel
condition.broadcast()
condition.unlock()
channel?.close()
}

deinit { listener.cancel() }
}
#endif
28 changes: 28 additions & 0 deletions apple/DriveUITests.example.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#if os(iOS)
import XCTest

@MainActor
final class DriveUITests: XCTestCase {
func testDriveSession() throws {
let environment = ProcessInfo.processInfo.environment
guard environment["DRIVE_XCTEST"] == "1" else { throw XCTSkip("Set DRIVE_XCTEST=1 to run the Drive host") }
guard let bundleID = environment["DRIVE_APP_BUNDLE_ID"], !bundleID.isEmpty,
let probePort = UInt16(environment["LUI_DRIVE_PROBE_PORT"] ?? "49378"),
let runnerPort = UInt16(environment["DRIVE_RUNNER_PORT"] ?? "49379"),
let actionTimeout = Double(environment["DRIVE_ACTION_TIMEOUT"] ?? "4"),
let sessionTimeout = Double(environment["DRIVE_SESSION_TIMEOUT"] ?? "300"),
probePort != runnerPort else {
throw DriveFailure.rejected("invalid Drive UI-test environment")
}
continueAfterFailure = false
let app = XCUIApplication(bundleIdentifier: bundleID)
app.launchEnvironment["LUI_DRIVE_PROBE"] = "1"
app.launchEnvironment["LUI_DRIVE_PROBE_PORT"] = String(probePort)
app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
app.launch()
defer { app.terminate() }
let runner = try DriveXCTestRunner(app: app, probePort: probePort, actionTimeout: actionTimeout)
try runner.run(port: runnerPort, sessionTimeout: sessionTimeout)
}
}
#endif
Loading
Loading