diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4e731b2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +All notable changes to ClaudePortable are documented here. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2026-05-10 + +### Added + +- **Schedule sidebar section** that enumerates every Windows scheduled task + via `schtasks.exe /Query /FO CSV /V` and flags Claude relevance with a + three-tier classifier (green = ClaudePortable-managed, orange = + foreign-but-Claude-related, gray = unrelated). Use it to spot a legacy + `\Claude-Desktop-Backup`-style PowerShell task that writes loose-file + backups into a long-path OneDrive folder and breaks sync. +- Per-row Run / Disable / Enable / Delete / View XML buttons in the new + Schedule view, with confirmation dialogs on destructive actions and + clipboard copy of the raw Task Scheduler XML. +- CLI: `claudeportable schedule list [--all|--managed|--relevant] + [--json]` to enumerate tasks from the terminal, plus + `schedule disable|enable|run ` for symmetry with the new GUI + buttons. +- `ScheduledTaskClassifier` in `ClaudePortable.Scheduler` with a stable, + testable marker list (`.claude`, `Claude_pzs8sxrjxfjjc`, `Cowork`, + `CoWork\Backup`, `local-agent-mode-sessions`, `claude-desktop`, + `anthropic`, etc.). +- `TaskSchedulerInstaller.EnumerateAsync` / `DisableAsync` / + `EnableAsync` / `RunNowAsync` / `GetTaskXmlAsync`, all going through a + single internal `Func<>` seam so unit tests can assert exact `schtasks` + argv without invoking the executable. +- Fixture-driven CSV-parser tests covering German `schtasks` headers, + quoted-path executables (`"C:\Program Files\..."`), unquoted paths with + spaces, subfolder task names, disabled state, and `ManagedBy` + classification. + +### Changed + +- README upgraded from "Five sections" to "Six sections" GUI overview and + refreshed test count (61 -> 98 xUnit cases). +- `scripts/build-exe.ps1` and `src/ClaudePortable.Installer/build-msi.ps1` + default version bumped from `0.1.x` to `0.2.0`. + +### Not changed (out of scope, intentionally) + +- ClaudePortable does NOT auto-disable or auto-delete any task it finds. + The user explicitly clicks each action; confirmation dialogs gate + Disable and Delete. +- The existing backup engine, restore engine, and archive format are + untouched. A 0.1.x backup ZIP restores identically on 0.2.0. + +## [0.1.x] + +Initial alpha releases focused on backup, restore, retention rotation, +sync-client discovery, and a single-task scheduler install command. See +git history and the GitHub Releases page for per-version detail. diff --git a/README.md b/README.md index 6400774..cbdbed2 100644 --- a/README.md +++ b/README.md @@ -51,13 +51,14 @@ Optional integrity check: ## GUI flows -Launch with no arguments (or `--gui`). Warm-dark UI in the Claude Desktop style, WCAG 2.1 AA contrast throughout, visible keyboard focus rings. Five sections: +Launch with no arguments (or `--gui`). Warm-dark UI in the Claude Desktop style, WCAG 2.1 AA contrast throughout, visible keyboard focus rings. Six sections: - **Status** - summary cards for backups / targets / discovered paths, plus a grid of existing snapshots per target. - **Targets** - folder list. Auto-discovers `\ClaudePortable` on every recognised sync client (OneDrive Personal / Business, Dropbox, Google Drive Desktop), so a restore on a second machine picks up the first machine's backups without configuration. Manual add/remove available. - **Discovery** - read-only view of detected Claude paths + sync clients. - **Restore** - backup grid with per-row `STATUS` (`Synced` / `Cloud-only` / `Unreadable`), `Restore from file...` escape hatch for a ZIP that is not in any configured target, and an **Advanced options** panel for overriding the target user profile (e.g. restoring a `sascha` backup onto a laptop with `sasch` as the user) and for the version-gate override. - **Logs** - last 500 log lines from the current session, rendered mono. +- **Schedule** - enumerates every Windows scheduled task on this machine via `schtasks.exe /Query /FO CSV /V`. ClaudePortable-managed entries are flagged green (name starts with `ClaudePortable-` or author contains `ClaudePortable`). Tasks that aren't managed but touch a Claude/Cowork/`.claude` path - including hand-written backup PowerShell scripts that compete with ClaudePortable - are flagged orange. Per-row buttons run/disable/enable/delete the task and copy its raw XML to the clipboard. Use this to spot legacy `\Claude-Desktop-Backup`-style tasks that write loose-file backups into a long-path OneDrive folder and break sync. A ProgressBar on the status bar appears for the duration of any backup or restore, showing the current phase (`Extracting archive`, `Writing cowork-projects/`, etc.) with file-level percentage. Both commands run on the thread pool so the window stays responsive during multi-GB operations. @@ -74,6 +75,8 @@ claudeportable list --in [--json] # list backups claudeportable restore --from --yes [--target-user ] [--ignore-version-mismatch] claudeportable rotate --in [--daily 7] [--weekly 3] [--monthly 2] claudeportable schedule install|show|remove|emit # Windows Task Scheduler integration +claudeportable schedule list [--all|--managed|--relevant] [--json] # enumerate all scheduled tasks, flag Claude relevance +claudeportable schedule disable|enable|run # toggle / trigger a scheduled task by full name ``` Exit codes: `0` ok, `1` usage error, `2` precondition fail (destination unwritable, Claude Desktop running), `3` runtime error (I/O, invalid backup, version block). @@ -178,14 +181,14 @@ dotnet build dotnet test ``` -61 xUnit cases cover exclusion globs (incl. Claude Extensions paths that must NOT be excluded), manifest (de)serialisation, path rewriter across escaped / single-backslash / forward-slash and arbitrary home-relative paths, retention rotation simulated over 10 weeks with a fake clock, FolderTarget atomic I/O, end-to-end backup roundtrip on synthetic data, Task Scheduler XML, and version gating. +98 xUnit cases cover exclusion globs (incl. Claude Extensions paths that must NOT be excluded), manifest (de)serialisation, path rewriter across escaped / single-backslash / forward-slash and arbitrary home-relative paths, retention rotation simulated over 10 weeks with a fake clock, FolderTarget atomic I/O, end-to-end backup roundtrip on synthetic data, Task Scheduler XML emission, version gating, and the scheduled-task enumerator (CSV parser for German-locale `schtasks.exe` output, Claude-relevance classifier, and command-shape assertions for the installer wrapper). CI runs the same commands on `windows-latest` via `.github/workflows/ci.yml`. The release pipeline at `.github/workflows/release.yml` builds the MSI + portable exe + SHA-256 on `v*` tag push and attaches them to the GitHub Release. Local portable-exe build: ```powershell -pwsh scripts/build-exe.ps1 -Version 0.1.13 +pwsh scripts/build-exe.ps1 -Version 0.2.0 ``` Local MSI build (needs the WiX dotnet tool): @@ -193,7 +196,7 @@ Local MSI build (needs the WiX dotnet tool): ```powershell dotnet tool install --global wix --version 7.0.0 wix eula accept wix7 -pwsh src/ClaudePortable.Installer/build-msi.ps1 -Version 0.1.13 +pwsh src/ClaudePortable.Installer/build-msi.ps1 -Version 0.2.0 ``` ## Known limitations diff --git a/scripts/build-exe.ps1 b/scripts/build-exe.ps1 index d64441d..38a69fa 100644 --- a/scripts/build-exe.ps1 +++ b/scripts/build-exe.ps1 @@ -15,11 +15,11 @@ Debug or Release (default Release). .EXAMPLE - pwsh .\build-exe.ps1 -Version 0.1.1 + pwsh .\build-exe.ps1 -Version 0.2.0 #> param( - [string] $Version = "0.1.1", + [string] $Version = "0.2.0", [string] $Configuration = "Release" ) diff --git a/src/ClaudePortable.App/Commands/ScheduleCommand.cs b/src/ClaudePortable.App/Commands/ScheduleCommand.cs index 58dce47..e8539d2 100644 --- a/src/ClaudePortable.App/Commands/ScheduleCommand.cs +++ b/src/ClaudePortable.App/Commands/ScheduleCommand.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.Globalization; using System.Runtime.Versioning; +using System.Text.Json; using ClaudePortable.Scheduler.Scheduling; namespace ClaudePortable.App.Commands; @@ -10,11 +11,15 @@ public static class ScheduleCommand { public static Command Build() { - var cmd = new Command("schedule", "Install, inspect, or remove a Windows Task Scheduler entry that runs daily backups."); + var cmd = new Command("schedule", "Install, inspect, list, or remove Windows Task Scheduler entries."); cmd.AddCommand(BuildInstall()); cmd.AddCommand(BuildShow()); cmd.AddCommand(BuildRemove()); cmd.AddCommand(BuildEmit()); + cmd.AddCommand(BuildList()); + cmd.AddCommand(BuildDisable()); + cmd.AddCommand(BuildEnable()); + cmd.AddCommand(BuildRun()); return cmd; } @@ -117,6 +122,169 @@ private static Command BuildRemove() return remove; } + private static Command BuildList() + { + var allOption = new Option(new[] { "--all" }, () => false, "Include foreign tasks unrelated to Claude (default: hide them)."); + var managedOption = new Option(new[] { "--managed" }, () => false, "Only list ClaudePortable-managed tasks."); + var relevantOption = new Option(new[] { "--relevant" }, () => false, "Only list ClaudePortable + Claude-related tasks (default)."); + var jsonOption = new Option(new[] { "--json" }, () => false, "Emit JSON instead of an aligned table."); + var list = new Command("list", "List Windows scheduled tasks and flag Claude relevance.") + { + allOption, managedOption, relevantOption, jsonOption, + }; + list.SetHandler(async (all, managed, relevant, json) => + { + var installer = new TaskSchedulerInstaller(); + var tasks = await installer.EnumerateAsync().ConfigureAwait(false); + + IEnumerable filtered = tasks; + if (managed) + { + filtered = filtered.Where(t => t.ManagedBy == ManagedBy.ClaudePortable); + } + else if (all) + { + // no filter + } + else + { + filtered = filtered.Where(t => t.ManagedBy is ManagedBy.ClaudePortable or ManagedBy.ForeignRelevant); + } + + _ = relevant; + + var ordered = filtered + .OrderBy(t => t.ManagedBy) + .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (json) + { + var payload = new + { + tasks = ordered.Select(t => new + { + name = t.Name, + fullName = t.FullName, + folderPath = t.FolderPath, + managedBy = t.ManagedBy.ToString(), + state = t.State, + author = t.Author, + nextRun = t.NextRunTime?.ToString("o", CultureInfo.InvariantCulture), + lastRun = t.LastRunTime?.ToString("o", CultureInfo.InvariantCulture), + lastResult = t.LastResult, + action = new + { + executable = t.Action.Executable, + arguments = t.Action.Arguments, + workingDirectory = t.Action.WorkingDirectory, + }, + trigger = t.TriggerSummary, + }), + }; + Console.WriteLine(JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })); + return; + } + + if (ordered.Count == 0) + { + Console.WriteLine("(no tasks matched the filter)"); + return; + } + + var headers = new[] { "NAME", "MANAGED-BY", "STATE", "NEXT RUN", "ACTION" }; + var rows = ordered.Select(t => new[] + { + t.FullName, + t.ManagedBy.ToString(), + t.State, + t.NextRunTime?.LocalDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "-", + Truncate(string.IsNullOrEmpty(t.Action.Arguments) ? t.Action.Executable : $"{t.Action.Executable} {t.Action.Arguments}", 80), + }).ToList(); + + var widths = headers + .Select((h, i) => Math.Max(h.Length, rows.Count == 0 ? 0 : rows.Max(r => r[i]?.Length ?? 0))) + .ToArray(); + + Console.WriteLine(string.Join(" ", headers.Select((h, i) => h.PadRight(widths[i])))); + Console.WriteLine(string.Join(" ", widths.Select(w => new string('-', w)))); + foreach (var row in rows) + { + Console.WriteLine(string.Join(" ", row.Select((c, i) => (c ?? string.Empty).PadRight(widths[i])))); + } + }, allOption, managedOption, relevantOption, jsonOption); + return list; + } + + private static Command BuildDisable() + { + var nameArg = new Argument("name", "Full task name (e.g. \\ClaudePortable-Daily)."); + var disable = new Command("disable", "Disable a scheduled task via schtasks.exe /Change /Disable.") + { + nameArg, + }; + disable.SetHandler(async taskName => + { + var installer = new TaskSchedulerInstaller(); + var exit = await installer.DisableAsync(taskName).ConfigureAwait(false); + if (exit != 0) + { + Console.Error.WriteLine($"error: schtasks.exe /Change /Disable exited with code {exit}."); + Environment.ExitCode = 3; + return; + } + Console.WriteLine($"disabled '{taskName}'."); + }, nameArg); + return disable; + } + + private static Command BuildEnable() + { + var nameArg = new Argument("name", "Full task name (e.g. \\ClaudePortable-Daily)."); + var enable = new Command("enable", "Enable a scheduled task via schtasks.exe /Change /Enable.") + { + nameArg, + }; + enable.SetHandler(async taskName => + { + var installer = new TaskSchedulerInstaller(); + var exit = await installer.EnableAsync(taskName).ConfigureAwait(false); + if (exit != 0) + { + Console.Error.WriteLine($"error: schtasks.exe /Change /Enable exited with code {exit}."); + Environment.ExitCode = 3; + return; + } + Console.WriteLine($"enabled '{taskName}'."); + }, nameArg); + return enable; + } + + private static Command BuildRun() + { + var nameArg = new Argument("name", "Full task name (e.g. \\ClaudePortable-Daily)."); + var run = new Command("run", "Trigger a scheduled task immediately via schtasks.exe /Run.") + { + nameArg, + }; + run.SetHandler(async taskName => + { + var installer = new TaskSchedulerInstaller(); + var exit = await installer.RunNowAsync(taskName).ConfigureAwait(false); + if (exit != 0) + { + Console.Error.WriteLine($"error: schtasks.exe /Run exited with code {exit}."); + Environment.ExitCode = 3; + return; + } + Console.WriteLine($"triggered '{taskName}'."); + }, nameArg); + return run; + } + + private static string Truncate(string s, int max) + => s.Length <= max ? s : s[..(max - 3)] + "..."; + private static Command BuildEmit() { var folderOption = new Option(new[] { "--folder", "-f" }, "Destination folder for scheduled backups.") { IsRequired = true }; diff --git a/src/ClaudePortable.App/Ui/Converters/ManagedByToBrushConverter.cs b/src/ClaudePortable.App/Ui/Converters/ManagedByToBrushConverter.cs new file mode 100644 index 0000000..994b636 --- /dev/null +++ b/src/ClaudePortable.App/Ui/Converters/ManagedByToBrushConverter.cs @@ -0,0 +1,29 @@ +using System.Globalization; +using System.Windows.Data; +using ClaudePortable.Scheduler.Scheduling; + +namespace ClaudePortable.App.Ui.Converters; + +public sealed class ManagedByToBrushConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not ManagedBy managed) + { + return System.Windows.Application.Current?.TryFindResource("TextMutedBrush") + ?? System.Windows.Media.Brushes.Gray; + } + + var key = managed switch + { + ManagedBy.ClaudePortable => "SuccessBrush", + ManagedBy.ForeignRelevant => "WarnBrush", + _ => "TextMutedBrush", + }; + return System.Windows.Application.Current?.TryFindResource(key) + ?? System.Windows.Media.Brushes.Gray; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs b/src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs index 5a53516..6e153e6 100644 --- a/src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs +++ b/src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs @@ -9,6 +9,7 @@ using ClaudePortable.Core.Manifest; using ClaudePortable.Core.Restore; using ClaudePortable.Scheduler.Retention; +using ClaudePortable.Scheduler.Scheduling; using ClaudePortable.Targets; using ClaudePortable.Targets.Models; @@ -32,6 +33,7 @@ public sealed class MainViewModel : ViewModelBase public ObservableCollection Backups { get; } = new(); public ObservableCollection ClaudePaths { get; } = new(); public ObservableCollection SyncClients { get; } = new(); + public ScheduledTasksViewModel ScheduledTasks { get; } = new(new TaskSchedulerInstaller()); [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822", Justification = "Instance binding target for XAML.")] public ObservableCollection LogEntries => UiLogSink.Instance.Entries; diff --git a/src/ClaudePortable.App/Ui/ViewModels/ScheduledTaskInfoVm.cs b/src/ClaudePortable.App/Ui/ViewModels/ScheduledTaskInfoVm.cs new file mode 100644 index 0000000..5bd89cc --- /dev/null +++ b/src/ClaudePortable.App/Ui/ViewModels/ScheduledTaskInfoVm.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using ClaudePortable.Scheduler.Scheduling; + +namespace ClaudePortable.App.Ui.ViewModels; + +public sealed class ScheduledTaskInfoVm : ViewModelBase +{ + public ScheduledTaskInfo Info { get; } + + public ScheduledTaskInfoVm( + ScheduledTaskInfo info, + Func runNow, + Func disable, + Func enable, + Func delete, + Func viewXml) + { + ArgumentNullException.ThrowIfNull(info); + Info = info; + RunNowCommand = new AsyncRelayCommand(() => runNow(this)); + DisableCommand = new AsyncRelayCommand(() => disable(this), () => !IsDisabled); + EnableCommand = new AsyncRelayCommand(() => enable(this), () => IsDisabled); + DeleteCommand = new AsyncRelayCommand(() => delete(this)); + ViewXmlCommand = new AsyncRelayCommand(() => viewXml(this)); + } + + public AsyncRelayCommand RunNowCommand { get; } + public AsyncRelayCommand DisableCommand { get; } + public AsyncRelayCommand EnableCommand { get; } + public AsyncRelayCommand DeleteCommand { get; } + public AsyncRelayCommand ViewXmlCommand { get; } + + public string Name => Info.Name; + public string FullName => Info.FullName; + public string FolderPath => Info.FolderPath; + public string State => Info.State; + public string TriggerSummary => Info.TriggerSummary; + public string? Author => Info.Author; + public string ActionDisplay => string.IsNullOrEmpty(Info.Action.Arguments) + ? Info.Action.Executable + : $"{Info.Action.Executable} {Info.Action.Arguments}"; + public ManagedBy ManagedBy => Info.ManagedBy; + public string ManagedByLabel => Info.ManagedBy switch + { + ManagedBy.ClaudePortable => "ClaudePortable", + ManagedBy.ForeignRelevant => "Claude-related", + _ => "Other", + }; + + public string NextRunDisplay => Info.NextRunTime is { } t + ? t.LocalDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + : "-"; + + public string LastRunDisplay => Info.LastRunTime is { } t + ? Info.LastResult is { } r + ? $"{t.LocalDateTime:yyyy-MM-dd HH:mm} (exit {r})" + : t.LocalDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + : "-"; + + public bool IsDisabled => string.Equals(Info.State, "Deaktiviert", StringComparison.OrdinalIgnoreCase) + || string.Equals(Info.State, "Disabled", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/ClaudePortable.App/Ui/ViewModels/ScheduledTasksViewModel.cs b/src/ClaudePortable.App/Ui/ViewModels/ScheduledTasksViewModel.cs new file mode 100644 index 0000000..b4284c5 --- /dev/null +++ b/src/ClaudePortable.App/Ui/ViewModels/ScheduledTasksViewModel.cs @@ -0,0 +1,230 @@ +using System.Collections.ObjectModel; +using System.Runtime.Versioning; +using ClaudePortable.App.Ui.Services; +using ClaudePortable.Scheduler.Scheduling; + +namespace ClaudePortable.App.Ui.ViewModels; + +public enum ScheduledTasksFilter +{ + Relevant, + Managed, + All, +} + +[SupportedOSPlatform("windows")] +public sealed class ScheduledTasksViewModel : ViewModelBase +{ + private readonly TaskSchedulerInstaller _installer; + private readonly List _allTasks = new(); + private ScheduledTasksFilter _filter = ScheduledTasksFilter.Relevant; + private bool _isLoading; + private string _statusLine = "Click Refresh to load scheduled tasks."; + + public ScheduledTasksViewModel() + : this(new TaskSchedulerInstaller()) + { + } + + public ScheduledTasksViewModel(TaskSchedulerInstaller installer) + { + _installer = installer; + RefreshCommand = new AsyncRelayCommand(RefreshAsync); + SetFilterAllCommand = new RelayCommand(() => Filter = ScheduledTasksFilter.All); + SetFilterManagedCommand = new RelayCommand(() => Filter = ScheduledTasksFilter.Managed); + SetFilterRelevantCommand = new RelayCommand(() => Filter = ScheduledTasksFilter.Relevant); + } + + public ObservableCollection Tasks { get; } = new(); + + public AsyncRelayCommand RefreshCommand { get; } + public RelayCommand SetFilterAllCommand { get; } + public RelayCommand SetFilterManagedCommand { get; } + public RelayCommand SetFilterRelevantCommand { get; } + + public ScheduledTasksFilter Filter + { + get => _filter; + set + { + if (SetField(ref _filter, value)) + { + Raise(nameof(IsFilterAll)); + Raise(nameof(IsFilterManaged)); + Raise(nameof(IsFilterRelevant)); + ApplyFilter(); + } + } + } + + public bool IsFilterAll + { + get => _filter == ScheduledTasksFilter.All; + set + { + if (value) + { + Filter = ScheduledTasksFilter.All; + } + } + } + + public bool IsFilterManaged + { + get => _filter == ScheduledTasksFilter.Managed; + set + { + if (value) + { + Filter = ScheduledTasksFilter.Managed; + } + } + } + + public bool IsFilterRelevant + { + get => _filter == ScheduledTasksFilter.Relevant; + set + { + if (value) + { + Filter = ScheduledTasksFilter.Relevant; + } + } + } + + public bool IsLoading + { + get => _isLoading; + set => SetField(ref _isLoading, value); + } + + public string StatusLine + { + get => _statusLine; + set => SetField(ref _statusLine, value); + } + + public async Task RefreshAsync() + { + IsLoading = true; + StatusLine = "Loading scheduled tasks..."; + try + { + var infos = await Task.Run(() => _installer.EnumerateAsync(CancellationToken.None)).ConfigureAwait(true); + _allTasks.Clear(); + _allTasks.AddRange(infos); + ApplyFilter(); + var managed = _allTasks.Count(t => t.ManagedBy == ManagedBy.ClaudePortable); + var relevant = _allTasks.Count(t => t.ManagedBy == ManagedBy.ForeignRelevant); + StatusLine = $"{_allTasks.Count} task(s) total · {managed} managed by ClaudePortable · {relevant} Claude-related"; + } + catch (Exception ex) + { + StatusLine = $"Failed to enumerate tasks: {ex.Message}"; + UiLogSink.Instance.Append($"schedule enumerate failed: {ex.Message}"); + } + finally + { + IsLoading = false; + } + } + + private void ApplyFilter() + { + Tasks.Clear(); + IEnumerable filtered = _filter switch + { + ScheduledTasksFilter.All => _allTasks, + ScheduledTasksFilter.Managed => _allTasks.Where(t => t.ManagedBy == ManagedBy.ClaudePortable), + _ => _allTasks.Where(t => t.ManagedBy is ManagedBy.ClaudePortable or ManagedBy.ForeignRelevant), + }; + foreach (var info in filtered.OrderBy(t => t.ManagedBy).ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase)) + { + Tasks.Add(new ScheduledTaskInfoVm( + info, + RunNowAsync, + DisableAsync, + EnableAsync, + DeleteAsync, + ViewXmlAsync)); + } + } + + private async Task RunNowAsync(ScheduledTaskInfoVm row) + { + var exit = await _installer.RunNowAsync(row.FullName).ConfigureAwait(true); + UiLogSink.Instance.Append(exit == 0 + ? $"schedule run: '{row.FullName}'" + : $"schedule run failed: '{row.FullName}' exit={exit}"); + await RefreshAsync().ConfigureAwait(true); + } + + private async Task DisableAsync(ScheduledTaskInfoVm row) + { + var ok = System.Windows.MessageBox.Show( + $"Disable scheduled task '{row.FullName}'?\n\nThe task will no longer trigger, but can be re-enabled later. Nothing is deleted.", + "Disable task", + System.Windows.MessageBoxButton.YesNo, + System.Windows.MessageBoxImage.Question) == System.Windows.MessageBoxResult.Yes; + if (!ok) + { + return; + } + var exit = await _installer.DisableAsync(row.FullName).ConfigureAwait(true); + UiLogSink.Instance.Append(exit == 0 + ? $"schedule disable: '{row.FullName}'" + : $"schedule disable failed: '{row.FullName}' exit={exit}"); + await RefreshAsync().ConfigureAwait(true); + } + + private async Task EnableAsync(ScheduledTaskInfoVm row) + { + var exit = await _installer.EnableAsync(row.FullName).ConfigureAwait(true); + UiLogSink.Instance.Append(exit == 0 + ? $"schedule enable: '{row.FullName}'" + : $"schedule enable failed: '{row.FullName}' exit={exit}"); + await RefreshAsync().ConfigureAwait(true); + } + + private async Task DeleteAsync(ScheduledTaskInfoVm row) + { + var ok = System.Windows.MessageBox.Show( + $"Delete scheduled task '{row.FullName}'?\n\nThis is permanent. To re-create it you would have to re-install via ClaudePortable or recreate the XML manually.", + "Delete task", + System.Windows.MessageBoxButton.YesNo, + System.Windows.MessageBoxImage.Warning) == System.Windows.MessageBoxResult.Yes; + if (!ok) + { + return; + } + var exit = await _installer.DeleteAsync(row.FullName).ConfigureAwait(true); + UiLogSink.Instance.Append(exit == 0 + ? $"schedule delete: '{row.FullName}'" + : $"schedule delete failed: '{row.FullName}' exit={exit}"); + await RefreshAsync().ConfigureAwait(true); + } + + private async Task ViewXmlAsync(ScheduledTaskInfoVm row) + { + var (exit, xml) = await _installer.GetTaskXmlAsync(row.FullName).ConfigureAwait(true); + if (exit != 0 || string.IsNullOrWhiteSpace(xml)) + { + System.Windows.MessageBox.Show($"Could not read XML for '{row.FullName}' (exit {exit}).", "View XML", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error); + return; + } + try + { + System.Windows.Clipboard.SetText(xml); + } + catch (System.Runtime.InteropServices.COMException) + { + // Clipboard occasionally throws on contested access; the XML is shown in the dialog regardless. + } + System.Windows.MessageBox.Show( + xml.Length > 4000 ? xml[..4000] + "\n...(truncated, full XML copied to clipboard)" : xml, + $"XML: {row.FullName}", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Information); + } +} diff --git a/src/ClaudePortable.App/Ui/Views/MainWindow.xaml b/src/ClaudePortable.App/Ui/Views/MainWindow.xaml index 5142a60..db6eb36 100644 --- a/src/ClaudePortable.App/Ui/Views/MainWindow.xaml +++ b/src/ClaudePortable.App/Ui/Views/MainWindow.xaml @@ -1,6 +1,7 @@ + + + @@ -89,6 +93,12 @@ + + + + + + @@ -353,6 +363,83 @@ + + + + + + + + + + + + + + Windows scheduled tasks visible from this machine. ClaudePortable-managed entries are highlighted in green; + foreign tasks that touch Claude or Cowork paths (like a hand-written backup PowerShell script) are highlighted + in orange. Use Refresh after creating or removing a task elsewhere. + + + +