Add app source and tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alvocool
2026-10-11 10:15:30 +03:00
parent 7e246255c2
commit 38881d1fe1
135 changed files with 21057 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
enum DeployStatus { success, failure, reverted }
extension DeployStatusX on DeployStatus {
String get label => switch (this) {
DeployStatus.success => 'Success',
DeployStatus.failure => 'Failure',
DeployStatus.reverted => 'Reverted',
};
}
/// One historical deployment attempt, tracked for auditing.
class DeploymentRecord {
final String id;
final String serverId;
final String serverName;
final String serviceId;
final String serviceLabel;
final String jarName;
final String remotePath;
final DateTime time;
final DeployStatus status;
final String message;
DeploymentRecord({
required this.id,
required this.serverId,
required this.serverName,
required this.serviceId,
required this.serviceLabel,
required this.jarName,
required this.remotePath,
required this.time,
required this.status,
required this.message,
});
Map<String, dynamic> toJson() => {
'id': id,
'serverId': serverId,
'serverName': serverName,
'serviceId': serviceId,
'serviceLabel': serviceLabel,
'jarName': jarName,
'remotePath': remotePath,
'time': time.toIso8601String(),
'status': status.name,
'message': message,
};
factory DeploymentRecord.fromJson(Map<String, dynamic> j) => DeploymentRecord(
id: j['id'] as String,
serverId: (j['serverId'] ?? '') as String,
serverName: (j['serverName'] ?? '') as String,
serviceId: (j['serviceId'] ?? '') as String,
serviceLabel: (j['serviceLabel'] ?? '') as String,
jarName: (j['jarName'] ?? '') as String,
remotePath: (j['remotePath'] ?? '') as String,
time: DateTime.tryParse((j['time'] ?? '') as String) ?? DateTime(2000),
status: DeployStatus.values.firstWhere(
(s) => s.name == j['status'],
orElse: () => DeployStatus.failure,
),
message: (j['message'] ?? '') as String,
);
}

View File

@@ -0,0 +1,3 @@
/// How a destination replaces (or stacks on) the current screen. Every
/// transition in the app goes through `PeachNavigation` with one of these.
enum NavigatorType { justOpen, openFully, replaceCurrent, makeNewMain }

View File

@@ -0,0 +1,2 @@
/// Severity of an in-app notification sheet (`showApplicationNotification`).
enum NotificationType { success, info, warning, error }

View File

@@ -0,0 +1,8 @@
/// A generic two-slot carrier, used where a call needs to hand back two values
/// without earning a named model (e.g. a resolved command plus its headers).
class Pair<K, V> {
final K key;
final V value;
const Pair(this.key, this.value);
}

View File

@@ -0,0 +1,85 @@
import 'package:uuid/uuid.dart';
import 'ServiceConfig.dart';
/// A unique server the user connects to. Holds the connection details plus a
/// list of deployable [ServiceConfig]s that live on it.
///
/// The [password] is NOT serialized into the plain config JSON — it is held in
/// memory and persisted separately via encrypted secure storage.
class ServerProfile {
final String id;
String name; // friendly label, e.g. "prod-1"
String host;
int port;
String username;
String password; // runtime only; persisted via SecretStore
bool useSudo;
List<ServiceConfig> services;
/// Saved deployment order: service ids in the order the user arranged them
/// for batch build & deploy. Ids not present are appended in natural order.
List<String> deployOrder;
ServerProfile({
String? id,
this.name = 'New server',
this.host = '',
this.port = 22,
this.username = '',
this.password = '',
this.useSudo = true,
List<ServiceConfig>? services,
List<String>? deployOrder,
}) : id = id ?? const Uuid().v4(),
services = services ?? [],
deployOrder = deployOrder ?? [];
/// Services arranged by the saved [deployOrder], with any not-listed ones
/// appended in their natural order.
List<ServiceConfig> get orderedServices {
final byId = {for (final s in services) s.id: s};
final result = <ServiceConfig>[];
for (final id in deployOrder) {
final s = byId.remove(id);
if (s != null) result.add(s);
}
// Preserve original order for the remaining (new/unordered) services.
for (final s in services) {
if (byId.containsKey(s.id)) result.add(s);
}
return result;
}
ServerProfile copy() {
final c = ServerProfile.fromJson(toJson());
c.password = password;
return c;
}
/// JSON WITHOUT the password (that goes to secure storage).
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'host': host,
'port': port,
'username': username,
'useSudo': useSudo,
'services': services.map((s) => s.toJson()).toList(),
'deployOrder': deployOrder,
};
factory ServerProfile.fromJson(Map<String, dynamic> j) => ServerProfile(
id: j['id'] as String?,
name: (j['name'] ?? 'server') as String,
host: (j['host'] ?? '') as String,
port: (j['port'] ?? 22) as int,
username: (j['username'] ?? '') as String,
useSudo: (j['useSudo'] ?? true) as bool,
services: ((j['services'] ?? []) as List<dynamic>)
.map((e) => ServiceConfig.fromJson(e as Map<String, dynamic>))
.toList(),
deployOrder: ((j['deployOrder'] ?? []) as List<dynamic>)
.map((e) => e.toString())
.toList(),
);
}

