diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx
index 6507084..0b90662 100644
--- a/ClawdDotNet.slnx
+++ b/ClawdDotNet.slnx
@@ -12,6 +12,7 @@
+
diff --git a/deploy/publish.py b/deploy/publish.py
index da490d4..1dd2906 100644
--- a/deploy/publish.py
+++ b/deploy/publish.py
@@ -36,6 +36,7 @@ from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
PROJECT = REPO / "src" / "ClawdDotNet.Desktop" / "ClawdDotNet.Desktop.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"
PACKAGER = Path(
"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]}")
-def publish(rid: str, out_dir: Path) -> None:
+def publish(rid: str, out_dir: Path, include_shell: bool = True) -> None:
if out_dir.exists():
shutil.rmtree(out_dir)
out_dir.mkdir(parents=True)
@@ -90,6 +91,9 @@ def publish(rid: str, out_dir: Path) -> None:
])
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
# einem Repository, das auf der Zielmaschine niemand hat.
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/")
+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:
"""Legt den Manager neben die Instanz.
@@ -183,6 +225,8 @@ def main() -> None:
parser.add_argument("--agent-file", type=Path,
help="vorhandene Kopie des update-agent statt Download; "
"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",
help="nur bauen und pruefen, nicht hochladen")
args = parser.parse_args()
@@ -193,7 +237,7 @@ def main() -> None:
out_dir = Path(tempfile.gettempdir()) / f"clawddotnet-publish-{args.rid}"
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")
if args.no_agent:
diff --git a/src/ClawdDotNet.Core/Instances/ManagerStarter.cs b/src/ClawdDotNet.Core/Instances/ManagerStarter.cs
new file mode 100644
index 0000000..0dc6609
--- /dev/null
+++ b/src/ClawdDotNet.Core/Instances/ManagerStarter.cs
@@ -0,0 +1,165 @@
+using System.Diagnostics;
+using System.Net.Sockets;
+using System.Text.Json;
+using ClawdDotNet.Core.Storage;
+
+namespace ClawdDotNet.Core.Instances;
+
+///
+/// #764: Finds the running instance manager, or starts it — for the two programs a person starts
+/// by hand: ClawdDotNet without a switch and the shell (ClawdDotNet.Shell).
+///
+/// Never a second manager. 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.
+///
+public static class ManagerStarter
+{
+ public const int DefaultPort = 7000;
+ public const string ServiceUnit = "clawddotnet-manager.service";
+ private static readonly TimeSpan StartWait = TimeSpan.FromSeconds(30);
+
+ /// Where manager.json lives — and next to it the manager's local socket.
+ public static string ConfigDirectory => AppPaths.ConfigDirectory;
+
+ ///
+ /// Makes sure the manager answers. Returns the port, or the reason it does not answer.
+ ///
+ /// Folder of the calling program; the manager is looked for there and one level up.
+ 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;
+ }
+ }
+
+ ///
+ /// The port from manager.json; the default on any doubt. Read as one field, not as the
+ /// manager's settings type, which the callers do not know.
+ ///
+ 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;
+ }
+ }
+
+ /// Why the manager could not be started, or null.
+ 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
+ }
+ }
+
+ ///
+ /// Next to the caller, one level up (the shell lives in shell/), or in the build folders
+ /// of a development checkout.
+ ///
+ /// 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
+ /// App.axaml.cs).
+ ///
+ 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;
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/LauncherRun.cs b/src/ClawdDotNet.Desktop/LauncherRun.cs
index 3b35a4b..853fc38 100644
--- a/src/ClawdDotNet.Desktop/LauncherRun.cs
+++ b/src/ClawdDotNet.Desktop/LauncherRun.cs
@@ -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;
///
/// 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.
+/// running, and its web interface in front of them — in the shell's window
+/// (shell/ClawdDotNet.Shell) where the package brings it, otherwise in the system
+/// browser.
///
-/// Never a second manager. 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.
+/// 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.
///
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 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;
}
- /// Why the manager could not be started, or null.
- private static string? StartManager()
+ /// The shell starts the manager itself and signs its window in.
+ 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
- }
- }
-
- ///
- /// Next to this program in an installation; the build folders in a development checkout.
- ///
- /// 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 App.axaml.cs).
- ///
- 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;
- }
-
- ///
- /// The port from manager.json; the default on any doubt. Read as one field, not as
- /// the manager's settings type, which this program does not know.
- ///
- 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;
- }
- }
}
diff --git a/src/ClawdDotNet.Shell/Assets/app.ico b/src/ClawdDotNet.Shell/Assets/app.ico
new file mode 100644
index 0000000..0dd1605
Binary files /dev/null and b/src/ClawdDotNet.Shell/Assets/app.ico differ
diff --git a/src/ClawdDotNet.Shell/ClawdDotNet.Shell.csproj b/src/ClawdDotNet.Shell/ClawdDotNet.Shell.csproj
new file mode 100644
index 0000000..9a54f41
--- /dev/null
+++ b/src/ClawdDotNet.Shell/ClawdDotNet.Shell.csproj
@@ -0,0 +1,50 @@
+
+
+
+
+ WinExe
+
+ Exe
+ net10.0
+ enable
+ enable
+ ClawdDotNet.Shell
+ ClawdDotNet.Shell
+ Assets\app.ico
+
+ app.manifest
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Shell/Program.cs b/src/ClawdDotNet.Shell/Program.cs
new file mode 100644
index 0000000..24458e7
--- /dev/null
+++ b/src/ClawdDotNet.Shell/Program.cs
@@ -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;
+
+///
+/// #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.
+///
+internal static class Program
+{
+ private const int ExitOk = 0;
+ private const int ExitNoManager = 6;
+
+ /// Where the window points; set before Avalonia starts.
+ internal static string StartUrl { get; private set; } = "";
+
+ internal static int ManagerPort { get; private set; }
+
+ ///
+ /// --selftest: 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.
+ ///
+ 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()
+ .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("no-first-run", "1"),
+ new KeyValuePair("no-default-browser-check", "1")]))
+ .StartWithClassicDesktopLifetime(args);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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().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}/";
+ }
+
+ ///
+ /// 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/.
+ ///
+ 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);
+}
diff --git a/src/ClawdDotNet.Shell/ShellApp.cs b/src/ClawdDotNet.Shell/ShellApp.cs
new file mode 100644
index 0000000..e5098bc
--- /dev/null
+++ b/src/ClawdDotNet.Shell/ShellApp.cs
@@ -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;
+
+///
+/// #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.
+///
+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("return !!sessionStorage.getItem('clawd.token')");
+ var hash = await browser.EvaluateJavaScript("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();
+ }
+
+ /// The manager's own view of its instances, over the local socket — no token needed there.
+ private async Task RefreshStatusAsync()
+ {
+ string text;
+ try
+ {
+ using var client = ManagerEndpoint.CreateClient(ManagerStarter.ConfigDirectory);
+ client.Timeout = TimeSpan.FromSeconds(3);
+ var status = await client.GetFromJsonAsync("/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);
+}
diff --git a/src/ClawdDotNet.Shell/Texts.cs b/src/ClawdDotNet.Shell/Texts.cs
new file mode 100644
index 0000000..1b7790c
--- /dev/null
+++ b/src/ClawdDotNet.Shell/Texts.cs
@@ -0,0 +1,26 @@
+using System.Globalization;
+
+namespace ClawdDotNet.Shell;
+
+///
+/// #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.
+///
+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";
+}
diff --git a/src/ClawdDotNet.Shell/app.manifest b/src/ClawdDotNet.Shell/app.manifest
new file mode 100644
index 0000000..46acd94
--- /dev/null
+++ b/src/ClawdDotNet.Shell/app.manifest
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+