Bundle go-core helper with macOS app and drop external CLI fallback

This commit is contained in:
Haitao Pan 2026-04-06 09:39:16 +08:00
parent f574045b6f
commit e109e43d99
14 changed files with 248 additions and 97 deletions

View File

@ -88,17 +88,18 @@ class ArisLlmChatClient {
required Map<String, String> environment,
required Map<String, dynamic> arguments,
}) async {
if (shouldBlockEmbeddedAgentLaunch(
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
throw UnsupportedError(
'App Store builds do not allow launching the bundled Go core process.',
);
}
final launch = await _bridgeLocator.locate();
if (launch == null) {
throw StateError('Go core is unavailable.');
}
if (shouldBlockGoCoreLaunch(
launch,
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
throw UnsupportedError(
'App Store builds only allow the bundled Go core helper inside the app bundle.',
);
}
final process = await _processStarter(
launch.executable,

View File

@ -1,4 +1,5 @@
import '../app/app_store_policy.dart';
import 'go_core.dart';
bool shouldBlockEmbeddedAgentLaunch({
required bool isAppleHost,
@ -9,3 +10,17 @@ bool shouldBlockEmbeddedAgentLaunch({
enabled: enabled,
);
}
bool shouldBlockGoCoreLaunch(
GoCoreLaunch launch, {
required bool isAppleHost,
bool? enabled,
}) {
if (!shouldApplyAppleAppStorePolicy(
isAppleHost: isAppleHost,
enabled: enabled,
)) {
return false;
}
return launch.source != GoCoreLaunchSource.bundledHelper;
}

View File

@ -195,15 +195,16 @@ class GoAgentCoreDesktopTransport implements GoAgentCoreClient {
}
Future<Uri?> _startLocalProcess() async {
if (shouldBlockEmbeddedAgentLaunch(
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
return null;
}
final launch = await _goCoreLocator.locate();
if (launch == null) {
return null;
}
if (shouldBlockGoCoreLaunch(
launch,
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
return null;
}
final reservedSocket = await ServerSocket.bind(
InternetAddress.loopbackIPv4,
0,

View File

@ -1,13 +1,20 @@
import 'dart:io';
enum GoCoreLaunchSource {
bundledHelper,
buildArtifact,
}
class GoCoreLaunch {
const GoCoreLaunch({
required this.executable,
required this.source,
this.arguments = const <String>[],
this.workingDirectory,
});
final String executable;
final GoCoreLaunchSource source;
final List<String> arguments;
final String? workingDirectory;
}
@ -33,39 +40,12 @@ class GoCoreLocator {
return bundled;
}
final override =
(Platform.environment['XWORKMATE_GO_CORE_BIN'] ??
Platform.environment['GO_CORE_BIN'] ??
'')
.trim();
if (override.isNotEmpty && await _binaryExists(override)) {
return GoCoreLaunch(executable: override);
}
for (final candidate in <String>['xworkmate-go-core', 'go-core']) {
if (await _binaryExists(candidate)) {
return GoCoreLaunch(executable: candidate);
}
}
final root = (_workspaceRoot ?? Directory.current.path).trim();
if (root.isNotEmpty) {
for (final path in <String>[
'$root/go/bin/xworkmate-go-core',
'$root/go/bin/go-core',
'$root/build/bin/xworkmate-go-core',
]) {
if (await File(path).exists()) {
return GoCoreLaunch(executable: path);
}
}
final packageDirectory = Directory('$root/go/go_core');
if (await packageDirectory.exists() && await _binaryExists('go')) {
for (final root in _candidateRoots()) {
final path = '$root/build/bin/xworkmate-go-core';
if (await _binaryExists(path)) {
return GoCoreLaunch(
executable: 'go',
arguments: const <String>['run', '.'],
workingDirectory: packageDirectory.path,
executable: path,
source: GoCoreLaunchSource.buildArtifact,
);
}
}
@ -94,25 +74,55 @@ class GoCoreLocator {
return null;
}
final bundledPath = '${contentsDirectory.path}/Helpers/xworkmate-go-core';
if (await File(bundledPath).exists()) {
return GoCoreLaunch(executable: bundledPath);
if (await _binaryExists(bundledPath)) {
return GoCoreLaunch(
executable: bundledPath,
source: GoCoreLaunchSource.bundledHelper,
);
}
return null;
}
Future<bool> _binaryExists(String command) async {
final resolver = _binaryExistsResolver;
if (resolver != null) {
return resolver(command);
List<String> _candidateRoots() {
final roots = <String>{};
final explicitRoot = _workspaceRoot?.trim() ?? '';
if (explicitRoot.isNotEmpty) {
roots.add(explicitRoot);
roots.addAll(_ancestorPaths(Directory(explicitRoot)));
}
if (command.contains(Platform.pathSeparator)) {
return File(command).exists();
final currentPath = Directory.current.path.trim();
if (currentPath.isNotEmpty) {
roots.add(currentPath);
roots.addAll(_ancestorPaths(Directory(currentPath)));
}
final check = await Process.run(
Platform.isWindows ? 'where' : 'which',
<String>[command],
runInShell: true,
);
return check.exitCode == 0 && '${check.stdout}'.trim().isNotEmpty;
final resolvedExecutable =
(_resolvedExecutableResolver?.call() ?? Platform.resolvedExecutable)
.trim();
if (resolvedExecutable.isNotEmpty) {
final executableDirectory = File(resolvedExecutable).parent;
roots.add(executableDirectory.path);
roots.addAll(_ancestorPaths(executableDirectory));
}
return roots.where((path) => path.trim().isNotEmpty).toList(growable: false);
}
List<String> _ancestorPaths(Directory start) {
final ancestors = <String>[];
var current = start.absolute;
while (true) {
final parent = current.parent;
if (parent.path == current.path) {
break;
}
ancestors.add(parent.path);
current = parent;
}
return ancestors;
}
Future<bool> _binaryExists(String command) async =>
(_binaryExistsResolver?.call(command)) ?? File(command).exists();
}

View File

@ -291,15 +291,16 @@ class GoGatewayRuntimeDesktopClient implements GatewayRuntimeSessionClient {
}
Future<Uri?> _startLocalProcess() async {
if (shouldBlockEmbeddedAgentLaunch(
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
return null;
}
final launch = await _goCoreLocator.locate();
if (launch == null) {
return null;
}
if (shouldBlockGoCoreLaunch(
launch,
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
return null;
}
final reservedSocket = await ServerSocket.bind(
InternetAddress.loopbackIPv4,
0,

View File

@ -132,15 +132,16 @@ class GoMultiAgentMountDesktopClient implements MultiAgentMountResolver {
}
Future<Uri?> _startLocalProcess() async {
if (shouldBlockEmbeddedAgentLaunch(
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
return null;
}
final launch = await _goCoreLocator.locate();
if (launch == null) {
return null;
}
if (shouldBlockGoCoreLaunch(
launch,
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
return null;
}
final reservedSocket = await ServerSocket.bind(
InternetAddress.loopbackIPv4,
0,

View File

@ -152,15 +152,16 @@ class GoRuntimeDispatchDesktopClient implements RuntimeDispatchResolver {
}
Future<Uri?> _startLocalProcess() async {
if (shouldBlockEmbeddedAgentLaunch(
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
return null;
}
final launch = await _goCoreLocator.locate();
if (launch == null) {
return null;
}
if (shouldBlockGoCoreLaunch(
launch,
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
return null;
}
final reservedSocket = await ServerSocket.bind(
InternetAddress.loopbackIPv4,
0,

View File

@ -248,6 +248,7 @@
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
A1B2C3084F0A000100000001 /* Embed Bundled Go Core Helper */,
93B26977D4D2EC7AFAB54C8E /* [CP] Embed Pods Frameworks */,
A1B2C3074F0A000100000001 /* Generate Missing Framework dSYMs */,
);
@ -432,6 +433,26 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
A1B2C3084F0A000100000001 /* Embed Bundled Go Core Helper */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Embed Bundled Go Core Helper";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/bash \"${PROJECT_DIR}/../scripts/embed-go-core-helper.sh\" \"${TARGET_BUILD_DIR}/${WRAPPER_NAME}\"\n";
showEnvVarsInLog = 0;
};
A1B2C3074F0A000100000001 /* Generate Missing Framework dSYMs */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;

33
scripts/embed-go-core-helper.sh Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
APP_BUNDLE_PATH="${1:-${APP_BUNDLE_PATH:-}}"
BRIDGE_BINARY_NAME="${BRIDGE_BINARY_NAME:-xworkmate-go-core}"
BRIDGE_BUILD_PATH="${ROOT_DIR}/build/bin/${BRIDGE_BINARY_NAME}"
if [[ -z "$APP_BUNDLE_PATH" ]]; then
echo "Missing app bundle path for embedded go-core helper" >&2
exit 1
fi
if [[ ! -d "$APP_BUNDLE_PATH" ]]; then
echo "App bundle does not exist: $APP_BUNDLE_PATH" >&2
exit 1
fi
HELPERS_DIR="$APP_BUNDLE_PATH/Contents/Helpers"
HELPER_PATH="$HELPERS_DIR/$BRIDGE_BINARY_NAME"
bash "$ROOT_DIR/scripts/build-go-core.sh"
mkdir -p "$HELPERS_DIR"
ditto "$BRIDGE_BUILD_PATH" "$HELPER_PATH"
chmod +x "$HELPER_PATH"
SIGN_IDENTITY="${EXPANDED_CODE_SIGN_IDENTITY:-${CODE_SIGN_IDENTITY:--}}"
if [[ -n "$SIGN_IDENTITY" ]]; then
codesign --force --sign "$SIGN_IDENTITY" --timestamp=none "$HELPER_PATH"
fi
echo "Embedded go-core helper: $HELPER_PATH"

View File

@ -9,8 +9,6 @@ APP_NAME="${APP_NAME:-XWorkmate}"
BUILD_MODE="${BUILD_MODE:-release}"
APP_STORE_DEFINE="${APP_STORE_DEFINE:---dart-define=XWORKMATE_APP_STORE=${XWORKMATE_APP_STORE:-true}}"
PRODUCTS_DIR_NAME="$(tr '[:lower:]' '[:upper:]' <<< "${BUILD_MODE:0:1}")${BUILD_MODE:1}"
BRIDGE_BINARY_NAME="${BRIDGE_BINARY_NAME:-xworkmate-go-core}"
BRIDGE_BUILD_PATH="${ROOT_DIR}/build/bin/${BRIDGE_BINARY_NAME}"
FLUTTER_BUILD_STATE_DIR="${ROOT_DIR}/.dart_tool/flutter_build"
MACOS_BUILD_DIR="${ROOT_DIR}/build/macos"
NATIVE_ASSETS_DIR="${ROOT_DIR}/build/native_assets"
@ -35,14 +33,8 @@ fi
BUILD_APP_PATH="$APP_DIR/build/macos/Build/Products/$PRODUCTS_DIR_NAME/$APP_NAME.app"
DIST_APP_PATH="$DIST_DIR/$APP_NAME.app"
DIST_DMG_PATH="$DIST_DIR/$APP_NAME-$APP_VERSION.dmg"
HELPERS_DIR="$DIST_APP_PATH/Contents/Helpers"
HELPER_PATH="$HELPERS_DIR/$BRIDGE_BINARY_NAME"
mkdir -p "$DIST_DIR"
echo "Building bundled Go core..."
bash "$ROOT_DIR/scripts/build-go-core.sh"
echo "Building $APP_NAME $APP_VERSION ($APP_BUILD) for macOS..."
# Flutter caches native-asset installation state under .dart_tool/flutter_build,
# but Xcode consumes the copied frameworks from build/native_assets/macos.
@ -74,13 +66,10 @@ bash "$ROOT_DIR/scripts/check-apple-export-compliance.sh" "$BUILD_APP_PATH"
rm -rf "$DIST_APP_PATH" "$DIST_DMG_PATH"
ditto "$BUILD_APP_PATH" "$DIST_APP_PATH"
mkdir -p "$HELPERS_DIR"
ditto "$BRIDGE_BUILD_PATH" "$HELPER_PATH"
chmod +x "$HELPER_PATH"
bash "$ROOT_DIR/scripts/embed-go-core-helper.sh" "$DIST_APP_PATH"
echo "Re-signing bundled helper and app..."
SIGN_IDENTITY="${XWORKMATE_SIGN_IDENTITY:--}"
codesign --force --sign "$SIGN_IDENTITY" --timestamp=none "$HELPER_PATH"
codesign --force --deep --sign "$SIGN_IDENTITY" --preserve-metadata=entitlements,requirements,flags,runtime --timestamp=none "$DIST_APP_PATH"
echo "Packaging DMG..."

View File

@ -113,11 +113,17 @@ void main() {
}
GoCoreLocator _fixedLocator() {
final appRoot = Directory('${Directory.systemTemp.path}/aris-llm-chat-app');
final helpersDir = Directory('${appRoot.path}/XWorkmate.app/Contents/Helpers');
helpersDir.createSync(recursive: true);
final helper = File('${helpersDir.path}/xworkmate-go-core');
if (!helper.existsSync()) {
helper.writeAsStringSync('#!/bin/sh\nexit 0\n');
Process.runSync('chmod', <String>['+x', helper.path]);
}
return GoCoreLocator(
binaryExistsResolver: (_) async => true,
workspaceRoot: Directory.systemTemp.path,
resolvedExecutableResolver: () =>
'${Directory.systemTemp.path}/XWorkmate.app/Contents/MacOS/XWorkmate',
'${appRoot.path}/XWorkmate.app/Contents/MacOS/XWorkmate',
);
}

View File

@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/runtime/embedded_agent_launch_policy.dart';
import 'package:xworkmate/runtime/go_core.dart';
void main() {
test('apple app store policy blocks embedded agent launches', () {
@ -12,4 +13,28 @@ void main() {
isFalse,
);
});
test('apple app store policy allows only bundled go core helpers', () {
const bundled = GoCoreLaunch(
executable: '/Applications/XWorkmate.app/Contents/Helpers/xworkmate-go-core',
source: GoCoreLaunchSource.bundledHelper,
);
const buildArtifact = GoCoreLaunch(
executable: '/tmp/build/bin/xworkmate-go-core',
source: GoCoreLaunchSource.buildArtifact,
);
expect(
shouldBlockGoCoreLaunch(bundled, isAppleHost: true, enabled: true),
isFalse,
);
expect(
shouldBlockGoCoreLaunch(buildArtifact, isAppleHost: true, enabled: true),
isTrue,
);
expect(
shouldBlockGoCoreLaunch(buildArtifact, isAppleHost: false, enabled: true),
isFalse,
);
});
}

View File

@ -37,13 +37,14 @@ void main() {
expect(launch, isNotNull);
expect(launch!.executable, helperFile.path);
expect(launch.source, GoCoreLaunchSource.bundledHelper);
expect(launch.arguments, isEmpty);
expect(launch.workingDirectory, isNull);
},
);
test(
'GoCoreLocator falls back to go run in the local bridge package',
'GoCoreLocator resolves the local build artifact from the workspace root',
() async {
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-go-core-',
@ -53,21 +54,68 @@ void main() {
await tempDirectory.delete(recursive: true);
}
});
await Directory(
'${tempDirectory.path}/go/go_core',
).create(recursive: true);
final bridgeFile = File('${tempDirectory.path}/build/bin/xworkmate-go-core');
await bridgeFile.parent.create(recursive: true);
await bridgeFile.writeAsString('#!/bin/sh\nexit 0\n');
await Process.run('chmod', <String>['+x', bridgeFile.path]);
final locator = GoCoreLocator(
workspaceRoot: tempDirectory.path,
binaryExistsResolver: (command) async => command == 'go',
);
final launch = await locator.locate();
expect(launch, isNotNull);
expect(launch!.executable, 'go');
expect(launch.arguments, const <String>['run', '.']);
expect(launch.workingDirectory, '${tempDirectory.path}/go/go_core');
expect(launch!.executable, bridgeFile.path);
expect(launch.source, GoCoreLaunchSource.buildArtifact);
expect(launch.arguments, isEmpty);
expect(launch.workingDirectory, isNull);
},
);
test(
'GoCoreLocator resolves build-root bridge binaries from the executable ancestry when cwd is outside the repo',
() async {
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-go-core-build-root-',
);
final outsideDirectory = await Directory.systemTemp.createTemp(
'xworkmate-go-core-outside-',
);
final originalCurrentDirectory = Directory.current;
addTearDown(() async {
Directory.current = originalCurrentDirectory;
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
if (await outsideDirectory.exists()) {
await outsideDirectory.delete(recursive: true);
}
});
final bridgeFile = File('${tempDirectory.path}/build/bin/xworkmate-go-core');
await bridgeFile.parent.create(recursive: true);
await bridgeFile.writeAsString('#!/bin/sh\nexit 0\n');
await Process.run('chmod', <String>['+x', bridgeFile.path]);
final executablePath =
'${tempDirectory.path}/build/macos/Build/Products/Debug/XWorkmate.app/Contents/MacOS/XWorkmate';
await File(executablePath).parent.create(recursive: true);
await File(executablePath).writeAsString('');
Directory.current = outsideDirectory;
final locator = GoCoreLocator(
resolvedExecutableResolver: () => executablePath,
);
final launch = await locator.locate();
expect(launch, isNotNull);
expect(launch!.executable, bridgeFile.path);
expect(launch.source, GoCoreLaunchSource.buildArtifact);
expect(launch.arguments, isEmpty);
expect(launch.workingDirectory, isNull);
},
);
}

View File

@ -89,7 +89,6 @@ void main() {
await Process.run('chmod', <String>['+x', helper.path]);
final locator = GoCoreLocator(
workspaceRoot: tempDir.path,
binaryExistsResolver: (_) async => false,
resolvedExecutableResolver: () =>
'${tempDir.path}/XWorkmate.app/Contents/MacOS/XWorkmate',
);