View File

@@ -0,0 +1,183 @@
import 'package:uuid/uuid.dart';
/// What kind of thing this service deploys.
enum ServiceKind { jarService, webApp, generic }
/// Front-end framework for a [ServiceKind.webApp].
enum WebFramework { angular, react, flutterWeb, staticFiles }
/// How a service's health is probed.
enum HealthCheckType { auto, http, tcp, command, none }
extension ServiceKindX on ServiceKind {
String get label => switch (this) {
ServiceKind.webApp => 'Web app (nginx)',
ServiceKind.generic => 'Service (any binary)',
ServiceKind.jarService => 'Jar service',
};
}
extension HealthCheckTypeX on HealthCheckType {
String get label => switch (this) {
HealthCheckType.auto => 'systemd (auto)',
HealthCheckType.http => 'HTTP request',
HealthCheckType.tcp => 'TCP port',
HealthCheckType.command => 'Command exit 0',
HealthCheckType.none => 'Disabled',
};
}
extension WebFrameworkX on WebFramework {
String get label => switch (this) {
WebFramework.angular => 'Angular',
WebFramework.react => 'React',
WebFramework.flutterWeb => 'Flutter web',
WebFramework.staticFiles => 'Static files',
};
/// Default build command (run through a shell). Blank = no build.
String get defaultBuild => switch (this) {
WebFramework.angular => 'npm install && npm run build',
WebFramework.react => 'npm install && npm run build',
WebFramework.flutterWeb => 'flutter build web --release',
WebFramework.staticFiles => '',
};
}
/// A single deployable service that lives on a [ServerProfile].
/// One server can host many of these — jar services and/or web apps.
class ServiceConfig {
final String id;
String name; // friendly label, e.g. "orders-api"
ServiceKind kind;
WebFramework framework;
/// Absolute target directory on the server.
/// - jar service: where the jar lives, e.g. /opt/orders
/// - web app: the nginx web root, e.g. /var/www/orders
String remoteDir;
/// The filename the uploaded jar should be saved as (jar services only).
String remoteJarName;
/// The systemd unit to (re)start for a jar service, e.g. "orders".
String serviceName;
/// The nginx unit to reload for a web app (web only). Default "nginx".
String nginxService;
/// Optional custom log command (WITHOUT sudo — the deployer adds it).
String logCommand;
/// Absolute path to the local source project. When set, PeachDeployments can build
/// from source before deploying.
String localProjectPath;
/// Optional custom build command. Blank auto-detects.
String buildCommand;
/// jar services: glob/path for the built jar (blank auto-detects).
String jarPattern;
/// web apps: build output directory relative to the project (blank
/// auto-detects, e.g. dist/, build/, build/web).
String outputSubdir;
/// How to probe this service's health.
HealthCheckType healthType;
/// Health target: a URL (http), port number (tcp), or shell command.
String healthTarget;
ServiceConfig({
String? id,
this.name = 'New service',
this.kind = ServiceKind.jarService,
this.framework = WebFramework.react,
this.remoteDir = '/opt/app',
this.remoteJarName = '',
this.serviceName = '',
this.nginxService = 'nginx',
this.logCommand = '',
this.localProjectPath = '',
this.buildCommand = '',
this.jarPattern = '',
this.outputSubdir = '',
this.healthType = HealthCheckType.auto,
this.healthTarget = '',
}) : id = id ?? const Uuid().v4();
bool get isWeb => kind == ServiceKind.webApp;
bool get isGeneric => kind == ServiceKind.generic;
bool get canBuild => localProjectPath.trim().isNotEmpty;
String get effectiveLogCommand {
if (logCommand.trim().isNotEmpty) return logCommand.trim();
if (isWeb) {
final unit = nginxService.trim().isEmpty ? 'nginx' : nginxService.trim();
return 'journalctl -u $unit -n 200 --no-pager';
}
return 'journalctl -u ${serviceName.isEmpty ? 'app' : serviceName} '
'-n 200 --no-pager';
}
/// Normalized remote directory (no trailing slash).
String get remoteRoot => remoteDir.endsWith('/')
? remoteDir.substring(0, remoteDir.length - 1)
: remoteDir;
String remotePath(String jarLabelFallback) {
final name = remoteJarName.trim().isNotEmpty
? remoteJarName.trim()
: jarLabelFallback;
return '$remoteRoot/$name';
}
ServiceConfig copy() => ServiceConfig.fromJson(toJson());
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'kind': kind.name,
'framework': framework.name,
'remoteDir': remoteDir,
'remoteJarName': remoteJarName,
'serviceName': serviceName,
'nginxService': nginxService,
'logCommand': logCommand,
'localProjectPath': localProjectPath,
'buildCommand': buildCommand,
'jarPattern': jarPattern,
'outputSubdir': outputSubdir,
'healthType': healthType.name,
'healthTarget': healthTarget,
};
factory ServiceConfig.fromJson(Map<String, dynamic> j) => ServiceConfig(
id: j['id'] as String?,
name: (j['name'] ?? 'service') as String,
kind: ServiceKind.values.firstWhere(
(k) => k.name == j['kind'],
orElse: () => ServiceKind.jarService,
),
framework: WebFramework.values.firstWhere(
(f) => f.name == j['framework'],
orElse: () => WebFramework.react,
),
remoteDir: (j['remoteDir'] ?? '/opt/app') as String,
remoteJarName: (j['remoteJarName'] ?? '') as String,
serviceName: (j['serviceName'] ?? '') as String,
nginxService: (j['nginxService'] ?? 'nginx') as String,
logCommand: (j['logCommand'] ?? '') as String,
localProjectPath: (j['localProjectPath'] ?? '') as String,
buildCommand: (j['buildCommand'] ?? '') as String,
jarPattern: (j['jarPattern'] ?? '') as String,
outputSubdir: (j['outputSubdir'] ?? '') as String,
healthType: HealthCheckType.values.firstWhere(
(h) => h.name == j['healthType'],
orElse: () => HealthCheckType.auto,
),
healthTarget: (j['healthTarget'] ?? '') as String,
);
}

