feat(ios): W4 app wiring — DI graph, event fan-out, lifecycle, privacy shade

T-iOS-15: AppEnvironment production DI (real Keychain/probe/transports), EventFanOut
(engine.events → TerminalVM + GateVM + activity bridge), scenePhase lifecycle
(.background→close, suspend→rebuild+reopen reclaims size), privacy shade on != .active,
spike screen deleted, cold-start routing
Walkthrough proxies: hosted live-server smoke over the production DI graph (probe→store→
attach→echo→close) + real-Keychain kSecAttrAccessible assertion (closes T-iOS-7 deferral)
Deviation closed: TerminalViewModel.lastSentDims (valid-only) + alive-engine .active →
notifyForegrounded(dims:) (orchestrator fix on behalf of T-iOS-11 owner)
Env finding: repo under ~/Documents → TCC blocks sim-spawned node; SimServerHarness dual-mode
(self-bootstrap / WEBTERM_SERVER_URL via simctl launchctl setenv)
Verified: 214 pkg + 76 app + 10 integration tests green; verify agent 8/8 PASS
This commit is contained in:
Yaojia Wang
2026-07-05 01:04:42 +02:00
parent 5098643355
commit cc4d3129cc
22 changed files with 1752 additions and 106 deletions

View File

@@ -0,0 +1,246 @@
//
// SimServerHarness.swift T-iOS-15 hosted live-server smoke
//
// WebTermTests App 宿iOS SDK
// Foundation.Process宿 mac
// `posix_spawn`iOS 2.0+ SDK spawn.h
// 宿 loopbackApp ATS 127.0.0.0/8 project.yml
//
// ios/IntegrationTests/ServerHarness.swift death-pipe
// spawn /bin/shnode_modules/.bin/tsx src/server.ts
// `read`stdin =
// 退 `read` EOF sh `kill`
// atexit
//
// ServerHarness A/B
// A. repo TCC posix_spawn
// B. TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<port>
// TEST_RUNNER_ xcodebuild env
// repo TEST_RUNNER_WEBTERM_REPO_ROOT #filePath
//
// TCC T-iOS-15 2026-07-05repo ~/Documents|Desktop|Downloads
// A App spawn node tccd
// sample 线 uv_cwd __open_nocancelsh head
// /bin/sh/bin/date harness B
// 60 CI checkout /Users/runner/work
import Darwin
import Foundation
import WireProtocol
enum SmokeTunables {
static let serverReadyTimeout: Duration = .seconds(60)
static let serverReadyPollInterval: Duration = .milliseconds(200)
/// bash + echo
static let outputTimeout: Duration = .seconds(45)
/// tsx shebang `#!/usr/bin/env node` sh node
static let spawnPathPrefix = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
}
enum SmokeHarnessError: Error, CustomStringConvertible {
case setup(String)
case timeout(String)
var description: String {
switch self {
case .setup(let detail): return "setup: \(detail)"
case .timeout(let detail): return "timeout: \(detail)"
}
}
}
/// spawn in-flight Task actor spawn
actor SimServerHarness {
static let shared = SimServerHarness()
private var bootTask: Task<HostEndpoint, any Error>?
/// death-pipe 退
private var deathPipeWriteFD: Int32?
func endpoint() async throws -> HostEndpoint {
if let bootTask { return try await bootTask.value }
let task = Task { try await self.bootstrap() }
bootTask = task
return try await task.value
}
private func bootstrap() async throws -> HostEndpoint {
let env = ProcessInfo.processInfo.environment
if let external = env["WEBTERM_SERVER_URL"] {
guard let url = URL(string: external), let endpoint = HostEndpoint(baseURL: url) else {
throw SmokeHarnessError.setup("WEBTERM_SERVER_URL 不是合法 http(s) URL: \(external)")
}
try await Self.awaitReady(endpoint, logPath: nil)
return endpoint
}
let repoRoot = Self.locateRepoRoot(environment: env)
if let folder = Self.tccProtectedFolder(containing: repoRoot.path) {
throw SmokeHarnessError.setup(Self.tccGuidance(folder: folder, repoRoot: repoRoot.path))
}
let tsxPath = repoRoot.appending(path: "node_modules/.bin/tsx").path
guard FileManager.default.isExecutableFile(atPath: tsxPath) else {
throw SmokeHarnessError.setup("找不到 \(tsxPath) — 先在 repo 根目录跑 npm install/npm ci")
}
let port = try Self.findFreeLoopbackPort()
guard let baseURL = URL(string: "http://127.0.0.1:\(port)"),
let endpoint = HostEndpoint(baseURL: baseURL) else {
throw SmokeHarnessError.setup("无法构造 endpoint, port=\(port)")
}
let logPath = FileManager.default.temporaryDirectory
.appending(path: "webterm-hosted-smoke-\(port).log").path
deathPipeWriteFD = try Self.spawnServerShell(
repoRoot: repoRoot.path, port: port, logPath: logPath
)
try await Self.awaitReady(endpoint, logPath: logPath)
return endpoint
}
// MARK: - Spawn (posix_spawn + death-pipe)
/// Returns the pipe WRITE fd the caller must keep open for the server's
/// lifetime (closing it including by process death kills the server).
private static func spawnServerShell(
repoRoot: String, port: Int, logPath: String
) throws -> Int32 {
var pipeFDs: [Int32] = [0, 0]
guard pipe(&pipeFDs) == 0 else {
throw SmokeHarnessError.setup("pipe() 失败: errno \(errno)")
}
let (readFD, writeFD) = (pipeFDs[0], pipeFDs[1])
let command = """
cd '\(repoRoot)' || exit 1
echo "[harness] sh up $(date +%T)" >> '\(logPath)'
PORT=\(port) BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 \
PATH="\(SmokeTunables.spawnPathPrefix):$PATH" \
node_modules/.bin/tsx src/server.ts >> '\(logPath)' 2>&1 &
SRV=$!
read _
echo "[harness] death-pipe EOF $(date +%T) killing $SRV" >> '\(logPath)'
kill $SRV 2>/dev/null
"""
var fileActions: posix_spawn_file_actions_t?
posix_spawn_file_actions_init(&fileActions)
defer { posix_spawn_file_actions_destroy(&fileActions) }
posix_spawn_file_actions_adddup2(&fileActions, readFD, 0) // sh stdin =
posix_spawn_file_actions_addclose(&fileActions, readFD)
posix_spawn_file_actions_addclose(&fileActions, writeFD) //
let argv = ["/bin/sh", "-c", command]
var argvC: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
argvC.append(nil)
// MINIMAL clean env the simulator app's own environment is poison
// for host binaries: its DYLD_ROOT_PATH/DYLD_FALLBACK_* point into the
// iOS simulator runtime. /bin/sh is SIP-protected (dyld ignores DYLD_*
// there), but /usr/local|homebrew node is NOT and dies at dyld stage
// before printing anything. Only PATH + writable HOME/TMPDIR survive.
let inherited = ProcessInfo.processInfo.environment
let cleanEnv = [
"PATH": SmokeTunables.spawnPathPrefix,
"HOME": inherited["HOME"] ?? NSTemporaryDirectory(),
"TMPDIR": inherited["TMPDIR"] ?? NSTemporaryDirectory(),
]
var envC: [UnsafeMutablePointer<CChar>?] = cleanEnv
.map { strdup("\($0.key)=\($0.value)") }
envC.append(nil)
defer {
for pointer in argvC { free(pointer) }
for pointer in envC { free(pointer) }
}
var pid: pid_t = 0
let status = posix_spawn(&pid, "/bin/sh", &fileActions, nil, argvC, envC)
close(readFD) //
guard status == 0 else {
close(writeFD)
throw SmokeHarnessError.setup("posix_spawn(/bin/sh) 失败: \(status)")
}
return writeFD
}
// MARK: - Readiness
private static func awaitReady(_ endpoint: HostEndpoint, logPath: String?) async throws {
let probeURL = endpoint.baseURL.appending(path: "live-sessions")
let deadline = ContinuousClock.now + SmokeTunables.serverReadyTimeout
while ContinuousClock.now < deadline {
if let (data, response) = try? await URLSession.shared.data(from: probeURL),
(response as? HTTPURLResponse)?.statusCode == 200,
(try? JSONSerialization.jsonObject(with: data)) is [Any] {
return
}
try await Task.sleep(for: SmokeTunables.serverReadyPollInterval)
}
throw SmokeHarnessError.timeout(
"服务器 \(endpoint.baseURL)\(SmokeTunables.serverReadyTimeout) 内未就绪 — 日志: \(logPath ?? "n/a")"
)
}
// MARK: - TCC guard (fail fast, actionable see header)
private static let tccProtectedFolders: Set<String> = ["Documents", "Desktop", "Downloads"]
/// `/Users/<name>/(Documents|Desktop|Downloads)/` the protected folder
/// name, else nil. Host-path heuristic (the sim's own HOME is a container
/// path, so the host home cannot come from the environment).
static func tccProtectedFolder(containing path: String) -> String? {
let components = URL(fileURLWithPath: path).pathComponents
guard components.count > 3, components[1] == "Users",
tccProtectedFolders.contains(components[3]) else { return nil }
return components[3]
}
private static func tccGuidance(folder: String, repoRoot: String) -> String {
"""
repo 位于 macOS TCC 保护目录(~/\(folder)):模拟器沙箱内 spawn 的 node \
读取该目录会被 tccd 无限阻塞(实测 open() 挂起),自举模式必挂。请在宿主机 \
自行起服务器后以外部模式重跑:
cd '\(repoRoot)' && PORT=<p> BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 npm start &
TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<p> xcodebuild … test
"""
}
// MARK: - Locations
private static func locateRepoRoot(environment: [String: String]) -> URL {
if let override = environment["WEBTERM_REPO_ROOT"] {
return URL(fileURLWithPath: override, isDirectory: true)
}
// #filePath = <repo>/ios/App/WebTermTests/SimServerHarness.swift
return URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // WebTermTests
.deletingLastPathComponent() // App
.deletingLastPathComponent() // ios
.deletingLastPathComponent() // repo root
}
/// bind(port=0) loopback IntegrationTests
private static func findFreeLoopbackPort() throws -> Int {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { throw SmokeHarnessError.setup("socket() 失败: errno \(errno)") }
defer { close(fd) }
var address = sockaddr_in()
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
address.sin_family = sa_family_t(AF_INET)
address.sin_port = 0
address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let bound = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard bound == 0 else { throw SmokeHarnessError.setup("bind() 失败: errno \(errno)") }
var assigned = sockaddr_in()
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
let named = withUnsafeMutablePointer(to: &assigned) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
getsockname(fd, $0, &length)
}
}
guard named == 0 else { throw SmokeHarnessError.setup("getsockname() 失败: errno \(errno)") }
return Int(UInt16(bigEndian: assigned.sin_port))
}
}