Compare commits

...

4 Commits

Author SHA1 Message Date
ospab 22c2d5edd8 feat(flutter): update UI theme to monochrome with eagle watermark 2026-07-09 23:57:58 +03:00
ospab fa7ec2cd9a feat(flutter): update launcher icons to new eagle design with round support 2026-07-09 23:52:37 +03:00
ospab 794ea5251b refactor(ci): target_version + per-channel iteration in gha.ps1
Previous scheme conflated "which release is this" with "how many times has
it been rebuilt": every run bumped the patch version, so by the time a build
was ready to promote to master the version number had already crept forward
by however many alpha/beta iterations it took to get there.

Now a release cycle has one fixed target version (e.g. 0.4.1) that stays in
every manifest unchanged through all alpha/beta iterations; only a
per-channel counter increments, and that counter lives ONLY in the git tag,
never in Cargo.toml:

  v0.4.1-alpha.1 -> v0.4.1-alpha.2 -> ... -> v0.4.1-alpha.N
  v0.4.1-beta.1  -> v0.4.1-beta.2  -> ... -> v0.4.1-beta.N
  v0.4.1                                          <- master, iteration dropped

Deliberately "0.4.1-alpha.N" (dot AFTER the hyphen — a semver pre-release
identifier), not "0.4.1.N-alpha" (a 4th dot component before the hyphen):
the latter isn't valid semver and Cargo's version parser rejects it outright,
so it can never appear in Cargo.toml. That's also why the target version
itself never needs to change on a plain iteration — bumping every manifest +
refreshing both Cargo.locks is now skipped entirely unless -Switch actually
changes the target, making a routine alpha/beta push fast (just the state
file's counter + a tag).

Also fixes a real bug found while touching this: release.yml's push trigger
is tags-only ("v*") with no branch trigger, so the old `git push origin
$branch`-only path for alpha/pre-release never actually started a CI run —
only the master path (which already pushed a tag) worked. Every channel now
always pushes a real tag, which is what actually triggers the build.

release.yml's resolve-channel needed no changes: its tag-channel detection
already does substring matching (*-alpha*/*-beta*), so it classifies
"v0.4.1-alpha.37" correctly without modification.

-Prefix is gone — channel was always 1:1 with -Branch (alpha/pre-release/
master), so it was a redundant, independently-settable axis that could
silently drift from the branch (e.g. -Branch alpha -Prefix beta).
2026-07-09 23:42:27 +03:00
ospab c6d506e6c0 feat(flutter): profiles + per-profile junk/frag, drop WSS
Merges the best of both lineages instead of a blind revert to v0.3.21:
kept from v0.3.21: multi-profile management (add via QR scan/link/manual,
single-select active profile, auto-mode transport/MTU probing). Kept from
current: Share Config (QR generation), Check for Updates, curated stealth-SNI
domain list, and actually-rendered exclusions fields (v0.3.21 loaded/saved
them but never showed them in the UI — dead code).

New: junk packets (pc/ps min/max) and TCP fragmentation (chunk/sleep) are now
per-profile fields in the profile editor, mirroring the desktop GUI's profile
object shape 1:1 (ostp-gui/src/main.js) so behavior matches across platforms.

WSS is gone — removed from the model, the UI, and the config builder. The
core dropped TLS-mimicry transports entirely (see §A of the rebuild), so
there was nothing left for it to configure.

Config building now targets the flat single-server schema
(ostp_client::config::ClientConfig) built from ONE active profile, not the
old modular inbounds/outbounds/urltest-failover config — the core no longer
supports connecting to multiple servers at once, matching how the desktop
GUI already works (single activeId). Also dropped a dead nested "tun": {...}
object that neither version's JSON producer nor the real ClientConfig struct
ever actually used — serde silently ignored it.
2026-07-09 23:42:10 +03:00
17 changed files with 940 additions and 791 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -26,11 +26,11 @@ class OstpApp extends StatelessWidget {
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF08080F),
scaffoldBackgroundColor: const Color(0xFF000000),
colorScheme: const ColorScheme.dark(
primary: Color(0xFF6C72FF),
secondary: Color(0xFF22D3A5),
surface: Color(0xFF151522),
primary: Color(0xFFFFFFFF),
secondary: Color(0xFFAAAAAA),
surface: Color(0xFF111111),
),
fontFamily: 'Inter',
useMaterial3: true,

View File

@ -0,0 +1,95 @@
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,16 +1,12 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../models/connection_state_enum.dart';
import '../models/ostp_profile.dart';
import 'settings_screen.dart';
import 'logs_screen.dart';
import 'app_routing_screen.dart';
import 'qr_scanner_screen.dart';
class HomeScreen extends StatefulWidget {
final SharedPreferences prefs;
@ -28,8 +24,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Timer? _uptimeTimer;
int _uptimeSecs = 0;
String _serverAddr = '127.0.0.1:443';
String _accessKey = 'default_key';
// Single active profile the core only ever connects to one server at a
// time (no multi-server/urltest failover since the 0.4.x flat config),
// matching how the desktop GUI picks exactly one profile as `activeId`.
OstpProfile? _activeProfile;
String _download = '0 B';
String _upload = '0 B';
@ -70,37 +68,42 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
void _loadSettings() {
setState(() {
_serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443';
_accessKey = widget.prefs.getString('access_key') ?? '';
final profiles = decodeProfiles(widget.prefs.getString('profiles_json'));
// Single-select: if more than one is somehow marked active (shouldn't
// 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();
}
void _updateLatestConfigJson() {
/// Builds the exact JSON the native core (ostp-jni) deserializes as
/// `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 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 dnsServer = widget.prefs.getString('dns_server');
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
final tunStack = 'ostp';
const 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 = {
return {
"mode": "client",
"debug": debugMode,
"ostp": {
"server_addr": _serverAddr,
"server_addr": p?.serverAddr ?? '',
"local_bind_addr": "0.0.0.0:0",
"access_key": _accessKey,
"access_key": p?.accessKey ?? '',
"handshake_timeout_ms": 10000,
"io_timeout_ms": 5000,
"mtu": int.tryParse(mtu) ?? 1140,
@ -109,18 +112,21 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
"bind_addr": localBind,
"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": {
"mode": transportMode,
"stealth_sni": stealthSni,
"mode": p?.transportMode ?? 'udp',
"stealth_sni": (p?.stealthSni.isNotEmpty ?? false) ? p!.stealthSni : 'vk.com',
"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": {
"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(),
@ -131,12 +137,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
"packages": appRoutingPackages,
},
"dns_server": effectiveDnsServer,
"tun_stack": tunStack
"tun_stack": tunStack,
};
}
void _updateLatestConfigJson() {
final configMap = _buildConfigMap();
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
platform.invokeMethod('saveConfig', {
"configJson": jsonEncode(configMap)
});
platform.invokeMethod('saveConfig', {"configJson": jsonEncode(configMap)});
}
@override
@ -150,9 +158,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Future<void> _toggleConnection() async {
if (_state == ConnectionStateEnum.disconnected) {
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
if (_activeProfile == null || _activeProfile!.serverAddr.isEmpty || _activeProfile!.accessKey.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please configure Server and Key in Settings')),
const SnackBar(content: Text('Please select or add a profile in Settings')),
);
return;
}
@ -163,73 +171,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_pulseController.repeat(reverse: true);
_spinController.repeat();
final dnsServer = widget.prefs.getString('dns_server');
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
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));
final configMap = _buildConfigMap();
final configStr = jsonEncode(configMap);
widget.prefs.setString('latest_config_json', configStr);
try {
await platform.invokeMethod('saveConfig', {
"configJson": jsonEncode(configMap)
});
await platform.invokeMethod('startTunnel', {
"configJson": jsonEncode(configMap)
});
await platform.invokeMethod('saveConfig', {"configJson": configStr});
await platform.invokeMethod('startTunnel', {"configJson": configStr});
bool started = false;
for (int i = 0; i < 10; i++) {
@ -289,30 +237,34 @@ 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 {
final mtus = [1500, 1350, 1280, 1140];
final modes = [
{'t': 'udp'},
{'t': 'uot'},
];
final modes = ['udp', 'uot'];
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
final active = _activeProfile;
if (active == null || active.serverAddr.isEmpty || active.accessKey.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please configure Server and Key first')),
const SnackBar(content: Text('Please select a profile with a server and key first')),
);
return;
}
for (var mode in modes) {
for (var mtu in mtus) {
final originalMode = active.transportMode;
final originalMtu = widget.prefs.getString('mtu') ?? '1140';
for (final mode in modes) {
for (final mtu in mtus) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Testing: ${mode['t']} | MTU: $mtu'), duration: const Duration(seconds: 2)),
SnackBar(content: Text('Testing: $mode | MTU: $mtu'), duration: const Duration(seconds: 2)),
);
// Update prefs
await widget.prefs.setString('mtu', mtu.toString());
await widget.prefs.setString('transport_mode', mode['t'] as String);
active.transportMode = mode;
_updateLatestConfigJson();
setState(() {
@ -337,7 +289,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
if (started) {
_setConnected();
// Wait to see if connection is stable and ping is successful
await Future.delayed(const Duration(seconds: 3));
try {
final metricsJson = await platform.invokeMethod('getMetrics');
@ -345,30 +296,37 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
final rttMs = parsed['rtt_ms'] as int? ?? 0;
if (rttMs > 0) {
// Working combo found persist it onto the profile.
_persistActiveProfile();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Success! Found working config: ${mode['t']} (MTU $mtu)')),
SnackBar(content: Text('Success! Found working config: $mode (MTU $mtu)')),
);
}
return; // Stop on first working config
return;
}
}
} catch (e) {
// Ignore metrics error
} catch (_) {
// Ignore metrics error, fall through to try next combo.
}
// Connection seems unstable or no ping, stop and try next
await platform.invokeMethod('stopTunnel');
_setDisconnected();
} else {
_setDisconnected();
}
} catch (e) {
} catch (_) {
_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) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Auto search finished. No working config found.')),
@ -376,6 +334,17 @@ 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() {
if (!mounted) return;
setState(() {
@ -510,31 +479,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
return Scaffold(
body: Stack(
children: [
Positioned(
top: -150, right: -100,
child: Container(
width: 400, height: 400,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primary.withOpacity(0.15),
),
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(),
Positioned.fill(
child: Opacity(
opacity: 0.03,
child: Center(
child: Image.asset(
'assets/logo.png',
width: MediaQuery.of(context).size.shortestSide * 0.6,
color: Colors.white,
),
),
),
),
@ -745,102 +698,100 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
const SizedBox(height: 30),
AnimatedOpacity(
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Column(
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: [
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(
_serverAddr,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.white70,
),
),
],
),
),
const SizedBox(height: 16),
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.03),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.06)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'CONNECTION TEST',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
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(
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)),
),
),
],
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(
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),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.03),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.06)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'CONNECTION TEST',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
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(
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)),
),
),
],
),
),
),
)
],
);
@ -911,4 +862,3 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -72,9 +72,8 @@ flutter:
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/logo.png
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images

View File

@ -1,63 +1,79 @@
<#
.SYNOPSIS
Cuts a new OSTP release and pushes it to the channel that triggers the
matching GitHub Actions build (see .github/workflows/release.yml).
Cuts a new OSTP release and pushes the tag that triggers the matching
GitHub Actions build (see .github/workflows/release.yml, which only
triggers on "v*" tag pushes + workflow_dispatch a bare branch push
does NOT start a build).
.DESCRIPTION
Three release channels, in increasing order of stability:
alpha -> pushes the `alpha` branch -> tag "{version}-alpha"
pre-release -> pushes the `pre-release` branch -> tag "{version}-beta"
master -> pushes an actual "v{version}" tag -> real stable release
A release cycle has ONE fixed target version (e.g. "0.4.1") that stays in
Cargo.toml/tauri.conf.json/package.json unchanged through every alpha and
beta build only a per-channel ITERATION counter increments, and that
counter lives ONLY in the git tag, never in the manifests:
Promoting to pre-release/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.
v0.4.1-alpha.1 -> v0.4.1-alpha.2 -> ... -> v0.4.1-alpha.100
v0.4.1-beta.1 -> v0.4.1-beta.2 -> ... -> v0.4.1-beta.100
v0.4.1 <- master: iteration dropped
Remembers the last {version, branch, prefix} it used in .release-state.json
at the repo root. Running with no arguments repeats last time's branch and
prefix, auto-incrementing the patch version. -Switch starts a new version
line (e.g. 0.3.x -> 0.4.0) without changing branch/prefix. -Branch/-Prefix
override just that one setting for this run (and become the new default).
This is deliberately NOT "0.4.1.5-alpha" (a 4th dot-separated component
before the hyphen) that is not valid semver, and Cargo's version parser
rejects it outright. "0.4.1-alpha.5" (dot AFTER the hyphen, a semver
pre-release identifier) is the only form that keeps Cargo.toml itself
parseable, so that's the only place the iteration number is allowed to
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
Set an exact version (e.g. "0.4.0") instead of auto-incrementing the patch
of the last released version. Becomes the new baseline for future bare runs.
Set a new target version (e.g. "0.4.2") instead of continuing the current
one. Resets both alpha_iteration and beta_iteration to 0. Defaults the
channel back to alpha unless -Branch is also given this run.
.PARAMETER Branch
Which branch to release from: master, pre-release, or alpha.
Which branch/channel to release from: master, pre-release (beta), or alpha.
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
.\scripts\gha.ps1
Re-releases the same branch/prefix as last time, with the patch version bumped by 1.
Bumps the current channel's iteration by one and pushes v{target}-{channel}.{N}.
.EXAMPLE
.\scripts\gha.ps1 -Switch 0.4.0
Starts releasing the 0.4.x line from now on; this run ships exactly 0.4.0.
.\scripts\gha.ps1 -Switch 0.4.2
Starts a fresh 0.4.2 cycle: manifests -> 0.4.2, alpha iteration resets to 1,
ships v0.4.2-alpha.1.
.EXAMPLE
.\scripts\gha.ps1 -Branch pre-release -Prefix beta
Promotes alpha -> pre-release and ships "{version}-beta".
.\scripts\gha.ps1 -Branch pre-release
Promotes alpha -> pre-release (beta channel), resets beta_iteration to 1
(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()]
param(
[string]$Switch,
[ValidateSet('master', 'beta', 'alpha')]
[string]$Branch,
[ValidateSet('beta', 'alpha')]
[string]$Prefix
[ValidateSet('master', 'pre-release', 'alpha')]
[string]$Branch
)
$ErrorActionPreference = "Stop"
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 }
# -- Locate repo root, regardless of where this script was invoked from ------
@ -75,38 +91,56 @@ if ($dirty) {
Fail "Working tree has uncommitted changes. Commit or stash them first."
}
# -- Load remembered state (branch/prefix/version from the last release) ----
# -- Load remembered state ----------------------------------------------------
$State = $null
if (Test-Path $StateFile) {
$State = Get-Content $StateFile -Raw | ConvertFrom-Json
}
$ResolvedBranch = if ($Branch) { $Branch } elseif ($State) { $State.branch } else { "alpha" }
$ResolvedPrefix = if ($Prefix) { $Prefix } elseif ($State) { $State.prefix } else { "alpha" }
$PrevBranch = if ($State) { $State.branch } else { $null }
$IsNewTarget = [bool]$Switch
# 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 = ""
}
$ResolvedBranch = if ($Branch) { $Branch } elseif ($PrevBranch) { $PrevBranch } else { "alpha" }
# -- 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 ($Switch) {
# -- Resolve the target version + per-channel iteration counters ------------
if ($IsNewTarget) {
if ($Switch -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { Fail "-Switch must be a bare X.Y.Z version, got '$Switch'." }
$NewVersion = $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 {
$parts = $CurrentVersion.Split('.')
$NewVersion = "{0}.{1}.{2}" -f $parts[0], $parts[1], ([int]$parts[2] + 1)
# 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 }
}
Write-Step "Releasing $NewVersion on '$ResolvedBranch'$(if ($ResolvedPrefix) { " (tag suffix: -$ResolvedPrefix)" } else { " (stable, tag v$NewVersion)" })"
$Channel = switch ($ResolvedBranch) { "alpha" { "alpha" }; "pre-release" { "beta" }; "master" { "stable" } }
# -- Checkout the target branch, promoting it from alpha first ------------
if ($Channel -eq "alpha") { $AlphaIter++ }
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
if ($CurrentBranch -ne $ResolvedBranch) {
Write-Step "Checking out $ResolvedBranch"
@ -124,78 +158,77 @@ if ($ResolvedBranch -ne "alpha") {
}
}
# -- Bump the version across every manifest that carries one ----------------
Write-Step "Bumping version $CurrentVersion -> $NewVersion"
# -- Bump manifests ONLY when the target version itself changes. A plain -----
# -- alpha/beta iteration touches nothing but the state file's counter. -----
if ($IsNewTarget -or -not $State) {
Write-Step "Bumping target version -> $TargetVersion"
function Set-VersionLine($Path, $Pattern, $Replacement) {
$full = Join-Path $RepoRoot $Path
$text = Get-Content $full -Raw
$updated = $text -replace $Pattern, $Replacement
if ($updated -eq $text) { Fail "Version pattern not found in $Path - refusing to proceed with a stale file." }
[System.IO.File]::WriteAllText($full, $updated)
function Set-VersionLine($Path, $Pattern, $Replacement) {
$full = Join-Path $RepoRoot $Path
$text = Get-Content $full -Raw
$updated = $text -replace $Pattern, $Replacement
if ($updated -eq $text) { Fail "Version pattern not found in $Path - refusing to proceed with a stale file." }
[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." }
}
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 = `"$NewVersion`""
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`": `"$NewVersion`""
# Flutter build number must increase monotonically (Android versionCode) -
# bump it alongside the version string, don't just rewrite the version part.
# 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: $NewVersion+$nextBuild"
$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."
}
# -- 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 ---------------------------------------------------
[PSCustomObject]@{
version = $NewVersion
branch = $ResolvedBranch
prefix = $ResolvedPrefix
target_version = $TargetVersion
branch = $ResolvedBranch
alpha_iteration = $AlphaIter
beta_iteration = $BetaIter
} | ConvertTo-Json | Set-Content $StateFile
# -- Commit -------------------------------------------------------------------
$suffixLabel = if ($ResolvedPrefix) { "-$ResolvedPrefix" } else { "" }
$commitMsg = "chore: release $NewVersion$suffixLabel on $ResolvedBranch"
$commitMsg = "chore: release $Tag on $ResolvedBranch"
Write-Step "Committing: $commitMsg"
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 `
.release-state.json
git commit -m $commitMsg | Out-Null
# -- Push: branch push for alpha/pre-release (CI computes the tag itself), -
# -- a real "vX.Y.Z" tag for master (the only path that yields a stable -
# -- release per release.yml's resolve-channel job). -
if ($ResolvedBranch -eq "master") {
$tag = "v$NewVersion"
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
}
# -- Push. release.yml triggers ONLY on "v*" tag pushes (no branch trigger), -
# -- so the tag push is what actually starts the build; the branch push is -
# -- just so the promotion chain (alpha -> pre-release -> master) itself -
# -- keeps moving forward for the next --ff-only. -
Write-Step "Tagging $Tag and pushing $ResolvedBranch + tag"
git tag $Tag
git push origin $ResolvedBranch
git push origin $Tag
Write-Host ""
Write-Host "Done. Watch the build: https://github.com/ospab/ostp/actions" -ForegroundColor Green