View File

@@ -0,0 +1,2 @@
/// How loudly a [PeachError] should be presented.
enum Severity { message, warning, critical }

View File

@@ -0,0 +1,104 @@
import 'dart:async';
enum StepState { pending, active, done, failed, skipped }
class DeployStep {
final String key;
final String label;
StepState state;
double? progress; // 0..1 within this step, when meaningful
DeployStep(
this.key,
this.label, {
this.state = StepState.pending,
this.progress,
});
}
/// Tracks an ordered list of steps for a build or deploy operation and streams
/// updates so the UI can render a live stepper with per-step progress.
class StepTracker {
final _controller = StreamController<List<DeployStep>>.broadcast();
Stream<List<DeployStep>> get stream => _controller.stream;
final List<DeployStep> steps = [];
void reset(List<(String, String)> defs) {
steps
..clear()
..addAll(defs.map((d) => DeployStep(d.$1, d.$2)));
_emit();
}
DeployStep? _find(String key) {
for (final s in steps) {
if (s.key == key) return s;
}
return null;
}
void active(String key) {
final s = _find(key);
if (s == null) return;
// mark earlier active/pending steps done as we move forward
for (final step in steps) {
if (step == s) break;
if (step.state == StepState.active) step.state = StepState.done;
}
s.state = StepState.active;
s.progress = null;
_emit();
}
void progress(String key, double? p) {
final s = _find(key);
if (s == null) return;
s.progress = p;
_emit();
}
void done(String key) => _set(key, StepState.done);
void fail(String key) => _set(key, StepState.failed);
void skip(String key) => _set(key, StepState.skipped);
void _set(String key, StepState state) {
final s = _find(key);
if (s == null) return;
s.state = state;
s.progress = null;
_emit();
}
/// Marks the currently-active step done (used before moving on).
void completeActive() {
for (final s in steps) {
if (s.state == StepState.active) s.state = StepState.done;
}
_emit();
}
/// Any still-pending/active steps become failed (on an error).
void failRemaining() {
for (final s in steps) {
if (s.state == StepState.active) {
s.state = StepState.failed;
} else if (s.state == StepState.pending) {
s.state = StepState.skipped;
}
}
_emit();
}
void clear() {
steps.clear();
_emit();
}
void _emit() {
if (!_controller.isClosed) {
_controller.add(List.unmodifiable(steps));
}
}
void dispose() => _controller.close();
}

