Merge Stufe 2 (#764): Huelle ClawdDotNet.Shell mit Einmal-Anmeldung
This commit is contained in:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -132,6 +132,19 @@ Probelauf am selben Tag mit `CefGlue.Next.Avalonia` 152.7977.831 (CEF 152, net10
|
|||||||
| Größe: ~370 MB (linux-x64) zusätzlich | Paket wird entsprechend größer |
|
| Größe: ~370 MB (linux-x64) zusätzlich | Paket wird entsprechend größer |
|
||||||
| Die Linux-/macOS-CEF-Pakete baut ein kleiner Fork (`Deon-Berlin/CefGlue`) selbst; Windows nutzt die offiziellen `chromiumembeddedframework.runtime` | Lieferkette: native Binärdateien von einem Einzelprojekt |
|
| Die Linux-/macOS-CEF-Pakete baut ein kleiner Fork (`Deon-Berlin/CefGlue`) selbst; Windows nutzt die offiziellen `chromiumembeddedframework.runtime` | Lieferkette: native Binärdateien von einem Einzelprojekt |
|
||||||
|
|
||||||
|
**Stand 2026-10-03: gebaut** (Branch `feat/764-stufe2-huelle`, nach Stufe 3):
|
||||||
|
|
||||||
|
| Teil | Wie |
|
||||||
|
|---|---|
|
||||||
|
| `src/ClawdDotNet.Shell` | Eigenes Programm, Avalonia 11.3.17 + CefGlue.Next 152, im Paket unter `shell/` (~378 MB unter Linux). Fenster mit der WebUI, Taskleistensymbol mit Zustand (über den Socket), Menü „öffnen" / „Fenster beenden". Der Manager läuft beim Schließen weiter. `--selftest` meldet, ob die Anmeldung trägt |
|
||||||
|
| Manager-Socket | `manager-shell.sock` neben `manager.json` (0600) bzw. Named Pipe nur für den eigenen Benutzer. Dort gibt es **nur** `/api/shell/*`, über TCP gibt es diese Pfade nicht (404 in beide Richtungen) |
|
||||||
|
| Einmal-Link | `POST /api/shell/login-code` (nur Socket) → Code mit 256 Bit, einmal, höchstens 60 s, im **Fragment** (`#code=`), erreicht also keinen Server und kein Protokoll. Die WebUI tauscht ihn über `POST /api/login/exchange` gegen das Token und entfernt ihn sofort aus der Adresse |
|
||||||
|
| Starter | `ClawdDotNet` ohne Schalter startet `shell/ClawdDotNet.Shell`, fehlt die Hülle, den Systembrowser. Manager finden oder starten (`ManagerStarter` in Core): Antwortet er, nichts tun; gibt es `clawddotnet-manager.service`, den Dienst starten; sonst das Programm |
|
||||||
|
| Packaging | `publish.py` baut die Hülle mit `dotnet build -r <rid>` (publish ließ Chromium aus) und zieht unter Linux `Resources/*` neben `libcef.so`. `OutputType` für Linux `Exe`, sonst kopiert das Laufzeitpaket nichts. `--no-shell` lässt sie weg |
|
||||||
|
| Geprüft | Tests: zweimal benutzt, zu spät, über TCP angefordert, Socket führt nur zu `/api/shell` (ShellLoginTests, 8). `--selftest` unter Windows (Hülle startet den Manager selbst) und auf der VM unter Xvfb :97: angemeldet, Code aus der Adresse, TCP 404, Socket `srw-------` |
|
||||||
|
|
||||||
|
Auf der VM stieß der Selbststart auf den echten Dienst `clawddotnet-manager.service` (Alltag) und startete — richtig — diesen statt eines zweiten Managers. Für einen Test mit eigener Konfiguration den Wegwerf-Manager deshalb vorher selbst starten.
|
||||||
|
|
||||||
### Stufe 3 — Avalonia-Fenster entfernen (Flash: mit Vorsicht)
|
### Stufe 3 — Avalonia-Fenster entfernen (Flash: mit Vorsicht)
|
||||||
|
|
||||||
**Stand 2026-10-02: erledigt, vor Stufe 2 gezogen.** Der Mensch hat so entschieden, weil CEF
|
**Stand 2026-10-02: erledigt, vor Stufe 2 gezogen.** Der Mensch hat so entschieden, weil CEF
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ public static class InstanceEndpoint
|
|||||||
ClearStaleFile(McpSocketPath(instanceDirectory));
|
ClearStaleFile(McpSocketPath(instanceDirectory));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ClearStaleFile(string path)
|
internal static void ClearStaleFile(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -152,7 +152,7 @@ public static class InstanceEndpoint
|
|||||||
RestrictFile(McpSocketPath(instanceDirectory));
|
RestrictFile(McpSocketPath(instanceDirectory));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RestrictFile(string path)
|
internal static void RestrictFile(string path)
|
||||||
{
|
{
|
||||||
if (OperatingSystem.IsWindows()) return;
|
if (OperatingSystem.IsWindows()) return;
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ public static class InstanceEndpoint
|
|||||||
public static HttpMessageHandler CreateHandler(string instanceDirectory)
|
public static HttpMessageHandler CreateHandler(string instanceDirectory)
|
||||||
=> CreateHandler(SocketPath(instanceDirectory), PipeName(instanceDirectory));
|
=> CreateHandler(SocketPath(instanceDirectory), PipeName(instanceDirectory));
|
||||||
|
|
||||||
private static HttpMessageHandler CreateHandler(string socketPath, string pipeName)
|
internal static HttpMessageHandler CreateHandler(string socketPath, string pipeName)
|
||||||
{
|
{
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
@@ -239,7 +239,7 @@ public static class InstanceEndpoint
|
|||||||
/// müssen denselben Pipenamen ergeben. Unter Linux sind es zwei Verzeichnisse und
|
/// müssen denselben Pipenamen ergeben. Unter Linux sind es zwei Verzeichnisse und
|
||||||
/// dürfen es nicht.</para>
|
/// dürfen es nicht.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string Fingerprint(string instanceDirectory)
|
internal static string Fingerprint(string instanceDirectory)
|
||||||
{
|
{
|
||||||
var full = Path.GetFullPath(instanceDirectory).TrimEnd(
|
var full = Path.GetFullPath(instanceDirectory).TrimEnd(
|
||||||
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace ClawdDotNet.Core.Instances;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #764: The instance manager's local socket — the way the shell (ClawdDotNet.Shell) talks to
|
||||||
|
/// it without the web access token.
|
||||||
|
///
|
||||||
|
/// <para><b>Why a socket and not the web port.</b> The shell opens the web interface signed in,
|
||||||
|
/// through a one-time code. Asking for that code must prove that the asker is the user who runs
|
||||||
|
/// the manager on this machine — not anybody who reaches the port. The file system says that:
|
||||||
|
/// the socket is 0600 (Linux) or a pipe for the current user only (Windows), exactly like the
|
||||||
|
/// instance socket (<see cref="InstanceEndpoint"/>). Over TCP the shell endpoints do not exist.</para>
|
||||||
|
///
|
||||||
|
/// <para>It lives next to <c>manager.json</c>: the shell finds the manager the same way it finds
|
||||||
|
/// its port.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class ManagerEndpoint
|
||||||
|
{
|
||||||
|
public const string SocketFileName = "manager-shell.sock";
|
||||||
|
|
||||||
|
public static string SocketPath(string configDirectory)
|
||||||
|
=> Path.Combine(Path.GetFullPath(configDirectory), SocketFileName);
|
||||||
|
|
||||||
|
/// <summary>Pipe names are machine-wide; a fingerprint of the folder keeps two installations apart.</summary>
|
||||||
|
public static string PipeName(string configDirectory)
|
||||||
|
=> "clawd-manager-" + InstanceEndpoint.Fingerprint(configDirectory);
|
||||||
|
|
||||||
|
public static HttpClient CreateClient(string configDirectory)
|
||||||
|
=> new(InstanceEndpoint.CreateHandler(SocketPath(configDirectory), PipeName(configDirectory)))
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("http://manager")
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>A socket file left by a hard end would block the next bind (see <see cref="InstanceEndpoint.ClearStale"/>).</summary>
|
||||||
|
public static void ClearStale(string configDirectory)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows()) return;
|
||||||
|
InstanceEndpoint.ClearStaleFile(SocketPath(configDirectory));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>0600 after binding; on Windows the pipe's CurrentUserOnly carries the same promise.</summary>
|
||||||
|
public static void Restrict(string configDirectory)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows()) return;
|
||||||
|
InstanceEndpoint.RestrictFile(SocketPath(configDirectory));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ public sealed class AccessToken(byte[] expected)
|
|||||||
|
|
||||||
/// <summary>Ob dieser Pfad ein Token braucht: alles unter <c>/api</c> und <c>/events</c>.</summary>
|
/// <summary>Ob dieser Pfad ein Token braucht: alles unter <c>/api</c> und <c>/events</c>.</summary>
|
||||||
public static bool NeedsToken(PathString path)
|
public static bool NeedsToken(PathString path)
|
||||||
=> path.StartsWithSegments("/api") || path.StartsWithSegments("/events");
|
=> (path.StartsWithSegments("/api") || path.StartsWithSegments("/events"))
|
||||||
|
// #764: Exchanging a one-time code is the login itself - like the page that asks
|
||||||
|
// for the token, it cannot require one. The code is the proof (ShellLogin).
|
||||||
|
&& !path.Equals(LoginExchangePath, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>#764: Where the WebUI trades a one-time code from the shell for the access token.</summary>
|
||||||
|
public const string LoginExchangePath = "/api/login/exchange";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Holt das Token aus der Anfrage.
|
/// Holt das Token aus der Anfrage.
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ public sealed record FtpSettingsUpdateDto(
|
|||||||
bool Enabled, string Host, int Port, string Username, string? Password,
|
bool Enabled, string Host, int Port, string Username, string? Password,
|
||||||
string RemoteDirectory, bool RequireTls, int KeepCount);
|
string RemoteDirectory, bool RequireTls, int KeepCount);
|
||||||
|
|
||||||
|
/// <summary>#764: A one-time sign-in code for the shell, and the address that uses it.</summary>
|
||||||
|
public sealed record ShellLoginCodeDto(string Code, string Url);
|
||||||
|
|
||||||
|
/// <summary>#764: The code the WebUI found in its address.</summary>
|
||||||
|
public sealed record ShellLoginExchangeDto(string? Code);
|
||||||
|
|
||||||
|
/// <summary>#764: The access token, in return for a valid code.</summary>
|
||||||
|
public sealed record ShellLoginTokenDto(string Token);
|
||||||
|
|
||||||
|
/// <summary>#764: What the shell shows next to its tray icon.</summary>
|
||||||
|
public sealed record ShellStatusDto(int Instances, int Running, int Troubled, bool LicenseHolds, int WebUiPort);
|
||||||
|
|
||||||
/// <summary>#764: The license as the web interface sees it — never the key, only whether one is set.</summary>
|
/// <summary>#764: The license as the web interface sees it — never the key, only whether one is set.</summary>
|
||||||
public sealed record LicenseStatusDto(bool Enforced, bool Holds, bool KeySet, string? HardwareId);
|
public sealed record LicenseStatusDto(bool Enforced, bool Holds, bool KeySet, string? HardwareId);
|
||||||
|
|
||||||
@@ -108,6 +120,39 @@ internal static class ManagerEndpoints
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private const long MaxImportBytes = 4L * 1024 * 1024 * 1024;
|
private const long MaxImportBytes = 4L * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
// ─── The shell (#764) ───
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the shell asks over the local socket, and the one endpoint over TCP that turns its
|
||||||
|
/// one-time code into a signed-in browser. The socket-only rule is enforced before routing
|
||||||
|
/// (<c>ManagerWebHost.CheckToken</c>): <c>/api/shell</c> answers only there, everything else
|
||||||
|
/// never there.
|
||||||
|
/// </summary>
|
||||||
|
public static void MapShell(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapPost("/api/shell/login-code", (ShellLogin login, ManagerCore manager) =>
|
||||||
|
{
|
||||||
|
var code = login.Issue();
|
||||||
|
// In the fragment, not the query: the fragment never reaches a server or its log.
|
||||||
|
return Results.Ok(new ShellLoginCodeDto(code, $"http://localhost:{manager.Settings.WebUiPort}/#code={code}"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/shell/status", (InstanceRegistry registry, ManagerCore manager) =>
|
||||||
|
Results.Ok(new ShellStatusDto(
|
||||||
|
registry.Instances.Count,
|
||||||
|
registry.Instances.Count(i => i.Condition == InstanceCondition.Running),
|
||||||
|
registry.Instances.Count(i => i.Condition is InstanceCondition.Crashed or InstanceCondition.Silent),
|
||||||
|
manager.LicenseHolds,
|
||||||
|
manager.Settings.WebUiPort)));
|
||||||
|
|
||||||
|
app.MapPost(AccessToken.LoginExchangePath, (ShellLoginExchangeDto input, ShellLogin login, ManagerCore manager) =>
|
||||||
|
login.Redeem(input.Code)
|
||||||
|
? Results.Ok(new ShellLoginTokenDto(manager.Settings.WebUiToken))
|
||||||
|
// Same answer for unknown, used and expired: telling them apart helps only a guesser.
|
||||||
|
: Results.Json(new OutcomeDto(false, "The sign-in link is no longer valid. Open the window again."),
|
||||||
|
statusCode: StatusCodes.Status401Unauthorized));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Was den Manager selbst angeht ───
|
// ─── Was den Manager selbst angeht ───
|
||||||
|
|
||||||
public static void MapVerwaltung(this WebApplication app)
|
public static void MapVerwaltung(this WebApplication app)
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
|
|||||||
var app = Build(manager, backups, updates, loggerFactory, url, token, forwarding);
|
var app = Build(manager, backups, updates, loggerFactory, url, token, forwarding);
|
||||||
|
|
||||||
app.StartAsync().GetAwaiter().GetResult();
|
app.StartAsync().GetAwaiter().GetResult();
|
||||||
|
ClawdDotNet.Core.Instances.ManagerEndpoint.Restrict(Path.GetDirectoryName(manager.SettingsPath)!);
|
||||||
|
|
||||||
var anzeige = loopback ? url : url.Replace("0.0.0.0", Environment.MachineName);
|
var anzeige = loopback ? url : url.Replace("0.0.0.0", Environment.MachineName);
|
||||||
|
|
||||||
@@ -126,7 +127,28 @@ public sealed class ManagerWebHost : IAsyncDisposable
|
|||||||
builder.Logging.AddFilter("Microsoft", Microsoft.Extensions.Logging.LogLevel.Warning);
|
builder.Logging.AddFilter("Microsoft", Microsoft.Extensions.Logging.LogLevel.Warning);
|
||||||
builder.Logging.AddFilter("System", Microsoft.Extensions.Logging.LogLevel.Warning);
|
builder.Logging.AddFilter("System", Microsoft.Extensions.Logging.LogLevel.Warning);
|
||||||
|
|
||||||
builder.WebHost.UseUrls(url);
|
// #764: The web port as before, and next to it the local socket for the shell. Both
|
||||||
|
// through ConfigureKestrel - UseUrls next to explicit listeners would be overridden.
|
||||||
|
var configDirectory = Path.GetDirectoryName(manager.SettingsPath)!;
|
||||||
|
var bindAddress = url.Contains("0.0.0.0") ? System.Net.IPAddress.Any : System.Net.IPAddress.Loopback;
|
||||||
|
|
||||||
|
// CurrentUserOnly is the default and is written anyway: on Windows it is the whole
|
||||||
|
// access restriction of the pipe - the counterpart of 0600 on Linux.
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
builder.WebHost.UseNamedPipes(pipes => pipes.CurrentUserOnly = true);
|
||||||
|
|
||||||
|
ClawdDotNet.Core.Instances.ManagerEndpoint.ClearStale(configDirectory);
|
||||||
|
builder.WebHost.ConfigureKestrel(kestrel =>
|
||||||
|
{
|
||||||
|
kestrel.Listen(bindAddress, manager.Settings.WebUiPort);
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
kestrel.ListenNamedPipe(ClawdDotNet.Core.Instances.ManagerEndpoint.PipeName(configDirectory),
|
||||||
|
listen => listen.Use(ShellTransport.Mark));
|
||||||
|
else
|
||||||
|
kestrel.ListenUnixSocket(ClawdDotNet.Core.Instances.ManagerEndpoint.SocketPath(configDirectory),
|
||||||
|
listen => listen.Use(ShellTransport.Mark));
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddSingleton(manager);
|
builder.Services.AddSingleton(manager);
|
||||||
builder.Services.AddSingleton(manager.Instances);
|
builder.Services.AddSingleton(manager.Instances);
|
||||||
@@ -135,6 +157,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
|
|||||||
builder.Services.AddSingleton(updates ?? new ManagerUpdates(manager, "0.0.0", loggerFactory.CreateLogger("ClawdDotNet.Manager.Update")));
|
builder.Services.AddSingleton(updates ?? new ManagerUpdates(manager, "0.0.0", loggerFactory.CreateLogger("ClawdDotNet.Manager.Update")));
|
||||||
builder.Services.AddSingleton(AccessToken.For(token));
|
builder.Services.AddSingleton(AccessToken.For(token));
|
||||||
builder.Services.AddSingleton(forwarding);
|
builder.Services.AddSingleton(forwarding);
|
||||||
|
builder.Services.AddSingleton(new ShellLogin());
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -143,6 +166,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
|
|||||||
|
|
||||||
MapStaticFiles(app);
|
MapStaticFiles(app);
|
||||||
|
|
||||||
|
app.MapShell();
|
||||||
app.MapVerwaltung();
|
app.MapVerwaltung();
|
||||||
app.MapEvents();
|
app.MapEvents();
|
||||||
app.MapForwarding();
|
app.MapForwarding();
|
||||||
@@ -181,7 +205,17 @@ public sealed class ManagerWebHost : IAsyncDisposable
|
|||||||
|
|
||||||
private static async Task CheckToken(HttpContext context, RequestDelegate next)
|
private static async Task CheckToken(HttpContext context, RequestDelegate next)
|
||||||
{
|
{
|
||||||
if (!AccessToken.NeedsToken(context.Request.Path))
|
// #764: The local socket serves the shell and nothing else; the shell endpoints do not
|
||||||
|
// exist over TCP. 404 in both directions: what does not exist needs no explanation.
|
||||||
|
var fromShell = ShellTransport.Applies(context);
|
||||||
|
var shellPath = context.Request.Path.StartsWithSegments(ShellTransport.PathPrefix);
|
||||||
|
if (fromShell != shellPath)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromShell || !AccessToken.NeedsToken(context.Request.Path))
|
||||||
{
|
{
|
||||||
await next(context);
|
await next(context);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Connections;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Manager.Web;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #764: One-time codes that sign the shell's browser window in.
|
||||||
|
///
|
||||||
|
/// <para><b>Why not hand the shell the access token.</b> The shell would have to put it into
|
||||||
|
/// the address it opens, and an address ends up in histories, logs and window titles. A code
|
||||||
|
/// that works once and for at most a minute is worth nothing afterwards. The token itself only
|
||||||
|
/// travels in the answer to the exchange, the same way it travels when someone types it in.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Who gets a code:</b> only a request over the manager's local socket
|
||||||
|
/// (<see cref="ShellTransport"/>), which the file system restricts to the user running the
|
||||||
|
/// manager. Over TCP the endpoint does not exist.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShellLogin(TimeProvider? clock = null)
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
|
||||||
|
|
||||||
|
/// <summary>Codes by their hash — the code itself is not kept, so a memory dump does not hold usable ones.</summary>
|
||||||
|
private readonly ConcurrentDictionary<string, DateTimeOffset> _open = new();
|
||||||
|
|
||||||
|
public string Issue()
|
||||||
|
{
|
||||||
|
Sweep();
|
||||||
|
var code = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||||
|
_open[Hash(code)] = _clock.GetUtcNow() + Lifetime;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True once for a code that was issued and has not expired. The code is gone afterwards
|
||||||
|
/// either way: a second use, or a use after expiry, fails.
|
||||||
|
/// </summary>
|
||||||
|
public bool Redeem(string? code)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(code) || code.Length > 100)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return _open.TryRemove(Hash(code.Trim()), out var expires) && _clock.GetUtcNow() <= expires;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Sweep()
|
||||||
|
{
|
||||||
|
var now = _clock.GetUtcNow();
|
||||||
|
foreach (var (key, expires) in _open)
|
||||||
|
{
|
||||||
|
if (expires < now) _open.TryRemove(key, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Hash(string code)
|
||||||
|
=> Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(code)));
|
||||||
|
|
||||||
|
private static string Base64Url(byte[] bytes)
|
||||||
|
=> Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #764: Marks connections that came in over the manager's local socket — the same pattern as
|
||||||
|
/// the instance's <c>LocalTransport</c>: a mark set by the listener, not a guess from a missing
|
||||||
|
/// IP address.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShellTransport
|
||||||
|
{
|
||||||
|
private static readonly ShellTransport Instance = new();
|
||||||
|
|
||||||
|
private ShellTransport() { }
|
||||||
|
|
||||||
|
/// <summary>The only paths the socket serves. Everything else stays behind the token.</summary>
|
||||||
|
public const string PathPrefix = "/api/shell";
|
||||||
|
|
||||||
|
public static Func<ConnectionDelegate, ConnectionDelegate> Mark
|
||||||
|
=> next => connection =>
|
||||||
|
{
|
||||||
|
connection.Features.Set(Instance);
|
||||||
|
return next(connection);
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool Applies(HttpContext context)
|
||||||
|
=> context.Features.Get<ShellTransport>() is not null;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
hatToken,
|
hatToken,
|
||||||
|
setzeToken,
|
||||||
loescheToken,
|
loescheToken,
|
||||||
beiUnberechtigt,
|
beiUnberechtigt,
|
||||||
holeAktiveInstanz,
|
holeAktiveInstanz,
|
||||||
@@ -405,8 +406,32 @@ async function starteHauptAnwendung(instanzen) {
|
|||||||
initialisiereRouter();
|
initialisiereRouter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #764: The shell opens this page with a one-time code in the fragment (#code=…). It is
|
||||||
|
// traded for the access token once and removed from the address right away, so it shows up in
|
||||||
|
// no history entry. A failed exchange falls through to the normal sign-in.
|
||||||
|
async function redeemShellCode() {
|
||||||
|
const match = /^#code=([A-Za-z0-9_-]{20,100})$/.exec(window.location.hash);
|
||||||
|
if (!match) return false;
|
||||||
|
history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||||
|
try {
|
||||||
|
const answer = await fetch("/api/login/exchange", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: match[1] })
|
||||||
|
});
|
||||||
|
if (!answer.ok) return false;
|
||||||
|
const { token } = await answer.json();
|
||||||
|
if (!token) return false;
|
||||||
|
setzeToken(token);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Prüfe bestehende Session
|
// Prüfe bestehende Session
|
||||||
if (hatToken()) {
|
redeemShellCode().then(() => {
|
||||||
|
if (hatToken()) {
|
||||||
api("/verwaltung/instanzen")
|
api("/verwaltung/instanzen")
|
||||||
.then((instanzen) => {
|
.then((instanzen) => {
|
||||||
starteHauptAnwendung(instanzen);
|
starteHauptAnwendung(instanzen);
|
||||||
@@ -415,6 +440,7 @@ if (hatToken()) {
|
|||||||
loescheToken();
|
loescheToken();
|
||||||
zeigeAnmeldung();
|
zeigeAnmeldung();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
zeigeAnmeldung();
|
zeigeAnmeldung();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|||||||
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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\ClawdDotNet.Manager\ClawdDotNet.Manager.csproj" />
|
<ProjectReference Include="..\..\src\ClawdDotNet.Manager\ClawdDotNet.Manager.csproj" />
|
||||||
|
<!-- #764: the shell's one-time sign-in lives in the manager's web host. -->
|
||||||
|
<ProjectReference Include="..\..\src\ClawdDotNet.Manager.Web\ClawdDotNet.Manager.Web.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using ClawdDotNet.Core.Instances;
|
||||||
|
using ClawdDotNet.Manager.Web;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Manager.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #764: The shell's one-time sign-in. A new way in — so the three ways it must fail are tested:
|
||||||
|
/// used twice, used too late, asked for over TCP instead of the local socket.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShellLoginTests
|
||||||
|
{
|
||||||
|
private sealed class Clock(DateTimeOffset start) : TimeProvider
|
||||||
|
{
|
||||||
|
public DateTimeOffset Now { get; set; } = start;
|
||||||
|
public override DateTimeOffset GetUtcNow() => Now;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_code_works_once()
|
||||||
|
{
|
||||||
|
var login = new ShellLogin();
|
||||||
|
var code = login.Issue();
|
||||||
|
|
||||||
|
login.Redeem(code).ShouldBeTrue();
|
||||||
|
login.Redeem(code).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_code_expires_after_a_minute()
|
||||||
|
{
|
||||||
|
var clock = new Clock(DateTimeOffset.UnixEpoch);
|
||||||
|
var login = new ShellLogin(clock);
|
||||||
|
var code = login.Issue();
|
||||||
|
|
||||||
|
clock.Now += ShellLogin.Lifetime + TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
login.Redeem(code).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_made_up_code_fails()
|
||||||
|
{
|
||||||
|
var login = new ShellLogin();
|
||||||
|
login.Issue();
|
||||||
|
|
||||||
|
login.Redeem("guess").ShouldBeFalse();
|
||||||
|
login.Redeem("").ShouldBeFalse();
|
||||||
|
login.Redeem(null).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Codes_are_not_predictable()
|
||||||
|
{
|
||||||
|
var login = new ShellLogin();
|
||||||
|
|
||||||
|
login.Issue().ShouldNotBe(login.Issue());
|
||||||
|
login.Issue().Length.ShouldBeGreaterThanOrEqualTo(40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#764: The same promises against the running web host, over real TCP and the real socket.</summary>
|
||||||
|
public sealed class ShellLoginHostTests : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private const string Token = "a-sufficiently-long-test-token-for-the-webui";
|
||||||
|
|
||||||
|
private readonly string _root = Path.Combine(
|
||||||
|
Path.GetTempPath(), "clawd-tests", "shl-" + Guid.NewGuid().ToString("N")[..8]);
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
try { Directory.Delete(_root, recursive: true); }
|
||||||
|
catch { /* A leftover test folder does not turn a run red. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FreePort()
|
||||||
|
{
|
||||||
|
using var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
|
||||||
|
probe.Start();
|
||||||
|
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
|
||||||
|
probe.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(ManagerWebHost Web, ManagerCore Manager, HttpClient Tcp, HttpClient Socket)> StartAsync()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
var store = new ManagerSettingsStore(_root);
|
||||||
|
store.Settings.WebUiToken = Token;
|
||||||
|
store.Settings.WebUiPort = FreePort();
|
||||||
|
store.Settings.InstancesDirectory = Path.Combine(_root, "Instances");
|
||||||
|
|
||||||
|
var registry = new InstanceRegistry(
|
||||||
|
new InstanceDirectoryManager(store.Settings.InstancesDirectory), new FakeLauncher(), new FakeChannel(),
|
||||||
|
NullLoggerFactory.Instance, SupervisionOptions.Default);
|
||||||
|
var manager = new ManagerCore(store, registry, new FakeTokenSource(), NullLoggerFactory.Instance);
|
||||||
|
await manager.StartAsync();
|
||||||
|
var backups = new ManagerBackups(store, registry, null, NullLogger.Instance);
|
||||||
|
|
||||||
|
var web = ManagerWebHost.TryStart(manager, backups, NullLoggerFactory.Instance);
|
||||||
|
web.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var tcp = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{store.Settings.WebUiPort}") };
|
||||||
|
var socket = ManagerEndpoint.CreateClient(_root);
|
||||||
|
return (web!, manager, tcp, socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_code_comes_only_over_the_socket()
|
||||||
|
{
|
||||||
|
var (web, manager, tcp, socket) = await StartAsync();
|
||||||
|
await using var _ = web;
|
||||||
|
await using var __ = manager;
|
||||||
|
|
||||||
|
// Over TCP: not there, even with the access token.
|
||||||
|
tcp.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}");
|
||||||
|
(await tcp.PostAsync("/api/shell/login-code", null)).StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||||
|
|
||||||
|
// Over the socket: there, without a token.
|
||||||
|
var answer = await socket.PostAsync("/api/shell/login-code", null);
|
||||||
|
answer.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_socket_leads_nowhere_but_the_shell_endpoints()
|
||||||
|
{
|
||||||
|
var (web, manager, _, socket) = await StartAsync();
|
||||||
|
await using var a = web;
|
||||||
|
await using var b = manager;
|
||||||
|
|
||||||
|
// No token needed on the socket - so nothing else may be reachable there.
|
||||||
|
(await socket.GetAsync("/api/verwaltung/instanzen")).StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||||
|
(await socket.GetAsync("/api/verwaltung/license")).StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_code_signs_in_once()
|
||||||
|
{
|
||||||
|
var (web, manager, tcp, socket) = await StartAsync();
|
||||||
|
await using var a = web;
|
||||||
|
await using var b = manager;
|
||||||
|
|
||||||
|
var issued = await (await socket.PostAsync("/api/shell/login-code", null)).Content.ReadFromJsonAsync<ShellLoginCodeDto>();
|
||||||
|
issued!.Url.ShouldContain("#code="); // in the fragment, never the query
|
||||||
|
|
||||||
|
var first = await tcp.PostAsJsonAsync(AccessToken.LoginExchangePath, new ShellLoginExchangeDto(issued.Code));
|
||||||
|
first.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
(await first.Content.ReadFromJsonAsync<ShellLoginTokenDto>())!.Token.ShouldBe(Token);
|
||||||
|
|
||||||
|
var second = await tcp.PostAsJsonAsync(AccessToken.LoginExchangePath, new ShellLoginExchangeDto(issued.Code));
|
||||||
|
second.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Without_a_code_or_token_nothing_is_open()
|
||||||
|
{
|
||||||
|
var (web, manager, tcp, _) = await StartAsync();
|
||||||
|
await using var a = web;
|
||||||
|
await using var b = manager;
|
||||||
|
|
||||||
|
(await tcp.PostAsJsonAsync(AccessToken.LoginExchangePath, new ShellLoginExchangeDto("made-up"))).StatusCode
|
||||||
|
.ShouldBe(HttpStatusCode.Unauthorized);
|
||||||
|
(await tcp.GetAsync("/api/verwaltung/instanzen")).StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user