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
+1
View File
@@ -12,6 +12,7 @@
<Project Path="src/ClawdDotNet.Tools.WebFetch/ClawdDotNet.Tools.WebFetch.csproj" /> <Project Path="src/ClawdDotNet.Tools.WebFetch/ClawdDotNet.Tools.WebFetch.csproj" />
<Project Path="src/ClawdDotNet.Tools.WebMonitor/ClawdDotNet.Tools.WebMonitor.csproj" /> <Project Path="src/ClawdDotNet.Tools.WebMonitor/ClawdDotNet.Tools.WebMonitor.csproj" />
<Project Path="src/ClawdDotNet.Tools.TelegramClient/ClawdDotNet.Tools.TelegramClient.csproj" /> <Project Path="src/ClawdDotNet.Tools.TelegramClient/ClawdDotNet.Tools.TelegramClient.csproj" />
<Project Path="src/ClawdDotNet.Shell/ClawdDotNet.Shell.csproj" />
<Project Path="src/ClawdDotNet.Tools.AgentComm/ClawdDotNet.Tools.AgentComm.csproj" /> <Project Path="src/ClawdDotNet.Tools.AgentComm/ClawdDotNet.Tools.AgentComm.csproj" />
<Project Path="src/ClawdDotNet.Tools.AgentSpawn/ClawdDotNet.Tools.AgentSpawn.csproj" /> <Project Path="src/ClawdDotNet.Tools.AgentSpawn/ClawdDotNet.Tools.AgentSpawn.csproj" />
<Project Path="src/ClawdDotNet.Tools.AgentEditor/ClawdDotNet.Tools.AgentEditor.csproj" /> <Project Path="src/ClawdDotNet.Tools.AgentEditor/ClawdDotNet.Tools.AgentEditor.csproj" />
+46 -2
View File
@@ -36,6 +36,7 @@ from pathlib import Path
REPO = Path(__file__).resolve().parent.parent REPO = Path(__file__).resolve().parent.parent
PROJECT = REPO / "src" / "ClawdDotNet.Desktop" / "ClawdDotNet.Desktop.csproj" PROJECT = REPO / "src" / "ClawdDotNet.Desktop" / "ClawdDotNet.Desktop.csproj"
MANAGER_PROJECT = REPO / "src" / "ClawdDotNet.ManagerApp" / "ClawdDotNet.ManagerApp.csproj" MANAGER_PROJECT = REPO / "src" / "ClawdDotNet.ManagerApp" / "ClawdDotNet.ManagerApp.csproj"
SHELL_PROJECT = REPO / "src" / "ClawdDotNet.Shell" / "ClawdDotNet.Shell.csproj"
CONFIG = REPO / "deploy" / "packager.config.json" CONFIG = REPO / "deploy" / "packager.config.json"
PACKAGER = Path( PACKAGER = Path(
"J:/Softwareprojekte/Deploymentcenter/client-dotnet/Deploymentcenter.Packager" "J:/Softwareprojekte/Deploymentcenter/client-dotnet/Deploymentcenter.Packager"
@@ -59,7 +60,7 @@ def run(cmd: list[str], **kw) -> None:
sys.exit(f"Abgebrochen (Rueckgabewert {result.returncode}): {cmd[0]}") sys.exit(f"Abgebrochen (Rueckgabewert {result.returncode}): {cmd[0]}")
def publish(rid: str, out_dir: Path) -> None: def publish(rid: str, out_dir: Path, include_shell: bool = True) -> None:
if out_dir.exists(): if out_dir.exists():
shutil.rmtree(out_dir) shutil.rmtree(out_dir)
out_dir.mkdir(parents=True) out_dir.mkdir(parents=True)
@@ -90,6 +91,9 @@ def publish(rid: str, out_dir: Path) -> None:
]) ])
merge_manager(manager_dir, out_dir) merge_manager(manager_dir, out_dir)
if include_shell:
publish_shell(rid, out_dir, stamp)
# #795: Die systemd-Vorlagen gehoeren ins Linux-Paket - sonst steht die Anleitung in # #795: Die systemd-Vorlagen gehoeren ins Linux-Paket - sonst steht die Anleitung in
# einem Repository, das auf der Zielmaschine niemand hat. # einem Repository, das auf der Zielmaschine niemand hat.
if rid.startswith("linux"): if rid.startswith("linux"):
@@ -100,6 +104,44 @@ def publish(rid: str, out_dir: Path) -> None:
print(f" systemd-Vorlagen: {len(list(units.glob('*.service')))} nach systemd/") print(f" systemd-Vorlagen: {len(list(units.glob('*.service')))} nach systemd/")
def publish_shell(rid: str, out_dir: Path, stamp: str) -> None:
"""#764: Die Huelle - Fenster mit eingebettetem Chromium - nach shell/.
Eigener Ordner, weil sie Avalonia 11.3 mitbringt (CefGlue ist dagegen gebaut) und der
Instanzprozess weder Avalonia noch Chromium laden soll. Kein merge_manager: Die
Bibliotheken der Huelle sollen gerade NICHT neben denen der Instanz liegen.
build statt publish: Das Chromium-Laufzeitpaket kopiert seine Dateien nur im
AfterBuild-Ziel mit gesetzter Plattform; ein publish liess sie aus (23 MB statt
~370 MB, und kein libcef). Unter Linux sucht Chromium icudtl.dat und die .pak-Dateien
neben libcef.so, das Paket legt sie nach Resources/ - sie werden hochgezogen.
"""
shell_dir = out_dir / "shell"
if shell_dir.exists():
shutil.rmtree(shell_dir)
run([
"dotnet", "build", str(SHELL_PROJECT),
"-c", "Release", "-r", rid, "--self-contained", "false",
"-o", str(shell_dir), "--nologo", "-v", "q", stamp,
])
resources = shell_dir / "Resources"
if rid.startswith("linux") and resources.is_dir():
for item in resources.iterdir():
target = shell_dir / item.name
if target.exists():
shutil.rmtree(target) if target.is_dir() else target.unlink()
shutil.move(str(item), str(target))
resources.rmdir()
for pdb in shell_dir.rglob("*.pdb"):
pdb.unlink()
size = sum(f.stat().st_size for f in shell_dir.rglob("*") if f.is_file())
print(f" Huelle: {size / 1_048_576:.1f} MB nach shell/")
def merge_manager(src: Path, dst: Path) -> None: def merge_manager(src: Path, dst: Path) -> None:
"""Legt den Manager neben die Instanz. """Legt den Manager neben die Instanz.
@@ -183,6 +225,8 @@ def main() -> None:
parser.add_argument("--agent-file", type=Path, parser.add_argument("--agent-file", type=Path,
help="vorhandene Kopie des update-agent statt Download; " help="vorhandene Kopie des update-agent statt Download; "
"die Pruefsumme wird trotzdem gegen installer.json geprueft") "die Pruefsumme wird trotzdem gegen installer.json geprueft")
parser.add_argument("--no-shell", action="store_true",
help="die Huelle (Fenster mit Chromium, ~370 MB) nicht mitliefern")
parser.add_argument("--dry-run", action="store_true", parser.add_argument("--dry-run", action="store_true",
help="nur bauen und pruefen, nicht hochladen") help="nur bauen und pruefen, nicht hochladen")
args = parser.parse_args() args = parser.parse_args()
@@ -193,7 +237,7 @@ def main() -> None:
out_dir = Path(tempfile.gettempdir()) / f"clawddotnet-publish-{args.rid}" out_dir = Path(tempfile.gettempdir()) / f"clawddotnet-publish-{args.rid}"
print("\n[1/3] dotnet publish") print("\n[1/3] dotnet publish")
publish(args.rid, out_dir) publish(args.rid, out_dir, include_shell=not args.no_shell)
print("\n[2/3] update-agent") print("\n[2/3] update-agent")
if args.no_agent: if args.no_agent:
@@ -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;
}
}
+19 -136
View File
@@ -1,6 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using System.Net.Sockets; using ClawdDotNet.Core.Instances;
using System.Text.Json;
using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Desktop; namespace ClawdDotNet.Desktop;
@@ -11,170 +10,54 @@ namespace ClawdDotNet.Desktop;
/// ///
/// <para>Until #764 that opened the Avalonia window. The windows are gone; the web interface is /// <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 /// 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 /// running, and its web interface in front of them — in the shell's window
/// (#764, stage 2) the system browser shows it.</para> /// (<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 /// <para>The shell is a program of its own, in its own folder: it carries Avalonia 11.3 and
/// a systemd service on this machine, the service is started — starting the program next to it /// Chromium, and the instance process — started once per instance by the manager — should load
/// would put two managers on the same instances. Only otherwise is the manager program started /// neither.</para>
/// directly.</para>
/// </summary> /// </summary>
internal static class LauncherRun internal static class LauncherRun
{ {
private const int ExitOk = 0; private const int ExitOk = 0;
private const int ExitNoManager = 6; 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() public static async Task<int> RunAsync()
{ {
var port = ManagerPort(); if (StartShell())
return ExitOk;
if (!Answers(port)) var (port, problem) = await ManagerStarter.EnsureRunningAsync(AppContext.BaseDirectory);
{
var problem = StartManager();
if (problem is not null) if (problem is not null)
{ {
await Console.Error.WriteLineAsync(problem); await Console.Error.WriteLineAsync(problem);
return ExitNoManager; 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;
}
}
var url = $"http://localhost:{port}/"; var url = $"http://localhost:{port}/";
Console.WriteLine(url); Console.WriteLine(url);
SystemShell.OpenUrl(url); SystemShell.OpenUrl(url);
return ExitOk; return ExitOk;
} }
/// <returns>Why the manager could not be started, or <c>null</c>.</returns> /// <summary>The shell starts the manager itself and signs its window in.</summary>
private static string? StartManager() private static bool StartShell()
{ {
if (OperatingSystem.IsLinux() && Systemctl("cat", ServiceUnit) == 0) var name = OperatingSystem.IsWindows() ? "ClawdDotNet.Shell.exe" : "ClawdDotNet.Shell";
{ var shell = Path.Combine(AppContext.BaseDirectory, "shell", name);
return Systemctl("start", ServiceUnit) == 0 if (!File.Exists(shell))
? null return false;
: $"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.";
}
try try
{ {
// On Windows with its own console window: that is where the manager shows the access Process.Start(new ProcessStartInfo(shell) { UseShellExecute = false, WorkingDirectory = Path.GetDirectoryName(shell)! });
// token on first start. Elsewhere a plain child that outlives us. return true;
Process.Start(new ProcessStartInfo(manager)
{
UseShellExecute = OperatingSystem.IsWindows(),
WorkingDirectory = Path.GetDirectoryName(manager)!
});
return null;
} }
catch (Exception ex) catch (Exception ex)
{ {
return $"The instance manager could not be started: {ex.Message}"; Console.Error.WriteLine($"The shell could not be started ({ex.Message}); opening the browser instead.");
}
}
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)
{
return false; 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;
}
}
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- #764, stage 2: the shell - a window around the web interface, with a tray icon.
Its own program in its own folder (shell/ in the package), on purpose:
- Chromium (CefGlue.Next) is built against Avalonia 11.3; with Avalonia 12 it fails at
the first browser (TypeLoadException, tried 2026-10-02). Two Avalonia versions do not
fit into one output folder.
- The instance process is started once per instance by the manager and should load
neither Avalonia nor Chromium. -->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<!-- The Chromium runtime package for Linux copies libcef & co. only for OutputType Exe; WinExe
makes no difference on Linux anyway, but skipped the copy when building for linux-x64. -->
<OutputType Condition="$(RuntimeIdentifier.StartsWith('linux'))">Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.Shell</RootNamespace>
<AssemblyName>ClawdDotNet.Shell</AssemblyName>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
<!-- Without it Windows refuses the child window that hosts Chromium
("Unable to create child window for native control host"). -->
<ApplicationManifest>app.manifest</ApplicationManifest>
<!-- CefGlue would raise the stack size of the apphost with editbin from Visual Studio;
the build machine does not need Visual Studio for that, Chromium's own processes are
started from this same executable and run with the default. -->
<IsEditbinEnabled>false</IsEditbinEnabled>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.17" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.17" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.17" />
<!-- A slim Linux image often has no system font at all; the tray menu and the error text
need one. (The same reason the old desktop window shipped Barlow.) -->
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.17" />
<PackageReference Include="CefGlue.Next.Avalonia" Version="152.7977.831" />
<PackageReference Include="CefGlue.Next.BrowserProcess.Core" Version="152.7977.831" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
</ItemGroup>
</Project>
+124
View File
@@ -0,0 +1,124 @@
using System.Net.Http.Json;
using Avalonia;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Storage;
using Xilium.CefGlue;
using Xilium.CefGlue.BrowserProcess;
using Xilium.CefGlue.Common;
namespace ClawdDotNet.Shell;
/// <summary>
/// #764, stage 2: the shell. Starts the instance manager if it is not running, asks it for a
/// one-time sign-in link over its local socket and shows the web interface in its own window,
/// signed in. The manager keeps running when the window closes — it supervises the instances,
/// the window only shows them.
/// </summary>
internal static class Program
{
private const int ExitOk = 0;
private const int ExitNoManager = 6;
/// <summary>Where the window points; set before Avalonia starts.</summary>
internal static string StartUrl { get; private set; } = "";
internal static int ManagerPort { get; private set; }
/// <summary>
/// <c>--selftest</c>: load the page, report whether the sign-in worked and the code left the
/// address, then end. For checking a package on a machine where nobody looks at the screen.
/// </summary>
internal static bool SelfTest { get; private set; }
public static int Main(string[] args)
{
// Chromium starts its helper processes from this same executable; they end here.
CefSubProcess.Run(args, true);
SelfTest = args.Contains("--selftest");
// One shell per user is enough: a second one would only open a second window.
using var single = new Mutex(true, "ClawdDotNet.Shell." + Environment.UserName, out var first);
if (!first)
return ExitOk;
var (port, problem) = ManagerStarter.EnsureRunningAsync(AppContext.BaseDirectory).GetAwaiter().GetResult();
if (problem is not null)
{
Console.Error.WriteLine(problem);
return ExitNoManager;
}
ManagerPort = port;
StartUrl = SignInUrl(port);
// Without a display (a server, an SSH session) there is no window to show — the system
// browser is the next best thing, and on a server usually not there either. Say where to go.
if (OperatingSystem.IsLinux()
&& string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY"))
&& string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")))
{
Console.WriteLine($"No display. The web interface: http://localhost:{port}/");
return ExitOk;
}
var cache = Path.Combine(AppPaths.DataDirectory, "shell-cache");
var resources = ResourcesDirectory();
AppDomain.CurrentDomain.ProcessExit += (_, _) => CefRuntime.Shutdown();
return AppBuilder.Configure<ShellApp>()
.UsePlatformDetect()
.WithInterFont()
.AfterSetup(_ => CefRuntimeLoader.Initialize(
new CefSettings
{
RootCachePath = cache,
ResourcesDirPath = resources,
LocalesDirPath = Path.Combine(resources, "locales"),
LogSeverity = CefLogSeverity.Warning,
LogFile = Path.Combine(AppPaths.DataDirectory, "Logs", "shell-cef.log")
},
// Without no-first-run Chromium waits behind a terms-of-service dialog that
// nobody can see inside this window (found on Linux, 2026-10-02).
[new KeyValuePair<string, string>("no-first-run", "1"),
new KeyValuePair<string, string>("no-default-browser-check", "1")]))
.StartWithClassicDesktopLifetime(args);
}
/// <summary>
/// A one-time link from the manager's local socket; without it the plain address, where the
/// sign-in page asks for the token as before.
/// </summary>
private static string SignInUrl(int port)
{
try
{
using var client = ManagerEndpoint.CreateClient(ManagerStarter.ConfigDirectory);
client.Timeout = TimeSpan.FromSeconds(5);
var answer = client.PostAsync("/api/shell/login-code", null).GetAwaiter().GetResult();
if (answer.IsSuccessStatusCode
&& answer.Content.ReadFromJsonAsync<LoginCode>().GetAwaiter().GetResult() is { Url: { Length: > 0 } url })
return url;
}
catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException or TimeoutException)
{
Console.Error.WriteLine($"No one-time sign-in from the manager ({ex.Message}); the page will ask for the token.");
}
return $"http://localhost:{port}/";
}
/// <summary>
/// Chromium's resources (icudtl.dat, *.pak, locales). The Linux runtime package puts them into
/// Resources/, Chromium looks next to libcef — the package step moves them; this covers a
/// development build, where they are still in Resources/.
/// </summary>
private static string ResourcesDirectory()
{
var nested = Path.Combine(AppContext.BaseDirectory, "Resources");
return File.Exists(Path.Combine(nested, "icudtl.dat")) ? nested : AppContext.BaseDirectory;
}
private sealed record LoginCode(string Code, string Url);
}
+135
View File
@@ -0,0 +1,135 @@
using System.Net.Http.Json;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform;
using Avalonia.Themes.Fluent;
using Avalonia.Threading;
using ClawdDotNet.Core.Instances;
using Xilium.CefGlue.Avalonia;
namespace ClawdDotNet.Shell;
/// <summary>
/// #764: The window and the tray icon. Everything shown inside the window is the web interface;
/// this class only hosts it and says, next to the clock, whether the instances run.
/// </summary>
internal sealed class ShellApp : Application
{
private static readonly TimeSpan StatusInterval = TimeSpan.FromSeconds(15);
private Window? _window;
private TrayIcon? _tray;
private DispatcherTimer? _timer;
public override void Initialize() => Styles.Add(new FluentTheme());
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var icon = new WindowIcon(AssetLoader.Open(new Uri("avares://ClawdDotNet.Shell/Assets/app.ico")));
_window = new Window
{
Title = "ClawdDotNet",
Icon = icon,
Width = 1360,
Height = 880,
MinWidth = 420,
MinHeight = 480,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Content = Browser(desktop)
};
desktop.MainWindow = _window;
_tray = new TrayIcon
{
Icon = icon,
ToolTipText = "ClawdDotNet",
Menu = new NativeMenu
{
Items =
{
Item(Texts.Open, ShowWindow),
new NativeMenuItemSeparator(),
Item(Texts.Quit, () => desktop.Shutdown())
}
}
};
_tray.Clicked += (_, _) => ShowWindow();
TrayIcon.SetIcons(this, [_tray]);
_timer = new DispatcherTimer { Interval = StatusInterval };
_timer.Tick += async (_, _) => await RefreshStatusAsync();
_timer.Start();
_ = RefreshStatusAsync();
}
base.OnFrameworkInitializationCompleted();
}
private static AvaloniaCefBrowser Browser(IClassicDesktopStyleApplicationLifetime desktop)
{
var browser = new AvaloniaCefBrowser { Address = Program.StartUrl };
if (!Program.SelfTest)
return browser;
browser.LoadEnd += (_, e) =>
{
if (!e.Frame.IsMain) return;
_ = Task.Run(async () =>
{
// The exchange runs after load; give it a moment.
await Task.Delay(3000);
var signedIn = await browser.EvaluateJavaScript<bool>("return !!sessionStorage.getItem('clawd.token')");
var hash = await browser.EvaluateJavaScript<string>("return location.hash");
Console.WriteLine($"SELFTEST status={e.HttpStatusCode} signedIn={signedIn} codeInAddress={hash.Contains("code=")}");
Dispatcher.UIThread.Post(() => desktop.Shutdown(signedIn && !hash.Contains("code=") ? 0 : 1));
});
};
return browser;
}
private static NativeMenuItem Item(string header, Action action)
{
var item = new NativeMenuItem(header);
item.Click += (_, _) => action();
return item;
}
private void ShowWindow()
{
if (_window is null) return;
_window.Show();
if (_window.WindowState == WindowState.Minimized)
_window.WindowState = WindowState.Normal;
_window.Activate();
}
/// <summary>The manager's own view of its instances, over the local socket — no token needed there.</summary>
private async Task RefreshStatusAsync()
{
string text;
try
{
using var client = ManagerEndpoint.CreateClient(ManagerStarter.ConfigDirectory);
client.Timeout = TimeSpan.FromSeconds(3);
var status = await client.GetFromJsonAsync<Status>("/api/shell/status");
text = status is null
? Texts.ManagerUnknown
: !status.LicenseHolds ? Texts.NoLicense
: status.Troubled > 0 ? Texts.Troubled(status.Troubled, status.Instances)
: Texts.Running(status.Running, status.Instances);
}
catch (Exception)
{
text = Texts.ManagerDown;
}
if (_tray is not null)
_tray.ToolTipText = $"ClawdDotNet — {text}";
}
private sealed record Status(int Instances, int Running, int Troubled, bool LicenseHolds, int WebUiPort);
}
+26
View File
@@ -0,0 +1,26 @@
using System.Globalization;
namespace ClawdDotNet.Shell;
/// <summary>
/// #764 / #661: The few texts of the shell itself — tray menu and tooltip. Everything else is the
/// web interface with its language packs. English first, German as the first additional language,
/// chosen by the system's UI language.
/// </summary>
internal static class Texts
{
private static readonly bool German =
CultureInfo.CurrentUICulture.TwoLetterISOLanguageName.Equals("de", StringComparison.OrdinalIgnoreCase);
public static string Open => German ? "ClawdDotNet öffnen" : "Open ClawdDotNet";
public static string Quit => German ? "Fenster beenden (Instanzen laufen weiter)" : "Quit window (instances keep running)";
public static string ManagerUnknown => German ? "Zustand unbekannt" : "state unknown";
public static string ManagerDown => German ? "Instanzmanager antwortet nicht" : "instance manager not answering";
public static string NoLicense => German ? "keine gültige Lizenz" : "no valid license";
public static string Running(int running, int total)
=> German ? $"{running} von {total} Instanzen laufen" : $"{running} of {total} instances running";
public static string Troubled(int troubled, int total)
=> German ? $"{troubled} von {total} Instanzen gestört" : $"{troubled} of {total} instances in trouble";
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="ClawdDotNet.Shell.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>