Compare commits
No commits in common. "22c2d5edd8a4b78d4881a011d76ef655c2a66ed3" and "61091b6d566bed9d0fb3efa468d9b47ea2213284" have entirely different histories.
22c2d5edd8
...
61091b6d56
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
|
@ -26,11 +26,11 @@ class OstpApp extends StatelessWidget {
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
scaffoldBackgroundColor: const Color(0xFF000000),
|
scaffoldBackgroundColor: const Color(0xFF08080F),
|
||||||
colorScheme: const ColorScheme.dark(
|
colorScheme: const ColorScheme.dark(
|
||||||
primary: Color(0xFFFFFFFF),
|
primary: Color(0xFF6C72FF),
|
||||||
secondary: Color(0xFFAAAAAA),
|
secondary: Color(0xFF22D3A5),
|
||||||
surface: Color(0xFF111111),
|
surface: Color(0xFF151522),
|
||||||
),
|
),
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
/// A saved server profile. Field shape mirrors the desktop GUI's profile
|
|
||||||
/// object (ostp-gui/src/main.js) 1:1 — server/key/transport/tcp_fragmentation/
|
|
||||||
/// frag_chunk/frag_sleep/junk_pc/junk_ps — so behavior matches across
|
|
||||||
/// platforms. `wss` was dropped: the core no longer supports TLS-mimicry
|
|
||||||
/// transports (only plain UDP / UoT), so there is nothing left to carry it.
|
|
||||||
class OstpProfile {
|
|
||||||
String id;
|
|
||||||
String name;
|
|
||||||
String serverAddr;
|
|
||||||
String accessKey;
|
|
||||||
String transportMode; // 'udp' | 'uot'
|
|
||||||
String stealthSni;
|
|
||||||
bool active;
|
|
||||||
|
|
||||||
// Junk packets + TCP fragmentation — per-profile, exactly like ostp-gui's
|
|
||||||
// profile editor. Defaults match ostp_client::config::TransportConfig's
|
|
||||||
// own defaults (frag_chunk=2, frag_sleep=2, junk_pc=[2,5], junk_ps=[100,1000]).
|
|
||||||
bool tcpFragmentation;
|
|
||||||
int fragChunk;
|
|
||||||
int fragSleep;
|
|
||||||
int junkPcMin;
|
|
||||||
int junkPcMax;
|
|
||||||
int junkPsMin;
|
|
||||||
int junkPsMax;
|
|
||||||
|
|
||||||
OstpProfile({
|
|
||||||
required this.id,
|
|
||||||
required this.name,
|
|
||||||
required this.serverAddr,
|
|
||||||
required this.accessKey,
|
|
||||||
this.transportMode = 'udp',
|
|
||||||
this.stealthSni = '',
|
|
||||||
this.active = false,
|
|
||||||
this.tcpFragmentation = false,
|
|
||||||
this.fragChunk = 2,
|
|
||||||
this.fragSleep = 2,
|
|
||||||
this.junkPcMin = 2,
|
|
||||||
this.junkPcMax = 5,
|
|
||||||
this.junkPsMin = 100,
|
|
||||||
this.junkPsMax = 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'id': id,
|
|
||||||
'name': name,
|
|
||||||
'serverAddr': serverAddr,
|
|
||||||
'accessKey': accessKey,
|
|
||||||
'transportMode': transportMode,
|
|
||||||
'stealthSni': stealthSni,
|
|
||||||
'active': active,
|
|
||||||
'tcpFragmentation': tcpFragmentation,
|
|
||||||
'fragChunk': fragChunk,
|
|
||||||
'fragSleep': fragSleep,
|
|
||||||
'junkPcMin': junkPcMin,
|
|
||||||
'junkPcMax': junkPcMax,
|
|
||||||
'junkPsMin': junkPsMin,
|
|
||||||
'junkPsMax': junkPsMax,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
factory OstpProfile.fromJson(Map<String, dynamic> json) {
|
|
||||||
return OstpProfile(
|
|
||||||
id: json['id'] as String? ?? '',
|
|
||||||
name: json['name'] as String? ?? 'Unnamed Profile',
|
|
||||||
serverAddr: json['serverAddr'] as String? ?? '',
|
|
||||||
accessKey: json['accessKey'] as String? ?? '',
|
|
||||||
transportMode: json['transportMode'] as String? ?? 'udp',
|
|
||||||
stealthSni: json['stealthSni'] as String? ?? '',
|
|
||||||
active: json['active'] as bool? ?? false,
|
|
||||||
tcpFragmentation: json['tcpFragmentation'] as bool? ?? false,
|
|
||||||
fragChunk: json['fragChunk'] as int? ?? 2,
|
|
||||||
fragSleep: json['fragSleep'] as int? ?? 2,
|
|
||||||
junkPcMin: json['junkPcMin'] as int? ?? 2,
|
|
||||||
junkPcMax: json['junkPcMax'] as int? ?? 5,
|
|
||||||
junkPsMin: json['junkPsMin'] as int? ?? 100,
|
|
||||||
junkPsMax: json['junkPsMax'] as int? ?? 1000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<OstpProfile> decodeProfiles(String? json) {
|
|
||||||
if (json == null || json.isEmpty) return [];
|
|
||||||
try {
|
|
||||||
final List<dynamic> decoded = jsonDecode(json);
|
|
||||||
return decoded.map((e) => OstpProfile.fromJson(e)).toList();
|
|
||||||
} catch (_) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String encodeProfiles(List<OstpProfile> profiles) =>
|
|
||||||
jsonEncode(profiles.map((e) => e.toJson()).toList());
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
import '../models/connection_state_enum.dart';
|
import '../models/connection_state_enum.dart';
|
||||||
import '../models/ostp_profile.dart';
|
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
import 'logs_screen.dart';
|
||||||
|
import 'app_routing_screen.dart';
|
||||||
|
import 'qr_scanner_screen.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
final SharedPreferences prefs;
|
final SharedPreferences prefs;
|
||||||
|
|
@ -18,17 +22,15 @@ class HomeScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
static const platform = MethodChannel('com.ospab.ostp/vpn');
|
static const platform = MethodChannel('com.ospab.ostp/vpn');
|
||||||
|
|
||||||
ConnectionStateEnum _state = ConnectionStateEnum.disconnected;
|
ConnectionStateEnum _state = ConnectionStateEnum.disconnected;
|
||||||
Timer? _pollTimer;
|
Timer? _pollTimer;
|
||||||
Timer? _uptimeTimer;
|
Timer? _uptimeTimer;
|
||||||
int _uptimeSecs = 0;
|
int _uptimeSecs = 0;
|
||||||
|
|
||||||
// Single active profile — the core only ever connects to one server at a
|
String _serverAddr = '127.0.0.1:443';
|
||||||
// time (no multi-server/urltest failover since the 0.4.x flat config),
|
String _accessKey = 'default_key';
|
||||||
// matching how the desktop GUI picks exactly one profile as `activeId`.
|
|
||||||
OstpProfile? _activeProfile;
|
|
||||||
|
|
||||||
String _download = '0 B';
|
String _download = '0 B';
|
||||||
String _upload = '0 B';
|
String _upload = '0 B';
|
||||||
|
|
||||||
|
|
@ -65,45 +67,40 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
debugPrint("Failed to check initial state: $e");
|
debugPrint("Failed to check initial state: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadSettings() {
|
void _loadSettings() {
|
||||||
setState(() {
|
setState(() {
|
||||||
final profiles = decodeProfiles(widget.prefs.getString('profiles_json'));
|
_serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443';
|
||||||
// Single-select: if more than one is somehow marked active (shouldn't
|
_accessKey = widget.prefs.getString('access_key') ?? '';
|
||||||
// happen — the editor enforces exclusivity — but don't crash on stale data).
|
|
||||||
final actives = profiles.where((p) => p.active).toList();
|
|
||||||
_activeProfile = actives.isNotEmpty ? actives.first : null;
|
|
||||||
});
|
});
|
||||||
_updateLatestConfigJson();
|
_updateLatestConfigJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the exact JSON the native core (ostp-jni) deserializes as
|
void _updateLatestConfigJson() {
|
||||||
/// `ostp_client::config::ClientConfig`. Field names/nesting must match that
|
|
||||||
/// struct precisely — unknown keys are silently ignored by serde, so a typo
|
|
||||||
/// here doesn't fail loudly, it just quietly does nothing.
|
|
||||||
Map<String, dynamic> _buildConfigMap() {
|
|
||||||
final p = _activeProfile;
|
|
||||||
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
||||||
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
||||||
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
|
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
|
||||||
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
||||||
|
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
|
||||||
|
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
|
||||||
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
||||||
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||||
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
|
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
|
||||||
final dnsServer = widget.prefs.getString('dns_server');
|
final dnsServer = widget.prefs.getString('dns_server');
|
||||||
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
|
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
|
||||||
const tunStack = 'ostp';
|
final tunStack = 'ostp';
|
||||||
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
||||||
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
|
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
|
||||||
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
|
|
||||||
|
|
||||||
return {
|
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
|
||||||
|
final configMap = {
|
||||||
"mode": "client",
|
"mode": "client",
|
||||||
"debug": debugMode,
|
"debug": debugMode,
|
||||||
"ostp": {
|
"ostp": {
|
||||||
"server_addr": p?.serverAddr ?? '',
|
"server_addr": _serverAddr,
|
||||||
"local_bind_addr": "0.0.0.0:0",
|
"local_bind_addr": "0.0.0.0:0",
|
||||||
"access_key": p?.accessKey ?? '',
|
"access_key": _accessKey,
|
||||||
"handshake_timeout_ms": 10000,
|
"handshake_timeout_ms": 10000,
|
||||||
"io_timeout_ms": 5000,
|
"io_timeout_ms": 5000,
|
||||||
"mtu": int.tryParse(mtu) ?? 1140,
|
"mtu": int.tryParse(mtu) ?? 1140,
|
||||||
|
|
@ -112,21 +109,18 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
"bind_addr": localBind,
|
"bind_addr": localBind,
|
||||||
"connect_timeout_ms": 15000,
|
"connect_timeout_ms": 15000,
|
||||||
},
|
},
|
||||||
// Junk packets + TCP fragmentation are per-profile settings — same
|
|
||||||
// shape as the desktop GUI's profile object — not global toggles.
|
|
||||||
"transport": {
|
"transport": {
|
||||||
"mode": p?.transportMode ?? 'udp',
|
"mode": transportMode,
|
||||||
"stealth_sni": (p?.stealthSni.isNotEmpty ?? false) ? p!.stealthSni : 'vk.com',
|
"stealth_sni": stealthSni,
|
||||||
"tcp_fragmentation": p?.tcpFragmentation ?? false,
|
|
||||||
"frag_chunk": p?.fragChunk ?? 2,
|
|
||||||
"frag_sleep": p?.fragSleep ?? 2,
|
|
||||||
"junk_pc": [p?.junkPcMin ?? 2, p?.junkPcMax ?? 5],
|
|
||||||
"junk_ps": [p?.junkPsMin ?? 100, p?.junkPsMax ?? 1000],
|
|
||||||
},
|
},
|
||||||
"multiplex": {
|
"multiplex": {
|
||||||
"enabled": muxEnabled,
|
"enabled": muxEnabled,
|
||||||
"sessions": int.tryParse(muxSessions) ?? 2,
|
"sessions": int.tryParse(muxSessions) ?? 2,
|
||||||
},
|
},
|
||||||
|
"tun": {
|
||||||
|
"enable": true,
|
||||||
|
"stack": tunStack
|
||||||
|
},
|
||||||
"exclusions": {
|
"exclusions": {
|
||||||
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
|
|
@ -137,14 +131,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
"packages": appRoutingPackages,
|
"packages": appRoutingPackages,
|
||||||
},
|
},
|
||||||
"dns_server": effectiveDnsServer,
|
"dns_server": effectiveDnsServer,
|
||||||
"tun_stack": tunStack,
|
"tun_stack": tunStack
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
void _updateLatestConfigJson() {
|
|
||||||
final configMap = _buildConfigMap();
|
|
||||||
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
|
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
|
||||||
platform.invokeMethod('saveConfig', {"configJson": jsonEncode(configMap)});
|
platform.invokeMethod('saveConfig', {
|
||||||
|
"configJson": jsonEncode(configMap)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -158,27 +150,87 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
|
|
||||||
Future<void> _toggleConnection() async {
|
Future<void> _toggleConnection() async {
|
||||||
if (_state == ConnectionStateEnum.disconnected) {
|
if (_state == ConnectionStateEnum.disconnected) {
|
||||||
if (_activeProfile == null || _activeProfile!.serverAddr.isEmpty || _activeProfile!.accessKey.isEmpty) {
|
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Please select or add a profile in Settings')),
|
const SnackBar(content: Text('Please configure Server and Key in Settings')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_state = ConnectionStateEnum.connecting;
|
_state = ConnectionStateEnum.connecting;
|
||||||
});
|
});
|
||||||
_pulseController.repeat(reverse: true);
|
_pulseController.repeat(reverse: true);
|
||||||
_spinController.repeat();
|
_spinController.repeat();
|
||||||
|
|
||||||
final configMap = _buildConfigMap();
|
final dnsServer = widget.prefs.getString('dns_server');
|
||||||
final configStr = jsonEncode(configMap);
|
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
|
||||||
widget.prefs.setString('latest_config_json', configStr);
|
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
||||||
|
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
||||||
|
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
|
||||||
|
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
||||||
|
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
|
||||||
|
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
|
||||||
|
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
||||||
|
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||||
|
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
|
||||||
|
final tunStack = 'ostp';
|
||||||
|
|
||||||
|
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
||||||
|
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
|
||||||
|
|
||||||
|
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
|
||||||
|
final configMap = {
|
||||||
|
"mode": "client",
|
||||||
|
"debug": debugMode,
|
||||||
|
"ostp": {
|
||||||
|
"server_addr": _serverAddr,
|
||||||
|
"local_bind_addr": "0.0.0.0:0",
|
||||||
|
"access_key": _accessKey,
|
||||||
|
"handshake_timeout_ms": 10000,
|
||||||
|
"io_timeout_ms": 5000,
|
||||||
|
"mtu": int.tryParse(mtu) ?? 1140,
|
||||||
|
},
|
||||||
|
"local_proxy": {
|
||||||
|
"bind_addr": localBind,
|
||||||
|
"connect_timeout_ms": 15000,
|
||||||
|
},
|
||||||
|
"transport": {
|
||||||
|
"mode": transportMode,
|
||||||
|
"stealth_sni": stealthSni,
|
||||||
|
},
|
||||||
|
"multiplex": {
|
||||||
|
"enabled": muxEnabled,
|
||||||
|
"sessions": int.tryParse(muxSessions) ?? 2,
|
||||||
|
},
|
||||||
|
"tun": {
|
||||||
|
"enable": true,
|
||||||
|
"stack": tunStack
|
||||||
|
},
|
||||||
|
"exclusions": {
|
||||||
|
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
|
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
|
"processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
|
},
|
||||||
|
"app_rules": {
|
||||||
|
"mode": appRoutingMode,
|
||||||
|
"packages": appRoutingPackages,
|
||||||
|
},
|
||||||
|
"dns_server": dnsServer,
|
||||||
|
"tun_stack": tunStack
|
||||||
|
};
|
||||||
|
|
||||||
|
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await platform.invokeMethod('saveConfig', {"configJson": configStr});
|
await platform.invokeMethod('saveConfig', {
|
||||||
await platform.invokeMethod('startTunnel', {"configJson": configStr});
|
"configJson": jsonEncode(configMap)
|
||||||
|
});
|
||||||
|
await platform.invokeMethod('startTunnel', {
|
||||||
|
"configJson": jsonEncode(configMap)
|
||||||
|
});
|
||||||
|
|
||||||
bool started = false;
|
bool started = false;
|
||||||
for (int i = 0; i < 10; i++) {
|
for (int i = 0; i < 10; i++) {
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
@ -188,7 +240,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (started) {
|
if (started) {
|
||||||
_setConnected();
|
_setConnected();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -237,34 +289,30 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cycles transport mode x MTU to find a working combination against the
|
|
||||||
/// active profile's server. WSS/Reality are gone (the core dropped
|
|
||||||
/// TLS-mimicry transports entirely — see §A), so this only has udp/uot x
|
|
||||||
/// MTU left to probe; junk/frag stay at whatever the active profile has set.
|
|
||||||
Future<void> _runAutoMode() async {
|
Future<void> _runAutoMode() async {
|
||||||
final mtus = [1500, 1350, 1280, 1140];
|
final mtus = [1500, 1350, 1280, 1140];
|
||||||
final modes = ['udp', 'uot'];
|
final modes = [
|
||||||
|
{'t': 'udp'},
|
||||||
|
{'t': 'uot'},
|
||||||
|
];
|
||||||
|
|
||||||
final active = _activeProfile;
|
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
|
||||||
if (active == null || active.serverAddr.isEmpty || active.accessKey.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Please select a profile with a server and key first')),
|
const SnackBar(content: Text('Please configure Server and Key first')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final originalMode = active.transportMode;
|
for (var mode in modes) {
|
||||||
final originalMtu = widget.prefs.getString('mtu') ?? '1140';
|
for (var mtu in mtus) {
|
||||||
|
|
||||||
for (final mode in modes) {
|
|
||||||
for (final mtu in mtus) {
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Testing: $mode | MTU: $mtu'), duration: const Duration(seconds: 2)),
|
SnackBar(content: Text('Testing: ${mode['t']} | MTU: $mtu'), duration: const Duration(seconds: 2)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update prefs
|
||||||
await widget.prefs.setString('mtu', mtu.toString());
|
await widget.prefs.setString('mtu', mtu.toString());
|
||||||
active.transportMode = mode;
|
await widget.prefs.setString('transport_mode', mode['t'] as String);
|
||||||
_updateLatestConfigJson();
|
_updateLatestConfigJson();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -289,6 +337,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
|
|
||||||
if (started) {
|
if (started) {
|
||||||
_setConnected();
|
_setConnected();
|
||||||
|
// Wait to see if connection is stable and ping is successful
|
||||||
await Future.delayed(const Duration(seconds: 3));
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
try {
|
try {
|
||||||
final metricsJson = await platform.invokeMethod('getMetrics');
|
final metricsJson = await platform.invokeMethod('getMetrics');
|
||||||
|
|
@ -296,37 +345,30 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
|
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
|
||||||
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
||||||
if (rttMs > 0) {
|
if (rttMs > 0) {
|
||||||
// Working combo found — persist it onto the profile.
|
|
||||||
_persistActiveProfile();
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Success! Found working config: $mode (MTU $mtu)')),
|
SnackBar(content: Text('Success! Found working config: ${mode['t']} (MTU $mtu)')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return; // Stop on first working config
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
// Ignore metrics error, fall through to try next combo.
|
// Ignore metrics error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection seems unstable or no ping, stop and try next
|
||||||
await platform.invokeMethod('stopTunnel');
|
await platform.invokeMethod('stopTunnel');
|
||||||
_setDisconnected();
|
_setDisconnected();
|
||||||
} else {
|
} else {
|
||||||
_setDisconnected();
|
_setDisconnected();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
_setDisconnected();
|
_setDisconnected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No working combo found — revert the active profile/mtu to what they
|
|
||||||
// were before probing so we don't leave it on a broken guess.
|
|
||||||
active.transportMode = originalMode;
|
|
||||||
await widget.prefs.setString('mtu', originalMtu);
|
|
||||||
_updateLatestConfigJson();
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Auto search finished. No working config found.')),
|
const SnackBar(content: Text('Auto search finished. No working config found.')),
|
||||||
|
|
@ -334,25 +376,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _persistActiveProfile() {
|
|
||||||
final active = _activeProfile;
|
|
||||||
if (active == null) return;
|
|
||||||
final profiles = decodeProfiles(widget.prefs.getString('profiles_json'));
|
|
||||||
final idx = profiles.indexWhere((p) => p.id == active.id);
|
|
||||||
if (idx >= 0) {
|
|
||||||
profiles[idx] = active;
|
|
||||||
widget.prefs.setString('profiles_json', encodeProfiles(profiles));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setConnected() {
|
void _setConnected() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_state = ConnectionStateEnum.connected;
|
_state = ConnectionStateEnum.connected;
|
||||||
});
|
});
|
||||||
_pulseController.stop();
|
_pulseController.stop();
|
||||||
_pulseController.value = 1.0;
|
_pulseController.value = 1.0;
|
||||||
|
|
||||||
_uptimeSecs = 0;
|
_uptimeSecs = 0;
|
||||||
_uptimeTimer?.cancel();
|
_uptimeTimer?.cancel();
|
||||||
_uptimeTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
_uptimeTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
|
|
@ -367,7 +398,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
try {
|
try {
|
||||||
final isRunning = await platform.invokeMethod('isRunning');
|
final isRunning = await platform.invokeMethod('isRunning');
|
||||||
|
|
||||||
if (isRunning == true && _state == ConnectionStateEnum.disconnected) {
|
if (isRunning == true && _state == ConnectionStateEnum.disconnected) {
|
||||||
_setConnected();
|
_setConnected();
|
||||||
} else if (isRunning == false && _state == ConnectionStateEnum.connected) {
|
} else if (isRunning == false && _state == ConnectionStateEnum.connected) {
|
||||||
|
|
@ -382,7 +413,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
final bytesRecv = parsed['bytes_recv'] as int? ?? 0;
|
final bytesRecv = parsed['bytes_recv'] as int? ?? 0;
|
||||||
final connState = parsed['connection_state'] as int? ?? 2;
|
final connState = parsed['connection_state'] as int? ?? 2;
|
||||||
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
||||||
|
|
||||||
if (connState == 0) {
|
if (connState == 0) {
|
||||||
try {
|
try {
|
||||||
await platform.invokeMethod('stopTunnel');
|
await platform.invokeMethod('stopTunnel');
|
||||||
|
|
@ -397,7 +428,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_download = _formatBytes(bytesRecv);
|
_download = _formatBytes(bytesRecv);
|
||||||
|
|
@ -431,15 +462,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
|
|
||||||
Future<void> _checkConnectionLatency() async {
|
Future<void> _checkConnectionLatency() async {
|
||||||
if (_state != ConnectionStateEnum.connected) return;
|
if (_state != ConnectionStateEnum.connected) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isCheckingPing = true;
|
_isCheckingPing = true;
|
||||||
_pingText = 'Updating...';
|
_pingText = 'Updating...';
|
||||||
_pingColor = Colors.white70;
|
_pingColor = Colors.white70;
|
||||||
});
|
});
|
||||||
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isCheckingPing = false;
|
_isCheckingPing = false;
|
||||||
|
|
@ -475,23 +506,39 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned(
|
||||||
child: Opacity(
|
top: -150, right: -100,
|
||||||
opacity: 0.03,
|
child: Container(
|
||||||
child: Center(
|
width: 400, height: 400,
|
||||||
child: Image.asset(
|
decoration: BoxDecoration(
|
||||||
'assets/logo.png',
|
shape: BoxShape.circle,
|
||||||
width: MediaQuery.of(context).size.shortestSide * 0.6,
|
color: theme.colorScheme.primary.withOpacity(0.15),
|
||||||
color: Colors.white,
|
),
|
||||||
),
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
|
||||||
|
child: Container(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: -100, left: -100,
|
||||||
|
child: Container(
|
||||||
|
width: 350, height: 350,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: theme.colorScheme.secondary.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
|
||||||
|
child: Container(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
|
@ -530,13 +577,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
width: 12, height: 12,
|
width: 12, height: 12,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
color: _state == ConnectionStateEnum.connected
|
color: _state == ConnectionStateEnum.connected
|
||||||
? theme.colorScheme.secondary
|
? theme.colorScheme.secondary
|
||||||
: theme.colorScheme.primary,
|
: theme.colorScheme.primary,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: _state == ConnectionStateEnum.connected
|
color: _state == ConnectionStateEnum.connected
|
||||||
? theme.colorScheme.secondary.withOpacity(0.5)
|
? theme.colorScheme.secondary.withOpacity(0.5)
|
||||||
: theme.colorScheme.primary.withOpacity(0.5),
|
: theme.colorScheme.primary.withOpacity(0.5),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
)
|
)
|
||||||
|
|
@ -630,7 +677,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
animation: _pulseController,
|
animation: _pulseController,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
|
|
@ -675,9 +722,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
_state == ConnectionStateEnum.disconnected ? 'Disconnected' :
|
_state == ConnectionStateEnum.disconnected ? 'Disconnected' :
|
||||||
_state == ConnectionStateEnum.connecting ? 'Connecting...' : 'Connected',
|
_state == ConnectionStateEnum.connecting ? 'Connecting...' : 'Connected',
|
||||||
|
|
@ -695,102 +742,104 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
color: Colors.white54,
|
color: Colors.white54,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withOpacity(0.08),
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.15)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
_activeProfile?.name ?? 'No profile selected',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.white70,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
AnimatedOpacity(
|
AnimatedOpacity(
|
||||||
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.only(top: 16),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Container(
|
children: [
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.03),
|
color: Colors.white.withOpacity(0.08),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(30),
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
border: Border.all(color: Colors.white.withOpacity(0.15)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
|
||||||
child: Column(
|
const SizedBox(width: 10),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text(
|
||||||
children: [
|
_serverAddr,
|
||||||
const Text(
|
style: const TextStyle(
|
||||||
'CONNECTION TEST',
|
fontFamily: 'monospace',
|
||||||
style: TextStyle(
|
fontSize: 15,
|
||||||
fontSize: 10,
|
fontWeight: FontWeight.w600,
|
||||||
fontWeight: FontWeight.bold,
|
color: Colors.white70,
|
||||||
color: Colors.white38,
|
),
|
||||||
letterSpacing: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_pingText,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: _pingColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
_isCheckingPing
|
),
|
||||||
? const SizedBox(
|
const SizedBox(height: 16),
|
||||||
width: 20, height: 20,
|
Container(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70),
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
)
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
: TextButton.icon(
|
decoration: BoxDecoration(
|
||||||
onPressed: _checkConnectionLatency,
|
color: Colors.white.withOpacity(0.03),
|
||||||
icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary),
|
borderRadius: BorderRadius.circular(20),
|
||||||
label: Text(
|
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
||||||
'Test Ping',
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'CONNECTION TEST',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 13,
|
color: Colors.white38,
|
||||||
color: theme.colorScheme.primary,
|
letterSpacing: 0.8,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: TextButton.styleFrom(
|
const SizedBox(height: 4),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
Text(
|
||||||
backgroundColor: theme.colorScheme.primary.withOpacity(0.1),
|
_pingText,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _pingColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_isCheckingPing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20, height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70),
|
||||||
|
)
|
||||||
|
: TextButton.icon(
|
||||||
|
onPressed: _checkConnectionLatency,
|
||||||
|
icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary),
|
||||||
|
label: Text(
|
||||||
|
'Test Ping',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
backgroundColor: theme.colorScheme.primary.withOpacity(0.1),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|
@ -862,3 +911,4 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,9 @@ flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
assets:
|
# assets:
|
||||||
- assets/logo.png
|
# - images/a_dot_burr.jpeg
|
||||||
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
|
||||||
239
scripts/gha.ps1
|
|
@ -1,79 +1,63 @@
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Cuts a new OSTP release and pushes the tag that triggers the matching
|
Cuts a new OSTP release and pushes it to the channel that triggers the
|
||||||
GitHub Actions build (see .github/workflows/release.yml, which only
|
matching GitHub Actions build (see .github/workflows/release.yml).
|
||||||
triggers on "v*" tag pushes + workflow_dispatch — a bare branch push
|
|
||||||
does NOT start a build).
|
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
A release cycle has ONE fixed target version (e.g. "0.4.1") that stays in
|
Three release channels, in increasing order of stability:
|
||||||
Cargo.toml/tauri.conf.json/package.json unchanged through every alpha and
|
alpha -> pushes the `alpha` branch -> tag "{version}-alpha"
|
||||||
beta build — only a per-channel ITERATION counter increments, and that
|
pre-release -> pushes the `pre-release` branch -> tag "{version}-beta"
|
||||||
counter lives ONLY in the git tag, never in the manifests:
|
master -> pushes an actual "v{version}" tag -> real stable release
|
||||||
|
|
||||||
v0.4.1-alpha.1 -> v0.4.1-alpha.2 -> ... -> v0.4.1-alpha.100
|
Promoting to pre-release/master first fast-forwards that branch to
|
||||||
v0.4.1-beta.1 -> v0.4.1-beta.2 -> ... -> v0.4.1-beta.100
|
`alpha` (--ff-only - this always succeeds cleanly as long as nobody ever
|
||||||
v0.4.1 <- master: iteration dropped
|
commits directly to pre-release/master, per CONTRIBUTING.md's branch
|
||||||
|
strategy), so a release always ships alpha's latest, not a stale branch.
|
||||||
|
|
||||||
This is deliberately NOT "0.4.1.5-alpha" (a 4th dot-separated component
|
Remembers the last {version, branch, prefix} it used in .release-state.json
|
||||||
before the hyphen) — that is not valid semver, and Cargo's version parser
|
at the repo root. Running with no arguments repeats last time's branch and
|
||||||
rejects it outright. "0.4.1-alpha.5" (dot AFTER the hyphen, a semver
|
prefix, auto-incrementing the patch version. -Switch starts a new version
|
||||||
pre-release identifier) is the only form that keeps Cargo.toml itself
|
line (e.g. 0.3.x -> 0.4.0) without changing branch/prefix. -Branch/-Prefix
|
||||||
parseable, so that's the only place the iteration number is allowed to
|
override just that one setting for this run (and become the new default).
|
||||||
live: the git tag.
|
|
||||||
|
|
||||||
Promoting to beta/master first fast-forwards that branch to `alpha`
|
|
||||||
(--ff-only — this always succeeds cleanly as long as nobody ever commits
|
|
||||||
directly to pre-release/master, per CONTRIBUTING.md's branch strategy), so
|
|
||||||
a release always ships alpha's latest, not a stale branch. Switching to a
|
|
||||||
channel for the first time in a cycle resets THAT channel's iteration
|
|
||||||
counter to 1 (a fresh promotion starts its own count; it doesn't inherit
|
|
||||||
wherever alpha's counter happened to be).
|
|
||||||
|
|
||||||
Remembers {target_version, branch, alpha_iteration, beta_iteration} in
|
|
||||||
.release-state.json at the repo root. Running with no arguments repeats
|
|
||||||
last time's branch, bumping that channel's iteration by one — manifests
|
|
||||||
are NOT touched (nothing to bump: the target version hasn't changed).
|
|
||||||
-Switch starts a new target version line and resets both iteration
|
|
||||||
counters to 0 — THIS is the one case that bumps every manifest.
|
|
||||||
|
|
||||||
.PARAMETER Switch
|
.PARAMETER Switch
|
||||||
Set a new target version (e.g. "0.4.2") instead of continuing the current
|
Set an exact version (e.g. "0.4.0") instead of auto-incrementing the patch
|
||||||
one. Resets both alpha_iteration and beta_iteration to 0. Defaults the
|
of the last released version. Becomes the new baseline for future bare runs.
|
||||||
channel back to alpha unless -Branch is also given this run.
|
|
||||||
|
|
||||||
.PARAMETER Branch
|
.PARAMETER Branch
|
||||||
Which branch/channel to release from: master, pre-release (beta), or alpha.
|
Which branch to release from: master, pre-release, or alpha.
|
||||||
Defaults to whatever was used last time (see .release-state.json).
|
Defaults to whatever was used last time (see .release-state.json).
|
||||||
|
|
||||||
|
.PARAMETER Prefix
|
||||||
|
Tag suffix for non-stable channels: beta or alpha. Ignored (forced empty)
|
||||||
|
when -Branch master, since stable releases are bare "vX.Y.Z" tags.
|
||||||
|
Defaults to whatever was used last time.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\scripts\gha.ps1
|
.\scripts\gha.ps1
|
||||||
Bumps the current channel's iteration by one and pushes v{target}-{channel}.{N}.
|
Re-releases the same branch/prefix as last time, with the patch version bumped by 1.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\scripts\gha.ps1 -Switch 0.4.2
|
.\scripts\gha.ps1 -Switch 0.4.0
|
||||||
Starts a fresh 0.4.2 cycle: manifests -> 0.4.2, alpha iteration resets to 1,
|
Starts releasing the 0.4.x line from now on; this run ships exactly 0.4.0.
|
||||||
ships v0.4.2-alpha.1.
|
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\scripts\gha.ps1 -Branch pre-release
|
.\scripts\gha.ps1 -Branch pre-release -Prefix beta
|
||||||
Promotes alpha -> pre-release (beta channel), resets beta_iteration to 1
|
Promotes alpha -> pre-release and ships "{version}-beta".
|
||||||
(or bumps it if already mid-beta), ships v{target}-beta.{N}.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\scripts\gha.ps1 -Branch master
|
|
||||||
Promotes to master and ships the bare v{target} stable tag — no iteration.
|
|
||||||
#>
|
#>
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[string]$Switch,
|
[string]$Switch,
|
||||||
[ValidateSet('master', 'pre-release', 'alpha')]
|
[ValidateSet('master', 'beta', 'alpha')]
|
||||||
[string]$Branch
|
[string]$Branch,
|
||||||
|
[ValidateSet('beta', 'alpha')]
|
||||||
|
[string]$Prefix
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
|
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
|
||||||
|
function Write-Warn2($msg) { Write-Host "!! $msg" -ForegroundColor Yellow }
|
||||||
function Fail($msg) { Write-Host "ERROR: $msg" -ForegroundColor Red; exit 1 }
|
function Fail($msg) { Write-Host "ERROR: $msg" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
# -- Locate repo root, regardless of where this script was invoked from ------
|
# -- Locate repo root, regardless of where this script was invoked from ------
|
||||||
|
|
@ -91,56 +75,38 @@ if ($dirty) {
|
||||||
Fail "Working tree has uncommitted changes. Commit or stash them first."
|
Fail "Working tree has uncommitted changes. Commit or stash them first."
|
||||||
}
|
}
|
||||||
|
|
||||||
# -- Load remembered state ----------------------------------------------------
|
# -- Load remembered state (branch/prefix/version from the last release) ----
|
||||||
$State = $null
|
$State = $null
|
||||||
if (Test-Path $StateFile) {
|
if (Test-Path $StateFile) {
|
||||||
$State = Get-Content $StateFile -Raw | ConvertFrom-Json
|
$State = Get-Content $StateFile -Raw | ConvertFrom-Json
|
||||||
}
|
}
|
||||||
|
|
||||||
$PrevBranch = if ($State) { $State.branch } else { $null }
|
$ResolvedBranch = if ($Branch) { $Branch } elseif ($State) { $State.branch } else { "alpha" }
|
||||||
$IsNewTarget = [bool]$Switch
|
$ResolvedPrefix = if ($Prefix) { $Prefix } elseif ($State) { $State.prefix } else { "alpha" }
|
||||||
|
|
||||||
$ResolvedBranch = if ($Branch) { $Branch } elseif ($PrevBranch) { $PrevBranch } else { "alpha" }
|
# Stable releases are always a bare "vX.Y.Z" tag, never suffixed - master
|
||||||
|
# never carries a prefix regardless of what was remembered or passed in.
|
||||||
# -- Resolve the target version + per-channel iteration counters ------------
|
if ($ResolvedBranch -eq "master") {
|
||||||
if ($IsNewTarget) {
|
if ($Prefix) { Write-Warn2 "-Prefix is ignored for -Branch master (stable releases are bare 'vX.Y.Z' tags)." }
|
||||||
if ($Switch -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { Fail "-Switch must be a bare X.Y.Z version, got '$Switch'." }
|
$ResolvedPrefix = ""
|
||||||
$TargetVersion = $Switch
|
|
||||||
$AlphaIter = 0
|
|
||||||
$BetaIter = 0
|
|
||||||
# A fresh target version starts a fresh cycle at the bottom of the chain,
|
|
||||||
# unless the caller explicitly asked for a different branch this run.
|
|
||||||
if (-not $Branch) { $ResolvedBranch = "alpha" }
|
|
||||||
} else {
|
|
||||||
# NOTE: a pre-migration state file (old schema: {version, branch, prefix})
|
|
||||||
# has no target_version property at all — PowerShell silently returns
|
|
||||||
# $null for a missing property on a PSCustomObject rather than erroring,
|
|
||||||
# so this must check for it explicitly or $TargetVersion would end up
|
|
||||||
# $null and corrupt every manifest below.
|
|
||||||
$TargetVersion = if ($State -and $State.target_version) { $State.target_version } else {
|
|
||||||
(Select-String -Path (Join-Path $RepoRoot "Cargo.toml") -Pattern '^version = "([0-9]+\.[0-9]+\.[0-9]+)"').Matches[0].Groups[1].Value
|
|
||||||
}
|
|
||||||
$AlphaIter = if ($State -and $State.alpha_iteration) { [int]$State.alpha_iteration } else { 0 }
|
|
||||||
$BetaIter = if ($State -and $State.beta_iteration) { [int]$State.beta_iteration } else { 0 }
|
|
||||||
|
|
||||||
# Entering a channel that wasn't active last run (a promotion) starts
|
|
||||||
# THAT channel's count fresh — it doesn't inherit alpha's iteration number.
|
|
||||||
$BranchChanged = ($ResolvedBranch -ne $PrevBranch)
|
|
||||||
if ($BranchChanged -and $ResolvedBranch -eq "pre-release") { $BetaIter = 0 }
|
|
||||||
if ($BranchChanged -and $ResolvedBranch -eq "alpha") { $AlphaIter = 0 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$Channel = switch ($ResolvedBranch) { "alpha" { "alpha" }; "pre-release" { "beta" }; "master" { "stable" } }
|
# -- Resolve the version: exact via -Switch, else auto-increment the patch --
|
||||||
|
$CurrentVersion = if ($State) { $State.version } else {
|
||||||
|
(Select-String -Path (Join-Path $RepoRoot "Cargo.toml") -Pattern '^version = "([0-9]+\.[0-9]+\.[0-9]+)"').Matches[0].Groups[1].Value
|
||||||
|
}
|
||||||
|
|
||||||
if ($Channel -eq "alpha") { $AlphaIter++ }
|
if ($Switch) {
|
||||||
elseif ($Channel -eq "beta") { $BetaIter++ }
|
if ($Switch -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { Fail "-Switch must be a bare X.Y.Z version, got '$Switch'." }
|
||||||
$Iteration = if ($Channel -eq "alpha") { $AlphaIter } else { $BetaIter }
|
$NewVersion = $Switch
|
||||||
|
} else {
|
||||||
|
$parts = $CurrentVersion.Split('.')
|
||||||
|
$NewVersion = "{0}.{1}.{2}" -f $parts[0], $parts[1], ([int]$parts[2] + 1)
|
||||||
|
}
|
||||||
|
|
||||||
$Tag = if ($Channel -eq "stable") { "v$TargetVersion" } else { "v$TargetVersion-$Channel.$Iteration" }
|
Write-Step "Releasing $NewVersion on '$ResolvedBranch'$(if ($ResolvedPrefix) { " (tag suffix: -$ResolvedPrefix)" } else { " (stable, tag v$NewVersion)" })"
|
||||||
|
|
||||||
Write-Step "Releasing $Tag on '$ResolvedBranch'"
|
# -- Checkout the target branch, promoting it from alpha first ------------
|
||||||
|
|
||||||
# -- Checkout the target branch, promoting it from alpha first --------------
|
|
||||||
$CurrentBranch = git rev-parse --abbrev-ref HEAD
|
$CurrentBranch = git rev-parse --abbrev-ref HEAD
|
||||||
if ($CurrentBranch -ne $ResolvedBranch) {
|
if ($CurrentBranch -ne $ResolvedBranch) {
|
||||||
Write-Step "Checking out $ResolvedBranch"
|
Write-Step "Checking out $ResolvedBranch"
|
||||||
|
|
@ -158,77 +124,78 @@ if ($ResolvedBranch -ne "alpha") {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# -- Bump manifests ONLY when the target version itself changes. A plain -----
|
# -- Bump the version across every manifest that carries one ----------------
|
||||||
# -- alpha/beta iteration touches nothing but the state file's counter. -----
|
Write-Step "Bumping version $CurrentVersion -> $NewVersion"
|
||||||
if ($IsNewTarget -or -not $State) {
|
|
||||||
Write-Step "Bumping target version -> $TargetVersion"
|
|
||||||
|
|
||||||
function Set-VersionLine($Path, $Pattern, $Replacement) {
|
function Set-VersionLine($Path, $Pattern, $Replacement) {
|
||||||
$full = Join-Path $RepoRoot $Path
|
$full = Join-Path $RepoRoot $Path
|
||||||
$text = Get-Content $full -Raw
|
$text = Get-Content $full -Raw
|
||||||
$updated = $text -replace $Pattern, $Replacement
|
$updated = $text -replace $Pattern, $Replacement
|
||||||
if ($updated -eq $text) { Fail "Version pattern not found in $Path - refusing to proceed with a stale file." }
|
if ($updated -eq $text) { Fail "Version pattern not found in $Path - refusing to proceed with a stale file." }
|
||||||
[System.IO.File]::WriteAllText($full, $updated)
|
[System.IO.File]::WriteAllText($full, $updated)
|
||||||
}
|
|
||||||
|
|
||||||
Set-VersionLine "Cargo.toml" '(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"' "version = `"$TargetVersion`""
|
|
||||||
Set-VersionLine "ostp-gui/src-tauri/Cargo.toml" '(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"' "version = `"$TargetVersion`""
|
|
||||||
Set-VersionLine "ostp-gui/src-tauri/tauri.conf.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$TargetVersion`""
|
|
||||||
Set-VersionLine "ostp-gui/package.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$TargetVersion`""
|
|
||||||
|
|
||||||
# Refresh Cargo.lock's per-package version entries. ostp-gui/src-tauri is
|
|
||||||
# excluded from the main workspace (its own Tauri build graph), so it has
|
|
||||||
# its own separate Cargo.lock that the main `cargo check` never touches.
|
|
||||||
Write-Step "Running cargo check to refresh Cargo.lock (main workspace)"
|
|
||||||
cargo check --workspace --exclude ostp-jni --quiet
|
|
||||||
if ($LASTEXITCODE -ne 0) { Fail "cargo check failed after the version bump - not committing a broken build." }
|
|
||||||
|
|
||||||
Write-Step "Running cargo check to refresh Cargo.lock (ostp-gui/src-tauri)"
|
|
||||||
Push-Location (Join-Path $RepoRoot "ostp-gui/src-tauri")
|
|
||||||
cargo check --quiet
|
|
||||||
$tauriCheckExit = $LASTEXITCODE
|
|
||||||
Pop-Location
|
|
||||||
if ($tauriCheckExit -ne 0) { Fail "cargo check failed in ostp-gui/src-tauri after the version bump." }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Flutter's build number (Android versionCode) must strictly increase on
|
Set-VersionLine "Cargo.toml" '(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"' "version = `"$NewVersion`""
|
||||||
# every single build ever shipped — unlike the semantic version, it does NOT
|
Set-VersionLine "ostp-gui/src-tauri/Cargo.toml" '(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"' "version = `"$NewVersion`""
|
||||||
# stay fixed across alpha/beta iterations, so this runs every time, not just
|
Set-VersionLine "ostp-gui/src-tauri/tauri.conf.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$NewVersion`""
|
||||||
# on a target-version switch.
|
Set-VersionLine "ostp-gui/package.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$NewVersion`""
|
||||||
|
|
||||||
|
# Flutter build number must increase monotonically (Android versionCode) -
|
||||||
|
# bump it alongside the version string, don't just rewrite the version part.
|
||||||
$pubspecPath = Join-Path $RepoRoot "ostp-flutter/pubspec.yaml"
|
$pubspecPath = Join-Path $RepoRoot "ostp-flutter/pubspec.yaml"
|
||||||
$pubspecText = Get-Content $pubspecPath -Raw
|
$pubspecText = Get-Content $pubspecPath -Raw
|
||||||
if ($pubspecText -match 'version: [0-9]+\.[0-9]+\.[0-9]+\+([0-9]+)') {
|
if ($pubspecText -match 'version: [0-9]+\.[0-9]+\.[0-9]+\+([0-9]+)') {
|
||||||
$nextBuild = [int]$Matches[1] + 1
|
$nextBuild = [int]$Matches[1] + 1
|
||||||
$pubspecText = $pubspecText -replace 'version: [0-9]+\.[0-9]+\.[0-9]+\+[0-9]+', "version: $TargetVersion+$nextBuild"
|
$pubspecText = $pubspecText -replace 'version: [0-9]+\.[0-9]+\.[0-9]+\+[0-9]+', "version: $NewVersion+$nextBuild"
|
||||||
[System.IO.File]::WriteAllText($pubspecPath, $pubspecText)
|
[System.IO.File]::WriteAllText($pubspecPath, $pubspecText)
|
||||||
} else {
|
} else {
|
||||||
Fail "Version pattern not found in ostp-flutter/pubspec.yaml."
|
Fail "Version pattern not found in ostp-flutter/pubspec.yaml."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# -- Refresh Cargo.lock's per-package version entries ------------------------
|
||||||
|
# ostp-gui/src-tauri is excluded from the main workspace (its own Tauri build
|
||||||
|
# graph), so it has its own separate Cargo.lock that the main `cargo check`
|
||||||
|
# below never touches - needs its own pass or it'd drift from Cargo.toml.
|
||||||
|
Write-Step "Running cargo check to refresh Cargo.lock (main workspace)"
|
||||||
|
cargo check --workspace --exclude ostp-jni --quiet
|
||||||
|
if ($LASTEXITCODE -ne 0) { Fail "cargo check failed after the version bump - not committing a broken build." }
|
||||||
|
|
||||||
|
Write-Step "Running cargo check to refresh Cargo.lock (ostp-gui/src-tauri)"
|
||||||
|
Push-Location (Join-Path $RepoRoot "ostp-gui/src-tauri")
|
||||||
|
cargo check --quiet
|
||||||
|
$tauriCheckExit = $LASTEXITCODE
|
||||||
|
Pop-Location
|
||||||
|
if ($tauriCheckExit -ne 0) { Fail "cargo check failed in ostp-gui/src-tauri after the version bump." }
|
||||||
|
|
||||||
# -- Persist the new state ---------------------------------------------------
|
# -- Persist the new state ---------------------------------------------------
|
||||||
[PSCustomObject]@{
|
[PSCustomObject]@{
|
||||||
target_version = $TargetVersion
|
version = $NewVersion
|
||||||
branch = $ResolvedBranch
|
branch = $ResolvedBranch
|
||||||
alpha_iteration = $AlphaIter
|
prefix = $ResolvedPrefix
|
||||||
beta_iteration = $BetaIter
|
|
||||||
} | ConvertTo-Json | Set-Content $StateFile
|
} | ConvertTo-Json | Set-Content $StateFile
|
||||||
|
|
||||||
# -- Commit -------------------------------------------------------------------
|
# -- Commit -------------------------------------------------------------------
|
||||||
$commitMsg = "chore: release $Tag on $ResolvedBranch"
|
$suffixLabel = if ($ResolvedPrefix) { "-$ResolvedPrefix" } else { "" }
|
||||||
|
$commitMsg = "chore: release $NewVersion$suffixLabel on $ResolvedBranch"
|
||||||
Write-Step "Committing: $commitMsg"
|
Write-Step "Committing: $commitMsg"
|
||||||
git add Cargo.toml Cargo.lock ostp-gui/src-tauri/Cargo.toml ostp-gui/src-tauri/Cargo.lock `
|
git add Cargo.toml Cargo.lock ostp-gui/src-tauri/Cargo.toml ostp-gui/src-tauri/Cargo.lock `
|
||||||
ostp-gui/src-tauri/tauri.conf.json ostp-gui/package.json ostp-flutter/pubspec.yaml `
|
ostp-gui/src-tauri/tauri.conf.json ostp-gui/package.json ostp-flutter/pubspec.yaml `
|
||||||
.release-state.json
|
.release-state.json
|
||||||
git commit -m $commitMsg | Out-Null
|
git commit -m $commitMsg | Out-Null
|
||||||
|
|
||||||
# -- Push. release.yml triggers ONLY on "v*" tag pushes (no branch trigger), -
|
# -- Push: branch push for alpha/pre-release (CI computes the tag itself), -
|
||||||
# -- so the tag push is what actually starts the build; the branch push is -
|
# -- a real "vX.Y.Z" tag for master (the only path that yields a stable -
|
||||||
# -- just so the promotion chain (alpha -> pre-release -> master) itself -
|
# -- release per release.yml's resolve-channel job). -
|
||||||
# -- keeps moving forward for the next --ff-only. -
|
if ($ResolvedBranch -eq "master") {
|
||||||
Write-Step "Tagging $Tag and pushing $ResolvedBranch + tag"
|
$tag = "v$NewVersion"
|
||||||
git tag $Tag
|
Write-Step "Tagging $tag and pushing master + tag"
|
||||||
git push origin $ResolvedBranch
|
git tag $tag
|
||||||
git push origin $Tag
|
git push origin master
|
||||||
|
git push origin $tag
|
||||||
|
} else {
|
||||||
|
Write-Step "Pushing $ResolvedBranch"
|
||||||
|
git push origin $ResolvedBranch
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Done. Watch the build: https://github.com/ospab/ostp/actions" -ForegroundColor Green
|
Write-Host "Done. Watch the build: https://github.com/ospab/ostp/actions" -ForegroundColor Green
|
||||||
|
|
|
||||||