Compare commits

...

2 Commits

Author SHA1 Message Date
Scott Hanselman
6656ef7bcb Apply NotificationSound setting to all toast notifications (#92)
Add ShowToast() helper that checks _settings.NotificationSound and
configures audio accordingly (None=silent, Subtle=IM sound, Default=
system default). Replace all 14 direct .Show() calls with ShowToast().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 14:25:01 -07:00
Scott Hanselman
0128883e9d refactor: extract TryParseArgv helper, fix operator precedence (closes #91)
- Extract TryParseArgv(JsonElement) to consolidate duplicated argv parsing
  in HandleRunPrepare and HandleRunAsync (~20 lines each → one 20-line helper)
- Fix operator precedence in HandleExecApprovalsSet: add parentheses to
  make (True || False) grouping explicit (was safe by coincidence but misleading)
- Add 5 new tests for argv parsing: array, string, empty, missing, multi-arg

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 14:18:20 -07:00
3 changed files with 173 additions and 104 deletions

View File

@ -162,45 +162,51 @@ public class SystemCapability : NodeCapabilityBase
private static string FormatExecCommand(string[] argv) => ShellQuoting.FormatExecCommand(argv);
/// <summary>
/// Parses a JSON "command" property as either a string array or a plain string.
/// Returns the argv array (command as first element) or null if missing/invalid.
/// </summary>
private static string[]? TryParseArgv(System.Text.Json.JsonElement requestArgs)
{
if (requestArgs.ValueKind == System.Text.Json.JsonValueKind.Undefined ||
!requestArgs.TryGetProperty("command", out var cmdEl))
return null;
if (cmdEl.ValueKind == System.Text.Json.JsonValueKind.Array)
{
var list = new List<string>();
foreach (var item in cmdEl.EnumerateArray())
{
if (item.ValueKind == System.Text.Json.JsonValueKind.String)
list.Add(item.GetString() ?? "");
}
return list.Count > 0 ? list.ToArray() : null;
}
if (cmdEl.ValueKind == System.Text.Json.JsonValueKind.String)
{
var command = cmdEl.GetString();
return command != null ? new[] { command } : null;
}
return null;
}
/// <summary>
/// Pre-flight for system.run: echoes back the execution plan without running anything.
/// The gateway uses this to build its approval context before the actual run.
/// </summary>
private NodeInvokeResponse HandleRunPrepare(NodeInvokeRequest request)
{
string? command = null;
string[]? argv = null;
string? rawCommand = null;
string? cwd = null;
if (request.Args.ValueKind != System.Text.Json.JsonValueKind.Undefined &&
request.Args.TryGetProperty("command", out var cmdEl))
{
if (cmdEl.ValueKind == System.Text.Json.JsonValueKind.Array)
{
var list = new List<string>();
foreach (var item in cmdEl.EnumerateArray())
{
if (item.ValueKind == System.Text.Json.JsonValueKind.String)
list.Add(item.GetString() ?? "");
}
argv = list.ToArray();
command = argv.Length > 0 ? argv[0] : null;
}
else if (cmdEl.ValueKind == System.Text.Json.JsonValueKind.String)
{
command = cmdEl.GetString();
argv = command != null ? new[] { command } : null;
}
}
if (string.IsNullOrWhiteSpace(command) || argv == null || argv.Length == 0)
var argv = TryParseArgv(request.Args);
if (argv == null || argv.Length == 0 || string.IsNullOrWhiteSpace(argv[0]))
{
return Error("Missing command parameter");
}
rawCommand = GetStringArg(request.Args, "rawCommand");
cwd = GetStringArg(request.Args, "cwd");
var command = argv[0];
var rawCommand = GetStringArg(request.Args, "rawCommand");
var cwd = GetStringArg(request.Args, "cwd");
var agentId = GetStringArg(request.Args, "agentId");
var sessionKey = GetStringArg(request.Args, "sessionKey");
@ -229,44 +235,22 @@ public class SystemCapability : NodeCapabilityBase
// Per OpenClaw spec, "command" is an argv array (e.g. ["echo","Hello"]).
// Also accept a plain string for backward compatibility.
string? command = null;
string[]? args = null;
var argv = TryParseArgv(request.Args);
string? command = argv?[0];
string[]? args = argv?.Length > 1 ? argv.Skip(1).ToArray() : null;
if (request.Args.ValueKind != System.Text.Json.JsonValueKind.Undefined &&
request.Args.TryGetProperty("command", out var cmdEl))
// When command is a string, also check for separate "args" array
if (argv?.Length == 1 && request.Args.TryGetProperty("args", out var argsEl) &&
argsEl.ValueKind == System.Text.Json.JsonValueKind.Array)
{
if (cmdEl.ValueKind == System.Text.Json.JsonValueKind.Array)
var list = new List<string>();
foreach (var item in argsEl.EnumerateArray())
{
var argv = new List<string>();
foreach (var item in cmdEl.EnumerateArray())
{
if (item.ValueKind == System.Text.Json.JsonValueKind.String)
argv.Add(item.GetString() ?? "");
}
if (argv.Count > 0)
{
command = argv[0];
args = argv.Count > 1 ? argv.Skip(1).ToArray() : null;
}
}
else if (cmdEl.ValueKind == System.Text.Json.JsonValueKind.String)
{
command = cmdEl.GetString();
// When command is a string, also check for separate "args" array
if (request.Args.TryGetProperty("args", out var argsEl) &&
argsEl.ValueKind == System.Text.Json.JsonValueKind.Array)
{
var list = new List<string>();
foreach (var item in argsEl.EnumerateArray())
{
if (item.ValueKind == System.Text.Json.JsonValueKind.String)
list.Add(item.GetString() ?? "");
}
if (list.Count > 0)
args = list.ToArray();
}
if (item.ValueKind == System.Text.Json.JsonValueKind.String)
list.Add(item.GetString() ?? "");
}
if (list.Count > 0)
args = list.ToArray();
}
if (string.IsNullOrWhiteSpace(command))
@ -402,7 +386,7 @@ public class SystemCapability : NodeCapabilityBase
if (ruleEl.TryGetProperty("description", out var descEl) && descEl.ValueKind == System.Text.Json.JsonValueKind.String)
rule.Description = descEl.GetString();
if (ruleEl.TryGetProperty("enabled", out var enEl) && enEl.ValueKind == System.Text.Json.JsonValueKind.True || enEl.ValueKind == System.Text.Json.JsonValueKind.False)
if (ruleEl.TryGetProperty("enabled", out var enEl) && (enEl.ValueKind == System.Text.Json.JsonValueKind.True || enEl.ValueKind == System.Text.Json.JsonValueKind.False))
rule.Enabled = enEl.GetBoolean();
if (ruleEl.TryGetProperty("shells", out var shellsEl) && shellsEl.ValueKind == System.Text.Json.JsonValueKind.Array)

View File

@ -568,10 +568,9 @@ public partial class App : Application
global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dataPackage);
// Show toast confirming copy
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_DeviceIdCopied"))
.AddText(string.Format(LocalizationHelper.GetString("Toast_DeviceIdCopiedDetail"), _nodeService.ShortDeviceId))
.Show();
.AddText(string.Format(LocalizationHelper.GetString("Toast_DeviceIdCopiedDetail"), _nodeService.ShortDeviceId)));
}
catch (Exception ex)
{
@ -597,10 +596,9 @@ public partial class App : Application
dataPackage.SetText(summary);
global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dataPackage);
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_NodeSummaryCopied"))
.AddText(string.Format(LocalizationHelper.GetString("Toast_NodeSummaryCopiedDetail"), _lastNodes.Length))
.Show();
.AddText(string.Format(LocalizationHelper.GetString("Toast_NodeSummaryCopiedDetail"), _lastNodes.Length)));
}
catch (Exception ex)
{
@ -654,10 +652,9 @@ public partial class App : Application
if (!sent)
{
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_SessionActionFailed"))
.AddText(LocalizationHelper.GetString("Toast_SessionActionFailedDetail"))
.Show();
.AddText(LocalizationHelper.GetString("Toast_SessionActionFailedDetail")));
return;
}
@ -671,10 +668,9 @@ public partial class App : Application
Logger.Warn($"Session action error ({action}): {ex.Message}");
try
{
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_SessionActionFailed"))
.AddText(ex.Message)
.Show();
.AddText(ex.Message));
}
catch { }
}
@ -1157,10 +1153,9 @@ public partial class App : Application
{
try
{
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_NodeModeActive"))
.AddText(LocalizationHelper.GetString("Toast_NodeModeActiveDetail"))
.Show();
.AddText(LocalizationHelper.GetString("Toast_NodeModeActiveDetail")));
}
catch { /* ignore */ }
}
@ -1176,18 +1171,16 @@ public partial class App : Application
{
AddRecentActivity("Node pairing pending", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
// Show toast with approval instructions
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_PairingPending"))
.AddText(string.Format(LocalizationHelper.GetString("Toast_PairingPendingDetail"), args.DeviceId.Substring(0, 16)))
.Show();
.AddText(string.Format(LocalizationHelper.GetString("Toast_PairingPendingDetail"), args.DeviceId.Substring(0, 16))));
}
else if (args.Status == OpenClaw.Shared.PairingStatus.Paired)
{
AddRecentActivity("Node paired", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_NodePaired"))
.AddText(LocalizationHelper.GetString("Toast_NodePairedDetail"))
.Show();
.AddText(LocalizationHelper.GetString("Toast_NodePairedDetail")));
}
}
catch { /* ignore */ }
@ -1200,10 +1193,9 @@ public partial class App : Application
// Agent requested a notification via node.invoke system.notify
try
{
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(args.Title)
.AddText(args.Body)
.Show();
.AddText(args.Body));
}
catch (Exception ex)
{
@ -1376,10 +1368,9 @@ public partial class App : Application
dashboardPath: !string.IsNullOrWhiteSpace(result.Key) ? $"sessions/{result.Key}" : "sessions",
sessionKey: result.Key);
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(title)
.AddText(message)
.Show();
.AddText(message));
}
catch (Exception ex)
{
@ -1434,7 +1425,7 @@ public partial class App : Application
.AddArgument("action", "open_chat"));
}
builder.Show();
ShowToast(builder);
}
catch (Exception ex)
{
@ -1498,10 +1489,9 @@ public partial class App : Application
{
if (userInitiated)
{
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_HealthCheck"))
.AddText(LocalizationHelper.GetString("Toast_HealthCheckNotConnected"))
.Show();
.AddText(LocalizationHelper.GetString("Toast_HealthCheckNotConnected")));
}
return;
}
@ -1512,10 +1502,9 @@ public partial class App : Application
await _gatewayClient.CheckHealthAsync();
if (userInitiated)
{
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_HealthCheck"))
.AddText(LocalizationHelper.GetString("Toast_HealthCheckSent"))
.Show();
.AddText(LocalizationHelper.GetString("Toast_HealthCheckSent")));
}
}
catch (Exception ex)
@ -1523,10 +1512,9 @@ public partial class App : Application
Logger.Warn($"Health check failed: {ex.Message}");
if (userInitiated)
{
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_HealthCheckFailed"))
.AddText(ex.Message)
.Show();
.AddText(ex.Message));
}
}
}
@ -1749,13 +1737,12 @@ public partial class App : Application
try
{
new ToastContentBuilder()
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_ActivityStreamTip"))
.AddText(LocalizationHelper.GetString("Toast_ActivityStreamTipDetail"))
.AddButton(new ToastButton()
.SetContent(LocalizationHelper.GetString("Toast_ActivityStreamTipButton"))
.AddArgument("action", "open_activity"))
.Show();
.AddArgument("action", "open_activity")));
}
catch (Exception ex)
{
@ -1765,6 +1752,20 @@ public partial class App : Application
#endregion
private void ShowToast(ToastContentBuilder builder)
{
var sound = _settings?.NotificationSound;
if (string.Equals(sound, "None", StringComparison.OrdinalIgnoreCase))
{
builder.AddAudio(new ToastAudio { Silent = true });
}
else if (string.Equals(sound, "Subtle", StringComparison.OrdinalIgnoreCase))
{
builder.AddAudio(new Uri("ms-winsoundevent:Notification.IM"), silent: false);
}
builder.Show();
}
#region Actions
private void OpenDashboard(string? path = null)

View File

@ -194,6 +194,90 @@ public class SystemRunTests
Assert.Contains("Execution failed", res.Error);
}
[Fact]
public async Task SystemRunPrepare_ParsesArgvArray()
{
var cap = new SystemCapability(NullLogger.Instance);
var req = new NodeInvokeRequest
{
Id = "rp1",
Command = "system.run.prepare",
Args = Parse("""{"command":["git","status","--short"]}""")
};
var res = await cap.ExecuteAsync(req);
Assert.True(res.Ok, res.Error);
}
[Fact]
public async Task SystemRunPrepare_ParsesStringCommand()
{
var cap = new SystemCapability(NullLogger.Instance);
var req = new NodeInvokeRequest
{
Id = "rp2",
Command = "system.run.prepare",
Args = Parse("""{"command":"echo hello"}""")
};
var res = await cap.ExecuteAsync(req);
Assert.True(res.Ok, res.Error);
}
[Fact]
public async Task SystemRunPrepare_ReturnsError_WhenMissingCommand()
{
var cap = new SystemCapability(NullLogger.Instance);
var req = new NodeInvokeRequest
{
Id = "rp3",
Command = "system.run.prepare",
Args = Parse("""{}""")
};
var res = await cap.ExecuteAsync(req);
Assert.False(res.Ok);
Assert.Contains("Missing command", res.Error);
}
[Fact]
public async Task SystemRunPrepare_ReturnsError_WhenEmptyArray()
{
var cap = new SystemCapability(NullLogger.Instance);
var req = new NodeInvokeRequest
{
Id = "rp4",
Command = "system.run.prepare",
Args = Parse("""{"command":[]}""")
};
var res = await cap.ExecuteAsync(req);
Assert.False(res.Ok);
Assert.Contains("Missing command", res.Error);
}
[Fact]
public async Task SystemRun_ParsesArgvArrayForRun()
{
var runner = new FakeCommandRunner();
var cap = new SystemCapability(NullLogger.Instance);
cap.SetCommandRunner(runner);
var req = new NodeInvokeRequest
{
Id = "ra1",
Command = "system.run",
Args = Parse("""{"command":["git","push","origin","main"]}""")
};
var res = await cap.ExecuteAsync(req);
Assert.True(res.Ok);
Assert.Equal("git", runner.LastRequest!.Command);
Assert.NotNull(runner.LastRequest.Args);
Assert.Equal(3, runner.LastRequest.Args!.Length);
Assert.Equal("push", runner.LastRequest.Args[0]);
}
/// <summary>
/// Fake runner for unit testing — no actual process execution.
/// </summary>