diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4068b0c..8980d174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -494,12 +494,17 @@ jobs: - name: Build standalone T4 host for Flutter macOS run: pnpm build:host + - name: Stage pinned OMP authority runtime + run: pnpm stage:omp-runtime:mac + - name: Build Flutter macOS application working-directory: apps/flutter run: flutter build macos --debug - - name: Verify bundled Flutter macOS host - run: test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host + - name: Verify bundled Flutter macOS runtime + run: | + test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host + test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/omp - name: Run Flutter macOS native tests working-directory: apps/flutter/macos diff --git a/README.md b/README.md index 50ed14db..bd2ece7e 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Some actions depend on what the host supports. When a host can't do something (s ## Local and paired hosts -**Local.** T4 Code looks for the `omp` executable via `$OMP_EXECUTABLE`, your `PATH`, and common install locations (`~/.local/bin`, `/usr/local/bin`, `/opt/omp/bin`, ...). It then manages one T4 host per OMP profile for you: a systemd user service on Linux, a launch agent on macOS. Named profiles under `~/.omp/profiles` appear as their own local hosts and can auto-start with the app. Host logs remain in the compatibility paths `~/.local/state/t4-code/appserver` (Linux) or `~/Library/Logs/T4 Code/appserver` (macOS); named profiles log under `profiles/` inside those directories. +**Local.** The Flutter macOS application includes its compatible T4 host and OMP authority runtimes. Opening the app installs or repairs its per-user launch agent, starts it when needed, and connects over the private Unix socket automatically. No host address or Tailnet setup is required for normal Mac use. Development and Linux builds can instead discover `omp` via `$OMP_EXECUTABLE`, `PATH`, and common install locations. Named profiles under `~/.omp/profiles` appear as their own local hosts. Host logs remain in the compatibility paths `~/.local/state/t4-code/appserver` (Linux) or `~/Library/Logs/T4 Code/appserver` (macOS); named profiles log under `profiles/` inside those directories. **Paired.** Connect to an OMP host on another machine through a `t4-code://pair/...` link generated on that host. Device credentials are encrypted with your OS keychain (Electron `safeStorage`) before they touch disk. Dropped connections reconnect automatically with backoff, and any settings you had staged stay staged until the host confirms. @@ -105,9 +105,9 @@ Some actions depend on what the host supports. When a host can't do something (s ## First run -1. Install and start OMP on the machine you want to work on. -2. Launch T4 Code. On the same machine, it finds `omp` and offers to start the T4 host. For another machine, open the pairing link from that host. -3. Pick a project, pick or create a session, and start working. +1. Install T4 Code and open it normally. On macOS, the bundled local runtime starts and connects automatically. +2. Pick a project, pick or create a session, and start working. +3. To use another machine, add it explicitly through the pairing or Tailnet workflow. ## Build from source diff --git a/apps/flutter/lib/main.dart b/apps/flutter/lib/main.dart index a4389f9e..0c341892 100644 --- a/apps/flutter/lib/main.dart +++ b/apps/flutter/lib/main.dart @@ -6,13 +6,23 @@ import 'package:flutter/foundation.dart'; import 'src/client/app_state.dart'; import 'src/client/t4_client_controller.dart'; import 'src/client/transcript_tail_store.dart'; +import 'src/client/web_socket_connector.dart'; import 'src/host/app_preferences.dart'; import 'src/host/persistent_host_stores.dart'; import 'src/platform/platform_lifecycle_controller.dart'; import 'src/ui/t4_app.dart'; void main() { - runApp(T4Bootstrap(developmentEndpoint: _developmentEndpoint())); + final configuredEndpoint = _developmentEndpoint(); + final localEndpoint = configuredEndpoint == null + ? platformLocalWebSocketEndpoint() + : null; + runApp( + T4Bootstrap( + developmentEndpoint: configuredEndpoint ?? localEndpoint, + manageLocalRuntime: localEndpoint != null, + ), + ); } Uri? _developmentEndpoint() { @@ -27,9 +37,14 @@ Uri? _developmentEndpoint() { } final class T4Bootstrap extends StatefulWidget { - const T4Bootstrap({this.developmentEndpoint, super.key}); + const T4Bootstrap({ + this.developmentEndpoint, + this.manageLocalRuntime = false, + super.key, + }); final Uri? developmentEndpoint; + final bool manageLocalRuntime; @override State createState() => _T4BootstrapState(); @@ -59,11 +74,20 @@ final class _T4BootstrapState extends State _platformController = PlatformLifecycleController(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - unawaited(_controller.initialize()); - unawaited(_platformController.initialize()); + unawaited(_initialize()); }); } + Future _initialize() async { + await _platformController.initialize(); + if (!mounted) return; + if (widget.manageLocalRuntime) { + await _platformController.ensureRuntimeReady(); + if (!mounted) return; + } + await _controller.initialize(); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { diff --git a/apps/flutter/lib/src/client/web_socket_connector.dart b/apps/flutter/lib/src/client/web_socket_connector.dart index 9424879c..04bcf3f2 100644 --- a/apps/flutter/lib/src/client/web_socket_connector.dart +++ b/apps/flutter/lib/src/client/web_socket_connector.dart @@ -7,5 +7,8 @@ import 'web_socket_connector_stub.dart' typedef WebSocketConnector = Future Function(Uri endpoint); +Uri? platformLocalWebSocketEndpoint() => + platform.platformLocalWebSocketEndpoint(); + Future connectPlatformWebSocket(Uri endpoint) => platform.connectPlatformWebSocket(endpoint); diff --git a/apps/flutter/lib/src/client/web_socket_connector_io.dart b/apps/flutter/lib/src/client/web_socket_connector_io.dart index 95e071f0..e4eaba35 100644 --- a/apps/flutter/lib/src/client/web_socket_connector_io.dart +++ b/apps/flutter/lib/src/client/web_socket_connector_io.dart @@ -1,8 +1,66 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -Future connectPlatformWebSocket(Uri endpoint) async => - IOWebSocketChannel.connect( - endpoint, - headers: const {'Origin': 'https://localhost'}, +final Uri _localEndpoint = Uri.parse('ws://omp.local/ws'); + +Uri? platformLocalWebSocketEndpoint() { + if (!Platform.isMacOS) return null; + final home = Platform.environment['HOME']; + if (home == null || !home.startsWith('/') || home.contains('\u0000')) { + return null; + } + return _localEndpoint; +} + +String _defaultLocalSocketPath() { + final home = Platform.environment['HOME']; + if (home == null || !home.startsWith('/') || home.contains('\u0000')) { + throw const FileSystemException('A safe home directory is unavailable.'); + } + return '$home/.omp/run/appserver.sock'; +} + +Future connectPlatformWebSocket(Uri endpoint) async { + if (Platform.isMacOS && endpoint == _localEndpoint) { + return connectUnixWebSocket(_defaultLocalSocketPath()); + } + return IOWebSocketChannel.connect( + endpoint, + headers: const {'Origin': 'https://localhost'}, + ); +} + +Future connectUnixWebSocket(String socketPath) async { + if (!socketPath.startsWith('/') || + socketPath.contains('\u0000') || + socketPath.length > 4096) { + throw const FileSystemException('The local T4 socket path is invalid.'); + } + final type = await FileSystemEntity.type(socketPath, followLinks: true); + if (type != FileSystemEntityType.unixDomainSock) { + throw FileSystemException( + 'The local T4 service socket is unavailable.', + socketPath, ); + } + final address = InternetAddress(socketPath, type: InternetAddressType.unix); + final client = HttpClient(); + client.findProxy = (_) => 'DIRECT'; + client.connectionFactory = (_, proxyHost, proxyPort) { + if (proxyHost != null || proxyPort != null) { + throw StateError('The local T4 socket cannot use a proxy.'); + } + return Socket.startConnect(address, 0); + }; + final channel = IOWebSocketChannel.connect( + _localEndpoint, + headers: const {'Origin': 'https://localhost'}, + customClient: client, + connectTimeout: const Duration(seconds: 5), + ); + unawaited(channel.ready.whenComplete(client.close)); + return channel; +} diff --git a/apps/flutter/lib/src/client/web_socket_connector_stub.dart b/apps/flutter/lib/src/client/web_socket_connector_stub.dart index 91c5a922..9fd53a28 100644 --- a/apps/flutter/lib/src/client/web_socket_connector_stub.dart +++ b/apps/flutter/lib/src/client/web_socket_connector_stub.dart @@ -1,5 +1,7 @@ import 'package:web_socket_channel/web_socket_channel.dart'; +Uri? platformLocalWebSocketEndpoint() => null; + Future connectPlatformWebSocket(Uri endpoint) => Future.error( UnsupportedError('WebSocket transport is unavailable on this platform.'), diff --git a/apps/flutter/lib/src/client/web_socket_connector_web.dart b/apps/flutter/lib/src/client/web_socket_connector_web.dart index d938204b..e31af227 100644 --- a/apps/flutter/lib/src/client/web_socket_connector_web.dart +++ b/apps/flutter/lib/src/client/web_socket_connector_web.dart @@ -1,5 +1,7 @@ import 'package:web_socket_channel/html.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +Uri? platformLocalWebSocketEndpoint() => null; + Future connectPlatformWebSocket(Uri endpoint) async => HtmlWebSocketChannel.connect(endpoint); diff --git a/apps/flutter/lib/src/platform/platform_lifecycle_controller.dart b/apps/flutter/lib/src/platform/platform_lifecycle_controller.dart index 5ce79f4a..e14c51bb 100644 --- a/apps/flutter/lib/src/platform/platform_lifecycle_controller.dart +++ b/apps/flutter/lib/src/platform/platform_lifecycle_controller.dart @@ -134,6 +134,20 @@ final class PlatformLifecycleController extends ChangeNotifier _notify(); } + Future ensureRuntimeReady() async { + if (!_bridge.supportsRuntimeService || _state.initializing) return; + final runtime = _state.runtime; + if (!runtime.supported || !runtime.available) return; + if (runtime.definition != RuntimeDefinitionState.current) { + await installRuntime(); + return; + } + if (runtime.service != RuntimeServicePhase.running && + runtime.service != RuntimeServicePhase.starting) { + await startRuntime(); + } + } + @override Future refreshPlatformState() => _runBoth( runtime: _bridge.supportsRuntimeService ? _bridge.inspectRuntime : null, diff --git a/apps/flutter/macos/Runner.xcodeproj/project.pbxproj b/apps/flutter/macos/Runner.xcodeproj/project.pbxproj index f3cfcf83..f3fc7ead 100644 --- a/apps/flutter/macos/Runner.xcodeproj/project.pbxproj +++ b/apps/flutter/macos/Runner.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; A4C1F1012E2C000100000001 /* PlatformLifecycleChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4C1F1002E2C000100000001 /* PlatformLifecycleChannel.swift */; }; A4C1F1032E2C000100000003 /* t4-host in Embed T4 Host */ = {isa = PBXBuildFile; fileRef = A4C1F1022E2C000100000002 /* t4-host */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + A4C1F1062E2C000100000006 /* omp in Embed T4 Runtime */ = {isa = PBXBuildFile; fileRef = A4C1F1052E2C000100000005 /* omp */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,15 +61,16 @@ name = "Bundle Framework"; runOnlyForDeploymentPostprocessing = 0; }; - A4C1F1042E2C000100000004 /* Embed T4 Host */ = { + A4C1F1042E2C000100000004 /* Embed T4 Runtime */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = runtime; dstSubfolderSpec = 7; files = ( A4C1F1032E2C000100000003 /* t4-host in Embed T4 Host */, + A4C1F1062E2C000100000006 /* omp in Embed T4 Runtime */, ); - name = "Embed T4 Host"; + name = "Embed T4 Runtime"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ @@ -93,6 +95,7 @@ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; A4C1F1002E2C000100000001 /* PlatformLifecycleChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformLifecycleChannel.swift; sourceTree = ""; }; A4C1F1022E2C000100000002 /* t4-host */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = ../../../packages/host-daemon/dist/t4-host; sourceTree = SOURCE_ROOT; }; + A4C1F1052E2C000100000005 /* omp */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = ../../../.artifacts/omp-runtime/omp; sourceTree = SOURCE_ROOT; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ @@ -159,6 +162,7 @@ isa = PBXGroup; children = ( A4C1F1022E2C000100000002 /* t4-host */, + A4C1F1052E2C000100000005 /* omp */, 33CC10F22044A3C60003C045 /* Assets.xcassets */, 33CC10F42044A3C60003C045 /* MainMenu.xib */, 33CC10F72044A3C60003C045 /* Info.plist */, @@ -229,7 +233,8 @@ 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, - A4C1F1042E2C000100000004 /* Embed T4 Host */, + A4C1F1042E2C000100000004 /* Embed T4 Runtime */, + A4C1F1072E2C000100000007 /* Sign T4 Runtime */, 3399D490228B24CF009A79C7 /* ShellScript */, ); buildRules = ( @@ -314,6 +319,26 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + A4C1F1072E2C000100000007 /* Sign T4 Runtime */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/runtime/omp", + "$(PROJECT_DIR)/../../desktop/build/entitlements.omp-runtime.plist", + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "runtime=\"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/runtime/omp\"\nidentity=\"${EXPANDED_CODE_SIGN_IDENTITY:--}\"\n/usr/bin/codesign --force --sign \"$identity\" --options runtime --entitlements \"$PROJECT_DIR/../../desktop/build/entitlements.omp-runtime.plist\" \"$runtime\"\n"; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/apps/flutter/macos/Runner/AppDelegate.swift b/apps/flutter/macos/Runner/AppDelegate.swift index d7e1a26a..b3c17614 100644 --- a/apps/flutter/macos/Runner/AppDelegate.swift +++ b/apps/flutter/macos/Runner/AppDelegate.swift @@ -3,18 +3,6 @@ import FlutterMacOS @main class AppDelegate: FlutterAppDelegate { - private var platformLifecycleChannel: PlatformLifecycleChannel? - - override func applicationDidFinishLaunching(_ notification: Notification) { - super.applicationDidFinishLaunching(notification) - guard let flutterViewController = mainFlutterWindow?.contentViewController as? FlutterViewController else { - return - } - platformLifecycleChannel = PlatformLifecycleChannel( - messenger: flutterViewController.engine.binaryMessenger - ) - } - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } diff --git a/apps/flutter/macos/Runner/MainFlutterWindow.swift b/apps/flutter/macos/Runner/MainFlutterWindow.swift index 3cc05eb2..7b09b018 100644 --- a/apps/flutter/macos/Runner/MainFlutterWindow.swift +++ b/apps/flutter/macos/Runner/MainFlutterWindow.swift @@ -2,6 +2,8 @@ import Cocoa import FlutterMacOS class MainFlutterWindow: NSWindow { + private var platformLifecycleChannel: PlatformLifecycleChannel? + override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame @@ -9,6 +11,9 @@ class MainFlutterWindow: NSWindow { self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) + platformLifecycleChannel = PlatformLifecycleChannel( + messenger: flutterViewController.engine.binaryMessenger + ) super.awakeFromNib() } diff --git a/apps/flutter/macos/Runner/PlatformLifecycleChannel.swift b/apps/flutter/macos/Runner/PlatformLifecycleChannel.swift index b8d02291..fa9ea995 100644 --- a/apps/flutter/macos/Runner/PlatformLifecycleChannel.swift +++ b/apps/flutter/macos/Runner/PlatformLifecycleChannel.swift @@ -4,7 +4,8 @@ import FlutterMacOS import Foundation private let runtimeOutputLimit = 16 * 1024 -private let runtimeProbeTimeout: TimeInterval = 1.5 +private let runtimeBridgeProbeTimeout: TimeInterval = 8 +private let runtimeStatusProbeTimeout: TimeInterval = 2 private let runtimeCommandTimeout: TimeInterval = 8 private let runtimeLabel = "dev.oh-my-pi.appserver" private let authorityBridgeHelpMarkers = [ @@ -25,7 +26,8 @@ protocol RuntimeProcessRunning { arguments: [String], environment: [String: String], timeout: TimeInterval, - maxOutputBytes: Int + maxOutputBytes: Int, + captureOutput: Bool ) throws -> RuntimeProcessResult } @@ -35,13 +37,37 @@ final class BoundedRuntimeProcessRunner: RuntimeProcessRunning { arguments: [String], environment: [String: String], timeout: TimeInterval, - maxOutputBytes: Int + maxOutputBytes: Int, + captureOutput: Bool ) throws -> RuntimeProcessResult { let process = Process() process.executableURL = executableURL process.arguments = arguments process.environment = environment + if !captureOutput { + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + let terminated = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in terminated.signal() } + try process.run() + var timedOut = false + if terminated.wait(timeout: .now() + timeout) == .timedOut { + timedOut = true + if process.isRunning { process.terminate() } + if terminated.wait(timeout: .now() + 0.2) == .timedOut, process.isRunning { + kill(process.processIdentifier, SIGKILL) + _ = terminated.wait(timeout: .now() + 0.2) + } + } + return RuntimeProcessResult( + exitCode: process.isRunning ? nil : process.terminationStatus, + output: "", + timedOut: timedOut, + overflowed: false + ) + } + let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe @@ -85,8 +111,17 @@ final class BoundedRuntimeProcessRunner: RuntimeProcessRunning { } pipe.fileHandleForReading.readabilityHandler = nil - let tail = pipe.fileHandleForReading.readDataToEndOfFile() lock.lock() + let descriptor = pipe.fileHandleForReading.fileDescriptor + let currentFlags = fcntl(descriptor, F_GETFL) + if currentFlags >= 0 { _ = fcntl(descriptor, F_SETFL, currentFlags | O_NONBLOCK) } + var tail = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(descriptor, &buffer, buffer.count) + if count <= 0 { break } + tail.append(contentsOf: buffer.prefix(count)) + } if output.count < maxOutputBytes + 1 { output.append(tail.prefix(maxOutputBytes + 1 - output.count)) } @@ -113,19 +148,26 @@ final class OmpRuntimeDiscovery { private let environment: [String: String] private let homeDirectory: String private let runner: RuntimeProcessRunning + private let packagedExecutable: String? init( environment: [String: String] = ProcessInfo.processInfo.environment, homeDirectory: String = FileManager.default.homeDirectoryForCurrentUser.path, - runner: RuntimeProcessRunning = BoundedRuntimeProcessRunner() + runner: RuntimeProcessRunning = BoundedRuntimeProcessRunner(), + packagedExecutable: String? = Bundle.main.resourceURL? + .appendingPathComponent("runtime/omp").path ) { self.environment = environment self.homeDirectory = homeDirectory self.runner = runner + self.packagedExecutable = packagedExecutable } func discover() -> RuntimeDiscovery { var candidates: [String] = [] + if let packagedExecutable, !packagedExecutable.isEmpty { + candidates.append(packagedExecutable) + } if let explicit = environment["OMP_EXECUTABLE"], !explicit.isEmpty { candidates.append(explicit) } @@ -183,16 +225,18 @@ final class OmpRuntimeDiscovery { executableURL: URL(fileURLWithPath: executable), arguments: ["bridge", "--help"], environment: safeEnvironment, - timeout: runtimeProbeTimeout, - maxOutputBytes: runtimeOutputLimit + timeout: runtimeBridgeProbeTimeout, + maxOutputBytes: runtimeOutputLimit, + captureOutput: true ), bridge.exitCode == 0, !bridge.timedOut, !bridge.overflowed, authorityBridgeHelpMarkers.allSatisfy({ bridge.output.contains($0) }), let result = try? runner.run( executableURL: URL(fileURLWithPath: executable), arguments: ["appserver", "status", "--json"], environment: safeEnvironment, - timeout: runtimeProbeTimeout, - maxOutputBytes: runtimeOutputLimit + timeout: runtimeStatusProbeTimeout, + maxOutputBytes: runtimeOutputLimit, + captureOutput: true ), !result.timedOut, !result.overflowed else { return .invalid } @@ -453,6 +497,8 @@ final class MacRuntimeLifecycle { private let runner: RuntimeProcessRunning private let files: RuntimeFileStoring private let packagedHostExecutable: String? + private let packagedOmpExecutable: String? + private let bundleIdentity: String init( environment: [String: String] = ProcessInfo.processInfo.environment, @@ -461,7 +507,10 @@ final class MacRuntimeLifecycle { runner: RuntimeProcessRunning = BoundedRuntimeProcessRunner(), files: RuntimeFileStoring = SecureRuntimeFileStore(), packagedHostExecutable: String? = Bundle.main.resourceURL? - .appendingPathComponent("runtime/t4-host").path + .appendingPathComponent("runtime/t4-host").path, + packagedOmpExecutable: String? = Bundle.main.resourceURL? + .appendingPathComponent("runtime/omp").path, + bundleIdentity: String = "\(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown")-\(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown")" ) { self.environment = environment self.homeDirectory = homeDirectory @@ -469,6 +518,8 @@ final class MacRuntimeLifecycle { self.runner = runner self.files = files self.packagedHostExecutable = packagedHostExecutable + self.packagedOmpExecutable = packagedOmpExecutable + self.bundleIdentity = bundleIdentity } private var definitionPath: String { @@ -504,9 +555,15 @@ final class MacRuntimeLifecycle { try files.ensureDirectory(logsDirectory) try files.writeAtomically(definitionPath, content: definition, mode: 0o600) } - if registered && changed { _ = try launchctl(["bootout", target]) } - if !registered || changed { _ = try launchctl(["bootstrap", domain, definitionPath]) } - _ = try launchctl(["kickstart", "-k", target]) + if registered && changed { + _ = try launchctl(["bootout", target]) + Thread.sleep(forTimeInterval: 0.5) + } + if !registered || changed { + try bootstrapService() + } else { + _ = try launchctl(["kickstart", "-k", target]) + } } catch { if changed { if let old = previous.content { @@ -515,8 +572,9 @@ final class MacRuntimeLifecycle { try? files.remove(definitionPath) } _ = try? launchctl(["bootout", target], allowMissing: true) + Thread.sleep(forTimeInterval: 0.5) if registered, previous.content != nil { - _ = try? launchctl(["bootstrap", domain, definitionPath]) + try? bootstrapService() if serviceState(status) == "running" { _ = try? launchctl(["kickstart", "-k", target]) } @@ -536,7 +594,7 @@ final class MacRuntimeLifecycle { ompExecutable: ompExecutable ) let status = try launchctl(["print", target], allowMissing: true) - if isMissingService(status) { _ = try launchctl(["bootstrap", domain, definitionPath]) } + if isMissingService(status) { try bootstrapService() } _ = try launchctl(["kickstart", "-k", target]) return inspect(discovery: discovery) } @@ -555,7 +613,7 @@ final class MacRuntimeLifecycle { ompExecutable: ompExecutable ) let status = try launchctl(["print", target], allowMissing: true) - if isMissingService(status) { _ = try launchctl(["bootstrap", domain, definitionPath]) } + if isMissingService(status) { try bootstrapService() } _ = try launchctl(["kickstart", "-k", target]) return inspect(discovery: discovery) } @@ -582,7 +640,8 @@ final class MacRuntimeLifecycle { OmpRuntimeDiscovery( environment: environment, homeDirectory: homeDirectory, - runner: runner + runner: runner, + packagedExecutable: packagedOmpExecutable ).discover() } @@ -721,7 +780,7 @@ final class MacRuntimeLifecycle { } private func renderDefinition(hostExecutable: String, ompExecutable: String) throws -> String { - let values = [hostExecutable, ompExecutable, logsDirectory] + let values = [hostExecutable, ompExecutable, logsDirectory, bundleIdentity] guard values.allSatisfy({ value in !value.isEmpty && value.utf8.count <= 4096 @@ -734,6 +793,7 @@ final class MacRuntimeLifecycle { let hostExecutableXML = escapeXML(hostExecutable) let ompExecutableXML = escapeXML(ompExecutable) let logsXML = escapeXML(logsDirectory) + let bundleIdentityXML = escapeXML(bundleIdentity) return [ "", "", @@ -759,6 +819,8 @@ final class MacRuntimeLifecycle { " ", " OMP_PROFILE", " default", + " T4_HOST_BUNDLE_IDENTITY", + " \(bundleIdentityXML)", " ", " ", "", @@ -781,7 +843,8 @@ final class MacRuntimeLifecycle { let result = try runLaunchctl(arguments) if result.timedOut { throw RuntimeBridgeFailure( - code: "runtime_command_timeout", message: "The LaunchAgent command timed out.") + code: "runtime_command_timeout", + message: "The LaunchAgent \(arguments.first ?? "unknown") command timed out.") } if result.overflowed { throw RuntimeBridgeFailure( @@ -797,6 +860,18 @@ final class MacRuntimeLifecycle { } return result } + + private func bootstrapService() throws { + for attempt in 0..<12 { + do { + _ = try launchctl(["bootstrap", domain, definitionPath]) + return + } catch let failure as RuntimeBridgeFailure { + guard failure.code == "runtime_command_failed", attempt < 11 else { throw failure } + Thread.sleep(forTimeInterval: 0.5) + } + } + } private func runLaunchctl(_ arguments: [String]) throws -> RuntimeProcessResult { do { return try runner.run( @@ -804,7 +879,8 @@ final class MacRuntimeLifecycle { arguments: arguments, environment: safeEnvironment(), timeout: runtimeCommandTimeout, - maxOutputBytes: runtimeOutputLimit + maxOutputBytes: runtimeOutputLimit, + captureOutput: !["bootout", "kickstart"].contains(arguments.first ?? "") ) } catch { throw RuntimeBridgeFailure( diff --git a/apps/flutter/macos/RunnerTests/RunnerTests.swift b/apps/flutter/macos/RunnerTests/RunnerTests.swift index 2e1e2981..3e1b8906 100644 --- a/apps/flutter/macos/RunnerTests/RunnerTests.swift +++ b/apps/flutter/macos/RunnerTests/RunnerTests.swift @@ -14,6 +14,7 @@ private final class StubRuntimeRunner: RuntimeProcessRunning { var probeExitCode: Int32 var bridgeOutput = "Expose the private OMP authority bridge used by T4 Code\n--stdio\n" var registered = false + var bootstrapFailuresRemaining = 0 var invocations: [Invocation] = [] init(probeOutput: String, probeExitCode: Int32 = 0) { @@ -26,7 +27,8 @@ private final class StubRuntimeRunner: RuntimeProcessRunning { arguments: [String], environment: [String: String], timeout: TimeInterval, - maxOutputBytes: Int + maxOutputBytes: Int, + captureOutput: Bool ) throws -> RuntimeProcessResult { invocations.append(Invocation( executable: executableURL.path, @@ -58,6 +60,15 @@ private final class StubRuntimeRunner: RuntimeProcessRunning { overflowed: false ) case "bootstrap": + if bootstrapFailuresRemaining > 0 { + bootstrapFailuresRemaining -= 1 + return RuntimeProcessResult( + exitCode: 5, + output: "Bootstrap failed: 5: Input/output error", + timedOut: false, + overflowed: false + ) + } registered = true case "bootout": registered = false @@ -100,7 +111,8 @@ final class RunnerTests: XCTestCase { "OPENAI_API_KEY": "must-not-leak", ], homeDirectory: home.path, - runner: runner + runner: runner, + packagedExecutable: nil ).discover() XCTAssertEqual(discovery, .found(executable)) @@ -115,6 +127,25 @@ final class RunnerTests: XCTestCase { XCTAssertEqual(runner.invocations[1].arguments, ["appserver", "status", "--json"]) } + func testProcessRunnerDoesNotWaitForAChildHoldingItsOutputPipe() throws { + let runner = BoundedRuntimeProcessRunner() + let started = Date() + + let result = try runner.run( + executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "sleep 5 & printf ready"], + environment: [:], + timeout: 1, + maxOutputBytes: 1024, + captureOutput: true + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.output, "ready") + XCTAssertFalse(result.timedOut) + XCTAssertLessThan(Date().timeIntervalSince(started), 2) + } + func testDiscoveryAcceptsExactStoppedStatus() throws { let home = try temporaryDirectory() let executable = try makeExecutable(home: home) @@ -125,11 +156,33 @@ final class RunnerTests: XCTestCase { let result = OmpRuntimeDiscovery( environment: ["OMP_EXECUTABLE": executable], homeDirectory: home.path, - runner: runner + runner: runner, + packagedExecutable: nil ).discover() XCTAssertEqual(result, .found(executable)) } + func testDiscoveryPrefersThePackagedAuthorityRuntime() throws { + let home = try temporaryDirectory() + let packaged = try makeExecutable(home: home) + let otherHome = try temporaryDirectory() + let explicit = try makeExecutable(home: otherHome) + let runner = StubRuntimeRunner( + probeOutput: #"{"state":"stopped","reason":"unreachable"}"#, + probeExitCode: 1 + ) + + let result = OmpRuntimeDiscovery( + environment: ["OMP_EXECUTABLE": explicit], + homeDirectory: home.path, + runner: runner, + packagedExecutable: packaged + ).discover() + + XCTAssertEqual(result, .found(packaged)) + XCTAssertEqual(runner.invocations.first?.executable, packaged) + } + func testDiscoveryReportsUnsupportedJSONDistinctly() throws { let home = try temporaryDirectory() let executable = try makeExecutable(home: home) @@ -137,7 +190,8 @@ final class RunnerTests: XCTestCase { let result = OmpRuntimeDiscovery( environment: ["OMP_EXECUTABLE": executable], homeDirectory: home.path, - runner: runner + runner: runner, + packagedExecutable: nil ).discover() XCTAssertEqual(result, .incompatible) } @@ -149,14 +203,16 @@ final class RunnerTests: XCTestCase { let malformed = OmpRuntimeDiscovery( environment: ["OMP_EXECUTABLE": executable], homeDirectory: home.path, - runner: runner + runner: runner, + packagedExecutable: nil ).discover() XCTAssertEqual(malformed, .missing) let missing = OmpRuntimeDiscovery( environment: ["OMP_EXECUTABLE": "\(home.path)/missing/omp", "PATH": ""], homeDirectory: home.path, - runner: runner + runner: runner, + packagedExecutable: nil ).discover() XCTAssertEqual(missing, .missing) } @@ -199,7 +255,9 @@ final class RunnerTests: XCTestCase { homeDirectory: home.path, uid: 501, runner: runner, - files: files + files: files, + packagedOmpExecutable: nil, + bundleIdentity: "test-1" ) let installed = try lifecycle.install() @@ -214,6 +272,8 @@ final class RunnerTests: XCTestCase { XCTAssertTrue(snapshot.content?.contains("--omp") == true) XCTAssertTrue(snapshot.content?.contains("--profile") == true) XCTAssertTrue(snapshot.content?.contains("OMP_PROFILE") == true) + XCTAssertTrue(snapshot.content?.contains("T4_HOST_BUNDLE_IDENTITY") == true) + XCTAssertTrue(snapshot.content?.contains("test-1") == true) XCTAssertTrue(snapshot.content?.contains("default") == true) XCTAssertTrue(snapshot.content?.contains("Library/Logs/T4 Code/appserver/appserver.log") == true) XCTAssertFalse(snapshot.content?.contains("must-not-leak") == true) @@ -221,9 +281,8 @@ final class RunnerTests: XCTestCase { $0.executable == "/bin/launchctl" && $0.arguments == ["bootstrap", "gui/501", definitionPath] })) - XCTAssertTrue(runner.invocations.contains(where: { - $0.executable == "/bin/launchctl" - && $0.arguments == ["kickstart", "-k", "gui/501/dev.oh-my-pi.appserver"] + XCTAssertFalse(runner.invocations.contains(where: { + $0.executable == "/bin/launchctl" && $0.arguments.first == "kickstart" })) let uninstalled = try lifecycle.uninstall() @@ -245,6 +304,39 @@ final class RunnerTests: XCTestCase { XCTAssertEqual(try String(contentsOf: target, encoding: .utf8), "safe") } + func testLaunchAgentInstallRetriesTransientBootstrapRace() throws { + let home = try temporaryDirectory() + let executable = try makeExecutable(home: home) + let hostExecutable = try makeExecutable(home: home, name: "t4-host") + let runner = StubRuntimeRunner(probeOutput: #"{"state":"stopped","reason":"unreachable"}"#) + runner.registered = true + runner.bootstrapFailuresRemaining = 1 + let files = SecureRuntimeFileStore() + let definitionPath = "\(home.path)/Library/LaunchAgents/dev.oh-my-pi.appserver.plist" + try files.writeAtomically(definitionPath, content: "stale definition", mode: 0o600) + let lifecycle = MacRuntimeLifecycle( + environment: [ + "HOME": home.path, + "OMP_EXECUTABLE": executable, + "T4_HOST_EXECUTABLE": hostExecutable, + ], + homeDirectory: home.path, + uid: 501, + runner: runner, + files: files, + packagedOmpExecutable: nil + ) + + let installed = try lifecycle.install() + + XCTAssertEqual(installed["definition"] as? String, "current") + XCTAssertEqual(installed["service"] as? String, "running") + XCTAssertEqual( + runner.invocations.filter { $0.arguments.first == "bootstrap" }.count, + 2 + ) + } + func testMacUpdateCheckSelectsCanonicalDMGAndOnlyOpensValidatedURL() throws { let manifest = try releaseManifest(version: "0.1.25") var opened: URL? diff --git a/apps/flutter/test/client/web_socket_connector_io_test.dart b/apps/flutter/test/client/web_socket_connector_io_test.dart new file mode 100644 index 00000000..42d30af7 --- /dev/null +++ b/apps/flutter/test/client/web_socket_connector_io_test.dart @@ -0,0 +1,49 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:t4code/src/client/web_socket_connector_io.dart'; + +void main() { + test('connects to the local T4 WebSocket over a Unix socket', () async { + final directory = await Directory.systemTemp.createTemp( + 't4-flutter-local-socket-', + ); + final backingSocketPath = '${directory.path}/.appserver-test.sock'; + final socketPath = '${directory.path}/appserver.sock'; + final server = await HttpServer.bind( + InternetAddress(backingSocketPath, type: InternetAddressType.unix), + 0, + ); + await Link(socketPath).create(backingSocketPath); + addTearDown(() async { + await server.close(force: true); + await directory.delete(recursive: true); + }); + final origin = Completer(); + server.listen((request) async { + if (!origin.isCompleted) origin.complete(request.headers.value('origin')); + final socket = await WebSocketTransformer.upgrade(request); + socket.listen(socket.add); + }); + + final channel = await connectUnixWebSocket(socketPath); + addTearDown(channel.sink.close); + await channel.ready; + channel.sink.add('local transport'); + + expect(await channel.stream.first, 'local transport'); + expect(await origin.future, 'https://localhost'); + }); + + final liveSocket = Platform.environment['T4_LIVE_UNIX_SOCKET']; + test( + 'connects to a live local T4 host when requested', + () async { + final channel = await connectUnixWebSocket(liveSocket!); + addTearDown(channel.sink.close); + await channel.ready.timeout(const Duration(seconds: 10)); + }, + skip: liveSocket == null ? 'Set T4_LIVE_UNIX_SOCKET for a live smoke test.' : false, + ); +} diff --git a/apps/flutter/test/platform/platform_lifecycle_controller_test.dart b/apps/flutter/test/platform/platform_lifecycle_controller_test.dart index 0f10e463..3b8b2934 100644 --- a/apps/flutter/test/platform/platform_lifecycle_controller_test.dart +++ b/apps/flutter/test/platform/platform_lifecycle_controller_test.dart @@ -50,6 +50,56 @@ void main() { expect(controller.state.runtimeOperationPending, isFalse); }); + test('installs a missing local runtime during desktop bootstrap', () async { + final bridge = _FakeBridge(runtimeSupported: true) + ..inspectedRuntime = const RuntimeServiceStatus( + supported: true, + available: true, + definition: RuntimeDefinitionState.missing, + service: RuntimeServicePhase.stopped, + diagnostics: 'Not installed.', + executable: '/Applications/T4 Code.app/Contents/Resources/runtime/omp', + ); + final controller = PlatformLifecycleController.withBridge(bridge); + + await controller.initialize(); + await controller.ensureRuntimeReady(); + + expect(bridge.runtimeInstalls, 1); + expect(bridge.runtimeStarts, 0); + }); + + test('starts an installed local runtime during desktop bootstrap', () async { + final bridge = _FakeBridge(runtimeSupported: true); + final controller = PlatformLifecycleController.withBridge(bridge); + + await controller.initialize(); + await controller.ensureRuntimeReady(); + + expect(bridge.runtimeInstalls, 0); + expect(bridge.runtimeStarts, 1); + expect(controller.state.runtime.service, RuntimeServicePhase.running); + }); + + test('leaves a running local runtime unchanged during bootstrap', () async { + final bridge = _FakeBridge(runtimeSupported: true) + ..inspectedRuntime = const RuntimeServiceStatus( + supported: true, + available: true, + definition: RuntimeDefinitionState.current, + service: RuntimeServicePhase.running, + diagnostics: 'Running.', + executable: '/Applications/T4 Code.app/Contents/Resources/runtime/omp', + ); + final controller = PlatformLifecycleController.withBridge(bridge); + + await controller.initialize(); + await controller.ensureRuntimeReady(); + + expect(bridge.runtimeInstalls, 0); + expect(bridge.runtimeStarts, 0); + }); + test('keeps unsupported operations inert', () async { final bridge = _FakeBridge(); final controller = PlatformLifecycleController.withBridge(bridge); @@ -88,6 +138,7 @@ final class _FakeBridge implements PlatformLifecycleBridge { final bool runtimeSupported; final bool updatesSupported; int runtimeInspections = 0; + int runtimeInstalls = 0; int runtimeStarts = 0; int runtimeRestarts = 0; int updateReads = 0; @@ -95,6 +146,7 @@ final class _FakeBridge implements PlatformLifecycleBridge { Object? runtimeFailure; Object? runtimeInspectionFailure; Object? updateReadFailure; + RuntimeServiceStatus? inspectedRuntime; @override bool get supportsRuntimeService => runtimeSupported; @@ -124,11 +176,14 @@ final class _FakeBridge implements PlatformLifecycleBridge { Future inspectRuntime() async { runtimeInspections += 1; if (runtimeInspectionFailure case final Object error) throw error; - return _stopped; + return inspectedRuntime ?? _stopped; } @override - Future installRuntime() async => _stopped; + Future installRuntime() async { + runtimeInstalls += 1; + return _running; + } @override Future startRuntime() async { diff --git a/package.json b/package.json index 35184fa1..d370629e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build:host": "pnpm --filter @t4-code/host-daemon build:binary", "build:site": "pnpm --filter @t4-code/site build", "build:flutter:web": "vp run --filter @t4-code/flutter build:web", - "build:flutter:macos": "pnpm build:host && vp run --filter @t4-code/flutter build:macos", + "build:flutter:macos": "pnpm stage:omp-runtime:mac && pnpm build:host && vp run --filter @t4-code/flutter build:macos", "build:flutter:android": "vp run --filter @t4-code/flutter build:android", "build:flutter:ios": "vp run --filter @t4-code/flutter build:ios", "build:demo": "pnpm --filter @t4-code/web exec vp build --mode demo --base /demo/ --outDir ../site/dist/demo --emptyOutDir", diff --git a/packages/host-service/src/omp-authority-bridge-contract.ts b/packages/host-service/src/omp-authority-bridge-contract.ts index 2b37a88a..c47f17e7 100644 --- a/packages/host-service/src/omp-authority-bridge-contract.ts +++ b/packages/host-service/src/omp-authority-bridge-contract.ts @@ -93,6 +93,7 @@ const METHOD_SET = new Set(OMP_AUTHORITY_BRIDGE_METHODS); const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; const ERROR_CODE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/u; const MAX_TEXT_BYTES = 256 * 1024; +const MAX_RESULT_TEXT_BYTES = 768 * 1024; const MAX_VALUE_DEPTH = 32; const MAX_VALUE_NODES = 50_000; @@ -124,7 +125,7 @@ function method(value: unknown): OmpAuthorityBridgeMethod { return value as OmpAuthorityBridgeMethod; } -function boundedJson(value: unknown, label: string): unknown { +function boundedJson(value: unknown, label: string, maxTextBytes = MAX_TEXT_BYTES): unknown { let nodes = 0; let textBytes = 0; const visit = (item: unknown, depth: number): void => { @@ -137,7 +138,7 @@ function boundedJson(value: unknown, label: string): unknown { } if (typeof item === "string") { textBytes += Buffer.byteLength(item, "utf8"); - if (textBytes > MAX_TEXT_BYTES) throw new Error(`${label} exceeds bridge text bounds`); + if (textBytes > maxTextBytes) throw new Error(`${label} exceeds bridge text bounds`); return; } if (Array.isArray(item)) { @@ -148,7 +149,7 @@ function boundedJson(value: unknown, label: string): unknown { throw new Error(`${label} contains a non-JSON value`); for (const [key, child] of Object.entries(item)) { textBytes += Buffer.byteLength(key, "utf8"); - if (textBytes > MAX_TEXT_BYTES) throw new Error(`${label} exceeds bridge text bounds`); + if (textBytes > maxTextBytes) throw new Error(`${label} exceeds bridge text bounds`); visit(child, depth + 1); } }; @@ -213,7 +214,7 @@ export function decodeOmpAuthorityBridgeServerFrame(value: unknown): OmpAuthorit type: "response", id, ok: true, - result: boundedJson(frame.result, "bridge result"), + result: boundedJson(frame.result, "bridge result", MAX_RESULT_TEXT_BYTES), }; } if (frame.ok !== false) throw new Error("bridge response status is invalid"); diff --git a/packages/host-service/test/omp-authority-bridge-contract.test.ts b/packages/host-service/test/omp-authority-bridge-contract.test.ts index 64b9bde9..9114a37a 100644 --- a/packages/host-service/test/omp-authority-bridge-contract.test.ts +++ b/packages/host-service/test/omp-authority-bridge-contract.test.ts @@ -91,4 +91,22 @@ describe("OMP authority bridge contract", () => { params: { invalid: undefined }, })).toThrow("non-JSON"); }); + + test("accepts large bounded session inventories without relaxing client request limits", () => { + const largeInventory = decodeOmpAuthorityBridgeServerFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: "request-1", + ok: true, + result: [{ title: "x".repeat(300_000) }], + }); + expect(largeInventory).toMatchObject({ type: "response", ok: true }); + expect(() => decodeOmpAuthorityBridgeServerFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: "request-2", + ok: true, + result: [{ title: "x".repeat(800_000) }], + })).toThrow("text bounds"); + }); }); diff --git a/scripts/check-release-consistency.mjs b/scripts/check-release-consistency.mjs index 4a5d9acf..1398eb1b 100644 --- a/scripts/check-release-consistency.mjs +++ b/scripts/check-release-consistency.mjs @@ -928,8 +928,10 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { 'xcrun simctl install "$DEVICE_ID" build/ios/iphonesimulator/Runner.app', 'kill -0 "$app_pid"', "Build standalone T4 host for Flutter macOS", - "Verify bundled Flutter macOS host", + "Stage pinned OMP authority runtime", + "Verify bundled Flutter macOS runtime", "test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host", + "test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/omp", "name: verify", "if: ${{ always() }}", "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index 4eab7107..162e684b 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -566,12 +566,18 @@ test("deploys release site source only after artifact publication", () => { ); assert.ok(ciWorkflow.includes('kill -0 "$app_pid"')); assert.ok(ciWorkflow.includes("Build standalone T4 host for Flutter macOS")); - assert.ok(ciWorkflow.includes("Verify bundled Flutter macOS host")); + assert.ok(ciWorkflow.includes("Stage pinned OMP authority runtime")); + assert.ok(ciWorkflow.includes("Verify bundled Flutter macOS runtime")); assert.ok( ciWorkflow.includes( "test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host", ), ); + assert.ok( + ciWorkflow.includes( + "test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/omp", + ), + ); assert.ok(ciWorkflow.includes("name: verify")); assert.ok(ciWorkflow.includes("if: ${{ always() }}")); assert.ok( diff --git a/scripts/packaging.test.mjs b/scripts/packaging.test.mjs index 3e934374..c075a379 100644 --- a/scripts/packaging.test.mjs +++ b/scripts/packaging.test.mjs @@ -157,6 +157,17 @@ test("signed macOS packaging relaxes library validation only for the bundled OMP }); }); +test("Flutter macOS signs its bundled OMP runtime with the native-addon entitlement", () => { + const project = readFileSync( + resolve(repoRoot, "apps/flutter/macos/Runner.xcodeproj/project.pbxproj"), + "utf8", + ); + + assert.match(project, /Sign T4 Runtime/u); + assert.match(project, /entitlements\.omp-runtime\.plist/u); + assert.match(project, /codesign --force --sign/u); +}); + test("macOS signing accepts current and legacy electron-builder callback shapes", () => { const current = { app: "/tmp/current.app", identity: "certificate" }; assert.equal(normalizeMacSignOptions(current), current);