feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar

This commit is contained in:
Richard
2026-08-10 10:48:34 +02:00
parent a0e18d2a57
commit b5bf97ae74
187 changed files with 20054 additions and 882 deletions
+123
View File
@@ -0,0 +1,123 @@
namespace ClawdDotNet.Core.Storage;
/// <summary>
/// Wo die Anwendung ihre eigenen Dateien ablegt — getrennt nach Programm und Daten.
///
/// Hintergrund (Linux-Portierung): Bisher lagen Einstellungen und Arbeitsordner neben
/// der Programmdatei (<c>AppDomain.CurrentDomain.BaseDirectory</c>). Unter Windows in
/// einem Benutzerverzeichnis geht das; unter Linux liegt die Anwendung in <c>/opt</c>
/// oder <c>/usr/lib</c> und ist für den Dienstbenutzer <b>nicht schreibbar</b>.
///
/// Deshalb hier die übliche Trennung: Programm bleibt, wo es installiert ist, Daten
/// wandern in das Verzeichnis des Benutzers (XDG unter Linux, <c>%APPDATA%</c> unter
/// Windows). Beides lässt sich per Umgebungsvariable überschreiben — für Dienste, die
/// nach <c>/var/lib</c> schreiben sollen, und für Tests.
/// </summary>
public static class AppPaths
{
private const string AppFolder = "ClawdDotNet";
/// <summary>Einstellungen und Schlüssel. Klein, selten geschrieben, gehört gesichert.</summary>
public static string ConfigDirectory { get; } = ResolveConfig();
/// <summary>Instanzen, Datenbanken, Logs. Groß, oft geschrieben.</summary>
public static string DataDirectory { get; } = ResolveData();
/// <summary>
/// Das Verzeichnis der Programmdatei.
///
/// Weiterhin nötig, um bestehende Windows-Installationen zu finden: Dort liegt die
/// <c>AppSettings.json</c> noch am alten Ort, und ein Update darf sie nicht
/// verwaisen lassen.
/// </summary>
public static string ProgramDirectory => AppContext.BaseDirectory;
/// <summary>Legt das Verzeichnis an und schränkt unter Unix die Rechte auf den Benutzer ein.</summary>
public static string EnsureDirectory(string path)
{
Directory.CreateDirectory(path);
// 0700: In den Konfigurationsverzeichnissen liegen Schlüssel und Zugangsdaten.
// Unter Windows regelt das die Vererbung aus dem Benutzerprofil, unter Linux
// wären es sonst je nach umask 0755 — für alle lesbar.
if (!OperatingSystem.IsWindows())
{
try
{
File.SetUnixFileMode(path,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
catch (IOException) { /* etwa auf Netzlaufwerken ohne Rechteverwaltung */ }
catch (UnauthorizedAccessException) { }
}
return path;
}
/// <summary>Schränkt eine Datei unter Unix auf <c>0600</c> ein. Unter Windows wirkungslos.</summary>
public static void RestrictToOwner(string filePath)
{
if (OperatingSystem.IsWindows()) return;
try
{
File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
// ─── Auflösung ───
private static string ResolveConfig()
{
if (FromEnvironment("CLAWD_CONFIG_DIR") is { } explicitDir)
return explicitDir;
if (OperatingSystem.IsWindows())
return Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppFolder);
if (FromEnvironment("XDG_CONFIG_HOME") is { } xdg)
return Path.Combine(xdg, AppFolder.ToLowerInvariant());
if (FromEnvironment("HOME") is { } home)
return Path.Combine(home, ".config", AppFolder.ToLowerInvariant());
// Ein Dienst ohne HOME. Lieber ein fester, dokumentierter Ort als ein relativer
// Pfad, der vom Arbeitsverzeichnis abhängt und beim nächsten Start woanders liegt.
return Path.Combine("/var/lib", AppFolder.ToLowerInvariant());
}
private static string ResolveData()
{
if (FromEnvironment("CLAWD_DATA_DIR") is { } explicitDir)
return explicitDir;
if (OperatingSystem.IsWindows())
return Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppFolder);
if (FromEnvironment("XDG_DATA_HOME") is { } xdg)
return Path.Combine(xdg, AppFolder.ToLowerInvariant());
if (FromEnvironment("HOME") is { } home)
return Path.Combine(home, ".local", "share", AppFolder.ToLowerInvariant());
return Path.Combine("/var/lib", AppFolder.ToLowerInvariant());
}
private static string? FromEnvironment(string name)
{
var value = Environment.GetEnvironmentVariable(name);
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
/// <summary>
/// <c>GetFolderPath</c> kann eine leere Zeichenkette liefern (Dienstkonto ohne
/// geladenes Profil). <c>Path.Combine("", …)</c> ergäbe dann einen relativen Pfad —
/// die Datei landete im Arbeitsverzeichnis und wäre beim nächsten Start verschwunden.
/// </summary>
private static string Combine(string root, string folder)
=> string.IsNullOrWhiteSpace(root)
? Path.Combine(AppContext.BaseDirectory, folder)
: Path.Combine(root, folder);
}
+5 -1
View File
@@ -29,11 +29,15 @@ public static class AtomicFile
/// einer gewinnt. Ohne Serialisierung scheitern sie aber zusätzlich: Windows lehnt
/// zwei gleichzeitige Ersetzungen desselben Ziels mit "Zugriff verweigert" ab.
/// Das Anstellen kostet nichts und macht das Ergebnis vorhersagbar.
///
/// Der Schlüssel kommt aus <see cref="PathBoundary.CanonicalKey"/>: Unter Windows
/// meinen "Config.json" und "config.json" dieselbe Datei und brauchen dieselbe
/// Sperre, unter Linux sind es zwei Dateien, die sich keine teilen dürfen.
/// </summary>
private static readonly ConcurrentDictionary<string, SemaphoreSlim> PathLocks = new();
private static SemaphoreSlim LockFor(string fullPath)
=> PathLocks.GetOrAdd(fullPath.ToLowerInvariant(), _ => new SemaphoreSlim(1, 1));
=> PathLocks.GetOrAdd(PathBoundary.CanonicalKey(fullPath), _ => new SemaphoreSlim(1, 1));
/// <summary>
/// Liest eine Datei, ohne einen gleichzeitigen Schreibvorgang zu blockieren.
@@ -0,0 +1,155 @@
namespace ClawdDotNet.Core.Storage;
/// <summary>
/// Vergleicht und begrenzt Dateipfade so, wie es das jeweilige Dateisystem tut.
///
/// Hintergrund: Die Einschließungsprüfungen im Projekt verglichen mit
/// <c>OrdinalIgnoreCase</c> — die Annahme von Windows, dass Groß- und Kleinschreibung
/// keine Rolle spielt. Unter Linux ist das falsch: <c>/home/x/Workspace</c> und
/// <c>/home/x/workspace</c> sind zwei verschiedene Verzeichnisse. Ein Kandidat im
/// zweiten würde als „innerhalb" des ersten durchgehen.
///
/// Betroffen waren drei Sandbox-Grenzen (FileRW, FTP) und die Archiventpackung.
///
/// Zweiter Punkt, den es unter Windows so nicht gab: <b>symbolische Verknüpfungen</b>.
/// <c>Path.GetFullPath</c> löst sie nicht auf — es rechnet nur <c>..</c> heraus. Legt
/// ein Agent in seinem Arbeitsverzeichnis eine Verknüpfung nach <c>/etc</c> an, liegt
/// <c>workspace/etc/passwd</c> nach reiner Zeichenkettenrechnung innerhalb, zeigt aber
/// hinaus. <see cref="Canonicalize"/> löst deshalb jeden Pfadabschnitt auf.
/// </summary>
public static class PathBoundary
{
/// <summary>
/// Wie das Dateisystem Pfade vergleicht.
///
/// Nur Windows und macOS führen Groß- und Kleinschreibung zusammen. Bei macOS ist
/// das genau genommen eine Frage des Dateisystems (HFS+/APFS meist ja, aber
/// case-sensitive formatierbar) — dort auf der sicheren Seite zu liegen heißt,
/// den zusammenführenden Vergleich zu wählen: Er weist im Zweifel zu viel ab,
/// statt zu wenig.
/// </summary>
public static StringComparison Comparison { get; } =
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
/// <inheritdoc cref="Comparison"/>
public static StringComparer Comparer { get; } =
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
/// <summary>Absoluter Pfad mit genau einem abschließenden Trenner.</summary>
public static string NormalizeDirectory(string path)
{
var full = Path.GetFullPath(path);
return full.EndsWith(Path.DirectorySeparatorChar)
? full
: full + Path.DirectorySeparatorChar;
}
/// <summary>
/// Prüft, ob <paramref name="candidate"/> im Verzeichnis <paramref name="root"/> liegt.
///
/// Verglichen wird auf Verzeichnisgrenzen, nicht auf Zeichenketten-Präfixen: Ohne
/// den abschließenden Trenner gälte <c>…/Workspace-Backup</c> als Teil von
/// <c>…/Workspace</c>.
///
/// Symbolische Verknüpfungen werden aufgelöst (<see cref="Canonicalize"/>) — ein
/// Pfad, der nur über eine Verknüpfung hinauszeigt, gilt als außerhalb.
/// </summary>
public static bool IsInside(string candidate, string root)
{
var normalizedRoot = Canonicalize(NormalizeDirectory(root));
var normalizedCandidate = Canonicalize(Path.GetFullPath(candidate));
// Der Root selbst gilt als innerhalb.
if (string.Equals(
normalizedCandidate.TrimEnd(Path.DirectorySeparatorChar),
normalizedRoot.TrimEnd(Path.DirectorySeparatorChar),
Comparison))
{
return true;
}
return normalizedCandidate.StartsWith(
NormalizeDirectory(normalizedRoot), Comparison);
}
/// <summary>
/// Schlüssel für Sperren und Wörterbücher, die einen Pfad eindeutig meinen sollen.
///
/// Unter Windows werden Schreibweisen zusammengeführt, unter Linux nicht — dort
/// sind zwei Schreibweisen zwei Dateien und dürfen sich keine Sperre teilen.
/// </summary>
public static string CanonicalKey(string path)
{
var full = Path.GetFullPath(path);
return Comparison == StringComparison.OrdinalIgnoreCase
? full.ToLowerInvariant()
: full;
}
/// <summary>
/// Löst symbolische Verknüpfungen in jedem Abschnitt des Pfades auf.
///
/// Abschnittsweise, weil eine Verknüpfung mitten im Pfad genügt: Zeigt
/// <c>workspace/daten</c> nach <c>/etc</c>, dann liegt <c>workspace/daten/passwd</c>
/// außerhalb — obwohl weder der Anfang noch das Ende des Pfades eine Verknüpfung
/// ist. <c>ResolveLinkTarget(returnFinalTarget: true)</c> folgt dabei ganzen Ketten,
/// ein Aufruf je Abschnitt genügt also.
///
/// Noch nicht existierende Abschnitte bleiben unverändert — für einen Pfad, der erst
/// angelegt werden soll, ist das der richtige Umgang: Was es nicht gibt, kann keine
/// Verknüpfung sein, und der bereits vorhandene Teil davor wurde geprüft.
/// </summary>
public static string Canonicalize(string path)
{
var full = Path.GetFullPath(path);
var root = Path.GetPathRoot(full);
if (string.IsNullOrEmpty(root))
return full;
var rest = full[root.Length..]
.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
StringSplitOptions.RemoveEmptyEntries);
var current = root;
foreach (var segment in rest)
{
current = Path.Combine(current, segment);
string? target;
try
{
// Gibt null zurück, wenn der Abschnitt keine Verknüpfung ist oder
// nicht existiert. Wirft bei Zyklen und bei zu tiefen Ketten.
target = Directory.Exists(current)
? Directory.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName
: File.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName;
}
catch (IOException)
{
// Zyklus oder unauflösbare Kette. Der Pfad ist damit nicht bestimmbar —
// wir geben zurück, was wir haben. Die Einschließungsprüfung entscheidet
// dann auf der bisherigen, unaufgelösten Fassung: Sie weist im Zweifel ab.
return full;
}
catch (UnauthorizedAccessException)
{
return full;
}
if (!string.IsNullOrEmpty(target))
{
current = Path.IsPathRooted(target)
? target
: Path.GetFullPath(Path.Combine(Path.GetDirectoryName(current) ?? root, target));
}
}
return Path.GetFullPath(current);
}
}
@@ -0,0 +1,55 @@
namespace ClawdDotNet.Core.Storage;
/// <summary>
/// Erzeugt Dateinamen, die auf Windows <b>und</b> Linux gültig sind.
///
/// Hintergrund (Linux-Portierung): <see cref="Path.GetInvalidFileNameChars"/> liefert
/// unter Windows 41 Zeichen, unter Unix genau zwei (<c>\0</c> und <c>/</c>). Wer sich
/// darauf verlässt, erzeugt unter Linux Namen wie <c>bericht:2026.zip</c> — dort
/// zulässig, unter Windows nicht anlegbar.
///
/// Das trifft alles, was zwischen Systemen wandert: Sicherungsarchive, Logdateien,
/// Task-Dateien im geteilten Arbeitsverzeichnis. Deshalb hier bewusst der strengere
/// Maßstab auf beiden Plattformen — ein paar Unterstriche mehr sind billiger als eine
/// Sicherung, die sich auf dem Zielsystem nicht auspacken lässt.
/// </summary>
public static class PortableFileName
{
/// <summary>Unter Windows unzulässig, unter Unix erlaubt — hier immer ersetzt.</summary>
private static readonly char[] WindowsReserved =
['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
/// <summary>
/// Gerätenamen, die Windows unabhängig von der Endung nicht als Datei zulässt.
/// Unter Linux völlig gewöhnliche Namen — deshalb fällt es dort erst beim
/// Zurückspielen auf.
/// </summary>
private static readonly string[] ReservedNames =
[
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
];
public static string Sanitize(string? name, string fallback = "unbenannt")
{
if (string.IsNullOrWhiteSpace(name))
return fallback;
var chars = name.Select(c =>
c < 32 || WindowsReserved.Contains(c) ? '_' : c).ToArray();
// Windows schneidet abschließende Punkte und Leerzeichen stillschweigend ab —
// aus "bericht." würde "bericht", und zwei Dateien fielen zusammen.
var result = new string(chars).TrimEnd('.', ' ');
if (result.Length == 0)
return fallback;
var stem = Path.GetFileNameWithoutExtension(result);
if (ReservedNames.Contains(stem, StringComparer.OrdinalIgnoreCase))
result = "_" + result;
return result;
}
}
@@ -152,6 +152,112 @@ public sealed class SqliteStorage
CREATE INDEX IF NOT EXISTS IX_RunUsage_Recent
ON RunUsage (OccurredAt DESC);
-- Taskboard (A1): Ausführungszustand der Aufgaben. Die Definition lebt in
-- Markdown-Dateien; diese Tabelle spiegelt sie und macht das Claiming atomar.
CREATE TABLE IF NOT EXISTS Tasks (
Id TEXT PRIMARY KEY,
Title TEXT NOT NULL DEFAULT '',
Status TEXT NOT NULL DEFAULT 'todo',
Type TEXT NOT NULL DEFAULT 'work',
Priority INTEGER NOT NULL DEFAULT 3,
Assignee TEXT NOT NULL DEFAULT '@human',
WhenKind TEXT NULL,
WhenValue TEXT NULL,
WhenTz TEXT NULL,
RequireApproval INTEGER NOT NULL DEFAULT 0,
Acceptance TEXT NOT NULL DEFAULT '',
-- Blocker als "|a|b|"; die Begrenzer verhindern Teiltreffer bei der Suche.
BlockedBy TEXT NOT NULL DEFAULT '',
OnlyWhenMarketOpen INTEGER NOT NULL DEFAULT 0,
Body TEXT NOT NULL DEFAULT '',
FileName TEXT NOT NULL DEFAULT '',
-- Nur für Typ tool_job: welches Tool mit welcher Job-Art getickt wird.
ToolName TEXT NOT NULL DEFAULT '',
JobTypeId TEXT NOT NULL DEFAULT '',
-- Ausführungszustand: nur das Board schreibt hier.
LastOccurrence TEXT NULL,
ClaimToken TEXT NULL,
ClaimedAt TEXT NULL,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS IX_Tasks_Status
ON Tasks (Status);
CREATE INDEX IF NOT EXISTS IX_Tasks_Assignee
ON Tasks (Assignee);
-- Audit-Log (A3): ein Eintrag je Tool-Aufruf. Append-only — die Herkunft
-- stempelt die Engine, Korrekturen sind neue Zeilen.
CREATE TABLE IF NOT EXISTS AuditLog (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
RunId TEXT NOT NULL,
AgentId TEXT NOT NULL,
Model TEXT NOT NULL DEFAULT '',
Source TEXT NOT NULL DEFAULT 'unknown',
Tool TEXT NOT NULL,
Arguments TEXT NOT NULL DEFAULT '',
Status TEXT NOT NULL,
Summary TEXT NOT NULL DEFAULT '',
DurationMs INTEGER NOT NULL DEFAULT 0,
OccurredAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS IX_AuditLog_Run
ON AuditLog (RunId, Id);
CREATE INDEX IF NOT EXISTS IX_AuditLog_Recent
ON AuditLog (Id DESC);
-- Abschluss-Belege (Receipts): ein Beleg je Lauf, verknüpft mit einem Task.
CREATE TABLE IF NOT EXISTS RunReceipts (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
RunId TEXT NOT NULL,
AgentId TEXT NOT NULL,
Model TEXT NOT NULL DEFAULT '',
Source TEXT NOT NULL DEFAULT 'unknown',
TaskId TEXT NULL,
Status TEXT NOT NULL DEFAULT '',
StepCount INTEGER NOT NULL DEFAULT 0,
PromptTokens INTEGER NOT NULL DEFAULT 0,
CompletionTokens INTEGER NOT NULL DEFAULT 0,
CachedTokens INTEGER NOT NULL DEFAULT 0,
CostUsd TEXT NOT NULL DEFAULT '0',
CostIsKnown INTEGER NOT NULL DEFAULT 0,
DurationMs INTEGER NOT NULL DEFAULT 0,
ResultRef TEXT NOT NULL DEFAULT '',
OccurredAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS IX_RunReceipts_Task
ON RunReceipts (TaskId);
CREATE INDEX IF NOT EXISTS IX_RunReceipts_Run
ON RunReceipts (RunId);
-- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird
-- genau der gespeicherte Argument-JSON (Plan-Freeze).
CREATE TABLE IF NOT EXISTS StagedCalls (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
RunId TEXT NOT NULL,
AgentId TEXT NOT NULL,
InstanceId TEXT NOT NULL DEFAULT '',
Tool TEXT NOT NULL,
Action TEXT NULL,
ArgumentsJson TEXT NOT NULL DEFAULT '',
Proposal TEXT NOT NULL DEFAULT '',
Status TEXT NOT NULL DEFAULT 'Pending',
CreatedAt TEXT NOT NULL,
DecidedAt TEXT NULL,
DecidedBy TEXT NULL,
ResultRef TEXT NULL,
RejectionReason TEXT NULL
);
CREATE INDEX IF NOT EXISTS IX_StagedCalls_Pending
ON StagedCalls (Status, Id);
""";
cmd.ExecuteNonQuery();
+101
View File
@@ -0,0 +1,101 @@
using System.Diagnostics;
namespace ClawdDotNet.Core.Storage;
/// <summary>
/// Öffnet Ordner und Dateien im Dateimanager des Systems.
///
/// Hintergrund (Linux-Portierung): Die Oberfläche rief an vier Stellen direkt
/// <c>explorer.exe</c> auf. Hier liegt das plattformneutral — und in Core statt in der
/// Oberfläche, weil die künftige Avalonia-Fassung dieselben Aufrufe braucht.
///
/// Bewusst ohne Rückmeldung im Fehlerfall: Einen Ordner zu öffnen ist eine
/// Bequemlichkeit. Schlägt es fehl (kein Dateimanager installiert, Dienst ohne
/// Sitzung), soll das den Aufrufer nicht beschäftigen.
/// </summary>
public static class SystemShell
{
/// <summary>Öffnet ein Verzeichnis im Dateimanager.</summary>
public static bool OpenFolder(string path)
{
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
return false;
return Open(path);
}
/// <summary>
/// Öffnet den Ordner einer Datei und hebt sie nach Möglichkeit hervor.
///
/// Das Hervorheben können nur Windows und macOS. Unter Linux gibt es keinen
/// Aufruf, der über alle Dateimanager hinweg funktioniert — dort wird nur der
/// Ordner geöffnet. Das ist der kleinere Verlust gegenüber einer Liste von
/// Sonderfällen je Desktop-Umgebung.
/// </summary>
public static bool RevealFile(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return false;
var folder = Path.GetDirectoryName(Path.GetFullPath(path));
if (string.IsNullOrEmpty(folder)) return false;
try
{
if (OperatingSystem.IsWindows())
return Start("explorer.exe", ["/select,", Path.GetFullPath(path)]);
if (OperatingSystem.IsMacOS())
return Start("open", ["-R", Path.GetFullPath(path)]);
}
catch { /* siehe Klassenkommentar */ }
return OpenFolder(folder);
}
private static bool Open(string target)
{
var full = Path.GetFullPath(target);
try
{
if (OperatingSystem.IsLinux())
return Start("xdg-open", [full]);
if (OperatingSystem.IsMacOS())
return Start("open", [full]);
// Windows: UseShellExecute lässt die Shell entscheiden, statt explorer.exe
// festzuschreiben.
using var process = Process.Start(new ProcessStartInfo(full)
{
UseShellExecute = true
});
return process is not null;
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
{
return false;
}
}
/// <summary>
/// Startet mit <c>ArgumentList</c> statt einer Argumentzeichenkette — so muss nichts
/// maskiert werden, und ein Pfad mit Leerzeichen oder Anführungszeichen kann keinen
/// zusätzlichen Aufruf einschleusen.
/// </summary>
private static bool Start(string fileName, string[] arguments)
{
var info = new ProcessStartInfo(fileName)
{
UseShellExecute = false,
CreateNoWindow = true
};
foreach (var argument in arguments)
info.ArgumentList.Add(argument);
using var process = Process.Start(info);
return process is not null;
}
}