View File

@@ -0,0 +1,7 @@
/// The three font roles every piece of display copy is drawn with. Resolved to
/// an actual family by `getTextType` in designs/text/Text.dart.
///
/// Capitalised deliberately: these read as type names at every call site
/// (`text('Peach', 20, TextType.Bold)`), matching the reference architecture.
// ignore_for_file: constant_identifier_names
enum TextType { Bold, Light, Regular }

View File

@@ -0,0 +1,372 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../application/ServiceConfig.dart';
import '../../../comms/Comms.dart' show LogLine, LogLevel;
import '../application/StepTracker.dart';
class BuildResult {
final bool ok;
final String message;
final File? jar; // jar services
final Directory? dir; // web apps (static output directory)
BuildResult(this.ok, this.message, {this.jar, this.dir});
}
/// Builds a jar from a local Maven/Gradle project (desktop only) and locates
/// the produced artifact so it can be deployed.
class BuildService {
final _logController = StreamController<LogLine>.broadcast();
Stream<LogLine> get logs => _logController.stream;
final steps = StepTracker();
Stream<List<DeployStep>> get stepStream => steps.stream;
Process? _proc;
void _log(LogLevel level, String text) {
for (final line in const LineSplitter().convert(text)) {
if (line.trim().isEmpty && level == LogLevel.remote) continue;
_logController.add(LogLine(level, line));
}
}
/// True on desktop, where local builds can run.
static bool get supported =>
Platform.isWindows || Platform.isMacOS || Platform.isLinux;
Future<BuildResult> build(ServiceConfig svc) async {
steps.reset([
('detect', 'Detect project'),
('compile', 'Compile'),
('locate', 'Locate jar'),
]);
if (!supported) {
steps.failRemaining();
return BuildResult(false, 'Local builds are only available on desktop.');
}
final dir = svc.localProjectPath.trim();
if (dir.isEmpty) {
steps.failRemaining();
return BuildResult(false, 'No project path set.');
}
if (!Directory(dir).existsSync()) {
steps.failRemaining();
return BuildResult(false, 'Project path does not exist: $dir');
}
steps.active('detect');
final (exec, args, tool) = _resolveCommand(svc, dir);
if (exec == null) {
steps.failRemaining();
return BuildResult(
false,
'Could not detect a Maven (pom.xml) or Gradle (build.gradle) '
'project in $dir. Set a custom build command in the service.',
);
}
steps.done('detect');
steps.active('compile');
_log(LogLevel.step, 'Building $tool project in $dir …');
_log(LogLevel.info, '\$ $exec ${args.join(' ')}');
try {
final proc = await Process.start(
exec,
args,
workingDirectory: dir,
runInShell: true,
);
_proc = proc;
proc.stdout
.transform(utf8.decoder)
.listen((d) => _log(LogLevel.remote, d.trimRight()));
proc.stderr
.transform(utf8.decoder)
.listen((d) => _log(LogLevel.remote, d.trimRight()));
final code = await proc.exitCode;
_proc = null;
if (code != 0) {
steps.failRemaining();
return BuildResult(false, 'Build failed (exit code $code).');
}
_log(LogLevel.success, 'Build finished ✓');
steps.done('compile');
} catch (e) {
_proc = null;
steps.failRemaining();
return BuildResult(false, 'Could not run build: $e');
}
// Locate the produced jar.
steps.active('locate');
_log(LogLevel.step, 'Locating built jar …');
final jar = _findJar(svc, dir, tool);
if (jar == null) {
steps.failRemaining();
return BuildResult(
false,
'Build succeeded but no jar was found. Set a jar pattern.',
);
}
steps.done('locate');
_log(LogLevel.success, 'Found jar: ${jar.path}');
return BuildResult(true, 'Built ${_basename(jar.path)}', jar: jar);
}
/// Builds a web app (Angular/React/Flutter web) and returns the directory of
/// static files to publish. For [WebFramework.staticFiles] no build runs.
Future<BuildResult> buildWeb(ServiceConfig svc) async {
steps.reset([
('compile', 'Build ${svc.framework.label}'),
('locate', 'Locate output'),
]);
if (!supported) {
steps.failRemaining();
return BuildResult(false, 'Local builds are only available on desktop.');
}
final dir = svc.localProjectPath.trim();
if (dir.isEmpty) {
steps.failRemaining();
return BuildResult(false, 'No project path set.');
}
if (!Directory(dir).existsSync()) {
steps.failRemaining();
return BuildResult(false, 'Project path does not exist: $dir');
}
final command = svc.buildCommand.trim().isNotEmpty
? svc.buildCommand.trim()
: svc.framework.defaultBuild;
steps.active('compile');
if (command.isNotEmpty) {
_log(LogLevel.step, 'Building ${svc.framework.label} in $dir …');
_log(LogLevel.info, '\$ $command');
final (exec, args) = Platform.isWindows
? ('cmd', ['/c', command])
: ('/bin/sh', ['-c', command]);
try {
final proc = await Process.start(
exec,
args,
workingDirectory: dir,
runInShell: true,
);
_proc = proc;
proc.stdout
.transform(utf8.decoder)
.listen((d) => _log(LogLevel.remote, d.trimRight()));
proc.stderr
.transform(utf8.decoder)
.listen((d) => _log(LogLevel.remote, d.trimRight()));
final code = await proc.exitCode;
_proc = null;
if (code != 0) {
steps.failRemaining();
return BuildResult(false, 'Build failed (exit $code).');
}
_log(LogLevel.success, 'Build finished ✓');
} catch (e) {
_proc = null;
steps.failRemaining();
return BuildResult(false, 'Could not run build: $e');
}
} else {
_log(LogLevel.info, 'Static files — no build step.');
}
steps.done('compile');
steps.active('locate');
_log(LogLevel.step, 'Locating web output …');
final out = _findWebOutput(svc, dir);
if (out == null) {
steps.failRemaining();
return BuildResult(
false,
'Could not find the built site (an index.html). '
'Set the output directory in the service.',
);
}
steps.done('locate');
_log(LogLevel.success, 'Publishing from: ${out.path}');
return BuildResult(true, 'Built ${svc.framework.label}', dir: out);
}
Directory? _findWebOutput(ServiceConfig svc, String dir) {
final sub = svc.outputSubdir.trim();
if (sub.isNotEmpty) {
final d = Directory(
sub.startsWith('/') || sub.contains(':') ? sub : '$dir/$sub',
);
if (!d.existsSync()) return null;
// If the pointed-at folder holds index.html use it; otherwise drill into
// it (e.g. user set `dist` but Angular nests dist/<proj>/browser).
return _withIndex(d) ?? _findDistWithIndex(d.path) ?? d;
}
switch (svc.framework) {
case WebFramework.flutterWeb:
return _withIndex(Directory('$dir/build/web'));
case WebFramework.angular:
return _findDistWithIndex('$dir/dist');
case WebFramework.react:
return _withIndex(Directory('$dir/build')) ??
_withIndex(Directory('$dir/dist'));
case WebFramework.staticFiles:
return _withIndex(Directory(dir)) ?? Directory(dir);
}
}
/// Returns [d] if it (or a nested `browser/` dir) contains index.html.
Directory? _withIndex(Directory d) {
if (!d.existsSync()) return null;
if (File('${d.path}/index.html').existsSync()) return d;
final browser = Directory('${d.path}/browser'); // Angular 17+
if (File('${browser.path}/index.html').existsSync()) return browser;
return null;
}
/// Angular puts output under `dist/<project>/` — find the one with index.html.
Directory? _findDistWithIndex(String distPath) {
final dist = Directory(distPath);
if (!dist.existsSync()) return null;
final direct = _withIndex(dist);
if (direct != null) return direct;
for (final entry in dist.listSync().whereType<Directory>()) {
final found = _withIndex(entry);
if (found != null) return found;
}
return null;
}
void cancel() {
_proc?.kill();
_proc = null;
}
void dispose() {
cancel();
_logController.close();
steps.dispose();
}
// ---- command resolution ---------------------------------------------------
(String?, List<String>, String) _resolveCommand(
ServiceConfig svc,
String dir,
) {
final custom = svc.buildCommand.trim();
if (custom.isNotEmpty) {
if (Platform.isWindows) {
return ('cmd', ['/c', custom], 'custom');
}
return ('/bin/sh', ['-c', custom], 'custom');
}
final hasPom = File('$dir/pom.xml').existsSync();
final hasGradle =
File('$dir/build.gradle').existsSync() ||
File('$dir/build.gradle.kts').existsSync();
if (hasPom) {
final wrapper = _wrapper(dir, 'mvnw', 'mvnw.cmd');
final exec = wrapper ?? 'mvn';
return (exec, ['-B', '-DskipTests', 'package'], 'Maven');
}
if (hasGradle) {
final wrapper = _wrapper(dir, 'gradlew', 'gradlew.bat');
final exec = wrapper ?? 'gradle';
return (exec, ['clean', 'build', '-x', 'test'], 'Gradle');
}
return (null, <String>[], '');
}
String? _wrapper(String dir, String unixName, String winName) {
if (Platform.isWindows) {
final w = File('$dir/$winName');
if (w.existsSync()) return w.path;
} else {
final w = File('$dir/$unixName');
if (w.existsSync()) return w.path;
}
return null;
}
// ---- jar location ---------------------------------------------------------
File? _findJar(ServiceConfig svc, String dir, String tool) {
final pattern = svc.jarPattern.trim();
if (pattern.isNotEmpty) {
final full = pattern.startsWith('/') || pattern.contains(':')
? pattern
: '$dir/$pattern';
if (!full.contains('*')) {
final f = File(full);
return f.existsSync() ? f : null;
}
final slash = full.lastIndexOf('/');
final searchDir = slash >= 0 ? full.substring(0, slash) : dir;
final glob = slash >= 0 ? full.substring(slash + 1) : full;
return _pickJar(searchDir, glob: glob);
}
// Auto: search the tool's default output dir. For a custom command the
// tool is unknown, so try both common locations.
final candidates = switch (tool) {
'Gradle' => ['$dir/build/libs'],
'Maven' => ['$dir/target'],
_ => ['$dir/build/libs', '$dir/target'],
};
for (final c in candidates) {
final jar = _pickJar(c);
if (jar != null) return jar;
}
return null;
}
File? _pickJar(String searchDir, {String glob = '*.jar'}) {
final d = Directory(searchDir);
if (!d.existsSync()) return null;
final re = _globToRegExp(glob);
var jars = d
.listSync()
.whereType<File>()
.where((f) => f.path.toLowerCase().endsWith('.jar'))
.where((f) => re.hasMatch(_basename(f.path)))
.toList();
if (jars.isEmpty) return null;
bool isArtifact(File f) {
final n = _basename(f.path).toLowerCase();
return !n.contains('-sources') &&
!n.contains('-javadoc') &&
!n.startsWith('original-') &&
!n.endsWith('-plain.jar');
}
final preferred = jars.where(isArtifact).toList();
final pool = preferred.isNotEmpty ? preferred : jars;
pool.sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified));
return pool.first;
}
RegExp _globToRegExp(String glob) {
final buf = StringBuffer('^');
for (final ch in glob.split('')) {
if (ch == '*') {
buf.write('.*');
} else if ('.[]{}()+-^\$|?\\'.contains(ch)) {
buf.write('\\$ch');
} else {
buf.write(ch);
}
}
buf.write(r'$');
return RegExp(buf.toString(), caseSensitive: false);
}
static String _basename(String path) {
final i = path.replaceAll('\\', '/').lastIndexOf('/');
return i >= 0 ? path.substring(i + 1) : path;
}
}

