Merge branch 'codex/linux-desktop-parity' into main

# Conflicts:
#	lib/app/app_controller.dart
#	test/features/settings_page_test.dart
#	test/test_support.dart
This commit is contained in:
Haitao Pan 2026-03-18 18:15:09 +08:00
commit 1e403db0be
20 changed files with 2006 additions and 6 deletions

View File

@ -7,7 +7,7 @@ PNPM ?= pnpm
DART ?= dart
DEVICE ?= macos
.PHONY: help deps analyze test check format run build-macos build-ios-sim package-mac install-mac clean
.PHONY: help deps analyze test check format run build-linux build-macos build-ios-sim package-deb package-rpm package-linux package-mac install-mac clean
help: ## Show available targets
@grep -E '^[a-zA-Z0-9_.-]+:.*?## ' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-18s %s\n", $$1, $$2}'
@ -29,12 +29,24 @@ format: ## Format Dart sources
run: ## Run the app on a device or desktop target (DEVICE=macos by default)
$(FLUTTER) run -d $(DEVICE)
build-linux: ## Build the Linux app in release mode
$(FLUTTER) build linux --release
build-macos: ## Build the macOS app in release mode
$(FLUTTER) build macos --release
build-ios-sim: ## Build the iOS app for the simulator
$(FLUTTER) build ios --simulator
package-deb: ## Create the Linux .deb package
bash scripts/package-linux-deb.sh
package-rpm: ## Create the Linux .rpm package
bash scripts/package-linux-rpm.sh
package-linux: ## Create both Linux packages
bash scripts/package-linux.sh
package-mac: ## Create the macOS .app and DMG
bash scripts/package-flutter-mac-app.sh

View File

