Compare commits

..

No commits in common. "22c2d5edd8a4b78d4881a011d76ef655c2a66ed3" and "61091b6d566bed9d0fb3efa468d9b47ea2213284" have entirely different histories.

17 changed files with 784 additions and 933 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -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,

View File

@ -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());

View File

@ -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;
@ -24,10 +28,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
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';
@ -68,42 +70,37 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
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,9 +150,9 @@ 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;
} }
@ -171,13 +163,73 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_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++) {
@ -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,17 +376,6 @@ 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(() {
@ -479,15 +510,31 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
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(),
), ),
), ),
), ),
@ -698,6 +745,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
const SizedBox(height: 30), const SizedBox(height: 30),
AnimatedOpacity(
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -711,7 +764,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70), const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
_activeProfile?.name ?? 'No profile selected', _serverAddr,
style: const TextStyle( style: const TextStyle(
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 15, fontSize: 15,
@ -722,13 +775,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
], ],
), ),
), ),
const SizedBox(height: 16),
AnimatedOpacity( Container(
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16), margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -791,6 +839,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
], ],
), ),
), ),
],
), ),
) )
], ],
@ -862,3 +911,4 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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.
if ($ResolvedBranch -eq "master") {
if ($Prefix) { Write-Warn2 "-Prefix is ignored for -Branch master (stable releases are bare 'vX.Y.Z' tags)." }
$ResolvedPrefix = ""
}
# -- Resolve the target version + per-channel iteration counters ------------ # -- Resolve the version: exact via -Switch, else auto-increment the patch --
if ($IsNewTarget) { $CurrentVersion = if ($State) { $State.version } else {
if ($Switch -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { Fail "-Switch must be a bare X.Y.Z version, got '$Switch'." }
$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 (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 if ($Switch) {
# THAT channel's count fresh — it doesn't inherit alpha's iteration number. if ($Switch -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { Fail "-Switch must be a bare X.Y.Z version, got '$Switch'." }
$BranchChanged = ($ResolvedBranch -ne $PrevBranch) $NewVersion = $Switch
if ($BranchChanged -and $ResolvedBranch -eq "pre-release") { $BetaIter = 0 } } else {
if ($BranchChanged -and $ResolvedBranch -eq "alpha") { $AlphaIter = 0 } $parts = $CurrentVersion.Split('.')
$NewVersion = "{0}.{1}.{2}" -f $parts[0], $parts[1], ([int]$parts[2] + 1)
} }
$Channel = switch ($ResolvedBranch) { "alpha" { "alpha" }; "pre-release" { "beta" }; "master" { "stable" } } Write-Step "Releasing $NewVersion on '$ResolvedBranch'$(if ($ResolvedPrefix) { " (tag suffix: -$ResolvedPrefix)" } else { " (stable, tag v$NewVersion)" })"
if ($Channel -eq "alpha") { $AlphaIter++ } # -- Checkout the target branch, promoting it from alpha first ------------
elseif ($Channel -eq "beta") { $BetaIter++ }
$Iteration = if ($Channel -eq "alpha") { $AlphaIter } else { $BetaIter }
$Tag = if ($Channel -eq "stable") { "v$TargetVersion" } else { "v$TargetVersion-$Channel.$Iteration" }
Write-Step "Releasing $Tag on '$ResolvedBranch'"
# -- 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,10 +124,8 @@ 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
@ -171,14 +135,27 @@ if ($IsNewTarget -or -not $State) {
[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 "Cargo.toml" '(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"' "version = `"$NewVersion`""
Set-VersionLine "ostp-gui/src-tauri/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 = `"$NewVersion`""
Set-VersionLine "ostp-gui/src-tauri/tauri.conf.json" '"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`": `"$NewVersion`""
Set-VersionLine "ostp-gui/package.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$TargetVersion`"" Set-VersionLine "ostp-gui/package.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$NewVersion`""
# Refresh Cargo.lock's per-package version entries. ostp-gui/src-tauri is # Flutter build number must increase monotonically (Android versionCode) -
# excluded from the main workspace (its own Tauri build graph), so it has # bump it alongside the version string, don't just rewrite the version part.
# its own separate Cargo.lock that the main `cargo check` never touches. $pubspecPath = Join-Path $RepoRoot "ostp-flutter/pubspec.yaml"
$pubspecText = Get-Content $pubspecPath -Raw
if ($pubspecText -match 'version: [0-9]+\.[0-9]+\.[0-9]+\+([0-9]+)') {
$nextBuild = [int]$Matches[1] + 1
$pubspecText = $pubspecText -replace 'version: [0-9]+\.[0-9]+\.[0-9]+\+[0-9]+', "version: $NewVersion+$nextBuild"
[System.IO.File]::WriteAllText($pubspecPath, $pubspecText)
} else {
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)" Write-Step "Running cargo check to refresh Cargo.lock (main workspace)"
cargo check --workspace --exclude ostp-jni --quiet cargo check --workspace --exclude ostp-jni --quiet
if ($LASTEXITCODE -ne 0) { Fail "cargo check failed after the version bump - not committing a broken build." } if ($LASTEXITCODE -ne 0) { Fail "cargo check failed after the version bump - not committing a broken build." }
@ -189,46 +166,36 @@ if ($IsNewTarget -or -not $State) {
$tauriCheckExit = $LASTEXITCODE $tauriCheckExit = $LASTEXITCODE
Pop-Location Pop-Location
if ($tauriCheckExit -ne 0) { Fail "cargo check failed in ostp-gui/src-tauri after the version bump." } 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
# every single build ever shipped — unlike the semantic version, it does NOT
# stay fixed across alpha/beta iterations, so this runs every time, not just
# on a target-version switch.
$pubspecPath = Join-Path $RepoRoot "ostp-flutter/pubspec.yaml"
$pubspecText = Get-Content $pubspecPath -Raw
if ($pubspecText -match 'version: [0-9]+\.[0-9]+\.[0-9]+\+([0-9]+)') {
$nextBuild = [int]$Matches[1] + 1
$pubspecText = $pubspecText -replace 'version: [0-9]+\.[0-9]+\.[0-9]+\+[0-9]+', "version: $TargetVersion+$nextBuild"
[System.IO.File]::WriteAllText($pubspecPath, $pubspecText)
} else {
Fail "Version pattern not found in ostp-flutter/pubspec.yaml."
}
# -- 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 tag $tag
git push origin master
git push origin $tag
} else {
Write-Step "Pushing $ResolvedBranch"
git push origin $ResolvedBranch git push origin $ResolvedBranch
git push origin $Tag }
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