View File

@@ -0,0 +1,38 @@
import 'dart:typed_data';
import '../../../comms/Comms.dart' show LogLine;
import '../application/ServiceConfig.dart';
import '../application/StepTracker.dart';
import 'BuildService.dart';
/// Local-side file work: building the artifact to ship, reading it into
/// memory, and packing a web build into a single archive. Kept behind an
/// interface so a ViewModel reaches it through the same [DataManager] as
/// everything else.
abstract class ConnectFileStorage {
/// True on desktop, where local builds can run.
bool get buildSupported;
/// Build log lines and step progress, for the console and step list.
Stream<LogLine> get buildLogs;
Stream<List<DeployStep>> get buildStepStream;
/// Compiles [service]'s project and locates the produced jar.
Future<BuildResult> build(ServiceConfig service);
/// Compiles [service]'s web project and locates its static output directory.
Future<BuildResult> buildWeb(ServiceConfig service);
/// Cancels a build in flight.
void cancelBuild();
/// Reads a local artifact into memory, ready to upload.
Future<Uint8List> readArtifact(String path);
/// True when [path] exists on this machine.
Future<bool> artifactExists(String path);
/// Packs a directory of static files into gzipped tar bytes, returning the
/// archive and the number of files it contains. Runs off the UI isolate.
Future<(Uint8List, int)> packageWeb(String directoryPath);
}