@ -8,6 +8,7 @@ import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';
import '../runtime/runtime_controllers.dart';
import '../runtime/runtime_models.dart';
@ -25,6 +26,7 @@ class AppController extends ChangeNotifier {
AppController({
SecureConfigStore? store,
RuntimeCoordinator? runtimeCoordinator,
DesktopPlatformService? desktopPlatformService,
}) {
_store = store ?? SecureConfigStore();
@ -58,6 +60,8 @@ class AppController extends ChangeNotifier {
_cronJobsController = CronJobsController(_runtimeCoordinator.gateway);
_devicesController = DevicesController(_runtimeCoordinator.gateway);
_tasksController = DerivedTasksController();
_desktopPlatformService =
desktopPlatformService ?? createDesktopPlatformService();
_attachChildListeners();
unawaited(_initialize());
}
@ -78,6 +82,7 @@ class AppController extends ChangeNotifier {
late final CronJobsController _cronJobsController;
late final DevicesController _devicesController;
late final DerivedTasksController _tasksController;
late final DesktopPlatformService _desktopPlatformService;
WorkspaceDestination _destination = WorkspaceDestination.assistant;
ThemeMode _themeMode = ThemeMode.light;
@ -119,6 +124,10 @@ class AppController extends ChangeNotifier {
CronJobsController get cronJobsController => _cronJobsController;
DevicesController get devicesController => _devicesController;
DerivedTasksController get tasksController => _tasksController;
DesktopIntegrationState get desktopIntegration =>
_desktopPlatformService.state;
bool get supportsDesktopIntegration => desktopIntegration.isSupported;
bool get desktopPlatformBusy => _desktopPlatformBusy;
GatewayConnectionSnapshot get connection => _runtime.snapshot;
SettingsSnapshot get settings => _settingsController.snapshot;
@ -161,6 +170,7 @@ class AppController extends ChangeNotifier {
CodeAgentRuntimeMode get effectiveCodeAgentRuntimeMode =>
configuredCodeAgentRuntimeMode;
CodexCooperationState get codexCooperationState => _codexCooperationState;
bool _desktopPlatformBusy = false;
Future<String> loadAiGatewayApiKey() async {
return (await _store.loadAiGatewayApiKey())?.trim() ?? '';
@ -670,9 +680,77 @@ class AppController extends ChangeNotifier {
_registerCodexExternalProvider(codexPath: sanitized.codexCliPath);
await _refreshCodexCliAvailability();
}
if (current.linuxDesktop.toJson().toString() !=
sanitized.linuxDesktop.toJson().toString() ||
current.launchAtLogin != sanitized.launchAtLogin) {
await _desktopPlatformService.syncConfig(sanitized.linuxDesktop);
await _desktopPlatformService.setLaunchAtLogin(sanitized.launchAtLogin);
}
if (refreshAfterSave) {
_recomputeTasks();
}
notifyListeners();
}
Future<void> refreshDesktopIntegration() async {
_desktopPlatformBusy = true;
notifyListeners();
try {
await _desktopPlatformService.refresh();
} finally {
_desktopPlatformBusy = false;
notifyListeners();
}
}
Future<void> saveLinuxDesktopConfig(LinuxDesktopConfig config) async {
await saveSettings(settings.copyWith(linuxDesktop: config));
}
Future<void> setDesktopVpnMode(VpnMode mode) async {
_desktopPlatformBusy = true;
notifyListeners();
try {
await saveSettings(
settings.copyWith(
linuxDesktop: settings.linuxDesktop.copyWith(preferredMode: mode),
),
refreshAfterSave: false,
);
await _desktopPlatformService.setMode(mode);
} finally {
_desktopPlatformBusy = false;
notifyListeners();
}
}
Future<void> connectDesktopTunnel() async {
_desktopPlatformBusy = true;
notifyListeners();
try {
await _desktopPlatformService.connectTunnel();
} finally {
_desktopPlatformBusy = false;
notifyListeners();
}
}
Future<void> disconnectDesktopTunnel() async {
_desktopPlatformBusy = true;
notifyListeners();
try {
await _desktopPlatformService.disconnectTunnel();
} finally {
_desktopPlatformBusy = false;
notifyListeners();
}
}
Future<void> setLaunchAtLogin(bool enabled) async {
await saveSettings(
settings.copyWith(launchAtLogin: enabled),
refreshAfterSave: false,
);
}
Future<void> toggleAssistantNavigationDestination(
@ -818,6 +896,7 @@ class AppController extends ChangeNotifier {
_devicesController.dispose();
_tasksController.dispose();
_store.dispose();
_desktopPlatformService.dispose();
super.dispose();
}
@ -853,6 +932,8 @@ class AppController extends ChangeNotifier {
}
_modelsController.restoreFromSettings(settings.aiGateway);
setActiveAppLanguage(settings.appLanguage);
await _desktopPlatformService.initialize(settings.linuxDesktop);
await _desktopPlatformService.setLaunchAtLogin(settings.launchAtLogin);
_registerCodexExternalProvider();
await _refreshCodexCliAvailability();
if (_disposed) {

View File

@ -185,7 +185,9 @@ class _SettingsPageState extends State<SettingsPage> {
),
),
_SwitchRow(
label: appText('显示 Dock 图标', 'Show dock icon'),
label: controller.supportsDesktopIntegration
? appText('显示托盘图标', 'Show tray icon')
: appText('显示 Dock 图标', 'Show dock icon'),
value: settings.showDockIcon,
onChanged: (value) => _saveSettings(
controller,
@ -203,6 +205,8 @@ class _SettingsPageState extends State<SettingsPage> {
],
),
),
if (controller.supportsDesktopIntegration)
_buildLinuxDesktopIntegration(context, controller, settings),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -242,6 +246,159 @@ class _SettingsPageState extends State<SettingsPage> {
];
}
Widget _buildLinuxDesktopIntegration(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
final desktop = controller.desktopIntegration;
final config = settings.linuxDesktop;
final theme = Theme.of(context);
return SurfaceCard(
key: const ValueKey('linux-desktop-integration-card'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('Linux 桌面集成', 'Linux Desktop Integration'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
appText(
'统一管理 GNOME / KDE 的代理模式、隧道连接、托盘菜单与开机自启。',
'Manage GNOME / KDE proxy mode, tunnel session, tray menu, and autostart from one surface.',
),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
_InfoRow(
label: appText('桌面环境', 'Desktop'),
value: desktop.environment.label,
),
_InfoRow(
label: 'NetworkManager',
value: desktop.networkManagerAvailable
? appText('可用', 'Available')
: appText('不可用', 'Unavailable'),
),
_InfoRow(
label: appText('当前模式', 'Current Mode'),
value: desktop.mode.label,
),
_InfoRow(
label: appText('隧道状态', 'Tunnel'),
value: desktop.tunnel.connected
? appText('已连接', 'Connected')
: desktop.tunnel.available
? appText('可连接', 'Ready')
: appText('未检测到配置', 'No profile detected'),
),
_InfoRow(
label: appText('系统代理', 'System Proxy'),
value: desktop.systemProxy.enabled
? '${desktop.systemProxy.host}:${desktop.systemProxy.port}'
: appText('未启用', 'Disabled'),
),
_SwitchRow(
label: appText('开机启动', 'Launch at login'),
value: settings.launchAtLogin,
onChanged: (value) => controller.setLaunchAtLogin(value),
),
_SwitchRow(
label: appText('托盘菜单', 'Tray menu'),
value: config.trayEnabled,
onChanged: (value) => controller.saveLinuxDesktopConfig(
config.copyWith(trayEnabled: value),
),
),
_EditableField(
label: appText('隧道连接名称', 'Tunnel Connection Name'),
value: config.vpnConnectionName,
onSubmitted: (value) => controller.saveLinuxDesktopConfig(
config.copyWith(vpnConnectionName: value.trim()),
),
),
Row(
children: [
Expanded(
child: _EditableField(
label: appText('代理主机', 'Proxy Host'),
value: config.proxyHost,
onSubmitted: (value) => controller.saveLinuxDesktopConfig(
config.copyWith(proxyHost: value.trim()),
),
),
),
const SizedBox(width: 12),
Expanded(
child: _EditableField(
label: appText('代理端口', 'Proxy Port'),
value: config.proxyPort.toString(),
onSubmitted: (value) {
final parsed = int.tryParse(value.trim());
if (parsed == null || parsed <= 0) {
return;
}
controller.saveLinuxDesktopConfig(
config.copyWith(proxyPort: parsed),
);
},
),
),
],
),
const SizedBox(height: 6),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.tonal(
onPressed: controller.desktopPlatformBusy
? null
: () => controller.setDesktopVpnMode(VpnMode.proxy),
child: Text(appText('切换到代理', 'Use Proxy')),
),
FilledButton.tonal(
onPressed: controller.desktopPlatformBusy
? null
: () => controller.setDesktopVpnMode(VpnMode.tunnel),
child: Text(appText('切换到隧道', 'Use Tunnel')),
),
OutlinedButton(
onPressed: controller.desktopPlatformBusy
? null
: controller.connectDesktopTunnel,
child: Text(appText('连接隧道', 'Connect Tunnel')),
),
OutlinedButton(
onPressed: controller.desktopPlatformBusy
? null
: controller.disconnectDesktopTunnel,
child: Text(appText('断开隧道', 'Disconnect Tunnel')),
),
OutlinedButton(
onPressed: controller.desktopPlatformBusy
? null
: controller.refreshDesktopIntegration,
child: Text(appText('刷新状态', 'Refresh Status')),
),
],
),
if (desktop.statusMessage.trim().isNotEmpty) ...[
const SizedBox(height: 16),
_buildNotice(
context,
tone: theme.colorScheme.surfaceContainerHighest,
title: appText('桌面状态', 'Desktop Status'),
message: desktop.statusMessage,
),
],
],
),
);
}
List<Widget> _buildWorkspace(
BuildContext context,
AppController controller,

View File

@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'runtime_models.dart';
abstract class DesktopPlatformService {
DesktopIntegrationState get state;
bool get isSupported => state.isSupported;
Future<void> initialize(LinuxDesktopConfig config);
Future<void> syncConfig(LinuxDesktopConfig config);
Future<void> refresh();
Future<void> setMode(VpnMode mode);
Future<void> connectTunnel();
Future<void> disconnectTunnel();
Future<void> setLaunchAtLogin(bool enabled);
void dispose() {}
}
DesktopPlatformService createDesktopPlatformService() {
if (Platform.isLinux) {
return MethodChannelDesktopPlatformService();
}
return UnsupportedDesktopPlatformService();
}
class UnsupportedDesktopPlatformService implements DesktopPlatformService {
DesktopIntegrationState _state = DesktopIntegrationState.unsupported();
@override
DesktopIntegrationState get state => _state;
@override
bool get isSupported => state.isSupported;
@override
Future<void> initialize(LinuxDesktopConfig config) async {
_state = DesktopIntegrationState.unsupported();
}
@override
Future<void> syncConfig(LinuxDesktopConfig config) async {}
@override
Future<void> refresh() async {}
@override
Future<void> setMode(VpnMode mode) async {}
@override
Future<void> connectTunnel() async {}
@override
Future<void> disconnectTunnel() async {}
@override
Future<void> setLaunchAtLogin(bool enabled) async {}
@override
void dispose() {}
}
class MethodChannelDesktopPlatformService implements DesktopPlatformService {
static const MethodChannel _channel = MethodChannel(
'plus.svc.xworkmate/desktop_platform',
);
DesktopIntegrationState _state = DesktopIntegrationState.loading();
LinuxDesktopConfig _config = LinuxDesktopConfig.defaults();
@override
DesktopIntegrationState get state => _state;
@override
bool get isSupported => state.isSupported;
@override
Future<void> initialize(LinuxDesktopConfig config) async {
_config = config;
await _invokeVoid('configure', _encodeConfig(config));
await refresh();
}
@override
Future<void> syncConfig(LinuxDesktopConfig config) async {
_config = config;
await _invokeVoid('configure', _encodeConfig(config));
await refresh();
}
@override
Future<void> refresh() async {
final payload = await _channel.invokeMethod<String>('getState');
_state = DesktopIntegrationState.fromJson(
_decodeJsonMap(payload),
fallbackConfig: _config,
);
}
@override
Future<void> setMode(VpnMode mode) async {
await _invokeVoid('setMode', mode.name);
await refresh();
}
@override
Future<void> connectTunnel() async {
await _invokeVoid('connectTunnel');
await refresh();
}
@override
Future<void> disconnectTunnel() async {
await _invokeVoid('disconnectTunnel');
await refresh();
}
@override
Future<void> setLaunchAtLogin(bool enabled) async {
await _invokeVoid('setAutostart', enabled);
await refresh();
}
@override
void dispose() {}
Future<void> _invokeVoid(String method, [Object? arguments]) async {
try {
await _channel.invokeMethod<void>(method, arguments);
} on MissingPluginException {
_state = DesktopIntegrationState.unsupported(
config: _config,
message: 'Desktop integration channel unavailable',
);
} on PlatformException catch (error) {
_state = _state.copyWith(statusMessage: error.message ?? error.code);
rethrow;
}
}
String _encodeConfig(LinuxDesktopConfig config) {
return jsonEncode(config.toJson());
}
Map<String, dynamic> _decodeJsonMap(String? payload) {
if (payload == null || payload.trim().isEmpty) {
return const <String, dynamic>{};
}
final decoded = jsonDecode(payload);
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map) {
return decoded.cast<String, dynamic>();
}
return const <String, dynamic>{};
}
}

View File

@ -92,6 +92,373 @@ extension CodeAgentRuntimeModeCopy on CodeAgentRuntimeMode {
}
}
enum VpnMode { tunnel, proxy }
extension VpnModeCopy on VpnMode {
String get label => switch (this) {
VpnMode.tunnel => appText('隧道', 'Tunnel'),
VpnMode.proxy => appText('代理', 'Proxy'),
};
static VpnMode fromJsonValue(String? value) {
return VpnMode.values.firstWhere(
(item) => item.name == value,
orElse: () => VpnMode.proxy,
);
}
}
enum DesktopEnvironment { unknown, gnome, kde }
extension DesktopEnvironmentCopy on DesktopEnvironment {
String get label => switch (this) {
DesktopEnvironment.unknown => appText('未知桌面', 'Unknown Desktop'),
DesktopEnvironment.gnome => 'GNOME',
DesktopEnvironment.kde => 'KDE Plasma',
};
static DesktopEnvironment fromJsonValue(String? value) {
return DesktopEnvironment.values.firstWhere(
(item) => item.name == value,
orElse: () => DesktopEnvironment.unknown,
);
}
}
class LinuxDesktopConfig {
const LinuxDesktopConfig({
required this.preferredMode,
required this.vpnConnectionName,
required this.proxyHost,
required this.proxyPort,
required this.trayEnabled,
});
final VpnMode preferredMode;
final String vpnConnectionName;
final String proxyHost;
final int proxyPort;
final bool trayEnabled;
factory LinuxDesktopConfig.defaults() {
return const LinuxDesktopConfig(
preferredMode: VpnMode.proxy,
vpnConnectionName: 'XWorkmate Tunnel',
proxyHost: '127.0.0.1',
proxyPort: 7890,
trayEnabled: true,
);
}
LinuxDesktopConfig copyWith({
VpnMode? preferredMode,
String? vpnConnectionName,
String? proxyHost,
int? proxyPort,
bool? trayEnabled,
}) {
return LinuxDesktopConfig(
preferredMode: preferredMode ?? this.preferredMode,
vpnConnectionName: vpnConnectionName ?? this.vpnConnectionName,
proxyHost: proxyHost ?? this.proxyHost,
proxyPort: proxyPort ?? this.proxyPort,
trayEnabled: trayEnabled ?? this.trayEnabled,
);
}
Map<String, dynamic> toJson() {
return {
'preferredMode': preferredMode.name,
'vpnConnectionName': vpnConnectionName,
'proxyHost': proxyHost,
'proxyPort': proxyPort,
'trayEnabled': trayEnabled,
};
}
factory LinuxDesktopConfig.fromJson(Map<String, dynamic> json) {
final defaults = LinuxDesktopConfig.defaults();
return LinuxDesktopConfig(
preferredMode: VpnModeCopy.fromJsonValue(
json['preferredMode'] as String?,
),
vpnConnectionName:
json['vpnConnectionName'] as String? ?? defaults.vpnConnectionName,
proxyHost: json['proxyHost'] as String? ?? defaults.proxyHost,
proxyPort: json['proxyPort'] as int? ?? defaults.proxyPort,
trayEnabled: json['trayEnabled'] as bool? ?? defaults.trayEnabled,
);
}
}
class SystemProxyState {
const SystemProxyState({
required this.enabled,
required this.host,
required this.port,
required this.backend,
required this.lastAppliedMode,
});
final bool enabled;
final String host;
final int port;
final String backend;
final VpnMode lastAppliedMode;
factory SystemProxyState.defaults({LinuxDesktopConfig? config}) {
final resolvedConfig = config ?? LinuxDesktopConfig.defaults();
return SystemProxyState(
enabled: resolvedConfig.preferredMode == VpnMode.proxy,
host: resolvedConfig.proxyHost,
port: resolvedConfig.proxyPort,
backend: '',
lastAppliedMode: resolvedConfig.preferredMode,
);
}
SystemProxyState copyWith({
bool? enabled,
String? host,
int? port,
String? backend,
VpnMode? lastAppliedMode,
}) {
return SystemProxyState(
enabled: enabled ?? this.enabled,
host: host ?? this.host,
port: port ?? this.port,
backend: backend ?? this.backend,
lastAppliedMode: lastAppliedMode ?? this.lastAppliedMode,
);
}
Map<String, dynamic> toJson() {
return {
'enabled': enabled,
'host': host,
'port': port,
'backend': backend,
'lastAppliedMode': lastAppliedMode.name,
};
}
factory SystemProxyState.fromJson(
Map<String, dynamic> json, {
LinuxDesktopConfig? config,
}) {
final defaults = SystemProxyState.defaults(config: config);
return SystemProxyState(
enabled: json['enabled'] as bool? ?? defaults.enabled,
host: json['host'] as String? ?? defaults.host,
port: json['port'] as int? ?? defaults.port,
backend: json['backend'] as String? ?? defaults.backend,
lastAppliedMode: VpnModeCopy.fromJsonValue(
json['lastAppliedMode'] as String?,
),
);
}
}
class TunnelSessionState {
const TunnelSessionState({
required this.available,
required this.connected,
required this.connectionName,
required this.backend,
required this.lastError,
});
final bool available;
final bool connected;
final String connectionName;
final String backend;
final String lastError;
factory TunnelSessionState.defaults({LinuxDesktopConfig? config}) {
final resolvedConfig = config ?? LinuxDesktopConfig.defaults();
return TunnelSessionState(
available: false,
connected: false,
connectionName: resolvedConfig.vpnConnectionName,
backend: '',
lastError: '',
);
}
TunnelSessionState copyWith({
bool? available,
bool? connected,
String? connectionName,
String? backend,
String? lastError,
}) {
return TunnelSessionState(
available: available ?? this.available,
connected: connected ?? this.connected,
connectionName: connectionName ?? this.connectionName,
backend: backend ?? this.backend,
lastError: lastError ?? this.lastError,
);
}
Map<String, dynamic> toJson() {
return {
'available': available,
'connected': connected,
'connectionName': connectionName,
'backend': backend,
'lastError': lastError,
};
}
factory TunnelSessionState.fromJson(
Map<String, dynamic> json, {
LinuxDesktopConfig? config,
}) {
final defaults = TunnelSessionState.defaults(config: config);
return TunnelSessionState(
available: json['available'] as bool? ?? defaults.available,
connected: json['connected'] as bool? ?? defaults.connected,
connectionName:
json['connectionName'] as String? ?? defaults.connectionName,
backend: json['backend'] as String? ?? defaults.backend,
lastError: json['lastError'] as String? ?? defaults.lastError,
);
}
}
class DesktopIntegrationState {
const DesktopIntegrationState({
required this.isSupported,
required this.environment,
required this.mode,
required this.trayAvailable,
required this.trayEnabled,
required this.autostartEnabled,
required this.networkManagerAvailable,
required this.systemProxy,
required this.tunnel,
required this.statusMessage,
});
final bool isSupported;
final DesktopEnvironment environment;
final VpnMode mode;
final bool trayAvailable;
final bool trayEnabled;
final bool autostartEnabled;
final bool networkManagerAvailable;
final SystemProxyState systemProxy;
final TunnelSessionState tunnel;
final String statusMessage;
factory DesktopIntegrationState.loading() {
final config = LinuxDesktopConfig.defaults();
return DesktopIntegrationState(
isSupported: true,
environment: DesktopEnvironment.unknown,
mode: config.preferredMode,
trayAvailable: false,
trayEnabled: config.trayEnabled,
autostartEnabled: false,
networkManagerAvailable: false,
systemProxy: SystemProxyState.defaults(config: config),
tunnel: TunnelSessionState.defaults(config: config),
statusMessage: '',
);
}
factory DesktopIntegrationState.unsupported({
LinuxDesktopConfig? config,
String message = '',
}) {
final resolvedConfig = config ?? LinuxDesktopConfig.defaults();
return DesktopIntegrationState(
isSupported: false,
environment: DesktopEnvironment.unknown,
mode: resolvedConfig.preferredMode,
trayAvailable: false,
trayEnabled: false,
autostartEnabled: false,
networkManagerAvailable: false,
systemProxy: SystemProxyState.defaults(config: resolvedConfig),
tunnel: TunnelSessionState.defaults(config: resolvedConfig),
statusMessage: message,
);
}
DesktopIntegrationState copyWith({
bool? isSupported,
DesktopEnvironment? environment,
VpnMode? mode,
bool? trayAvailable,
bool? trayEnabled,
bool? autostartEnabled,
bool? networkManagerAvailable,
SystemProxyState? systemProxy,
TunnelSessionState? tunnel,
String? statusMessage,
}) {
return DesktopIntegrationState(
isSupported: isSupported ?? this.isSupported,
environment: environment ?? this.environment,
mode: mode ?? this.mode,
trayAvailable: trayAvailable ?? this.trayAvailable,
trayEnabled: trayEnabled ?? this.trayEnabled,
autostartEnabled: autostartEnabled ?? this.autostartEnabled,
networkManagerAvailable:
networkManagerAvailable ?? this.networkManagerAvailable,
systemProxy: systemProxy ?? this.systemProxy,
tunnel: tunnel ?? this.tunnel,
statusMessage: statusMessage ?? this.statusMessage,
);
}
Map<String, dynamic> toJson() {
return {
'isSupported': isSupported,
'environment': environment.name,
'mode': mode.name,
'trayAvailable': trayAvailable,
'trayEnabled': trayEnabled,
'autostartEnabled': autostartEnabled,
'networkManagerAvailable': networkManagerAvailable,
'systemProxy': systemProxy.toJson(),
'tunnel': tunnel.toJson(),
'statusMessage': statusMessage,
};
}
factory DesktopIntegrationState.fromJson(
Map<String, dynamic> json, {
LinuxDesktopConfig? fallbackConfig,
}) {
final config = fallbackConfig ?? LinuxDesktopConfig.defaults();
return DesktopIntegrationState(
isSupported: json['isSupported'] as bool? ?? true,
environment: DesktopEnvironmentCopy.fromJsonValue(
json['environment'] as String?,
),
mode: VpnModeCopy.fromJsonValue(json['mode'] as String?),
trayAvailable: json['trayAvailable'] as bool? ?? false,
trayEnabled: json['trayEnabled'] as bool? ?? config.trayEnabled,
autostartEnabled: json['autostartEnabled'] as bool? ?? false,
networkManagerAvailable:
json['networkManagerAvailable'] as bool? ?? false,
systemProxy: SystemProxyState.fromJson(
(json['systemProxy'] as Map?)?.cast<String, dynamic>() ?? const {},
config: config,
),
tunnel: TunnelSessionState.fromJson(
(json['tunnel'] as Map?)?.cast<String, dynamic>() ?? const {},
config: config,
),
statusMessage: json['statusMessage'] as String? ?? '',
);
}
}
class GatewayConnectionProfile {
const GatewayConnectionProfile({
required this.mode,
@ -526,6 +893,7 @@ class SettingsSnapshot {
required this.accountUsername,
required this.accountWorkspace,
required this.accountLocalMode,
required this.linuxDesktop,
required this.assistantExecutionTarget,
required this.assistantPermissionLevel,
required this.assistantNavigationDestinations,
@ -554,6 +922,7 @@ class SettingsSnapshot {
final String accountUsername;
final String accountWorkspace;
final bool accountLocalMode;
final LinuxDesktopConfig linuxDesktop;
final AssistantExecutionTarget assistantExecutionTarget;
final AssistantPermissionLevel assistantPermissionLevel;
final List<WorkspaceDestination> assistantNavigationDestinations;
@ -583,6 +952,7 @@ class SettingsSnapshot {
accountUsername: '',
accountWorkspace: 'Default Workspace',
accountLocalMode: true,
linuxDesktop: LinuxDesktopConfig.defaults(),
assistantExecutionTarget: AssistantExecutionTarget.local,
assistantPermissionLevel: AssistantPermissionLevel.defaultAccess,
assistantNavigationDestinations: kAssistantNavigationDestinationDefaults,
@ -613,6 +983,7 @@ class SettingsSnapshot {
String? accountUsername,
String? accountWorkspace,
bool? accountLocalMode,
LinuxDesktopConfig? linuxDesktop,
AssistantExecutionTarget? assistantExecutionTarget,
AssistantPermissionLevel? assistantPermissionLevel,
List<WorkspaceDestination>? assistantNavigationDestinations,
@ -641,6 +1012,7 @@ class SettingsSnapshot {
accountUsername: accountUsername ?? this.accountUsername,
accountWorkspace: accountWorkspace ?? this.accountWorkspace,
accountLocalMode: accountLocalMode ?? this.accountLocalMode,
linuxDesktop: linuxDesktop ?? this.linuxDesktop,
assistantExecutionTarget:
assistantExecutionTarget ?? this.assistantExecutionTarget,
assistantPermissionLevel:
@ -676,6 +1048,7 @@ class SettingsSnapshot {
'accountUsername': accountUsername,
'accountWorkspace': accountWorkspace,
'accountLocalMode': accountLocalMode,
'linuxDesktop': linuxDesktop.toJson(),
'assistantExecutionTarget': assistantExecutionTarget.name,
'assistantPermissionLevel': assistantPermissionLevel.name,
'assistantNavigationDestinations': assistantNavigationDestinations
@ -753,6 +1126,9 @@ class SettingsSnapshot {
json['accountWorkspace'] as String? ??
SettingsSnapshot.defaults().accountWorkspace,
accountLocalMode: json['accountLocalMode'] as bool? ?? true,
linuxDesktop: LinuxDesktopConfig.fromJson(
(json['linuxDesktop'] as Map?)?.cast<String, dynamic>() ?? const {},
),
assistantExecutionTarget: AssistantExecutionTargetCopy.fromJsonValue(
json['assistantExecutionTarget'] as String?,
),

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1b3a57"/>
<stop offset="100%" stop-color="#2c7da0"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
<path d="M64 84h54l28 38 28-38h18l-37 50 37 52h-54l-28-38-28 38H64l37-52z" fill="#f6f7eb"/>
<circle cx="188" cy="72" r="16" fill="#f4d35e"/>

After

Width:  |  Height:  |  Size: 461 B

View File

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=XWorkmate
Exec=/opt/xworkmate/xworkmate
Icon=xworkmate
Terminal=false
NoDisplay=true
X-GNOME-Autostart-enabled=true

View File

@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=XWorkmate
Comment=Desktop AI workspace shell with Linux proxy and tunnel integration
Exec=/opt/xworkmate/xworkmate
Icon=xworkmate
Terminal=false
Categories=Network;Utility;Development;
StartupNotify=true

View File

@ -7,6 +7,7 @@ project(runner LANGUAGES CXX)
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"desktop_platform_channel.cc"
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
@ -23,4 +24,9 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
set_source_files_properties(
desktop_platform_channel.cc
PROPERTIES COMPILE_OPTIONS "-Wno-deprecated-declarations"
)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")

View File

@ -0,0 +1,734 @@
#include "desktop_platform_channel.h"
#include <errno.h>
#include <glib.h>
#include <glib/gstdio.h>
#include <limits.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <memory>
#include <optional>
#include <regex>
#include <sstream>
#include <string>
#include <vector>
namespace {
constexpr char kChannelName[] = "plus.svc.xworkmate/desktop_platform";
constexpr char kDesktopFileId[] = "plus.svc.xworkmate.desktop";
struct CommandResult {
bool ok = false;
int exit_status = -1;
std::string stdout_text;
std::string stderr_text;
};
struct DesktopPlatformChannel {
MyApplication* application;
GtkWindow* window;
FlView* view;
FlMethodChannel* channel;
GtkStatusIcon* status_icon;
GtkWidget* menu;
std::string preferred_mode = "proxy";
std::string vpn_connection_name = "XWorkmate Tunnel";
std::string proxy_host = "127.0.0.1";
int proxy_port = 7890;
bool tray_enabled = true;
bool tray_available = true;
bool autostart_enabled = false;
bool network_manager_available = false;
std::string desktop_environment = "unknown";
std::string status_message;
};
std::string json_escape(const std::string& input) {
std::ostringstream escaped;
for (const char ch : input) {
switch (ch) {
case '\\':
escaped << "\\\\";
break;
case '"':
escaped << "\\\"";
break;
case '\n':
escaped << "\\n";
break;
default:
escaped << ch;
break;
}
}
return escaped.str();
}
std::string trim_quotes(const std::string& value) {
if (value.size() >= 2 && value.front() == '"' && value.back() == '"') {
return value.substr(1, value.size() - 2);
}
return value;
}
std::optional<std::string> json_string(const std::string& payload,
const std::string& key) {
const std::regex pattern("\"" + key + "\"\\s*:\\s*\"((?:\\\\.|[^\"])*)\"");
std::smatch match;
if (!std::regex_search(payload, match, pattern) || match.size() < 2) {
return std::nullopt;
}
std::string value = match[1].str();
value = std::regex_replace(value, std::regex("\\\\\""), "\"");
value = std::regex_replace(value, std::regex("\\\\\\\\"), "\\");
return value;
}
std::optional<int> json_int(const std::string& payload, const std::string& key) {
const std::regex pattern("\"" + key + "\"\\s*:\\s*(\\d+)");
std::smatch match;
if (!std::regex_search(payload, match, pattern) || match.size() < 2) {
return std::nullopt;
}
return std::stoi(match[1].str());
}
std::optional<bool> json_bool(const std::string& payload,
const std::string& key) {
const std::regex pattern("\"" + key + "\"\\s*:\\s*(true|false)");
std::smatch match;
if (!std::regex_search(payload, match, pattern) || match.size() < 2) {
return std::nullopt;
}
return match[1].str() == "true";
}
CommandResult run_command(const std::vector<std::string>& args) {
std::vector<std::unique_ptr<gchar, decltype(&g_free)>> quoted;
quoted.reserve(args.size());
std::ostringstream command;
for (size_t index = 0; index < args.size(); index++) {
if (index > 0) {
command << ' ';
}
quoted.emplace_back(g_shell_quote(args[index].c_str()), g_free);
command << quoted.back().get();
}
gchar* stdout_text = nullptr;
gchar* stderr_text = nullptr;
gint exit_status = -1;
GError* error = nullptr;
const gboolean ok = g_spawn_command_line_sync(command.str().c_str(),
&stdout_text, &stderr_text,
&exit_status, &error);
CommandResult result;
result.ok = ok && error == nullptr;
result.exit_status = exit_status;
if (stdout_text != nullptr) {
result.stdout_text = stdout_text;
}
if (stderr_text != nullptr) {
result.stderr_text = stderr_text;
}
if (error != nullptr) {
result.stderr_text = error->message;
g_error_free(error);
}
g_free(stdout_text);
g_free(stderr_text);
return result;
}
bool command_succeeds(const std::vector<std::string>& args) {
const CommandResult result = run_command(args);
return result.ok && result.exit_status == 0;
}
std::string detect_desktop_environment() {
const char* current = g_getenv("XDG_CURRENT_DESKTOP");
const std::string desktop = current == nullptr ? "" : current;
std::unique_ptr<gchar, decltype(&g_free)> lowered_raw(
g_ascii_strdown(desktop.c_str(), -1), g_free);
const std::string lowered =
lowered_raw == nullptr ? std::string() : lowered_raw.get();
if (lowered.find("gnome") != std::string::npos) {
return "gnome";
}
if (lowered.find("kde") != std::string::npos ||
lowered.find("plasma") != std::string::npos) {
return "kde";
}
if (g_getenv("KDE_FULL_SESSION") != nullptr) {
return "kde";
}
return "unknown";
}
std::string autostart_path() {
const char* config_home = g_get_user_config_dir();
std::ostringstream path;
path << config_home << "/autostart/" << kDesktopFileId;
return path.str();
}
std::string executable_path() {
gchar buffer[PATH_MAX];
const ssize_t size = readlink("/proc/self/exe", buffer, sizeof(buffer) - 1);
if (size <= 0) {
return "xworkmate";
}
buffer[size] = '\0';
return buffer;
}
bool write_autostart_file() {
const std::string path = autostart_path();
const std::string directory = path.substr(0, path.find_last_of('/'));
if (g_mkdir_with_parents(directory.c_str(), 0755) != 0) {
return false;
}
std::ostringstream contents;
contents << "[Desktop Entry]\n";
contents << "Type=Application\n";
contents << "Version=1.0\n";
contents << "Name=XWorkmate\n";
contents << "Exec=" << executable_path() << "\n";
contents << "Icon=xworkmate\n";
contents << "Terminal=false\n";
contents << "Categories=Network;Utility;\n";
contents << "StartupNotify=true\n";
return g_file_set_contents(path.c_str(), contents.str().c_str(), -1,
nullptr);
}
bool remove_autostart_file() {
return g_remove(autostart_path().c_str()) == 0 || errno == ENOENT;
}
bool autostart_enabled() {
return g_file_test(autostart_path().c_str(), G_FILE_TEST_EXISTS);
}
bool network_manager_available() {
return command_succeeds({"nmcli", "--version"});
}
bool tunnel_profile_exists(const std::string& connection_name) {
const CommandResult result = run_command(
{"nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"});
if (!result.ok || result.exit_status != 0) {
return false;
}
std::istringstream lines(result.stdout_text);
std::string line;
while (std::getline(lines, line)) {
if (line.rfind(connection_name + ":", 0) == 0) {
return true;
}
}
return false;
}
bool tunnel_connected(const std::string& connection_name) {
const CommandResult result = run_command(
{"nmcli", "-t", "-f", "NAME", "connection", "show", "--active"});
if (!result.ok || result.exit_status != 0) {
return false;
}
std::istringstream lines(result.stdout_text);
std::string line;
while (std::getline(lines, line)) {
if (line == connection_name) {
return true;
}
}
return false;
}
std::string gsettings_read(const std::vector<std::string>& args) {
const CommandResult result = run_command(args);
if (!result.ok || result.exit_status != 0) {
return "";
}
std::string value = result.stdout_text;
value.erase(value.find_last_not_of(" \n\r\t") + 1);
return trim_quotes(value);
}
bool apply_gnome_proxy(const DesktopPlatformChannel* self) {
const bool mode_ok = command_succeeds({
"gsettings", "set", "org.gnome.system.proxy", "mode", "manual"});
const bool http_host = command_succeeds({
"gsettings", "set", "org.gnome.system.proxy.http", "host",
self->proxy_host});
const bool http_port = command_succeeds({
"gsettings", "set", "org.gnome.system.proxy.http", "port",
std::to_string(self->proxy_port)});
const bool https_host = command_succeeds({
"gsettings", "set", "org.gnome.system.proxy.https", "host",
self->proxy_host});
const bool https_port = command_succeeds({
"gsettings", "set", "org.gnome.system.proxy.https", "port",
std::to_string(self->proxy_port)});
const bool socks_host = command_succeeds({
"gsettings", "set", "org.gnome.system.proxy.socks", "host",
self->proxy_host});
const bool socks_port = command_succeeds({
"gsettings", "set", "org.gnome.system.proxy.socks", "port",
std::to_string(self->proxy_port)});
return mode_ok && http_host && http_port && https_host && https_port &&
socks_host && socks_port;
}
bool disable_gnome_proxy() {
return command_succeeds(
{"gsettings", "set", "org.gnome.system.proxy", "mode", "none"});
}
bool apply_kde_proxy(const DesktopPlatformChannel* self) {
const bool type_ok = command_succeeds({
"kwriteconfig5", "--file", "kioslaverc", "--group",
"Proxy Settings", "--key", "ProxyType", "1"});
const std::string proxy_value =
"http://" + self->proxy_host + " " + std::to_string(self->proxy_port);
const bool http_ok = command_succeeds({
"kwriteconfig5", "--file", "kioslaverc", "--group",
"Proxy Settings", "--key", "httpProxy", proxy_value});
const bool https_ok = command_succeeds({
"kwriteconfig5", "--file", "kioslaverc", "--group",
"Proxy Settings", "--key", "httpsProxy", proxy_value});
const bool socks_ok = command_succeeds({
"kwriteconfig5", "--file", "kioslaverc", "--group",
"Proxy Settings", "--key", "socksProxy", proxy_value});
command_succeeds({"qdbus", "org.kde.KIO", "/KIO/Scheduler",
"org.kde.KIO.Scheduler.reparseConfiguration", ""});
return type_ok && http_ok && https_ok && socks_ok;
}
bool disable_kde_proxy() {
const bool ok = command_succeeds({
"kwriteconfig5", "--file", "kioslaverc", "--group", "Proxy Settings",
"--key", "ProxyType", "0"});
command_succeeds({"qdbus", "org.kde.KIO", "/KIO/Scheduler",
"org.kde.KIO.Scheduler.reparseConfiguration", ""});
return ok;
}
bool apply_proxy_mode(DesktopPlatformChannel* self) {
if (self->desktop_environment == "gnome") {
return apply_gnome_proxy(self);
}
if (self->desktop_environment == "kde") {
return apply_kde_proxy(self);
}
return false;
}
bool disable_system_proxy(DesktopPlatformChannel* self) {
if (self->desktop_environment == "gnome") {
return disable_gnome_proxy();
}
if (self->desktop_environment == "kde") {
return disable_kde_proxy();
}
return false;
}
std::string gnome_proxy_mode() {
return gsettings_read(
{"gsettings", "get", "org.gnome.system.proxy", "mode"});
}
std::string gnome_proxy_host(const std::string& group) {
const std::string schema = "org.gnome.system.proxy." + group;
return gsettings_read({"gsettings", "get", schema, "host"});
}
int gnome_proxy_port(const std::string& group) {
const std::string schema = "org.gnome.system.proxy." + group;
const std::string value =
gsettings_read({"gsettings", "get", schema, "port"});
return value.empty() ? 0 : std::atoi(value.c_str());
}
std::string kde_proxy_value(const char* key) {
const CommandResult result = run_command({"kreadconfig5", "--file", "kioslaverc",
"--group", "Proxy Settings",
"--key", key});
if (!result.ok || result.exit_status != 0) {
return "";
}
std::string value = result.stdout_text;
value.erase(value.find_last_not_of(" \n\r\t") + 1);
return value;
}
void refresh_runtime_state(DesktopPlatformChannel* self) {
self->desktop_environment = detect_desktop_environment();
self->network_manager_available = network_manager_available();
self->autostart_enabled = autostart_enabled();
}
std::string state_json(DesktopPlatformChannel* self) {
refresh_runtime_state(self);
bool proxy_enabled = false;
std::string proxy_backend;
std::string proxy_host = self->proxy_host;
int proxy_port = self->proxy_port;
if (self->desktop_environment == "gnome") {
proxy_backend = "gsettings";
proxy_enabled = gnome_proxy_mode() == "manual";
if (proxy_enabled) {
const std::string detected_host = gnome_proxy_host("http");
const int detected_port = gnome_proxy_port("http");
if (!detected_host.empty()) {
proxy_host = detected_host;
}
if (detected_port > 0) {
proxy_port = detected_port;
}
}
} else if (self->desktop_environment == "kde") {
proxy_backend = "kioslaverc";
const std::string detected = kde_proxy_value("httpProxy");
proxy_enabled = !detected.empty();
if (proxy_enabled) {
const std::regex pattern(R"(http://([^ ]+)\s+(\d+))");
std::smatch match;
if (std::regex_search(detected, match, pattern) && match.size() >= 3) {
proxy_host = match[1].str();
proxy_port = std::stoi(match[2].str());
}
}
}
const bool tunnel_available =
self->network_manager_available &&
tunnel_profile_exists(self->vpn_connection_name);
const bool tunnel_is_connected =
tunnel_available && tunnel_connected(self->vpn_connection_name);
const std::string mode =
tunnel_is_connected ? "tunnel" : (proxy_enabled ? "proxy" : self->preferred_mode);
std::ostringstream json;
json << "{";
json << "\"isSupported\":true,";
json << "\"environment\":\"" << json_escape(self->desktop_environment) << "\",";
json << "\"mode\":\"" << json_escape(mode) << "\",";
json << "\"trayAvailable\":" << (self->tray_available ? "true" : "false") << ",";
json << "\"trayEnabled\":" << (self->tray_enabled ? "true" : "false") << ",";
json << "\"autostartEnabled\":" << (self->autostart_enabled ? "true" : "false") << ",";
json << "\"networkManagerAvailable\":"
<< (self->network_manager_available ? "true" : "false") << ",";
json << "\"systemProxy\":{";
json << "\"enabled\":" << (proxy_enabled ? "true" : "false") << ",";
json << "\"host\":\"" << json_escape(proxy_host) << "\",";
json << "\"port\":" << proxy_port << ",";
json << "\"backend\":\"" << json_escape(proxy_backend) << "\",";
json << "\"lastAppliedMode\":\"" << json_escape(self->preferred_mode) << "\"";
json << "},";
json << "\"tunnel\":{";
json << "\"available\":" << (tunnel_available ? "true" : "false") << ",";
json << "\"connected\":" << (tunnel_is_connected ? "true" : "false") << ",";
json << "\"connectionName\":\"" << json_escape(self->vpn_connection_name) << "\",";
json << "\"backend\":\"nmcli\",";
json << "\"lastError\":\"" << json_escape(self->status_message) << "\"";
json << "},";
json << "\"statusMessage\":\"" << json_escape(self->status_message) << "\"";
json << "}";
return json.str();
}
void update_status_icon(DesktopPlatformChannel* self) {
if (self->status_icon == nullptr) {
return;
}
gtk_status_icon_set_visible(self->status_icon, self->tray_enabled);
gtk_status_icon_set_from_icon_name(self->status_icon, "network-vpn-symbolic");
const std::string json = state_json(self);
const std::string tooltip =
"XWorkmate • " + self->desktop_environment + "" + self->preferred_mode;
gtk_status_icon_set_tooltip_text(self->status_icon, tooltip.c_str());
}
void show_window(DesktopPlatformChannel* self) {
gtk_widget_show_all(GTK_WIDGET(self->window));
gtk_window_present(self->window);
}
void on_open_activate(GtkMenuItem*, gpointer user_data) {
show_window(static_cast<DesktopPlatformChannel*>(user_data));
}
void on_status_icon_activate(GtkStatusIcon*, gpointer user_data) {
show_window(static_cast<DesktopPlatformChannel*>(user_data));
}
void on_quit_activate(GtkMenuItem*, gpointer user_data) {
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
g_application_quit(G_APPLICATION(self->application));
}
void on_use_proxy_activate(GtkMenuItem*, gpointer user_data) {
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
self->preferred_mode = "proxy";
if (!apply_proxy_mode(self)) {
self->status_message =
"Failed to apply system proxy; verify gsettings/kwriteconfig5";
} else {
self->status_message = "System proxy enabled";
}
update_status_icon(self);
}
void on_use_tunnel_activate(GtkMenuItem*, gpointer user_data) {
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
self->preferred_mode = "tunnel";
if (!disable_system_proxy(self)) {
self->status_message = "Tunnel mode selected; proxy disable may require manual follow-up";
} else {
self->status_message = "Tunnel mode selected";
}
update_status_icon(self);
}
void on_connect_tunnel_activate(GtkMenuItem*, gpointer user_data) {
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
self->preferred_mode = "tunnel";
disable_system_proxy(self);
if (!command_succeeds({"nmcli", "connection", "up", "id",
self->vpn_connection_name})) {
self->status_message = "Failed to connect NetworkManager tunnel";
} else {
self->status_message = "Tunnel connected";
}
update_status_icon(self);
}
void on_disconnect_tunnel_activate(GtkMenuItem*, gpointer user_data) {
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
if (!command_succeeds({"nmcli", "connection", "down", "id",
self->vpn_connection_name})) {
self->status_message = "Failed to disconnect tunnel";
} else {
self->status_message = "Tunnel disconnected";
}
update_status_icon(self);
}
void on_status_icon_popup(GtkStatusIcon* status_icon,
guint button,
guint activate_time,
gpointer user_data) {
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
gtk_menu_popup(GTK_MENU(self->menu), nullptr, nullptr,
gtk_status_icon_position_menu, status_icon, button,
activate_time);
}
GtkWidget* build_menu(DesktopPlatformChannel* self) {
GtkWidget* menu = gtk_menu_new();
GtkWidget* open_item = gtk_menu_item_new_with_label("Open XWorkmate");
GtkWidget* connect_item = gtk_menu_item_new_with_label("Connect Tunnel");
GtkWidget* disconnect_item = gtk_menu_item_new_with_label("Disconnect Tunnel");
GtkWidget* proxy_item = gtk_menu_item_new_with_label("Use Proxy Mode");
GtkWidget* tunnel_item = gtk_menu_item_new_with_label("Use Tunnel Mode");
GtkWidget* quit_item = gtk_menu_item_new_with_label("Quit");
g_signal_connect(open_item, "activate", G_CALLBACK(on_open_activate), self);
g_signal_connect(connect_item, "activate",
G_CALLBACK(on_connect_tunnel_activate), self);
g_signal_connect(disconnect_item, "activate",
G_CALLBACK(on_disconnect_tunnel_activate), self);
g_signal_connect(proxy_item, "activate", G_CALLBACK(on_use_proxy_activate),
self);
g_signal_connect(tunnel_item, "activate", G_CALLBACK(on_use_tunnel_activate),
self);
g_signal_connect(quit_item, "activate", G_CALLBACK(on_quit_activate), self);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), open_item);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), connect_item);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), disconnect_item);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), proxy_item);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), tunnel_item);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new());
gtk_menu_shell_append(GTK_MENU_SHELL(menu), quit_item);
gtk_widget_show_all(menu);
return menu;
}
void ensure_status_icon(DesktopPlatformChannel* self) {
if (self->status_icon == nullptr) {
self->status_icon = gtk_status_icon_new();
self->menu = build_menu(self);
g_signal_connect(self->status_icon, "popup-menu",
G_CALLBACK(on_status_icon_popup), self);
g_signal_connect(self->status_icon, "activate",
G_CALLBACK(on_status_icon_activate),
self);
}
update_status_icon(self);
}
FlMethodResponse* success_response_with_json(const std::string& payload) {
g_autoptr(FlValue) result = fl_value_new_string(payload.c_str());
return FL_METHOD_RESPONSE(fl_method_success_response_new(result));
}
FlMethodResponse* method_error(const char* code, const std::string& message) {
return FL_METHOD_RESPONSE(
fl_method_error_response_new(code, message.c_str(), nullptr));
}
FlMethodResponse* handle_method_call(DesktopPlatformChannel* self,
FlMethodCall* method_call) {
const gchar* method = fl_method_call_get_name(method_call);
FlValue* args = fl_method_call_get_args(method_call);
if (strcmp(method, "getState") == 0) {
return success_response_with_json(state_json(self));
}
if (strcmp(method, "configure") == 0) {
const char* payload = args == nullptr ? nullptr : fl_value_get_string(args);
const std::string json = payload == nullptr ? "" : payload;
if (const auto value = json_string(json, "preferredMode"); value.has_value()) {
self->preferred_mode = *value;
}
if (const auto value = json_string(json, "vpnConnectionName"); value.has_value()) {
self->vpn_connection_name = *value;
}
if (const auto value = json_string(json, "proxyHost"); value.has_value()) {
self->proxy_host = *value;
}
if (const auto value = json_int(json, "proxyPort"); value.has_value()) {
self->proxy_port = *value;
}
if (const auto value = json_bool(json, "trayEnabled"); value.has_value()) {
self->tray_enabled = *value;
}
ensure_status_icon(self);
return success_response_with_json(state_json(self));
}
if (strcmp(method, "setMode") == 0) {
const char* value = args == nullptr ? nullptr : fl_value_get_string(args);
if (value == nullptr) {
return method_error("INVALID_ARGS", "mode is required");
}
self->preferred_mode = value;
if (self->preferred_mode == "proxy") {
if (!apply_proxy_mode(self)) {
self->status_message = "Failed to apply system proxy";
} else {
self->status_message = "System proxy enabled";
}
} else {
disable_system_proxy(self);
self->status_message = "Tunnel mode selected";
}
update_status_icon(self);
return success_response_with_json(state_json(self));
}
if (strcmp(method, "connectTunnel") == 0) {
self->preferred_mode = "tunnel";
disable_system_proxy(self);
if (!command_succeeds({"nmcli", "connection", "up", "id",
self->vpn_connection_name})) {
return method_error("NM_CONNECT_FAILED",
"Failed to connect NetworkManager tunnel");
}
self->status_message = "Tunnel connected";
update_status_icon(self);
return success_response_with_json(state_json(self));
}
if (strcmp(method, "disconnectTunnel") == 0) {
if (!command_succeeds({"nmcli", "connection", "down", "id",
self->vpn_connection_name})) {
return method_error("NM_DISCONNECT_FAILED", "Failed to disconnect tunnel");
}
self->status_message = "Tunnel disconnected";
update_status_icon(self);
return success_response_with_json(state_json(self));
}
if (strcmp(method, "setAutostart") == 0) {
const bool enabled = args != nullptr && fl_value_get_bool(args);
const bool ok = enabled ? write_autostart_file() : remove_autostart_file();
if (!ok) {
return method_error("AUTOSTART_FAILED", "Failed to update autostart");
}
self->status_message =
enabled ? "Autostart enabled" : "Autostart disabled";
update_status_icon(self);
return success_response_with_json(state_json(self));
}
if (strcmp(method, "showWindow") == 0) {
show_window(self);
return success_response_with_json(state_json(self));
}
return FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
}
void method_call_cb(FlMethodChannel* channel,
FlMethodCall* method_call,
gpointer user_data) {
auto* self = static_cast<DesktopPlatformChannel*>(user_data);
g_autoptr(FlMethodResponse) response = handle_method_call(self, method_call);
GError* error = nullptr;
if (!fl_method_call_respond(method_call, response, &error) && error != nullptr) {
g_warning("Failed to send response: %s", error->message);
g_error_free(error);
}
}
} // namespace
DesktopPlatformChannel* desktop_platform_channel_new(MyApplication* application,
GtkWindow* window,
FlView* view) {
auto* self = new DesktopPlatformChannel();
self->application = application;
self->window = window;
self->view = view;
self->desktop_environment = detect_desktop_environment();
ensure_status_icon(self);
FlEngine* engine = fl_view_get_engine(view);
FlBinaryMessenger* messenger = fl_engine_get_binary_messenger(engine);
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
self->channel =
fl_method_channel_new(messenger, kChannelName, FL_METHOD_CODEC(codec));
fl_method_channel_set_method_call_handler(self->channel, method_call_cb, self,
nullptr);
return self;
}
void desktop_platform_channel_free(DesktopPlatformChannel* channel) {
if (channel == nullptr) {
return;
}
if (channel->status_icon != nullptr) {
gtk_status_icon_set_visible(channel->status_icon, FALSE);
g_object_unref(channel->status_icon);
}
if (channel->menu != nullptr) {
gtk_widget_destroy(channel->menu);
}
if (channel->channel != nullptr) {
g_object_unref(channel->channel);
}
delete channel;
}

View File

@ -0,0 +1,23 @@
#ifndef RUNNER_DESKTOP_PLATFORM_CHANNEL_H_
#define RUNNER_DESKTOP_PLATFORM_CHANNEL_H_
#include <gtk/gtk.h>
#include <flutter_linux/flutter_linux.h>
typedef struct _MyApplication MyApplication;
G_BEGIN_DECLS
typedef struct _DesktopPlatformChannel DesktopPlatformChannel;
DesktopPlatformChannel* desktop_platform_channel_new(
MyApplication* application,
GtkWindow* window,
FlView* view);
void desktop_platform_channel_free(DesktopPlatformChannel* channel);
G_END_DECLS
#endif // RUNNER_DESKTOP_PLATFORM_CHANNEL_H_

View File

@ -5,11 +5,13 @@
#include <gdk/gdkx.h>
#endif
#include "desktop_platform_channel.h"
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
DesktopPlatformChannel* desktop_platform_channel;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
@ -75,6 +77,8 @@ static void my_application_activate(GApplication* application) {
gtk_widget_realize(GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
self->desktop_platform_channel =
desktop_platform_channel_new(self, window, view);
gtk_widget_grab_focus(GTK_WIDGET(view));
}
@ -111,9 +115,10 @@ static void my_application_startup(GApplication* application) {
// Implements GApplication::shutdown.
static void my_application_shutdown(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
MyApplication* self = MY_APPLICATION(application);
// Perform any actions required at application shutdown.
desktop_platform_channel_free(self->desktop_platform_channel);
self->desktop_platform_channel = nullptr;
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
}

10
scripts/linux-postinst.sh Normal file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q /usr/share/icons/hicolor >/dev/null 2>&1 || true
fi

10
scripts/linux-postrm.sh Normal file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q /usr/share/icons/hicolor >/dev/null 2>&1 || true
fi

View File

@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
app_name="xworkmate"
version="$(python3 - <<'PY'
from pathlib import Path
import re
text = Path("pubspec.yaml").read_text()
match = re.search(r"^version:\s*([^\n+]+)", text, re.M)
print(match.group(1) if match else "0.0.0")
PY
)"
build_dir="$repo_root/build/linux/x64/release/bundle"
stage_dir="$repo_root/build/linux/deb-stage"
out_dir="$repo_root/dist/linux"
cd "$repo_root"
flutter build linux --release
rm -rf "$stage_dir"
mkdir -p "$stage_dir/DEBIAN"
mkdir -p "$stage_dir/opt/$app_name"
mkdir -p "$stage_dir/usr/share/applications"
mkdir -p "$stage_dir/usr/share/icons/hicolor/scalable/apps"
mkdir -p "$stage_dir/usr/share/$app_name/autostart"
cp -R "$build_dir/." "$stage_dir/opt/$app_name/"
cp "$repo_root/linux/packaging/xworkmate.desktop" \
"$stage_dir/usr/share/applications/$app_name.desktop"
cp "$repo_root/linux/packaging/xworkmate-autostart.desktop" \
"$stage_dir/usr/share/$app_name/autostart/$app_name.desktop"
cp "$repo_root/linux/packaging/icons/xworkmate.svg" \
"$stage_dir/usr/share/icons/hicolor/scalable/apps/$app_name.svg"
cp "$repo_root/scripts/linux-postinst.sh" "$stage_dir/DEBIAN/postinst"
cp "$repo_root/scripts/linux-postrm.sh" "$stage_dir/DEBIAN/postrm"
chmod 0755 "$stage_dir/DEBIAN/postinst" "$stage_dir/DEBIAN/postrm"
cat > "$stage_dir/DEBIAN/control" <<EOF
Package: $app_name
Version: $version
Section: utils
Priority: optional
Architecture: amd64
Maintainer: XWorkmate
Depends: network-manager, libgtk-3-0
Description: XWorkmate Linux desktop shell with GNOME/KDE proxy and tunnel integration
EOF
mkdir -p "$out_dir"
dpkg-deb --build "$stage_dir" "$out_dir/${app_name}_${version}_amd64.deb"

View File

@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
app_name="xworkmate"
version="$(python3 - <<'PY'
from pathlib import Path
import re
text = Path("pubspec.yaml").read_text()
match = re.search(r"^version:\s*([^\n+]+)", text, re.M)
print(match.group(1) if match else "0.0.0")
PY
)"
bundle_dir="$repo_root/build/linux/x64/release/bundle"
rpm_root="$repo_root/build/linux/rpm"
spec_file="$rpm_root/SPECS/${app_name}.spec"
out_dir="$repo_root/dist/linux"
cd "$repo_root"
flutter build linux --release
rm -rf "$rpm_root"
mkdir -p "$rpm_root/BUILD" "$rpm_root/RPMS" "$rpm_root/SOURCES" \
"$rpm_root/SPECS" "$rpm_root/SRPMS"
cp -R "$bundle_dir" "$rpm_root/SOURCES/bundle"
cp "$repo_root/linux/packaging/xworkmate.desktop" \
"$rpm_root/SOURCES/$app_name.desktop"
cp "$repo_root/linux/packaging/xworkmate-autostart.desktop" \
"$rpm_root/SOURCES/$app_name-autostart.desktop"
cp "$repo_root/linux/packaging/icons/xworkmate.svg" \
"$rpm_root/SOURCES/$app_name.svg"
cat > "$spec_file" <<EOF
Name: $app_name
Version: $version
Release: 1%{?dist}
Summary: XWorkmate Linux desktop shell
License: Proprietary
BuildArch: x86_64
Requires: NetworkManager, gtk3
%description
XWorkmate Linux desktop shell with GNOME/KDE proxy and tunnel integration.
%install
mkdir -p %{buildroot}/opt/$app_name
mkdir -p %{buildroot}/usr/share/applications
mkdir -p %{buildroot}/usr/share/icons/hicolor/scalable/apps
mkdir -p %{buildroot}/usr/share/$app_name/autostart
cp -a %{_sourcedir}/bundle/. %{buildroot}/opt/$app_name/
cp %{_sourcedir}/$app_name.desktop %{buildroot}/usr/share/applications/$app_name.desktop
cp %{_sourcedir}/$app_name-autostart.desktop %{buildroot}/usr/share/$app_name/autostart/$app_name.desktop
cp %{_sourcedir}/$app_name.svg %{buildroot}/usr/share/icons/hicolor/scalable/apps/$app_name.svg
%post
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
gtk-update-icon-cache -q /usr/share/icons/hicolor >/dev/null 2>&1 || true
%postun
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
gtk-update-icon-cache -q /usr/share/icons/hicolor >/dev/null 2>&1 || true
%files
/opt/$app_name
/usr/share/applications/$app_name.desktop
/usr/share/icons/hicolor/scalable/apps/$app_name.svg
/usr/share/$app_name/autostart/$app_name.desktop
EOF
mkdir -p "$out_dir"
rpmbuild --define "_topdir $rpm_root" --define "__spec_install_post %{nil}" \
-bb "$spec_file"
find "$rpm_root/RPMS" -name '*.rpm' -exec cp {} "$out_dir/" \;

7
scripts/package-linux.sh Normal file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
"$repo_root/scripts/package-linux-deb.sh"
"$repo_root/scripts/package-linux-rpm.sh"

View File

@ -1,10 +1,122 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/features/settings/settings_page.dart';
import 'package:xworkmate/runtime/desktop_platform_service.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import '../test_support.dart';
class _DesktopServiceStub implements DesktopPlatformService {
@override
DesktopIntegrationState get state =>
DesktopIntegrationState.fromJson(const <String, dynamic>{
'isSupported': true,
'environment': 'kde',
'mode': 'proxy',
'trayAvailable': true,
'trayEnabled': true,
'autostartEnabled': false,
'networkManagerAvailable': true,
'systemProxy': {
'enabled': true,
'host': '127.0.0.1',
'port': 7890,
'backend': 'kioslaverc',
'lastAppliedMode': 'proxy',
},
'tunnel': {
'available': true,
'connected': false,
'connectionName': 'XWorkmate Tunnel',
'backend': 'nmcli',
'lastError': '',
},
'statusMessage': '',
});
@override
bool get isSupported => state.isSupported;
@override
Future<void> initialize(LinuxDesktopConfig config) async {}
@override
Future<void> syncConfig(LinuxDesktopConfig config) async {}
@override
Future<void> refresh() async {}
@override
Future<void> setMode(VpnMode mode) async {}
@override
Future<void> connectTunnel() async {}
@override
Future<void> disconnectTunnel() async {}
@override
Future<void> setLaunchAtLogin(bool enabled) async {}
@override
void dispose() {}
}
void main() {
testWidgets('SettingsPage theme chips update controller theme mode', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(tester, child: SettingsPage(controller: controller));
await tester.tap(find.text('外观'));
await tester.pumpAndSettle();
await tester.tap(find.text('深色'));
await tester.pumpAndSettle();
expect(controller.themeMode, ThemeMode.dark);
await tester.tap(find.text('浅色'));
await tester.pumpAndSettle();
expect(controller.themeMode, ThemeMode.light);
});
testWidgets('SettingsPage gateway tab exposes device pairing controls', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(tester, child: SettingsPage(controller: controller));
await tester.tap(find.text('集成'));
await tester.pumpAndSettle();
expect(find.text('打开连接面板'), findsOneWidget);
expect(
find.byKey(const ValueKey('gateway-device-security-card')),
findsOneWidget,
);
});
testWidgets('SettingsPage shows Linux desktop integration controls', (
WidgetTester tester,
) async {
final controller = await createTestController(
tester,
desktopPlatformService: _DesktopServiceStub(),
);
await pumpPage(tester, child: SettingsPage(controller: controller));
expect(
find.byKey(const ValueKey('linux-desktop-integration-card')),
findsOneWidget,
);
expect(find.text('Linux 桌面集成'), findsOneWidget);
expect(find.text('切换到代理'), findsOneWidget);
expect(find.text('连接隧道'), findsOneWidget);
});
testWidgets('SettingsPage diagnostics tab filters and clears runtime logs', (
WidgetTester tester,
) async {
@ -44,6 +156,6 @@ void main() {
await tester.tap(find.text('清空'));
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('当前没有运行日志。'), findsOneWidget);
expect(controller.runtimeLogs, isEmpty);
});
}

