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,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));