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
@@ -0,0 +1,165 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Instances;
/// <summary>
/// #764: Finds the running instance manager, or starts it — for the two programs a person starts
/// by hand: <c>ClawdDotNet</c> without a switch and the shell (<c>ClawdDotNet.Shell</c>).
///
/// <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>
/// </summary>
public static class ManagerStarter
{
public const int DefaultPort = 7000;
public const string ServiceUnit = "clawddotnet-manager.service";
private static readonly TimeSpan StartWait = TimeSpan.FromSeconds(30);
/// <summary>Where <c>manager.json</c> lives — and next to it the manager's local socket.</summary>
public static string ConfigDirectory => AppPaths.ConfigDirectory;
/// <summary>
/// Makes sure the manager answers. Returns the port, or the reason it does not answer.
/// </summary>
/// <param name="searchFrom">Folder of the calling program; the manager is looked for there and one level up.</param>
public static async Task<(int Port, string? Problem)> EnsureRunningAsync(string searchFrom)
{
var port = Port(ConfigDirectory);
if (Answers(port))
return (port, null);
if (Start(searchFrom) is { } problem)
return (port, problem);
var until = DateTime.UtcNow + StartWait;
while (!Answers(port) && DateTime.UtcNow < until)
await Task.Delay(500).ConfigureAwait(false);
return Answers(port)
? (port, null)
: (port, $"The instance manager did not answer on port {port} within {StartWait.TotalSeconds:0} s. "
+ "Its log is under Logs/Manager in the data directory.");
}
public 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)
{
return false;
}
}
/// <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 the callers do not know.
/// </summary>
public static int Port(string configDirectory)
{
try
{
var file = Path.Combine(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;
}
}
/// <returns>Why the manager could not be started, or <c>null</c>.</returns>
private static string? Start(string searchFrom)
{
if (OperatingSystem.IsLinux() && Systemctl("cat", ServiceUnit) == 0)
{
return Systemctl("start", ServiceUnit) == 0
? null
: $"systemctl --user start {ServiceUnit} failed; see journalctl --user -u {ServiceUnit}.";
}
if (FindExecutable(searchFrom) is not { } manager)
return "The instance manager (ClawdDotNet.Instanzmanager) was not found next to this program.";
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 the caller.
Process.Start(new ProcessStartInfo(manager)
{
UseShellExecute = OperatingSystem.IsWindows(),
WorkingDirectory = Path.GetDirectoryName(manager)!
});
return null;
}
catch (Exception ex)
{
return $"The instance manager could not be started: {ex.Message}";
}
}
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 the caller, one level up (the shell lives in <c>shell/</c>), or in the build folders
/// of 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 the old
/// <c>App.axaml.cs</c>).</para>
/// </summary>
public static string? FindExecutable(string searchFrom)
{
var name = OperatingSystem.IsWindows() ? "ClawdDotNet.Instanzmanager.exe" : "ClawdDotNet.Instanzmanager";
string[][] candidates =
[
[],
[".."],
["..", "..", "..", "..", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"],
["..", "..", "..", "..", "..", "src", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"]
];
foreach (var segments in candidates)
{
var path = Path.GetFullPath(Path.Combine([searchFrom, .. segments, name]));
if (File.Exists(path)) return path;
}
return null;
}
}