View File

@@ -0,0 +1,47 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../../../comms/Comms.dart' show LogLine;
import '../application/ServiceConfig.dart';
import '../application/StepTracker.dart';
import 'BuildService.dart';
import 'ConnectFileStorage.dart';
import 'WebArchive.dart';
/// The one concrete [ConnectFileStorage]. Owns the [BuildService] and delegates
/// archiving to [WebArchive]; holds no state of its own beyond the builder.
class FileStorage implements ConnectFileStorage {
final BuildService _builder = BuildService();
@override
bool get buildSupported => BuildService.supported;
@override
Stream<LogLine> get buildLogs => _builder.logs;
@override
Stream<List<DeployStep>> get buildStepStream => _builder.stepStream;
@override
Future<BuildResult> build(ServiceConfig service) => _builder.build(service);
@override
Future<BuildResult> buildWeb(ServiceConfig service) =>
_builder.buildWeb(service);
@override
void cancelBuild() => _builder.cancel();
@override
Future<Uint8List> readArtifact(String path) => File(path).readAsBytes();
@override
Future<bool> artifactExists(String path) => File(path).exists();
@override
Future<(Uint8List, int)> packageWeb(String directoryPath) =>
// Packaging a large static tree blocks long enough to drop frames, so it
// runs on a background isolate.
compute(packageWebDir, directoryPath);
}

