Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
372
lib/Peach/about/internal/file/BuildService.dart
Normal file
372
lib/Peach/about/internal/file/BuildService.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
38
lib/Peach/about/internal/file/ConnectFileStorage.dart
Normal file
38
lib/Peach/about/internal/file/ConnectFileStorage.dart
Normal 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);
|
||||
}
|
||||
47
lib/Peach/about/internal/file/FileStorage.dart
Normal file
47
lib/Peach/about/internal/file/FileStorage.dart
Normal 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);
|
||||
}
|
||||
34
lib/Peach/about/internal/file/WebArchive.dart
Normal file
34
lib/Peach/about/internal/file/WebArchive.dart
Normal 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));
|
||||
Reference in New Issue
Block a user