Stufe 2 (#764): Huelle ClawdDotNet.Shell mit eingebettetem Chromium

Avalonia 11.3.17 + CefGlue.Next 152 in eigenem Ordner shell/. Startet den
Manager (ManagerStarter, nach Core gezogen), holt ueber den lokalen Socket
einen Einmal-Link und zeigt die WebUI angemeldet; Taskleistensymbol mit
Zustand. ClawdDotNet ohne Schalter startet die Huelle, sonst den Browser.
publish.py baut sie mit (--no-shell zum Weglassen), unter Linux mit den
Chromium-Ressourcen neben libcef.so. --selftest fuer die Pruefung ohne Blick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-03 18:49:04 +02:00
co-authored by Claude Opus 5.5
parent 775c4dbc8e
commit c9c56ea53b
10 changed files with 579 additions and 141 deletions
+22 -139
View File
@@ -1,6 +1,5 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Desktop;
@@ -11,47 +10,29 @@ namespace ClawdDotNet.Desktop;
///
/// <para>Until #764 that opened the Avalonia window. The windows are gone; the web interface is
/// the only one. What a person who starts the program wants is therefore: the instance manager
/// running, and its web interface in front of them. Until the shell with its own window exists
/// (#764, stage 2) the system browser shows it.</para>
/// running, and its web interface in front of them — in the shell's window
/// (<c>shell/ClawdDotNet.Shell</c>) where the package brings it, otherwise in the system
/// browser.</para>
///
/// <para><b>Never a second manager.</b> If the manager answers, it is left alone. If it runs as
/// a systemd service on this machine, the service is started — starting the program next to it
/// would put two managers on the same instances. Only otherwise is the manager program started
/// directly.</para>
/// <para>The shell is a program of its own, in its own folder: it carries Avalonia 11.3 and
/// Chromium, and the instance process — started once per instance by the manager — should load
/// neither.</para>
/// </summary>
internal static class LauncherRun
{
private const int ExitOk = 0;
private const int ExitNoManager = 6;
private const int DefaultPort = 7000;
private const string ServiceUnit = "clawddotnet-manager.service";
private static readonly TimeSpan StartWait = TimeSpan.FromSeconds(30);
public static async Task<int> RunAsync()
{
var port = ManagerPort();
if (StartShell())
return ExitOk;
if (!Answers(port))
var (port, problem) = await ManagerStarter.EnsureRunningAsync(AppContext.BaseDirectory);
if (problem is not null)
{
var problem = StartManager();
if (problem is not null)
{
await Console.Error.WriteLineAsync(problem);
return ExitNoManager;
}
var until = DateTime.UtcNow + StartWait;
while (!Answers(port) && DateTime.UtcNow < until)
await Task.Delay(500);
if (!Answers(port))
{
await Console.Error.WriteLineAsync(
$"The instance manager did not answer on port {port} within {StartWait.TotalSeconds:0} s. "
+ "Its log is under Logs/Manager in the data directory.");
return ExitNoManager;
}
await Console.Error.WriteLineAsync(problem);
return ExitNoManager;
}
var url = $"http://localhost:{port}/";
@@ -60,121 +41,23 @@ internal static class LauncherRun
return ExitOk;
}
/// <returns>Why the manager could not be started, or <c>null</c>.</returns>
private static string? StartManager()
/// <summary>The shell starts the manager itself and signs its window in.</summary>
private static bool StartShell()
{
if (OperatingSystem.IsLinux() && Systemctl("cat", ServiceUnit) == 0)
{
return Systemctl("start", ServiceUnit) == 0
? null
: $"systemctl --user start {ServiceUnit} failed; see journalctl --user -u {ServiceUnit}.";
}
if (FindManagerExecutable() is not { } manager)
{
return "The instance manager (ClawdDotNet.Instanzmanager) was not found next to this program.";
}
var name = OperatingSystem.IsWindows() ? "ClawdDotNet.Shell.exe" : "ClawdDotNet.Shell";
var shell = Path.Combine(AppContext.BaseDirectory, "shell", name);
if (!File.Exists(shell))
return false;
try
{
// On Windows with its own console window: that is where the manager shows the access
// token on first start. Elsewhere a plain child that outlives us.
Process.Start(new ProcessStartInfo(manager)
{
UseShellExecute = OperatingSystem.IsWindows(),
WorkingDirectory = Path.GetDirectoryName(manager)!
});
return null;
Process.Start(new ProcessStartInfo(shell) { UseShellExecute = false, WorkingDirectory = Path.GetDirectoryName(shell)! });
return true;
}
catch (Exception ex)
{
return $"The instance manager could not be started: {ex.Message}";
}
}
private static bool Answers(int port)
{
try
{
using var client = new TcpClient();
return client.ConnectAsync("127.0.0.1", port).Wait(TimeSpan.FromSeconds(1)) && client.Connected;
}
catch (Exception)
{
Console.Error.WriteLine($"The shell could not be started ({ex.Message}); opening the browser instead.");
return false;
}
}
private static int Systemctl(string verb, string unit)
{
try
{
var start = new ProcessStartInfo("systemctl") { RedirectStandardOutput = true, RedirectStandardError = true };
start.ArgumentList.Add("--user");
start.ArgumentList.Add(verb);
start.ArgumentList.Add(unit);
using var p = Process.Start(start)!;
p.StandardOutput.ReadToEnd();
p.StandardError.ReadToEnd();
p.WaitForExit(30_000);
return p.HasExited ? p.ExitCode : -1;
}
catch (Exception)
{
return -1; // no systemd here
}
}
/// <summary>
/// Next to this program in an installation; the build folders in a development checkout.
///
/// <para>Built from segments, not as a string: a path with backslashes is one file name on
/// Linux, and the search never found anything there (moved here from <c>App.axaml.cs</c>).</para>
/// </summary>
private static string? FindManagerExecutable()
{
var name = OperatingSystem.IsWindows() ? "ClawdDotNet.Instanzmanager.exe" : "ClawdDotNet.Instanzmanager";
var nextTo = Path.Combine(AppContext.BaseDirectory, name);
if (File.Exists(nextTo)) return nextTo;
string[][] developmentPaths =
[
["..", "..", "..", "..", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"],
["..", "..", "..", "..", "..", "src", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"]
];
foreach (var segments in developmentPaths)
{
var path = Path.GetFullPath(Path.Combine([AppContext.BaseDirectory, .. segments, name]));
if (File.Exists(path)) return path;
}
return null;
}
/// <summary>
/// The port from <c>manager.json</c>; the default on any doubt. Read as one field, not as
/// the manager's settings type, which this program does not know.
/// </summary>
private static int ManagerPort()
{
try
{
var file = Path.Combine(AppPaths.ConfigDirectory, "manager.json");
if (!File.Exists(file)) return DefaultPort;
using var doc = JsonDocument.Parse(File.ReadAllText(file));
return doc.RootElement.TryGetProperty("webUiPort", out var port)
&& port.ValueKind == JsonValueKind.Number
&& port.TryGetInt32(out var value)
&& value is > 0 and < 65536
? value
: DefaultPort;
}
catch (Exception)
{
return DefaultPort;
}
}
}