View File

@@ -0,0 +1,34 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive_io.dart';
/// Packs a directory of static files into a gzip-compressed tar archive
/// (contents at the archive root) for a single-shot upload + remote extract.
class WebArchive {
/// Returns the .tar.gz bytes of [dir]'s contents and the number of files.
static (Uint8List, int) tarGz(Directory dir) {
final archive = createArchiveFromDirectory(dir, includeDirName: false);
// CRITICAL: on Windows the entry names use backslashes; a POSIX `tar` on
// the server would then create single files literally named
// `assets\main.js` instead of the `assets/main.js` tree — breaking any
// app with subdirectories (Angular/React assets, etc.). Force forward
// slashes so the archive is valid on Linux.
for (final f in archive.files) {
f.name = f.name.replaceAll('\\', '/');
}
final tar = TarEncoder().encode(archive);
final gz = GZipEncoder().encode(tar);
final count = archive.files.where((f) => f.isFile).length;
// Release the file handles opened while reading the directory so the
// source tree isn't left locked (notably on Windows).
for (final f in archive.files) {
f.closeSync();
}
return (Uint8List.fromList(gz), count);
}
}
/// Top-level entry so packaging can run off the UI isolate via `compute`.
(Uint8List, int) packageWebDir(String path) =>
WebArchive.tarGz(Directory(path));