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 @@ + + + + + + + + + +