View File

@ -0,0 +1,137 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/runtime/desktop_platform_service.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
class _FakeDesktopPlatformService implements DesktopPlatformService {
_FakeDesktopPlatformService()
: _state = DesktopIntegrationState.fromJson(const <String, dynamic>{
'isSupported': true,
'environment': 'gnome',
'mode': 'proxy',
'trayAvailable': true,
'trayEnabled': true,
'autostartEnabled': false,
'networkManagerAvailable': true,
'systemProxy': {
'enabled': true,
'host': '127.0.0.1',
'port': 7890,
'backend': 'gsettings',
'lastAppliedMode': 'proxy',
},
'tunnel': {
'available': true,
'connected': false,
'connectionName': 'XWorkmate Tunnel',
'backend': 'nmcli',
'lastError': '',
},
'statusMessage': '',
});
DesktopIntegrationState _state;
LinuxDesktopConfig config = LinuxDesktopConfig.defaults();
bool autostartEnabled = false;
@override
DesktopIntegrationState get state =>
_state.copyWith(autostartEnabled: autostartEnabled);
@override
bool get isSupported => state.isSupported;
@override
Future<void> initialize(LinuxDesktopConfig config) async {
this.config = config;
}
@override
Future<void> syncConfig(LinuxDesktopConfig config) async {
this.config = config;
_state = _state.copyWith(
mode: config.preferredMode,
trayEnabled: config.trayEnabled,
tunnel: _state.tunnel.copyWith(connectionName: config.vpnConnectionName),
systemProxy: _state.systemProxy.copyWith(
host: config.proxyHost,
port: config.proxyPort,
),
);
}
@override
Future<void> refresh() async {}
@override
Future<void> setMode(VpnMode mode) async {
_state = _state.copyWith(
mode: mode,
systemProxy: _state.systemProxy.copyWith(enabled: mode == VpnMode.proxy),
);
}
@override
Future<void> connectTunnel() async {
_state = _state.copyWith(
mode: VpnMode.tunnel,
tunnel: _state.tunnel.copyWith(connected: true),
systemProxy: _state.systemProxy.copyWith(enabled: false),
);
}
@override
Future<void> disconnectTunnel() async {
_state = _state.copyWith(tunnel: _state.tunnel.copyWith(connected: false));
}
@override
Future<void> setLaunchAtLogin(bool enabled) async {
autostartEnabled = enabled;
}
@override
void dispose() {}
}
void main() {
test(
'AppController syncs Linux desktop settings into platform service',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final service = _FakeDesktopPlatformService();
final controller = AppController(desktopPlatformService: service);
addTearDown(controller.dispose);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(controller.supportsDesktopIntegration, isTrue);
expect(
controller.desktopIntegration.environment,
DesktopEnvironment.gnome,
);
await controller.saveLinuxDesktopConfig(
controller.settings.linuxDesktop.copyWith(
vpnConnectionName: 'Corp Tunnel',
proxyHost: '10.0.0.2',
proxyPort: 8080,
),
);
expect(service.config.vpnConnectionName, 'Corp Tunnel');
expect(service.config.proxyHost, '10.0.0.2');
expect(service.config.proxyPort, 8080);
await controller.setDesktopVpnMode(VpnMode.tunnel);
expect(controller.desktopIntegration.mode, VpnMode.tunnel);
await controller.connectDesktopTunnel();
expect(controller.desktopIntegration.tunnel.connected, isTrue);
await controller.setLaunchAtLogin(true);
expect(service.autostartEnabled, isTrue);
},
);
}

View File

@ -7,8 +7,12 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/theme/app_theme.dart';
import 'package:xworkmate/runtime/desktop_platform_service.dart';
Future<AppController> createTestController(WidgetTester tester) async {
Future<AppController> createTestController(
WidgetTester tester, {
DesktopPlatformService? desktopPlatformService,
}) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final controller = AppController(
store: SecureConfigStore(
@ -16,6 +20,7 @@ Future<AppController> createTestController(WidgetTester tester) async {
fallbackDirectoryPathResolver: () async =>
'${Directory.systemTemp.path}/xworkmate-widget-tests',
),
desktopPlatformService: desktopPlatformService,
);
addTearDown(controller.dispose);
await tester.pump(const Duration(milliseconds: 100));