Skip to content
Closed
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
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,17 @@ 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/<id>` 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/<id>` 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.

**Tailnet browser.** A source checkout can serve the web app to a phone through Tailscale Serve; see [Tailnet remote access](docs/TAILNET_REMOTE.md). There is no T4 app password in this mode. Tailscale identity plus your tailnet ACLs or grants are the access boundary, so keep the route on Serve and never enable Funnel. Anyone allowed to reach the node and port can operate the connected T4 host and its OMP sessions.

## 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

Expand Down
32 changes: 28 additions & 4 deletions apps/flutter/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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<T4Bootstrap> createState() => _T4BootstrapState();
Expand Down Expand Up @@ -59,11 +74,20 @@ final class _T4BootstrapState extends State<T4Bootstrap>
_platformController = PlatformLifecycleController();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_controller.initialize());
unawaited(_platformController.initialize());
unawaited(_initialize());
});
}

Future<void> _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) {
Expand Down
3 changes: 3 additions & 0 deletions apps/flutter/lib/src/client/web_socket_connector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@ import 'web_socket_connector_stub.dart'

typedef WebSocketConnector = Future<WebSocketChannel> Function(Uri endpoint);

Uri? platformLocalWebSocketEndpoint() =>
platform.platformLocalWebSocketEndpoint();

Future<WebSocketChannel> connectPlatformWebSocket(Uri endpoint) =>
platform.connectPlatformWebSocket(endpoint);
66 changes: 62 additions & 4 deletions apps/flutter/lib/src/client/web_socket_connector_io.dart
Original file line number Diff line number Diff line change
@@ -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<WebSocketChannel> connectPlatformWebSocket(Uri endpoint) async =>
IOWebSocketChannel.connect(
endpoint,
headers: const <String, String>{'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<WebSocketChannel> connectPlatformWebSocket(Uri endpoint) async {
if (Platform.isMacOS && endpoint == _localEndpoint) {
return connectUnixWebSocket(_defaultLocalSocketPath());
}
return IOWebSocketChannel.connect(
endpoint,
headers: const <String, String>{'Origin': 'https://localhost'},
);
}

Future<WebSocketChannel> 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 <String, String>{'Origin': 'https://localhost'},
customClient: client,
connectTimeout: const Duration(seconds: 5),
);
unawaited(channel.ready.whenComplete(client.close));
return channel;
}
2 changes: 2 additions & 0 deletions apps/flutter/lib/src/client/web_socket_connector_stub.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:web_socket_channel/web_socket_channel.dart';

Uri? platformLocalWebSocketEndpoint() => null;

Future<WebSocketChannel> connectPlatformWebSocket(Uri endpoint) =>
Future<WebSocketChannel>.error(
UnsupportedError('WebSocket transport is unavailable on this platform.'),
Expand Down
2 changes: 2 additions & 0 deletions apps/flutter/lib/src/client/web_socket_connector_web.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:web_socket_channel/html.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

Uri? platformLocalWebSocketEndpoint() => null;

Future<WebSocketChannel> connectPlatformWebSocket(Uri endpoint) async =>
HtmlWebSocketChannel.connect(endpoint);
14 changes: 14 additions & 0 deletions apps/flutter/lib/src/platform/platform_lifecycle_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,20 @@ final class PlatformLifecycleController extends ChangeNotifier
_notify();
}

Future<void> 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<void> refreshPlatformState() => _runBoth(
runtime: _bridge.supportsRuntimeService ? _bridge.inspectRuntime : null,
Expand Down
31 changes: 28 additions & 3 deletions apps/flutter/macos/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */
Expand All @@ -93,6 +95,7 @@
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
A4C1F1002E2C000100000001 /* PlatformLifecycleChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformLifecycleChannel.swift; sourceTree = "<group>"; };
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 = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -159,6 +162,7 @@
isa = PBXGroup;
children = (
A4C1F1022E2C000100000002 /* t4-host */,
A4C1F1052E2C000100000005 /* omp */,
33CC10F22044A3C60003C045 /* Assets.xcassets */,
33CC10F42044A3C60003C045 /* MainMenu.xib */,
33CC10F72044A3C60003C045 /* Info.plist */,
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 0 additions & 12 deletions apps/flutter/macos/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
5 changes: 5 additions & 0 deletions apps/flutter/macos/Runner/MainFlutterWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ import Cocoa
import FlutterMacOS

class MainFlutterWindow: NSWindow {
private var platformLifecycleChannel: PlatformLifecycleChannel?

override func awakeFromNib() {
let flutterViewController = FlutterViewController()
let windowFrame = self.frame
self.contentViewController = flutterViewController
self.setFrame(windowFrame, display: true)

RegisterGeneratedPlugins(registry: flutterViewController)
platformLifecycleChannel = PlatformLifecycleChannel(
messenger: flutterViewController.engine.binaryMessenger
)

super.awakeFromNib()
}
Expand Down
Loading
Loading