From 46c3de44d2e74c629caadb71e5181a554b3b0af0 Mon Sep 17 00:00:00 2001 From: Amir Hossein Samiee <81751940+amir-samiee@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:21:56 +0000 Subject: [PATCH 01/16] feat(workflow): add workflow execution core --- .../Handler/WorkflowHandlerTests.cs | 278 ++++++++++++++++++ v2rayN/ServiceLib/Base/IWorkflowRuntime.cs | 32 ++ v2rayN/ServiceLib/Enums/EServerSelectType.cs | 15 + v2rayN/ServiceLib/Enums/EWorkflowAction.cs | 28 ++ v2rayN/ServiceLib/Handler/ConfigHandler.cs | 63 ++++ v2rayN/ServiceLib/Handler/WorkflowHandler.cs | 117 ++++++++ .../ServiceLib/Helper/WorkflowStepOptions.cs | 69 +++++ v2rayN/ServiceLib/Manager/AppManager.cs | 15 + v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs | 121 ++++++++ .../Models/Entities/WorkflowItem.cs | 19 ++ v2rayN/ServiceLib/Resx/ResUI.Designer.cs | 207 +++++++++++++ v2rayN/ServiceLib/Resx/ResUI.resx | 69 +++++ 12 files changed, 1033 insertions(+) create mode 100644 v2rayN/ServiceLib.Tests/Handler/WorkflowHandlerTests.cs create mode 100644 v2rayN/ServiceLib/Base/IWorkflowRuntime.cs create mode 100644 v2rayN/ServiceLib/Enums/EServerSelectType.cs create mode 100644 v2rayN/ServiceLib/Enums/EWorkflowAction.cs create mode 100644 v2rayN/ServiceLib/Handler/WorkflowHandler.cs create mode 100644 v2rayN/ServiceLib/Helper/WorkflowStepOptions.cs create mode 100644 v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs create mode 100644 v2rayN/ServiceLib/Models/Entities/WorkflowItem.cs diff --git a/v2rayN/ServiceLib.Tests/Handler/WorkflowHandlerTests.cs b/v2rayN/ServiceLib.Tests/Handler/WorkflowHandlerTests.cs new file mode 100644 index 00000000000..63aa471af56 --- /dev/null +++ b/v2rayN/ServiceLib.Tests/Handler/WorkflowHandlerTests.cs @@ -0,0 +1,278 @@ +namespace ServiceLib.Tests.Handler; + +public class WorkflowHandlerTests +{ + [Test] + public async Task SaveSteps_ThenDeserialize_PreservesStepFields() + { + var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" }; + List steps = + [ + new() { Action = EWorkflowAction.UpdateSubscriptions, SubId = "sub-1", BoolParameter = true }, + new() { Action = EWorkflowAction.SortServers, Parameter = nameof(EServerColName.SpeedVal), BoolParameter = false }, + new() { Action = EWorkflowAction.SystemProxy, Parameter = nameof(ESysProxyType.ForcedClear), Enabled = false }, + ]; + + WorkflowHandler.SaveSteps(workflow, steps); + var loaded = WorkflowHandler.DeserializeSteps(workflow); + + await loaded.Count.Should().BeEqualTo(3); + await loaded[0].Action.Should().BeEqualTo(EWorkflowAction.UpdateSubscriptions); + await loaded[0].SubId.Should().BeEqualTo("sub-1"); + await loaded[0].BoolParameter.Should().BeTrue(); + await loaded[1].Parameter.Should().BeEqualTo(nameof(EServerColName.SpeedVal)); + await loaded[2].Enabled.Should().BeFalse(); + } + + [Test] + public async Task SaveSteps_ShouldNotPersistEditorOnlyFields() + { + var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" }; + List steps = + [ + new() + { + Action = EWorkflowAction.SortServers, + SubDisplay = "Group A", + Parameter = "DelayVal", + ActionOptions = ["SortServers"], + GroupOptions = ["Group A"], + }, + ]; + + WorkflowHandler.SaveSteps(workflow, steps); + + await workflow.StepsJson.Contains("Group A").Should().BeFalse(); + await workflow.StepsJson.Contains("SubDisplay").Should().BeFalse(); + await workflow.StepsJson.Contains("ActionDisplay").Should().BeFalse(); + await workflow.StepsJson.Contains("ActionOptions").Should().BeFalse(); + await workflow.StepsJson.Contains("GroupOptions").Should().BeFalse(); + await workflow.StepsJson.Contains("ParameterOptions").Should().BeFalse(); + await workflow.StepsJson.Contains("ParameterText").Should().BeFalse(); + } + + [Test] + public async Task Parameters_ShouldOfferOnlyValuesTheRunnerAccepts() + { + // These actions take no parameter at all. + await WorkflowStepOptions.ParametersFor(EWorkflowAction.UpdateSubscriptions).Should().BeEmpty(); + await WorkflowStepOptions.ParametersFor(EWorkflowAction.DeduplicateServers).Should().BeEmpty(); + await WorkflowStepOptions.ParametersFor(EWorkflowAction.RemoveInvalidServers).Should().BeEmpty(); + + // The sort column placeholder is not sortable, so offering it would be a no-op. + await WorkflowStepOptions.ParametersFor(EWorkflowAction.SortServers) + .Should().NotContain(nameof(EServerColName.Def)); + await WorkflowStepOptions.ParametersFor(EWorkflowAction.SortServers) + .Should().Contain(nameof(EServerColName.SpeedVal)); + + await WorkflowStepOptions.ParametersFor(EWorkflowAction.TestServers) + .Should().Contain(nameof(ESpeedActionType.Speedtest)); + await WorkflowStepOptions.ParametersFor(EWorkflowAction.SystemProxy) + .Should().Contain(nameof(ESysProxyType.ForcedChange)); + + // Which server a group activates is a fixed choice, not free text. + await WorkflowStepOptions.ParametersFor(EWorkflowAction.ActivateServer) + .Should().Contain(nameof(EServerSelectType.Last)); + } + + [Test] + public async Task Normalize_ShouldReplaceParameterTheActionDoesNotAccept() + { + // A test type makes no sense for a sort, so it falls back to the default column. + await WorkflowStepOptions.Normalize(EWorkflowAction.SortServers, nameof(ESpeedActionType.Realping)) + .Should().BeEqualTo(nameof(EServerColName.DelayVal)); + + // An action with no parameter must not keep a leftover one. + await WorkflowStepOptions.Normalize(EWorkflowAction.DeduplicateServers, nameof(EServerColName.DelayVal)) + .Should().BeNull(); + + // A valid parameter is left alone. + await WorkflowStepOptions.Normalize(EWorkflowAction.TestServers, nameof(ESpeedActionType.Speedtest)) + .Should().BeEqualTo(nameof(ESpeedActionType.Speedtest)); + } + + [Test] + public async Task Step_ShouldDropParameterWhenActionChanges() + { + var step = new WorkflowStep + { + Action = EWorkflowAction.SortServers, + Parameter = nameof(EServerColName.SpeedVal), + }; + + step.Action = EWorkflowAction.SystemProxy; + + await step.Parameter.Should().BeEqualTo(nameof(ESysProxyType.ForcedClear)); + await step.ParameterOptions.Should().Contain(nameof(ESysProxyType.ForcedChange)); + await step.ParameterOptions.Should().NotContain(nameof(EServerColName.SpeedVal)); + } + + [Test] + public async Task Step_ShouldNotifyDependentCellsWhenActionChanges() + { + var step = new WorkflowStep(); + var changed = new List(); + step.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + step.Action = EWorkflowAction.TestServers; + + // The parameter dropdown binds to both of these, so both must refresh. + await changed.Should().Contain(nameof(WorkflowStep.ParameterOptions)); + await changed.Should().Contain(nameof(WorkflowStep.ParameterText)); + } + + [Test] + [Arguments(null)] + [Arguments("")] + [Arguments("not json")] + [Arguments("{\"not\":\"a list\"}")] + public async Task DeserializeSteps_ShouldTolerateMissingOrMalformedJson(string? json) + { + var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly", StepsJson = json }; + + var loaded = WorkflowHandler.DeserializeSteps(workflow); + + await loaded.Count.Should().BeEqualTo(0); + } + + [Test] + public async Task DeserializeSteps_ShouldHandleNullWorkflow() + { + await WorkflowHandler.DeserializeSteps(null).Count.Should().BeEqualTo(0); + } + + [Test] + public async Task Run_ShouldSkipDisabledStepsAndDispatchEveryAction() + { + var runtime = new RecordingRuntime(); + var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" }; + WorkflowHandler.SaveSteps(workflow, + [ + new() { Action = EWorkflowAction.UpdateSubscriptions, BoolParameter = true }, + new() { Action = EWorkflowAction.DeduplicateServers }, + new() { Action = EWorkflowAction.SortServers, Parameter = nameof(EServerColName.SpeedVal), BoolParameter = false }, + new() { Action = EWorkflowAction.TestServers, Parameter = nameof(ESpeedActionType.Speedtest) }, + new() { Action = EWorkflowAction.RemoveInvalidServers, Enabled = false }, + new() { Action = EWorkflowAction.ActivateServer, Parameter = nameof(EServerSelectType.First) }, + new() { Action = EWorkflowAction.SystemProxy, Parameter = nameof(ESysProxyType.ForcedChange) }, + ]); + + await WorkflowHandler.Run(new Config { SubIndexId = "default-sub" }, workflow, runtime); + + await runtime.Calls.Should().BeEquivalentTo( + [ + // A subscription step with no group updates every subscription, so SubId stays null. + "update::True", + "dedup:default-sub", + "sort:default-sub:SpeedVal:False", + "test:default-sub:Speedtest", + "activate:default-sub:First", + "sysproxy:ForcedChange", + ]); + } + + [Test] + public async Task Run_ShouldPreferStepSubIdOverConfigGroup() + { + var runtime = new RecordingRuntime(); + var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" }; + WorkflowHandler.SaveSteps(workflow, + [ + new() { Action = EWorkflowAction.DeduplicateServers, SubId = "sub-9" }, + ]); + + await WorkflowHandler.Run(new Config { SubIndexId = "default-sub" }, workflow, runtime); + + await runtime.Calls.Should().BeEquivalentTo(["dedup:sub-9"]); + } + + [Test] + public async Task Run_ShouldFallBackToDefaultsForUnknownParameters() + { + var runtime = new RecordingRuntime(); + var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" }; + WorkflowHandler.SaveSteps(workflow, + [ + new() { Action = EWorkflowAction.SortServers, Parameter = null }, + new() { Action = EWorkflowAction.TestServers, Parameter = "not-a-test-type" }, + new() { Action = EWorkflowAction.SystemProxy, Parameter = null }, + new() { Action = EWorkflowAction.ActivateServer, Parameter = "not-a-select-type" }, + ]); + + await WorkflowHandler.Run(new Config { SubIndexId = "default-sub" }, workflow, runtime); + + await runtime.Calls.Should().BeEquivalentTo( + [ + $"sort:default-sub:{nameof(EServerColName.DelayVal)}:False", + $"test:default-sub:{nameof(ESpeedActionType.Realping)}", + $"sysproxy:{nameof(ESysProxyType.ForcedClear)}", + $"activate:default-sub:{nameof(EServerSelectType.First)}", + ]); + } + + [Test] + public async Task Run_ShouldNotDispatchAnythingWhenEveryStepIsDisabled() + { + var runtime = new RecordingRuntime(); + var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" }; + WorkflowHandler.SaveSteps(workflow, + [ + new() { Action = EWorkflowAction.UpdateSubscriptions, Enabled = false }, + ]); + + await WorkflowHandler.Run(new Config { SubIndexId = "default-sub" }, workflow, runtime); + + await runtime.Calls.Count.Should().BeEqualTo(0); + } + + /// + /// Records the calls the handler makes so the dispatch logic can be asserted without + /// touching the UI layer. This is an implementation of the runtime seam, not a mock. + /// + private sealed class RecordingRuntime : IWorkflowRuntime + { + public List Calls { get; } = []; + + public Task UpdateSubscriptions(string? subId, bool viaProxy) + { + Calls.Add($"update:{subId}:{viaProxy}"); + return Task.CompletedTask; + } + + public Task DeduplicateServers(string subId) + { + Calls.Add($"dedup:{subId}"); + return Task.FromResult(0); + } + + public Task SortServers(string subId, string column, bool asc) + { + Calls.Add($"sort:{subId}:{column}:{asc}"); + return Task.CompletedTask; + } + + public Task TestServers(string subId, ESpeedActionType actionType) + { + Calls.Add($"test:{subId}:{actionType}"); + return Task.CompletedTask; + } + + public Task RemoveInvalidServers(string subId) + { + Calls.Add($"remove-invalid:{subId}"); + return Task.FromResult(0); + } + + public Task ActivateServer(string subId, EServerSelectType selectType) + { + Calls.Add($"activate:{subId}:{selectType}"); + return Task.CompletedTask; + } + + public Task SetSystemProxy(ESysProxyType type) + { + Calls.Add($"sysproxy:{type}"); + return Task.CompletedTask; + } + } +} diff --git a/v2rayN/ServiceLib/Base/IWorkflowRuntime.cs b/v2rayN/ServiceLib/Base/IWorkflowRuntime.cs new file mode 100644 index 00000000000..22a37769983 --- /dev/null +++ b/v2rayN/ServiceLib/Base/IWorkflowRuntime.cs @@ -0,0 +1,32 @@ +namespace ServiceLib.Base; + +/// +/// The operations a workflow can invoke at runtime. Implemented by the UI layer +/// so that stays free of view models. +/// +public interface IWorkflowRuntime +{ + /// Update one subscription (or all when is empty). + Task UpdateSubscriptions(string? subId, bool viaProxy); + + /// Remove duplicate servers in a group. Returns the number of removed servers. + Task DeduplicateServers(string subId); + + /// Sort a group by a column. + Task SortServers(string subId, string column, bool asc); + + /// Run a connectivity/speed test over a group. + Task TestServers(string subId, ESpeedActionType actionType); + + /// Remove servers whose last test result was invalid. Returns the number of removed servers. + Task RemoveInvalidServers(string subId); + + /// + /// Activate a server of a group. chooses the topmost or + /// bottom row of the group's persisted sort order. + /// + Task ActivateServer(string subId, EServerSelectType selectType); + + /// Set the system proxy mode. + Task SetSystemProxy(ESysProxyType type); +} diff --git a/v2rayN/ServiceLib/Enums/EServerSelectType.cs b/v2rayN/ServiceLib/Enums/EServerSelectType.cs new file mode 100644 index 00000000000..0bfcd4c2e3f --- /dev/null +++ b/v2rayN/ServiceLib/Enums/EServerSelectType.cs @@ -0,0 +1,15 @@ +namespace ServiceLib.Enums; + +/// +/// Which server of a group a workflow activates. The group uses the persisted sort +/// order (the same order the server list shows), so is the topmost +/// row and lets a preceding sort decide the winner. +/// +public enum EServerSelectType +{ + /// Topmost server of the group, i.e. the first row as displayed. + First = 1, + + /// Bottom server of the group, i.e. the last row as displayed. + Last = 2, +} diff --git a/v2rayN/ServiceLib/Enums/EWorkflowAction.cs b/v2rayN/ServiceLib/Enums/EWorkflowAction.cs new file mode 100644 index 00000000000..a45ce5ac031 --- /dev/null +++ b/v2rayN/ServiceLib/Enums/EWorkflowAction.cs @@ -0,0 +1,28 @@ +namespace ServiceLib.Enums; + +/// +/// A single reusable operation that can be chained in a workflow. +/// +public enum EWorkflowAction +{ + /// Update one subscription, or every subscription when no sub is set. + UpdateSubscriptions = 0, + + /// Remove duplicate servers in the current group. + DeduplicateServers = 1, + + /// Sort servers in the current group by a column, ascending or descending. + SortServers = 2, + + /// Run a connectivity/speed test over the current group. + TestServers = 3, + + /// Remove servers whose last test result was invalid (timeout). + RemoveInvalidServers = 4, + + /// Activate the best (first) server of the current group, as the server list orders it. + ActivateServer = 5, + + /// Set or clear the system proxy mode. + SystemProxy = 6, +} diff --git a/v2rayN/ServiceLib/Handler/ConfigHandler.cs b/v2rayN/ServiceLib/Handler/ConfigHandler.cs index 55d2ab6b5f9..927ad06788e 100644 --- a/v2rayN/ServiceLib/Handler/ConfigHandler.cs +++ b/v2rayN/ServiceLib/Handler/ConfigHandler.cs @@ -2420,6 +2420,69 @@ public static async Task MoveToGroup(Config config, List lstPr #endregion Sub & Group + #region Workflow + + /// + /// Add or update a workflow item. + /// + /// Current configuration + /// Workflow to add or update + /// 0 if successful, -1 if failed + public static async Task AddWorkflowItem(Config config, WorkflowItem workflow) + { + var item = await AppManager.Instance.GetWorkflowItem(workflow.Id); + if (item is null) + { + item = workflow; + } + else + { + item.Remarks = workflow.Remarks; + item.Enabled = workflow.Enabled; + item.Sort = workflow.Sort; + item.Memo = workflow.Memo; + item.StepsJson = workflow.StepsJson; + } + + if (item.Id.IsNullOrEmpty()) + { + item.Id = Utils.GetGuid(false); + + if (item.Sort <= 0) + { + var maxSort = 0; + if (await SQLiteHelper.Instance.TableAsync().CountAsync() > 0) + { + var lstWorkflows = await AppManager.Instance.WorkflowItems(); + maxSort = lstWorkflows.LastOrDefault()?.Sort ?? 0; + } + item.Sort = maxSort + 1; + } + } + + return await SQLiteHelper.Instance.ReplaceAsync(item) > 0 ? 0 : -1; + } + + /// + /// Delete a workflow item. + /// + /// Current configuration + /// Workflow id to delete + /// 0 if successful + public static async Task DeleteWorkflowItem(Config config, string id) + { + var item = await AppManager.Instance.GetWorkflowItem(id); + if (item is null) + { + return 0; + } + await SQLiteHelper.Instance.DeleteAsync(item); + + return 0; + } + + #endregion Workflow + #region Routing /// diff --git a/v2rayN/ServiceLib/Handler/WorkflowHandler.cs b/v2rayN/ServiceLib/Handler/WorkflowHandler.cs new file mode 100644 index 00000000000..4f36f6c8183 --- /dev/null +++ b/v2rayN/ServiceLib/Handler/WorkflowHandler.cs @@ -0,0 +1,117 @@ +namespace ServiceLib.Handler; + +/// +/// Runs a workflow: a named, ordered list of steps, each mapping onto an existing +/// app operation (subscription update, dedup, sort, test, ...). Steps are executed +/// sequentially so that later steps can consume the results of earlier ones. +/// +public static class WorkflowHandler +{ + private static readonly string _tag = "WorkflowHandler"; + + /// + /// Execute every enabled step of the workflow. A failing step does not abort the run. + /// + public static async Task Run(Config config, WorkflowItem workflow, IWorkflowRuntime runtime) + { + var steps = DeserializeSteps(workflow).Where(t => t.Enabled).ToList(); + if (steps.Count == 0) + { + NoticeManager.Instance.Enqueue(ResUI.WorkflowNoStepTip); + return; + } + + var subId = config.SubIndexId; + NoticeManager.Instance.SendMessageEx($"{ResUI.WorkflowStartTip} - {workflow.Remarks}"); + for (var i = 0; i < steps.Count; i++) + { + var step = steps[i]; + var prefix = $"[{i + 1}/{steps.Count}] {(step.SubId.IsNotEmpty() ? step.SubId : subId)}"; + try + { + await RunStep(config, step, subId, runtime); + } + catch (Exception ex) + { + Logging.SaveLog(_tag, ex); + NoticeManager.Instance.SendMessageEx($"{prefix} {ResUI.WorkflowStepFailedTip}: {ex.Message}"); + } + } + NoticeManager.Instance.Enqueue($"{ResUI.WorkflowEndTip} - {workflow.Remarks}"); + } + + private static async Task RunStep(Config config, WorkflowStep step, string? subId, IWorkflowRuntime runtime) + { + switch (step.Action) + { + case EWorkflowAction.UpdateSubscriptions: + await runtime.UpdateSubscriptions(step.SubId, step.BoolParameter); + break; + + case EWorkflowAction.DeduplicateServers: + await runtime.DeduplicateServers(step.SubId.IsNotEmpty() ? step.SubId : subId); + break; + + case EWorkflowAction.SortServers: + await runtime.SortServers(step.SubId.IsNotEmpty() ? step.SubId : subId, + step.Parameter ?? nameof(EServerColName.DelayVal), step.BoolParameter); + break; + + case EWorkflowAction.TestServers: + await runtime.TestServers(step.SubId.IsNotEmpty() ? step.SubId : subId, ParseTestType(step.Parameter)); + break; + + case EWorkflowAction.RemoveInvalidServers: + await runtime.RemoveInvalidServers(step.SubId.IsNotEmpty() ? step.SubId : subId); + break; + + case EWorkflowAction.ActivateServer: + await runtime.ActivateServer(step.SubId.IsNotEmpty() ? step.SubId : subId, + ParseServerSelectType(step.Parameter)); + break; + + case EWorkflowAction.SystemProxy: + await runtime.SetSystemProxy(ParseSysProxyType(step.Parameter)); + break; + } + } + + private static ESpeedActionType ParseTestType(string? parameter) + { + return Enum.TryParse(parameter, true, out var type) ? type : ESpeedActionType.Realping; + } + + private static ESysProxyType ParseSysProxyType(string? parameter) + { + return Enum.TryParse(parameter, true, out var type) ? type : ESysProxyType.ForcedClear; + } + + private static EServerSelectType ParseServerSelectType(string? parameter) + { + return Enum.TryParse(parameter, true, out var type) ? type : EServerSelectType.First; + } + + /// Deserialize the step list of a workflow, tolerating malformed json. + public static List DeserializeSteps(WorkflowItem? workflow) + { + if (workflow?.StepsJson.IsNullOrEmpty() != false) + { + return []; + } + try + { + return JsonUtils.Deserialize>(workflow.StepsJson) ?? []; + } + catch (Exception ex) + { + Logging.SaveLog(_tag, ex); + return []; + } + } + + /// Serialize the step list of a workflow to its storage field. + public static void SaveSteps(WorkflowItem workflow, List steps) + { + workflow.StepsJson = JsonUtils.Serialize(steps, false); + } +} diff --git a/v2rayN/ServiceLib/Helper/WorkflowStepOptions.cs b/v2rayN/ServiceLib/Helper/WorkflowStepOptions.cs new file mode 100644 index 00000000000..2c7e449713d --- /dev/null +++ b/v2rayN/ServiceLib/Helper/WorkflowStepOptions.cs @@ -0,0 +1,69 @@ +namespace ServiceLib.Helper; + +/// +/// The values each action accepts. +/// The parameter is persisted as a string, so the editor offers these instead of +/// asking the user to type an enum name from memory. +/// +public static class WorkflowStepOptions +{ + // Actions that take no parameter get no choices: the editor leaves the cell blank and + // disables it. An empty dropdown is never offered, which would otherwise open as an + // empty popup and look broken. + private static readonly List _none = []; + + /// + /// Sortable columns. is a placeholder that + /// silently ignores, so it is + /// deliberately not offered. + /// + private static readonly List _sortColumns = + Utils.GetEnumNames().Where(t => t != nameof(EServerColName.Def)).ToList(); + + private static readonly List _testTypes = Utils.GetEnumNames(); + + private static readonly List _proxyTypes = Utils.GetEnumNames(); + + private static readonly List _selectTypes = Utils.GetEnumNames(); + + public static IReadOnlyList ParametersFor(EWorkflowAction action) + { + return action switch + { + EWorkflowAction.SortServers => _sortColumns, + EWorkflowAction.TestServers => _testTypes, + EWorkflowAction.SystemProxy => _proxyTypes, + EWorkflowAction.ActivateServer => _selectTypes, + _ => _none, + }; + } + + /// + /// Parameter a freshly chosen action starts with. Mirrors the fallbacks in + /// so the editor never shows a value the + /// runner would treat differently. Null for actions that take no parameter. + /// + public static string? DefaultParameter(EWorkflowAction action) + { + return action switch + { + EWorkflowAction.SortServers => nameof(EServerColName.DelayVal), + EWorkflowAction.TestServers => nameof(ESpeedActionType.Realping), + EWorkflowAction.SystemProxy => nameof(ESysProxyType.ForcedClear), + EWorkflowAction.ActivateServer => nameof(EServerSelectType.First), + _ => null, + }; + } + + /// + /// Coerce to a value the action accepts, falling back to its + /// default. Keeps the stored value honest when the user switches action, so a leftover + /// value that would ignore cannot survive. + /// + public static string? Normalize(EWorkflowAction action, string? parameter) + { + return parameter.IsNotEmpty() && ParametersFor(action).Contains(parameter) + ? parameter + : DefaultParameter(action); + } +} diff --git a/v2rayN/ServiceLib/Manager/AppManager.cs b/v2rayN/ServiceLib/Manager/AppManager.cs index 72d2930550d..a079503df55 100644 --- a/v2rayN/ServiceLib/Manager/AppManager.cs +++ b/v2rayN/ServiceLib/Manager/AppManager.cs @@ -89,6 +89,7 @@ public bool InitApp() SQLiteHelper.Instance.CreateTable(); SQLiteHelper.Instance.CreateTable(); SQLiteHelper.Instance.CreateTable(); + SQLiteHelper.Instance.CreateTable(); #pragma warning disable CS0618 SQLiteHelper.Instance.CreateTable(); #pragma warning restore CS0618 @@ -177,6 +178,20 @@ public int GetLocalPort(EInboundProtocol protocol) return await SQLiteHelper.Instance.TableAsync().OrderBy(t => t.Sort).ToListAsync(); } + public async Task> WorkflowItems() + { + return await SQLiteHelper.Instance.TableAsync().OrderBy(t => t.Sort).ToListAsync(); + } + + public async Task GetWorkflowItem(string? id) + { + if (id.IsNullOrEmpty()) + { + return null; + } + return await SQLiteHelper.Instance.TableAsync().FirstOrDefaultAsync(t => t.Id == id); + } + public async Task GetSubItem(string? subid) { return await SQLiteHelper.Instance.TableAsync().FirstOrDefaultAsync(t => t.Id == subid); diff --git a/v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs b/v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs new file mode 100644 index 00000000000..558fe5adb23 --- /dev/null +++ b/v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs @@ -0,0 +1,121 @@ +namespace ServiceLib.Models.Dto; + +/// +/// One step of a workflow. Kept as a plain serializable DTO so it can be +/// persisted inside . +/// +[Serializable] +public class WorkflowStep : INotifyPropertyChanged +{ + // Events are not serialized, so the editor can watch this DTO for changes + // without adding anything to the persisted json. + public event PropertyChangedEventHandler? PropertyChanged; + + private EWorkflowAction _action; + private string? _parameter; + + public EWorkflowAction Action + { + get => _action; + set + { + if (_action == value) + { + return; + } + _action = value; + _parameter = WorkflowStepOptions.Normalize(value, _parameter); + Notify(nameof(Action)); + Notify(nameof(ActionDisplay)); + // Order matters: the parameter dropdown must learn its new choices before it is + // told which one is selected, otherwise the selection is applied to the old list. + Notify(nameof(ParameterOptions)); + Notify(nameof(HasParameter)); + Notify(nameof(Parameter)); + Notify(nameof(ParameterText)); + } + } + + public bool Enabled { get; set; } = true; + + /// Subscription id for ; empty means all. + public string? SubId { get; set; } + + /// Action dependent argument: sort column, test type or system proxy type. + public string? Parameter + { + get => _parameter; + set + { + if (_parameter == value) + { + return; + } + _parameter = value; + Notify(nameof(Parameter)); + } + } + + /// Action dependent flag: ascending sort, or update through proxy. + public bool BoolParameter { get; set; } + + /// Editor-only action name, so the step grid can offer a plain text dropdown. + [JsonIgnore] + public string? ActionDisplay + { + get => Action.ToString(); + set + { + if (Enum.TryParse(value, true, out var action)) + { + Action = action; + } + } + } + + /// + /// Editor-only group name. resolves it + /// back into when the workflow is saved. + /// + [JsonIgnore] + public string? SubDisplay { get; set; } + + /// Editor-only action choices, so the step grid can bind its dropdown to the row item. + [JsonIgnore] + public IReadOnlyList ActionOptions { get; set; } = []; + + /// + /// Editor-only group choices. This is the editor's live collection, so subscriptions + /// that finish loading after a row was created still reach that row's dropdown. + /// + [JsonIgnore] + public IReadOnlyList GroupOptions { get; set; } = []; + + /// + /// Editor-only parameter choices for ; empty for actions that take + /// none. Every parameter is one of these, so the editor offers a closed list instead of + /// free text and a value the runner would ignore cannot be typed in. + /// + [JsonIgnore] + public IReadOnlyList ParameterOptions => WorkflowStepOptions.ParametersFor(Action); + + /// Editor-only flag: false for actions that take no parameter. + [JsonIgnore] + public bool HasParameter => ParameterOptions.Count > 0; + + /// + /// Editor-only binding target for the parameter cell, matching a non-null item of + /// . Null (an empty cell) when the action takes no parameter. + /// + [JsonIgnore] + public string? ParameterText + { + get => Parameter; + set => Parameter = value.IsNotEmpty() ? value : null; + } + + private void Notify(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/v2rayN/ServiceLib/Models/Entities/WorkflowItem.cs b/v2rayN/ServiceLib/Models/Entities/WorkflowItem.cs new file mode 100644 index 00000000000..47f55fc20b4 --- /dev/null +++ b/v2rayN/ServiceLib/Models/Entities/WorkflowItem.cs @@ -0,0 +1,19 @@ +namespace ServiceLib.Models.Entities; + +[Serializable] +public class WorkflowItem +{ + [PrimaryKey] + public string Id { get; set; } + + public string Remarks { get; set; } + + public bool Enabled { get; set; } = true; + + public int Sort { get; set; } + + public string? Memo { get; set; } + + /// Serialized . + public string? StepsJson { get; set; } +} diff --git a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs index 373fef1a6bc..f406e7fdabe 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs +++ b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs @@ -1904,6 +1904,213 @@ public static string menuSubShare { return ResourceManager.GetString("menuSubShare", resourceCulture); } } + + /// + /// 查找类似 Workflow 的本地化字符串。 + /// + public static string menuWorkflowSetting { + get { + return ResourceManager.GetString("menuWorkflowSetting", resourceCulture); + } + } + + /// + /// 查找类似 Add 的本地化字符串。 + /// + public static string menuWorkflowAdd { + get { + return ResourceManager.GetString("menuWorkflowAdd", resourceCulture); + } + } + + /// + /// 查找类似 Delete 的本地化字符串。 + /// + public static string menuWorkflowDelete { + get { + return ResourceManager.GetString("menuWorkflowDelete", resourceCulture); + } + } + + /// + /// 查找类似 Edit 的本地化字符串。 + /// + public static string menuWorkflowEdit { + get { + return ResourceManager.GetString("menuWorkflowEdit", resourceCulture); + } + } + + /// + /// 查找类似 Run 的本地化字符串。 + /// + public static string menuWorkflowRun { + get { + return ResourceManager.GetString("menuWorkflowRun", resourceCulture); + } + } + + /// + /// 查找类似 Remarks 的本地化字符串。 + /// + public static string LvWorkflowRemarks { + get { + return ResourceManager.GetString("LvWorkflowRemarks", resourceCulture); + } + } + + /// + /// 查找类似 Enabled 的本地化字符串。 + /// + public static string LvWorkflowEnabled { + get { + return ResourceManager.GetString("LvWorkflowEnabled", resourceCulture); + } + } + + /// + /// 查找类似 Order 的本地化字符串。 + /// + public static string LvWorkflowSort { + get { + return ResourceManager.GetString("LvWorkflowSort", resourceCulture); + } + } + + /// + /// 查找类似 Steps 的本地化字符串。 + /// + public static string LvWorkflowSteps { + get { + return ResourceManager.GetString("LvWorkflowSteps", resourceCulture); + } + } + + /// + /// 查找类似 Add step 的本地化字符串。 + /// + public static string WorkflowStepAdd { + get { + return ResourceManager.GetString("WorkflowStepAdd", resourceCulture); + } + } + + /// + /// 查找类似 Delete step 的本地化字符串。 + /// + public static string WorkflowStepDelete { + get { + return ResourceManager.GetString("WorkflowStepDelete", resourceCulture); + } + } + + /// + /// 查找类似 Move up 的本地化字符串。 + /// + public static string WorkflowStepUp { + get { + return ResourceManager.GetString("WorkflowStepUp", resourceCulture); + } + } + + /// + /// 查找类似 Move down 的本地化字符串。 + /// + public static string WorkflowStepDown { + get { + return ResourceManager.GetString("WorkflowStepDown", resourceCulture); + } + } + + /// + /// 查找类似 Action 的本地化字符串。 + /// + public static string WorkflowStepAction { + get { + return ResourceManager.GetString("WorkflowStepAction", resourceCulture); + } + } + + /// + /// 查找类似 Parameter 的本地化字符串。 + /// + public static string WorkflowStepParameter { + get { + return ResourceManager.GetString("WorkflowStepParameter", resourceCulture); + } + } + + /// + /// 查找类似 Group 的本地化字符串。 + /// + public static string WorkflowStepSub { + get { + return ResourceManager.GetString("WorkflowStepSub", resourceCulture); + } + } + + /// + /// 查找类似 Enabled 的本地化字符串。 + /// + public static string WorkflowStepEnabled { + get { + return ResourceManager.GetString("WorkflowStepEnabled", resourceCulture); + } + } + + /// + /// 查找类似 Optional: sort column, test type or proxy mode 的本地化字符串。 + /// + public static string WorkflowParamHint { + get { + return ResourceManager.GetString("WorkflowParamHint", resourceCulture); + } + } + + /// + /// 查找类似 The workflow has no enabled step 的本地化字符串。 + /// + public static string WorkflowNoStepTip { + get { + return ResourceManager.GetString("WorkflowNoStepTip", resourceCulture); + } + } + + /// + /// 查找类似 Workflow started 的本地化字符串。 + /// + public static string WorkflowStartTip { + get { + return ResourceManager.GetString("WorkflowStartTip", resourceCulture); + } + } + + /// + /// 查找类似 Workflow finished 的本地化字符串。 + /// + public static string WorkflowEndTip { + get { + return ResourceManager.GetString("WorkflowEndTip", resourceCulture); + } + } + + /// + /// 查找类似 Step failed 的本地化字符串。 + /// + public static string WorkflowStepFailedTip { + get { + return ResourceManager.GetString("WorkflowStepFailedTip", resourceCulture); + } + } + + /// + /// 查找类似 Please select a workflow 的本地化字符串。 + /// + public static string WorkflowRunSelectTip { + get { + return ResourceManager.GetString("WorkflowRunSelectTip", resourceCulture); + } + } /// /// 查找类似 Update subscriptions without proxy 的本地化字符串。 diff --git a/v2rayN/ServiceLib/Resx/ResUI.resx b/v2rayN/ServiceLib/Resx/ResUI.resx index 39cd5cf2ac2..0ccff2c6fdb 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.resx @@ -558,6 +558,75 @@ Share + + Workflow + + + Add + + + Delete + + + Edit + + + Run + + + Remarks + + + Enabled + + + Order + + + Steps + + + Add step + + + Delete step + + + Move up + + + Move down + + + Action + + + Parameter + + + Group + + + Enabled + + + Option + + + The workflow has no enabled step + + + Workflow started + + + Workflow finished + + + Step failed + + + Please select a workflow + Enable update From edc073ae1c144902230475457f45f90fc94c3fe4 Mon Sep 17 00:00:00 2001 From: Amir Hossein Samiee <81751940+amir-samiee@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:22:15 +0000 Subject: [PATCH 02/16] feat(workflow): integrate with application view models --- v2rayN/ServiceLib/Events/AppEvents.cs | 6 + .../ViewModels/MainWindowViewModel.cs | 33 ++++ .../ViewModels/ProfilesViewModel.cs | 88 +++++++++ .../ViewModels/SubSettingViewModel.cs | 1 + .../ViewModels/WorkflowEditViewModel.cs | 170 ++++++++++++++++++ .../ServiceLib/ViewModels/WorkflowRunner.cs | 30 ++++ .../ServiceLib/ViewModels/WorkflowRuntime.cs | 48 +++++ .../ViewModels/WorkflowViewModel.cs | 122 +++++++++++++ .../v2rayN.Desktop/DesignData/DesignData.cs | 4 + 9 files changed, 502 insertions(+) create mode 100644 v2rayN/ServiceLib/ViewModels/WorkflowEditViewModel.cs create mode 100644 v2rayN/ServiceLib/ViewModels/WorkflowRunner.cs create mode 100644 v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs create mode 100644 v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs diff --git a/v2rayN/ServiceLib/Events/AppEvents.cs b/v2rayN/ServiceLib/Events/AppEvents.cs index 64a4521b052..6253902af46 100644 --- a/v2rayN/ServiceLib/Events/AppEvents.cs +++ b/v2rayN/ServiceLib/Events/AppEvents.cs @@ -14,4 +14,10 @@ public static class AppEvents public static readonly EventChannel ShutdownRequested = new(); public static readonly EventChannel SysProxyChangeRequested = new(); + + /// Raised after servers are added, removed or reordered outside the profile view. + public static readonly EventChannel ServerStateChanged = new(); + + /// Raised to open the workflow manager window. + public static readonly EventChannel WorkflowRequested = new(); } diff --git a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs index 050c4335914..97efaf80659 100644 --- a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs @@ -48,6 +48,7 @@ public partial class MainWindowViewModel : MyReactiveObject public ReactiveCommand SubUpdateViaProxyCmd { get; } public ReactiveCommand SubGroupUpdateCmd { get; } public ReactiveCommand SubGroupUpdateViaProxyCmd { get; } + public ReactiveCommand WorkflowCmd { get; } //Setting public ReactiveCommand OptionSettingCmd { get; } @@ -197,6 +198,11 @@ public MainWindowViewModel() await UpdateSubscriptionProcess(_config.SubIndexId, true); }); + WorkflowCmd = ReactiveCommand.CreateFromTask(async () => + { + await WorkflowAsync(); + }); + //Setting OptionSettingCmd = ReactiveCommand.CreateFromTask(async () => { @@ -269,6 +275,16 @@ public MainWindowViewModel() .ObserveOn(RxSchedulers.MainThreadScheduler) .Subscribe(bl => BlNewUpdate = bl); + AppEvents.ServerStateChanged + .AsObservable() + .ObserveOn(RxSchedulers.MainThreadScheduler) + .SubscribeAsync(async _ => await RefreshServersDispatcherAsync()); + + AppEvents.WorkflowRequested + .AsObservable() + .ObserveOn(RxSchedulers.MainThreadScheduler) + .SubscribeAsync(async _ => await WorkflowAsync()); + #endregion AppEvents ProfilesViewModel.RefreshServersRequested @@ -573,6 +589,7 @@ private async Task SubSettingAsync() if (await AppManager.Instance.WindowDialog.ShowDialogAsync(subSettingViewModel) == true) { await RefreshSubscriptions(); + await ProfilesViewModel.RefreshServers(); } } @@ -581,6 +598,22 @@ public async Task UpdateSubscriptionProcess(string subId, bool blProxy) await Task.Run(async () => await SubscriptionHandler.UpdateProcess(_config, subId, blProxy, UpdateTaskHandler)); } + private async Task WorkflowAsync() + { + RefreshWorkflowRunner(); + var workflowViewModel = new WorkflowViewModel(); + await AppManager.Instance.WindowDialog.ShowDialogAsync(workflowViewModel); + } + + /// + /// Make the workflow runtime available to the workflow manager window. Uses the + /// already built profile view model and the main window update pipeline. + /// + private void RefreshWorkflowRunner() + { + WorkflowRunner.Instance.Register(new WorkflowRuntime(ProfilesViewModel, UpdateSubscriptionProcess)); + } + #endregion Subscription #region Setting diff --git a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs index 0f6c859d40a..ee47029fd77 100644 --- a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs @@ -87,6 +87,7 @@ public partial class ProfilesViewModel : MyReactiveObject public ReactiveCommand AddSubCmd { get; } public ReactiveCommand EditSubCmd { get; } public ReactiveCommand DeleteSubCmd { get; } + public ReactiveCommand WorkflowCmd { get; } #endregion Menu @@ -237,6 +238,7 @@ public ProfilesViewModel() { await DeleteSubAsync(); }); + WorkflowCmd = ReactiveCommand.Create(() => AppEvents.WorkflowRequested.Publish()); #endregion WhenAnyValue && ReactiveCommand @@ -656,6 +658,92 @@ public async Task RemoveInvalidServerResult() NoticeManager.Instance.Enqueue(string.Format(ResUI.RemoveInvalidServerResultTip, count)); } + #region Workflow helpers + + /// Run a test over a whole group. Completes once the test run finished. + public async Task TestServersAsync(string? subId, ESpeedActionType actionType) + { + var lstSelected = await GetProfilesForGroup(subId); + if (lstSelected.Count == 0) + { + return; + } + _speedtestService ??= new SpeedtestService(_config, async result => + { + RxSchedulers.MainThreadScheduler.Schedule(() => _ = SetSpeedTestResult(result)); + await Task.CompletedTask; + }); + await _speedtestService.RunLoop(actionType, lstSelected); + await RefreshServers(); + } + + /// Sort a whole group by a column and persist the new order. + public async Task SortServersAsync(string? subId, string colName, bool asc) + { + if (await ConfigHandler.SortServers(_config, subId, colName, asc) == 0) + { + await RefreshServers(); + } + } + + /// Remove duplicates inside a group and return how many were removed. + public async Task DedupServersAsync(string? subId) + { + var tuple = await ConfigHandler.DedupServerList(_config, subId); + if (tuple.Item1 > 0) + { + await RefreshServers(); + } + return tuple.Item1; + } + + /// Remove servers with an invalid test result and return how many were removed. + public async Task RemoveInvalidServersAsync(string? subId) + { + var count = await ConfigHandler.RemoveInvalidServerResult(_config, subId); + await RefreshServers(); + return count; + } + + /// + /// Activate the first or last server of a group, using the persisted sort order (the + /// order the server list displays). therefore + /// picks the topmost row, so a sort step run beforehand decides which server wins. + /// + public async Task SetDefaultServerForGroupAsync(string? subId, EServerSelectType selectType) + { + var lstModel = await AppManager.Instance.ProfileModels(subId ?? _config.SubIndexId, string.Empty); + if (lstModel is not { Count: > 0 }) + { + return; + } + + // Sort is persisted on ProfileExItem, not on ProfileItem, so it is not part of + // ProfileModels. Look it up per row instead of ordering by the always-zero default. + var ordered = selectType == EServerSelectType.Last + ? lstModel.OrderByDescending(t => ProfileExManager.Instance.GetSort(t.IndexId)) + : lstModel.OrderBy(t => ProfileExManager.Instance.GetSort(t.IndexId)); + var target = ordered.FirstOrDefault(); + if (target is null) + { + return; + } + await SetDefaultServer(target.IndexId); + } + + private async Task> GetProfilesForGroup(string? subId) + { + var lstModel = await AppManager.Instance.ProfileModels(subId ?? _config.SubIndexId, string.Empty); + if (lstModel is not { Count: > 0 }) + { + return []; + } + var ids = lstModel.Select(t => t.IndexId).ToList(); + return await AppManager.Instance.GetProfileItemsByIndexIds(ids) ?? []; + } + + #endregion Workflow helpers + //move server private async Task MoveToGroup() { diff --git a/v2rayN/ServiceLib/ViewModels/SubSettingViewModel.cs b/v2rayN/ServiceLib/ViewModels/SubSettingViewModel.cs index d3a75a34e74..2f9f35ec4a3 100644 --- a/v2rayN/ServiceLib/ViewModels/SubSettingViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/SubSettingViewModel.cs @@ -93,6 +93,7 @@ private async Task DeleteSubAsync() await ConfigHandler.DeleteSubItem(_config, it.Id); } await RefreshSubItems(); + AppEvents.ServerStateChanged.Publish(); NoticeManager.Instance.Enqueue(ResUI.OperationSuccess); IsModified = true; } diff --git a/v2rayN/ServiceLib/ViewModels/WorkflowEditViewModel.cs b/v2rayN/ServiceLib/ViewModels/WorkflowEditViewModel.cs new file mode 100644 index 00000000000..67edf28dc14 --- /dev/null +++ b/v2rayN/ServiceLib/ViewModels/WorkflowEditViewModel.cs @@ -0,0 +1,170 @@ +namespace ServiceLib.ViewModels; + +/// +/// Editor for a single workflow: its name/enabled flag and the ordered list of steps. +/// +public partial class WorkflowEditViewModel : MyReactiveObject, ICloseable +{ + public event EventHandler? RequestClose; + + [Reactive] + public partial WorkflowItem SelectedSource { get; set; } + + [Reactive] + public partial WorkflowStep SelectedStep { get; set; } + + public BulkObservableCollection Steps { get; } = []; + + public List ActionNames { get; } = Utils.GetEnumNames(); + + /// Group choices for the step editor's "Group" column. + public BulkObservableCollection GroupNames { get; } = []; + + private void ApplyStepOptions() + { + foreach (var step in Steps) + { + step.ActionOptions = ActionNames; + step.GroupOptions = GroupNames; + step.Parameter = WorkflowStepOptions.Normalize(step.Action, step.Parameter); + } + } + + public ReactiveCommand AddStepCmd { get; } + public ReactiveCommand DeleteStepCmd { get; } + public ReactiveCommand StepUpCmd { get; } + public ReactiveCommand StepDownCmd { get; } + public ReactiveCommand SaveCmd { get; } + + public WorkflowEditViewModel(WorkflowItem workflow) + { + _config = AppManager.Instance.Config; + + var canEditStep = this.WhenAnyValue( + x => x.SelectedStep, + (WorkflowStep selected) => selected != null); + + AddStepCmd = ReactiveCommand.Create(AddStep); + DeleteStepCmd = ReactiveCommand.Create(DeleteStep, canEditStep); + StepUpCmd = ReactiveCommand.Create(() => MoveStep(-1), canEditStep); + StepDownCmd = ReactiveCommand.Create(() => MoveStep(1), canEditStep); + SaveCmd = ReactiveCommand.CreateFromTask(async () => await SaveWorkflowAsync()); + + SelectedSource = workflow.Id.IsNullOrEmpty() ? workflow : JsonUtils.DeepCopy(workflow); + LoadSteps(workflow.Id.IsNullOrEmpty() ? CreateSample() : WorkflowHandler.DeserializeSteps(workflow)); + _ = InitAsync(); + } + + private async Task InitAsync() + { + var subItems = await AppManager.Instance.SubItems() ?? []; + var names = subItems.Select(t => t.Remarks).ToList(); + names.Insert(0, ResUI.AllGroupServers); + GroupNames.ReplaceRange(names); + + var map = subItems.ToDictionary(t => t.Id, t => t.Remarks); + foreach (var step in Steps.Where(t => t.SubId.IsNotEmpty())) + { + map.TryGetValue(step.SubId, out var remarks); + step.SubDisplay = remarks ?? ResUI.AllGroupServers; + } + ApplyStepOptions(); + } + + /// Turn the editor's group name back into a subscription id before saving. + private async Task ResolveStepSubsAsync() + { + var subItems = await AppManager.Instance.SubItems() ?? []; + var map = subItems.ToDictionary(t => t.Remarks, t => t.Id); + foreach (var step in Steps) + { + step.SubId = step.SubDisplay.IsNotEmpty() + && step.SubDisplay != ResUI.AllGroupServers + && map.TryGetValue(step.SubDisplay, out var id) + ? id + : null; + } + } + + private void LoadSteps(List steps) + { + Steps.ReplaceRange(steps); + ApplyStepOptions(); + SelectedStep = Steps.FirstOrDefault(); + } + + /// Starter chain offered for a brand new workflow; the user can freely edit it. + private static List CreateSample() + { + return + [ + new() { Action = EWorkflowAction.UpdateSubscriptions }, + new() { Action = EWorkflowAction.DeduplicateServers }, + new() { Action = EWorkflowAction.SortServers, Parameter = nameof(EServerColName.DelayVal) }, + new() { Action = EWorkflowAction.TestServers, Parameter = nameof(ESpeedActionType.Realping) }, + new() { Action = EWorkflowAction.RemoveInvalidServers }, + new() { Action = EWorkflowAction.SortServers, Parameter = nameof(EServerColName.SpeedVal), BoolParameter = false }, + new() { Action = EWorkflowAction.ActivateServer, Parameter = nameof(EServerSelectType.First) }, + new() { Action = EWorkflowAction.SystemProxy, Parameter = nameof(ESysProxyType.ForcedChange) }, + ]; + } + + private void AddStep() + { + var step = new WorkflowStep + { + ActionOptions = ActionNames, + GroupOptions = GroupNames, + }; + Steps.Add(step); + SelectedStep = step; + } + + private void DeleteStep() + { + if (SelectedStep is null) + { + return; + } + var index = Steps.IndexOf(SelectedStep); + Steps.Remove(SelectedStep); + SelectedStep = Steps.Count > 0 ? Steps[Math.Clamp(index, 0, Steps.Count - 1)] : null; + } + + private void MoveStep(int offset) + { + if (SelectedStep is null) + { + return; + } + var index = Steps.IndexOf(SelectedStep); + var target = index + offset; + if (index < 0 || target < 0 || target >= Steps.Count) + { + return; + } + Steps.Move(index, target); + SelectedStep = Steps[target]; + } + + private async Task SaveWorkflowAsync() + { + if (SelectedSource.Remarks.IsNullOrEmpty()) + { + NoticeManager.Instance.Enqueue(ResUI.PleaseFillRemarks); + return; + } + + await ResolveStepSubsAsync(); + WorkflowHandler.SaveSteps(SelectedSource, [.. Steps]); + if (await ConfigHandler.AddWorkflowItem(_config, SelectedSource) == 0) + { + NoticeManager.Instance.Enqueue(ResUI.OperationSuccess); + RequestClose?.Invoke(this, EventArgs.Empty); + } + else + { + NoticeManager.Instance.Enqueue(ResUI.OperationFailed); + } + } +} diff --git a/v2rayN/ServiceLib/ViewModels/WorkflowRunner.cs b/v2rayN/ServiceLib/ViewModels/WorkflowRunner.cs new file mode 100644 index 00000000000..3bb9cff046f --- /dev/null +++ b/v2rayN/ServiceLib/ViewModels/WorkflowRunner.cs @@ -0,0 +1,30 @@ +namespace ServiceLib.ViewModels; + +/// +/// Holds the used when a workflow runs. The UI layer +/// registers it after the main view models are built; a registration failure is +/// surfaced instead of silently doing nothing. +/// +public sealed class WorkflowRunner +{ + private static readonly Lazy _instance = new(() => new()); + public static WorkflowRunner Instance => _instance.Value; + + private IWorkflowRuntime? _runtime; + + public void Register(IWorkflowRuntime runtime) + { + _runtime = runtime; + } + + public async Task Run(Config config, WorkflowItem workflow) + { + if (_runtime is null) + { + NoticeManager.Instance.Enqueue(ResUI.OperationFailed); + Logging.SaveLog("WorkflowRunner", new InvalidOperationException("Workflow runtime is not registered.")); + return; + } + await WorkflowHandler.Run(config, workflow, _runtime); + } +} diff --git a/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs b/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs new file mode 100644 index 00000000000..1bc923f514f --- /dev/null +++ b/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs @@ -0,0 +1,48 @@ +namespace ServiceLib.ViewModels; + +/// +/// Bridges to the profile and status bar view models. +/// Created by the UI layer once the main view models exist. +/// +public sealed class WorkflowRuntime(ProfilesViewModel profiles, Func updateSubscriptions) : IWorkflowRuntime +{ + public async Task UpdateSubscriptions(string? subId, bool viaProxy) + { + await updateSubscriptions(subId, viaProxy); + } + + public async Task DeduplicateServers(string subId) + { + var count = await profiles.DedupServersAsync(subId); + NoticeManager.Instance.Enqueue(string.Format(ResUI.RemoveDuplicateServerResult, count, 0)); + return count; + } + + public async Task SortServers(string subId, string column, bool asc) + { + await profiles.SortServersAsync(subId, column, asc); + } + + public async Task TestServers(string subId, ESpeedActionType actionType) + { + await profiles.TestServersAsync(subId, actionType); + } + + public async Task RemoveInvalidServers(string subId) + { + var count = await profiles.RemoveInvalidServersAsync(subId); + NoticeManager.Instance.Enqueue(string.Format(ResUI.RemoveInvalidServerResultTip, count)); + return count; + } + + public async Task ActivateServer(string subId, EServerSelectType selectType) + { + await profiles.SetDefaultServerForGroupAsync(subId, selectType); + } + + public Task SetSystemProxy(ESysProxyType type) + { + AppEvents.SysProxyChangeRequested.Publish(type); + return Task.CompletedTask; + } +} diff --git a/v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs b/v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs new file mode 100644 index 00000000000..c3aa46d60c1 --- /dev/null +++ b/v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs @@ -0,0 +1,122 @@ +namespace ServiceLib.ViewModels; + +/// A workflow list entry with a human readable summary of its steps. +public class WorkflowItemModel +{ + public string Id { get; set; } + public string Remarks { get; set; } + public bool Enabled { get; set; } + public int Sort { get; set; } + public int StepCount { get; set; } + public string StepsDisplay { get; set; } +} + +/// +/// Workflow manager window: lists stored workflows and lets the user +/// add, edit, delete and run them. +/// +public partial class WorkflowViewModel : MyReactiveObject +{ + public Interaction ShowYesNoInteraction { get; } = new(); + public Interaction EditWorkflowInteraction { get; } = new(); + + public BulkObservableCollection WorkflowItems { get; } = []; + + [Reactive] + public partial WorkflowItemModel SelectedWorkflow { get; set; } + + public ReactiveCommand AddCmd { get; } + public ReactiveCommand EditCmd { get; } + public ReactiveCommand DeleteCmd { get; } + public ReactiveCommand RunCmd { get; } + + public bool IsModified { get; set; } + + public WorkflowViewModel() + { + _config = AppManager.Instance.Config; + + var canEdit = this.WhenAnyValue( + x => x.SelectedWorkflow, + selected => selected != null && !selected.Id.IsNullOrEmpty()); + + AddCmd = ReactiveCommand.CreateFromTask(async () => await EditWorkflowAsync(true)); + EditCmd = ReactiveCommand.CreateFromTask(async () => await EditWorkflowAsync(false), canEdit); + DeleteCmd = ReactiveCommand.CreateFromTask(async () => await DeleteWorkflowAsync(), canEdit); + RunCmd = ReactiveCommand.CreateFromTask(async () => await RunWorkflowAsync(), canEdit); + + _ = Init(); + } + + private async Task Init() + { + SelectedWorkflow = new(); + await RefreshWorkflowItems(); + } + + public async Task RefreshWorkflowItems() + { + var workflows = await AppManager.Instance.WorkflowItems(); + WorkflowItems.ReplaceRange(workflows.Select(ToModel)); + } + + private static WorkflowItemModel ToModel(WorkflowItem item) + { + var steps = WorkflowHandler.DeserializeSteps(item); + return new() + { + Id = item.Id, + Remarks = item.Remarks, + Enabled = item.Enabled, + Sort = item.Sort, + StepCount = steps.Count, + StepsDisplay = Utils.List2String(steps.Select(t => t.Action.ToString()).ToList(), true), + }; + } + + public async Task EditWorkflowAsync(bool blNew) + { + WorkflowItem item; + if (blNew) + { + item = new() { Remarks = ResUI.menuWorkflowSetting }; + } + else + { + item = await AppManager.Instance.GetWorkflowItem(SelectedWorkflow?.Id); + if (item is null) + { + return; + } + } + + if (await EditWorkflowInteraction.HandleSafe(item) == true) + { + await RefreshWorkflowItems(); + IsModified = true; + } + } + + private async Task DeleteWorkflowAsync() + { + if (await ShowYesNoInteraction.HandleSafe(ResUI.menuWorkflowDelete) == false) + { + return; + } + await ConfigHandler.DeleteWorkflowItem(_config, SelectedWorkflow.Id); + await RefreshWorkflowItems(); + NoticeManager.Instance.Enqueue(ResUI.OperationSuccess); + IsModified = true; + } + + private async Task RunWorkflowAsync() + { + var item = await AppManager.Instance.GetWorkflowItem(SelectedWorkflow?.Id); + if (item is null) + { + NoticeManager.Instance.Enqueue(ResUI.WorkflowRunSelectTip); + return; + } + await WorkflowRunner.Instance.Run(_config, item); + } +} diff --git a/v2rayN/v2rayN.Desktop/DesignData/DesignData.cs b/v2rayN/v2rayN.Desktop/DesignData/DesignData.cs index 807d1af5a37..102b7c76b5c 100644 --- a/v2rayN/v2rayN.Desktop/DesignData/DesignData.cs +++ b/v2rayN/v2rayN.Desktop/DesignData/DesignData.cs @@ -59,6 +59,10 @@ public static class DesignData public static SubEditViewModel? SubEdit { get; } = SafeCreate(() => new SubEditViewModel(new SubItem { Remarks = "Design Subscription", Url = "https://example.com/sub" })); + public static WorkflowViewModel? Workflow { get; } = SafeCreate(() => new WorkflowViewModel()); + + public static WorkflowEditViewModel? WorkflowEdit { get; } = SafeCreate(() => new WorkflowEditViewModel(new WorkflowItem { Remarks = "Design Workflow" })); + // ── Helper factories ─────────────────────────────────────────────────── private static MainWindowViewModel CreateMainWindow() From 8c9dd2c40242511861de1ba1b25b5891fbfb7a3c Mon Sep 17 00:00:00 2001 From: Amir Hossein Samiee <81751940+amir-samiee@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:22:34 +0000 Subject: [PATCH 03/16] feat(workflow): add Avalonia workflow views --- .../Common/SimpleViewLocator.cs | 2 + v2rayN/v2rayN.Desktop/Views/MainWindow.axaml | 2 + .../v2rayN.Desktop/Views/MainWindow.axaml.cs | 1 + .../v2rayN.Desktop/Views/ProfilesView.axaml | 9 ++ .../Views/ProfilesView.axaml.cs | 1 + .../Views/WorkflowEditWindow.axaml | 142 ++++++++++++++++++ .../Views/WorkflowEditWindow.axaml.cs | 22 +++ .../v2rayN.Desktop/Views/WorkflowWindow.axaml | 67 +++++++++ .../Views/WorkflowWindow.axaml.cs | 61 ++++++++ 9 files changed, 307 insertions(+) create mode 100644 v2rayN/v2rayN.Desktop/Views/WorkflowEditWindow.axaml create mode 100644 v2rayN/v2rayN.Desktop/Views/WorkflowEditWindow.axaml.cs create mode 100644 v2rayN/v2rayN.Desktop/Views/WorkflowWindow.axaml create mode 100644 v2rayN/v2rayN.Desktop/Views/WorkflowWindow.axaml.cs diff --git a/v2rayN/v2rayN.Desktop/Common/SimpleViewLocator.cs b/v2rayN/v2rayN.Desktop/Common/SimpleViewLocator.cs index 8b4328f5fef..ec1440cca74 100644 --- a/v2rayN/v2rayN.Desktop/Common/SimpleViewLocator.cs +++ b/v2rayN/v2rayN.Desktop/Common/SimpleViewLocator.cs @@ -34,6 +34,8 @@ private SimpleViewLocator() RegisterViewFactory(); RegisterViewFactory(); RegisterViewFactory(); + RegisterViewFactory(); + RegisterViewFactory(); } public static SimpleViewLocator Instance => _instance.Value; diff --git a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml index 208ae01df56..53bd789d428 100644 --- a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml @@ -61,6 +61,8 @@ + + diff --git a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs index 650917b595a..ada5225e106 100644 --- a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs +++ b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs @@ -67,6 +67,7 @@ public MainWindow() this.BindCommand(ViewModel, vm => vm.SubUpdateViaProxyCmd, v => v.menuSubUpdateViaProxy).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.SubGroupUpdateCmd, v => v.menuSubGroupUpdate).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.SubGroupUpdateViaProxyCmd, v => v.menuSubGroupUpdateViaProxy).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.WorkflowCmd, v => v.menuWorkflow).DisposeWith(disposables); //setting this.BindCommand(ViewModel, vm => vm.OptionSettingCmd, v => v.menuOptionSetting).DisposeWith(disposables); diff --git a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml index ef971f9a6fe..8066c037333 100644 --- a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml +++ b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml @@ -102,6 +102,15 @@ + vm.ServerFilter, v => v.txtServerFilter.Text).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddSubCmd, v => v.btnAddSub).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.EditSubCmd, v => v.btnEditSub).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.WorkflowCmd, v => v.btnWorkflow).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.EditSubCmd, v => v.menuSubEdit).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddSubCmd, v => v.menuSubAdd).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.DeleteSubCmd, v => v.menuSubDelete).DisposeWith(disposables); diff --git a/v2rayN/v2rayN.Desktop/Views/WorkflowEditWindow.axaml b/v2rayN/v2rayN.Desktop/Views/WorkflowEditWindow.axaml new file mode 100644 index 00000000000..c97248b2db0 --- /dev/null +++ b/v2rayN/v2rayN.Desktop/Views/WorkflowEditWindow.axaml @@ -0,0 +1,142 @@ + + + + + + + + + + + + vm.ServerFilter, v => v.txtServerFilter.Text).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddSubCmd, v => v.btnAddSub).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.EditSubCmd, v => v.btnEditSub).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.WorkflowCmd, v => v.btnWorkflow).DisposeWith(disposables); //servers delete this.BindCommand(ViewModel, vm => vm.EditServerCmd, v => v.menuEditServer).DisposeWith(disposables); diff --git a/v2rayN/v2rayN/Views/WorkflowEditWindow.xaml b/v2rayN/v2rayN/Views/WorkflowEditWindow.xaml new file mode 100644 index 00000000000..6e3fd9d96b0 --- /dev/null +++ b/v2rayN/v2rayN/Views/WorkflowEditWindow.xaml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/v2rayN/v2rayN/Views/WorkflowWindow.xaml.cs b/v2rayN/v2rayN/Views/WorkflowWindow.xaml.cs new file mode 100644 index 00000000000..44bd64e7920 --- /dev/null +++ b/v2rayN/v2rayN/Views/WorkflowWindow.xaml.cs @@ -0,0 +1,42 @@ +namespace v2rayN.Views; + +public partial class WorkflowWindow +{ + public WorkflowWindow() + { + InitializeComponent(); + + lstWorkflow.MouseDoubleClick += (_, _) => ViewModel?.EditWorkflowAsync(false); + menuClose.Click += (_, _) => Close(); + + this.WhenActivated(disposables => + { + this.OneWayBind(ViewModel, vm => vm.WorkflowItems, v => v.lstWorkflow.ItemsSource).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.SelectedWorkflow, v => v.lstWorkflow.SelectedItem).DisposeWith(disposables); + + this.BindCommand(ViewModel, vm => vm.AddCmd, v => v.menuWorkflowAdd).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.EditCmd, v => v.menuWorkflowEdit).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.DeleteCmd, v => v.menuWorkflowDelete).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.RunCmd, v => v.menuWorkflowRun).DisposeWith(disposables); + + this.BindCommand(ViewModel, vm => vm.AddCmd, v => v.menuWorkflowAdd2).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.EditCmd, v => v.menuWorkflowEdit2).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.DeleteCmd, v => v.menuWorkflowDelete2).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.RunCmd, v => v.menuWorkflowRun2).DisposeWith(disposables); + + ViewModel.ShowYesNoInteraction.RegisterHandler(interaction => + { + var result = UI.ShowYesNo(interaction.Input) != MessageBoxResult.No; + interaction.SetOutput(result); + }).DisposeWith(disposables); + + ViewModel.EditWorkflowInteraction.RegisterHandler(async interaction => + { + var workflowEditViewModel = new WorkflowEditViewModel(interaction.Input); + var result = await AppManager.Instance.WindowDialog.ShowDialogAsync(workflowEditViewModel); + interaction.SetOutput(result == true); + }).DisposeWith(disposables); + }); + WindowsUtils.SetDarkBorder(this, AppManager.Instance.Config.UiItem.CurrentTheme); + } +} From e21a7dfb29a9c4ff1ad830246cc4f5dbab6c6741 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 3 Nov 2026 19:49:24 +0000 Subject: [PATCH 05/16] fix(workflow): register workflow types with the JSON source generator Rebasing onto master broke workflow persistence: JsonUtils now resolves types through the source-generated AppJsonContext, and WorkflowItem and WorkflowStep were not registered there. Serialize returned an empty string and Deserialize returned null, so saved workflows silently lost every step. Register both types (and their lists). Co-authored-by: openhands --- v2rayN/ServiceLib/Common/JsonUtils.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/v2rayN/ServiceLib/Common/JsonUtils.cs b/v2rayN/ServiceLib/Common/JsonUtils.cs index b333be93cc7..899f9f6083c 100644 --- a/v2rayN/ServiceLib/Common/JsonUtils.cs +++ b/v2rayN/ServiceLib/Common/JsonUtils.cs @@ -229,6 +229,10 @@ public static string SerializeCamelCase(T? obj) [JsonSerializable(typeof(WebDavItem))] [JsonSerializable(typeof(KeyEventItem))] [JsonSerializable(typeof(SubItem))] +[JsonSerializable(typeof(WorkflowItem))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(WorkflowStep))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(SingboxConfig))] [JsonSerializable(typeof(Dns4Sbox))] [JsonSerializable(typeof(Inbound4Sbox))] From e9987ccc88643b09423b13b1bf224211ada28c31 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 3 Nov 2026 19:59:20 +0000 Subject: [PATCH 06/16] fix(workflow): notify when SubDisplay changes The editor loads subscription names asynchronously and assigns SubDisplay after the step grid has already bound its group cell, so a plain auto-property left the cell blank. Back it with a field and raise PropertyChanged, matching the other editable step fields. Co-authored-by: openhands --- v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs b/v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs index 558fe5adb23..617909add4d 100644 --- a/v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs +++ b/v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs @@ -13,6 +13,7 @@ public class WorkflowStep : INotifyPropertyChanged private EWorkflowAction _action; private string? _parameter; + private string? _subDisplay; public EWorkflowAction Action { @@ -75,10 +76,24 @@ public string? ActionDisplay /// /// Editor-only group name. resolves it - /// back into when the workflow is saved. + /// back into when the workflow is saved. Notifies on change because + /// the subscriptions load asynchronously, so the value is often assigned after the + /// editor has already bound its group dropdown. /// [JsonIgnore] - public string? SubDisplay { get; set; } + public string? SubDisplay + { + get => _subDisplay; + set + { + if (_subDisplay == value) + { + return; + } + _subDisplay = value; + Notify(nameof(SubDisplay)); + } + } /// Editor-only action choices, so the step grid can bind its dropdown to the row item. [JsonIgnore] From 9f91e260330b912167ca832633a678a549d7a84a Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 3 Nov 2026 19:59:25 +0000 Subject: [PATCH 07/16] fix(workflow): normalize FastRealping in the test step SpeedtestService has no FastRealping case, so a workflow step that picked it finished without testing anything. The interactive speedtest command treats FastRealping as Realping; mirror that here so the step and the command behave the same. Co-authored-by: openhands --- v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs index ee47029fd77..afaf5db74da 100644 --- a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs @@ -663,6 +663,14 @@ public async Task RemoveInvalidServerResult() /// Run a test over a whole group. Completes once the test run finished. public async Task TestServersAsync(string? subId, ESpeedActionType actionType) { + // SpeedtestService has no FastRealping case, so a step that selects it would + // finish without testing anything. Realping is the behavior the interactive + // command uses for it, so normalize here to keep the two in step. + if (actionType == ESpeedActionType.FastRealping) + { + actionType = ESpeedActionType.Realping; + } + var lstSelected = await GetProfilesForGroup(subId); if (lstSelected.Count == 0) { From 17936d82929aaa9ccb815b923b2e81d96382783b Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 3 Nov 2026 19:59:31 +0000 Subject: [PATCH 08/16] perf(workflow): order activation candidates from one sort lookup SetDefaultServerForGroupAsync called ProfileExManager.GetSort per row, and each call scans the whole ProfileEx bag, so ordering a group was O(n*m) on the UI thread. Build the IndexId-to-Sort map once and order with it. Co-authored-by: openhands --- v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs index afaf5db74da..ae1dd6ad6a0 100644 --- a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs @@ -726,11 +726,14 @@ public async Task SetDefaultServerForGroupAsync(string? subId, EServerSelectType return; } - // Sort is persisted on ProfileExItem, not on ProfileItem, so it is not part of - // ProfileModels. Look it up per row instead of ordering by the always-zero default. + // Sort lives on ProfileExItem, not on ProfileItem, so ProfileModels cannot order by it. + // GetSort scans the whole bag per call, so build the lookup once instead of per row. + var sorts = (await ProfileExManager.Instance.GetProfileExs()) + .GroupBy(t => t.IndexId) + .ToDictionary(g => g.Key, g => g.First().Sort); var ordered = selectType == EServerSelectType.Last - ? lstModel.OrderByDescending(t => ProfileExManager.Instance.GetSort(t.IndexId)) - : lstModel.OrderBy(t => ProfileExManager.Instance.GetSort(t.IndexId)); + ? lstModel.OrderByDescending(t => sorts.GetValueOrDefault(t.IndexId)) + : lstModel.OrderBy(t => sorts.GetValueOrDefault(t.IndexId)); var target = ordered.FirstOrDefault(); if (target is null) { From 44376344174fdbc0e3d286df69746857e2b34e1c Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 3 Nov 2026 20:00:12 +0000 Subject: [PATCH 09/16] fix(workflow): treat an empty group as all groups, not the selected group The editor's 'All groups' choice serializes to an empty SubId, but the runner replaced an empty id with the currently selected subscription, so an all-groups step silently scoped to one group. Pass null through to the runtime (documented as every group) and drop the selected-group fallback, including in the activation and test helpers. The runtime seam and its callers now take a nullable group id; tests cover the all-groups case. Co-authored-by: openhands --- .../Handler/WorkflowHandlerTests.cs | 60 +++++++++++++------ v2rayN/ServiceLib/Base/IWorkflowRuntime.cs | 31 ++++++---- v2rayN/ServiceLib/Handler/WorkflowHandler.cs | 27 +++++---- v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs | 6 +- .../ViewModels/ProfilesViewModel.cs | 4 +- .../ServiceLib/ViewModels/WorkflowRunner.cs | 4 +- .../ServiceLib/ViewModels/WorkflowRuntime.cs | 10 ++-- .../ViewModels/WorkflowViewModel.cs | 2 +- 8 files changed, 91 insertions(+), 53 deletions(-) diff --git a/v2rayN/ServiceLib.Tests/Handler/WorkflowHandlerTests.cs b/v2rayN/ServiceLib.Tests/Handler/WorkflowHandlerTests.cs index 63aa471af56..324bb916481 100644 --- a/v2rayN/ServiceLib.Tests/Handler/WorkflowHandlerTests.cs +++ b/v2rayN/ServiceLib.Tests/Handler/WorkflowHandlerTests.cs @@ -157,22 +157,22 @@ public async Task Run_ShouldSkipDisabledStepsAndDispatchEveryAction() new() { Action = EWorkflowAction.SystemProxy, Parameter = nameof(ESysProxyType.ForcedChange) }, ]); - await WorkflowHandler.Run(new Config { SubIndexId = "default-sub" }, workflow, runtime); + await WorkflowHandler.Run(workflow, runtime); await runtime.Calls.Should().BeEquivalentTo( [ - // A subscription step with no group updates every subscription, so SubId stays null. + // A step with no group means every group, so SubId stays null (not the selected group). "update::True", - "dedup:default-sub", - "sort:default-sub:SpeedVal:False", - "test:default-sub:Speedtest", - "activate:default-sub:First", + "dedup:", + "sort::SpeedVal:False", + "test::Speedtest", + "activate::First", "sysproxy:ForcedChange", ]); } [Test] - public async Task Run_ShouldPreferStepSubIdOverConfigGroup() + public async Task Run_ShouldUseStepSubIdWhenSet() { var runtime = new RecordingRuntime(); var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" }; @@ -181,7 +181,7 @@ public async Task Run_ShouldPreferStepSubIdOverConfigGroup() new() { Action = EWorkflowAction.DeduplicateServers, SubId = "sub-9" }, ]); - await WorkflowHandler.Run(new Config { SubIndexId = "default-sub" }, workflow, runtime); + await WorkflowHandler.Run(workflow, runtime); await runtime.Calls.Should().BeEquivalentTo(["dedup:sub-9"]); } @@ -199,14 +199,14 @@ public async Task Run_ShouldFallBackToDefaultsForUnknownParameters() new() { Action = EWorkflowAction.ActivateServer, Parameter = "not-a-select-type" }, ]); - await WorkflowHandler.Run(new Config { SubIndexId = "default-sub" }, workflow, runtime); + await WorkflowHandler.Run(workflow, runtime); await runtime.Calls.Should().BeEquivalentTo( [ - $"sort:default-sub:{nameof(EServerColName.DelayVal)}:False", - $"test:default-sub:{nameof(ESpeedActionType.Realping)}", + $"sort::{nameof(EServerColName.DelayVal)}:False", + $"test::{nameof(ESpeedActionType.Realping)}", $"sysproxy:{nameof(ESysProxyType.ForcedClear)}", - $"activate:default-sub:{nameof(EServerSelectType.First)}", + $"activate::{nameof(EServerSelectType.First)}", ]); } @@ -220,11 +220,35 @@ public async Task Run_ShouldNotDispatchAnythingWhenEveryStepIsDisabled() new() { Action = EWorkflowAction.UpdateSubscriptions, Enabled = false }, ]); - await WorkflowHandler.Run(new Config { SubIndexId = "default-sub" }, workflow, runtime); + await WorkflowHandler.Run(workflow, runtime); await runtime.Calls.Count.Should().BeEqualTo(0); } + /// + /// An all-groups step must reach the runtime as null, never as the currently selected + /// group, so it does not silently narrow to whatever group happens to be selected. + /// + [Test] + public async Task Run_ShouldKeepAllGroupsStepUnscopedRegardlessOfSelectedGroup() + { + var runtime = new RecordingRuntime(); + var workflow = new WorkflowItem { Id = "w1", Remarks = "nightly" }; + WorkflowHandler.SaveSteps(workflow, + [ + new() { Action = EWorkflowAction.SortServers, SubId = null, Parameter = nameof(EServerColName.SpeedVal) }, + new() { Action = EWorkflowAction.RemoveInvalidServers, SubId = null }, + ]); + + await WorkflowHandler.Run(workflow, runtime); + + await runtime.Calls.Should().BeEquivalentTo( + [ + $"sort::{nameof(EServerColName.SpeedVal)}:False", + "remove-invalid:", + ]); + } + /// /// Records the calls the handler makes so the dispatch logic can be asserted without /// touching the UI layer. This is an implementation of the runtime seam, not a mock. @@ -239,31 +263,31 @@ public Task UpdateSubscriptions(string? subId, bool viaProxy) return Task.CompletedTask; } - public Task DeduplicateServers(string subId) + public Task DeduplicateServers(string? subId) { Calls.Add($"dedup:{subId}"); return Task.FromResult(0); } - public Task SortServers(string subId, string column, bool asc) + public Task SortServers(string? subId, string column, bool asc) { Calls.Add($"sort:{subId}:{column}:{asc}"); return Task.CompletedTask; } - public Task TestServers(string subId, ESpeedActionType actionType) + public Task TestServers(string? subId, ESpeedActionType actionType) { Calls.Add($"test:{subId}:{actionType}"); return Task.CompletedTask; } - public Task RemoveInvalidServers(string subId) + public Task RemoveInvalidServers(string? subId) { Calls.Add($"remove-invalid:{subId}"); return Task.FromResult(0); } - public Task ActivateServer(string subId, EServerSelectType selectType) + public Task ActivateServer(string? subId, EServerSelectType selectType) { Calls.Add($"activate:{subId}:{selectType}"); return Task.CompletedTask; diff --git a/v2rayN/ServiceLib/Base/IWorkflowRuntime.cs b/v2rayN/ServiceLib/Base/IWorkflowRuntime.cs index 22a37769983..dc61c4322fd 100644 --- a/v2rayN/ServiceLib/Base/IWorkflowRuntime.cs +++ b/v2rayN/ServiceLib/Base/IWorkflowRuntime.cs @@ -6,26 +6,33 @@ namespace ServiceLib.Base; /// public interface IWorkflowRuntime { - /// Update one subscription (or all when is empty). + /// Update one subscription, or every subscription when is null. Task UpdateSubscriptions(string? subId, bool viaProxy); - /// Remove duplicate servers in a group. Returns the number of removed servers. - Task DeduplicateServers(string subId); + /// + /// Remove duplicate servers in a group, or in every group when is null. + /// Returns the number of removed servers. + /// + Task DeduplicateServers(string? subId); - /// Sort a group by a column. - Task SortServers(string subId, string column, bool asc); + /// Sort a group, or every group when is null. + Task SortServers(string? subId, string column, bool asc); - /// Run a connectivity/speed test over a group. - Task TestServers(string subId, ESpeedActionType actionType); + /// Run a connectivity/speed test over a group, or every group when is null. + Task TestServers(string? subId, ESpeedActionType actionType); - /// Remove servers whose last test result was invalid. Returns the number of removed servers. - Task RemoveInvalidServers(string subId); + /// + /// Remove servers whose last test result was invalid, in a group or in every group when + /// is null. Returns the number of removed servers. + /// + Task RemoveInvalidServers(string? subId); /// - /// Activate a server of a group. chooses the topmost or - /// bottom row of the group's persisted sort order. + /// Activate a server of a group, or of every group when is null. + /// chooses the topmost or bottom row of the group's persisted + /// sort order. /// - Task ActivateServer(string subId, EServerSelectType selectType); + Task ActivateServer(string? subId, EServerSelectType selectType); /// Set the system proxy mode. Task SetSystemProxy(ESysProxyType type); diff --git a/v2rayN/ServiceLib/Handler/WorkflowHandler.cs b/v2rayN/ServiceLib/Handler/WorkflowHandler.cs index 4f36f6c8183..4848f62d1a4 100644 --- a/v2rayN/ServiceLib/Handler/WorkflowHandler.cs +++ b/v2rayN/ServiceLib/Handler/WorkflowHandler.cs @@ -12,7 +12,7 @@ public static class WorkflowHandler /// /// Execute every enabled step of the workflow. A failing step does not abort the run. /// - public static async Task Run(Config config, WorkflowItem workflow, IWorkflowRuntime runtime) + public static async Task Run(WorkflowItem workflow, IWorkflowRuntime runtime) { var steps = DeserializeSteps(workflow).Where(t => t.Enabled).ToList(); if (steps.Count == 0) @@ -21,15 +21,14 @@ public static async Task Run(Config config, WorkflowItem workflow, IWorkflowRunt return; } - var subId = config.SubIndexId; NoticeManager.Instance.SendMessageEx($"{ResUI.WorkflowStartTip} - {workflow.Remarks}"); for (var i = 0; i < steps.Count; i++) { var step = steps[i]; - var prefix = $"[{i + 1}/{steps.Count}] {(step.SubId.IsNotEmpty() ? step.SubId : subId)}"; + var prefix = $"[{i + 1}/{steps.Count}] {(step.SubId.IsNotEmpty() ? step.SubId : ResUI.AllGroupServers)}"; try { - await RunStep(config, step, subId, runtime); + await RunStep(step, runtime); } catch (Exception ex) { @@ -40,34 +39,38 @@ public static async Task Run(Config config, WorkflowItem workflow, IWorkflowRunt NoticeManager.Instance.Enqueue($"{ResUI.WorkflowEndTip} - {workflow.Remarks}"); } - private static async Task RunStep(Config config, WorkflowStep step, string? subId, IWorkflowRuntime runtime) + private static async Task RunStep(WorkflowStep step, IWorkflowRuntime runtime) { + // An empty SubId is the editor's "All groups" choice, so it is passed through as null + // rather than replaced with the selected group. A step that means "everything" must not + // silently narrow to one group depending on what the user happened to have selected. + var subId = step.SubId.IsNotEmpty() ? step.SubId : null; + switch (step.Action) { case EWorkflowAction.UpdateSubscriptions: - await runtime.UpdateSubscriptions(step.SubId, step.BoolParameter); + await runtime.UpdateSubscriptions(subId, step.BoolParameter); break; case EWorkflowAction.DeduplicateServers: - await runtime.DeduplicateServers(step.SubId.IsNotEmpty() ? step.SubId : subId); + await runtime.DeduplicateServers(subId); break; case EWorkflowAction.SortServers: - await runtime.SortServers(step.SubId.IsNotEmpty() ? step.SubId : subId, + await runtime.SortServers(subId, step.Parameter ?? nameof(EServerColName.DelayVal), step.BoolParameter); break; case EWorkflowAction.TestServers: - await runtime.TestServers(step.SubId.IsNotEmpty() ? step.SubId : subId, ParseTestType(step.Parameter)); + await runtime.TestServers(subId, ParseTestType(step.Parameter)); break; case EWorkflowAction.RemoveInvalidServers: - await runtime.RemoveInvalidServers(step.SubId.IsNotEmpty() ? step.SubId : subId); + await runtime.RemoveInvalidServers(subId); break; case EWorkflowAction.ActivateServer: - await runtime.ActivateServer(step.SubId.IsNotEmpty() ? step.SubId : subId, - ParseServerSelectType(step.Parameter)); + await runtime.ActivateServer(subId, ParseServerSelectType(step.Parameter)); break; case EWorkflowAction.SystemProxy: diff --git a/v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs b/v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs index 617909add4d..b721a7c0bfb 100644 --- a/v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs +++ b/v2rayN/ServiceLib/Models/Dto/WorkflowStep.cs @@ -39,7 +39,11 @@ public EWorkflowAction Action public bool Enabled { get; set; } = true; - /// Subscription id for ; empty means all. + /// + /// Subscription id the step operates on; empty means every group. Empty is the editor's + /// "All groups" choice and is deliberately not substituted with the selected group, so an + /// explicit all-groups step stays all-groups at run time. + /// public string? SubId { get; set; } /// Action dependent argument: sort column, test type or system proxy type. diff --git a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs index ae1dd6ad6a0..9bcc30c7828 100644 --- a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs @@ -720,7 +720,7 @@ public async Task RemoveInvalidServersAsync(string? subId) /// public async Task SetDefaultServerForGroupAsync(string? subId, EServerSelectType selectType) { - var lstModel = await AppManager.Instance.ProfileModels(subId ?? _config.SubIndexId, string.Empty); + var lstModel = await AppManager.Instance.ProfileModels(subId ?? string.Empty, string.Empty); if (lstModel is not { Count: > 0 }) { return; @@ -744,7 +744,7 @@ public async Task SetDefaultServerForGroupAsync(string? subId, EServerSelectType private async Task> GetProfilesForGroup(string? subId) { - var lstModel = await AppManager.Instance.ProfileModels(subId ?? _config.SubIndexId, string.Empty); + var lstModel = await AppManager.Instance.ProfileModels(subId ?? string.Empty, string.Empty); if (lstModel is not { Count: > 0 }) { return []; diff --git a/v2rayN/ServiceLib/ViewModels/WorkflowRunner.cs b/v2rayN/ServiceLib/ViewModels/WorkflowRunner.cs index 3bb9cff046f..7d140a5516b 100644 --- a/v2rayN/ServiceLib/ViewModels/WorkflowRunner.cs +++ b/v2rayN/ServiceLib/ViewModels/WorkflowRunner.cs @@ -17,7 +17,7 @@ public void Register(IWorkflowRuntime runtime) _runtime = runtime; } - public async Task Run(Config config, WorkflowItem workflow) + public async Task Run(WorkflowItem workflow) { if (_runtime is null) { @@ -25,6 +25,6 @@ public async Task Run(Config config, WorkflowItem workflow) Logging.SaveLog("WorkflowRunner", new InvalidOperationException("Workflow runtime is not registered.")); return; } - await WorkflowHandler.Run(config, workflow, _runtime); + await WorkflowHandler.Run(workflow, _runtime); } } diff --git a/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs b/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs index 1bc923f514f..2d102a4277e 100644 --- a/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs +++ b/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs @@ -11,31 +11,31 @@ public async Task UpdateSubscriptions(string? subId, bool viaProxy) await updateSubscriptions(subId, viaProxy); } - public async Task DeduplicateServers(string subId) + public async Task DeduplicateServers(string? subId) { var count = await profiles.DedupServersAsync(subId); NoticeManager.Instance.Enqueue(string.Format(ResUI.RemoveDuplicateServerResult, count, 0)); return count; } - public async Task SortServers(string subId, string column, bool asc) + public async Task SortServers(string? subId, string column, bool asc) { await profiles.SortServersAsync(subId, column, asc); } - public async Task TestServers(string subId, ESpeedActionType actionType) + public async Task TestServers(string? subId, ESpeedActionType actionType) { await profiles.TestServersAsync(subId, actionType); } - public async Task RemoveInvalidServers(string subId) + public async Task RemoveInvalidServers(string? subId) { var count = await profiles.RemoveInvalidServersAsync(subId); NoticeManager.Instance.Enqueue(string.Format(ResUI.RemoveInvalidServerResultTip, count)); return count; } - public async Task ActivateServer(string subId, EServerSelectType selectType) + public async Task ActivateServer(string? subId, EServerSelectType selectType) { await profiles.SetDefaultServerForGroupAsync(subId, selectType); } diff --git a/v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs b/v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs index c3aa46d60c1..d9ec0717521 100644 --- a/v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs @@ -117,6 +117,6 @@ private async Task RunWorkflowAsync() NoticeManager.Instance.Enqueue(ResUI.WorkflowRunSelectTip); return; } - await WorkflowRunner.Instance.Run(_config, item); + await WorkflowRunner.Instance.Run(item); } } From 649d5437fef5933ff7fc452329d01e0bb4d95c85 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 3 Nov 2026 20:00:22 +0000 Subject: [PATCH 10/16] fix(workflow): await the core reload after activating a server Activation only published ReloadRequested, so the next workflow step ran while the core was still restarting with the previous server. Add an awaitable ReloadAsync hook wired to the main reload, and have the activation path opt into it. The interactive command keeps firing the request and returning, as before. Co-authored-by: openhands --- .../ViewModels/MainWindowViewModel.cs | 4 +++ .../ViewModels/ProfilesViewModel.cs | 27 ++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs index 97efaf80659..16ca8d2dc0d 100644 --- a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs @@ -306,6 +306,10 @@ public MainWindowViewModel() .SubscribeAsync(async _ => await Reload()); } + // Let the profiles view model await a reload, e.g. from a workflow step that must not + // continue until the new server is actually live. + ProfilesViewModel.ReloadAsync = Reload; + StatusBarViewModel.AddServerViaScanRequested .AsObservable() .ObserveOn(RxSchedulers.MainThreadScheduler) diff --git a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs index 9bcc30c7828..942dadf5fdb 100644 --- a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs @@ -11,6 +11,14 @@ public partial class ProfilesViewModel : MyReactiveObject public Interaction AdjustMainLvColWidthInteraction { get; } = new(); public EventChannel ReloadRequested { get; } = new(); + + /// + /// Awaitable version of , set by the main view model. The + /// channel only signals; workflows need to know when the reload actually finished before + /// running the next step. Null when no owner is wired (design mode, tests). + /// + public Func? ReloadAsync { get; set; } + public EventChannel RefreshServersRequested { get; } = new(); #region private prop @@ -568,7 +576,7 @@ public async Task SetDefaultServer() await SetDefaultServer(SelectedProfile.IndexId); } - public async Task SetDefaultServer(string? indexId) + public async Task SetDefaultServer(string? indexId, bool waitForReload = false) { if (indexId.IsNullOrEmpty()) { @@ -588,7 +596,16 @@ public async Task SetDefaultServer(string? indexId) if (await ConfigHandler.SetDefaultServerIndex(_config, indexId) == 0) { await RefreshServers(); - Reload(); + // Workflows need the reload to be finished before the next step runs; the + // interactive command keeps firing the request and returning. + if (waitForReload && ReloadAsync is not null) + { + await ReloadAsync(); + } + else + { + Reload(); + } } } @@ -717,6 +734,8 @@ public async Task RemoveInvalidServersAsync(string? subId) /// Activate the first or last server of a group, using the persisted sort order (the /// order the server list displays). therefore /// picks the topmost row, so a sort step run beforehand decides which server wins. + /// With a null the candidate is drawn from every group. + /// Completes only after the core reload triggered by the activation has finished. /// public async Task SetDefaultServerForGroupAsync(string? subId, EServerSelectType selectType) { @@ -739,7 +758,9 @@ public async Task SetDefaultServerForGroupAsync(string? subId, EServerSelectType { return; } - await SetDefaultServer(target.IndexId); + // Wait for the reload the activation triggers, so the next workflow step sees the + // new server instead of the old core still running. + await SetDefaultServer(target.IndexId, true); } private async Task> GetProfilesForGroup(string? subId) From 33ec7bc318a79b4f7b218972a8fe8cb90eb1916e Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 3 Nov 2026 20:01:29 +0000 Subject: [PATCH 11/16] fix(workflow): report real counts in the deduplicate notice DedupServersAsync returned the post-dedup total, and the notice filled the removed field with it and hard-coded New to 0, so the message was wrong. Return removed/old/new and pass both totals to the notice. Co-authored-by: openhands --- v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs | 9 ++++++--- v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs index 942dadf5fdb..725dba8d42f 100644 --- a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs @@ -711,15 +711,18 @@ public async Task SortServersAsync(string? subId, string colName, bool asc) } } - /// Remove duplicates inside a group and return how many were removed. - public async Task DedupServersAsync(string? subId) + /// + /// Remove duplicates inside a group, or in every group when is null. + /// Returns the number of removed servers and reports the before/after counts for the notice. + /// + public async Task<(int Removed, int Old, int New)> DedupServersAsync(string? subId) { var tuple = await ConfigHandler.DedupServerList(_config, subId); if (tuple.Item1 > 0) { await RefreshServers(); } - return tuple.Item1; + return (tuple.Item1 - tuple.Item2, tuple.Item1, tuple.Item2); } /// Remove servers with an invalid test result and return how many were removed. diff --git a/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs b/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs index 2d102a4277e..85993aad941 100644 --- a/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs +++ b/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs @@ -13,9 +13,9 @@ public async Task UpdateSubscriptions(string? subId, bool viaProxy) public async Task DeduplicateServers(string? subId) { - var count = await profiles.DedupServersAsync(subId); - NoticeManager.Instance.Enqueue(string.Format(ResUI.RemoveDuplicateServerResult, count, 0)); - return count; + var (removed, oldCount, newCount) = await profiles.DedupServersAsync(subId); + NoticeManager.Instance.Enqueue(string.Format(ResUI.RemoveDuplicateServerResult, oldCount, newCount)); + return removed; } public async Task SortServers(string? subId, string column, bool asc) From dbce0324edf62ec2f13a04040fb485d34726351d Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 3 Nov 2026 20:01:36 +0000 Subject: [PATCH 12/16] fix(workflow): await the system proxy change SetSystemProxy only published an event and returned, so the next workflow step ran before the OS proxy actually changed. Inject the status bar's listener routine (now public) and await it, the same way subscription updates are awaited. Co-authored-by: openhands --- .../ServiceLib/ViewModels/MainWindowViewModel.cs | 3 ++- v2rayN/ServiceLib/ViewModels/StatusBarViewModel.cs | 6 +++++- v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs | 14 +++++++++----- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs index 16ca8d2dc0d..79bb579b85a 100644 --- a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs @@ -615,7 +615,8 @@ private async Task WorkflowAsync() /// private void RefreshWorkflowRunner() { - WorkflowRunner.Instance.Register(new WorkflowRuntime(ProfilesViewModel, UpdateSubscriptionProcess)); + WorkflowRunner.Instance.Register(new WorkflowRuntime( + ProfilesViewModel, UpdateSubscriptionProcess, StatusBarViewModel.SetListenerType)); } #endregion Subscription diff --git a/v2rayN/ServiceLib/ViewModels/StatusBarViewModel.cs b/v2rayN/ServiceLib/ViewModels/StatusBarViewModel.cs index 91895e3fa42..079f5ac41d7 100644 --- a/v2rayN/ServiceLib/ViewModels/StatusBarViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/StatusBarViewModel.cs @@ -360,7 +360,11 @@ public async Task TestServerAvailabilityResult(string msg) #region System proxy and Routings - private async Task SetListenerType(ESysProxyType type) + /// + /// Apply a system proxy mode and persist it. Public so a workflow can await the actual + /// OS proxy change instead of only publishing the request event. + /// + public async Task SetListenerType(ESysProxyType type) { if (_config.SystemProxyItem.SysProxyType == type) { diff --git a/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs b/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs index 85993aad941..eb90f4b6f20 100644 --- a/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs +++ b/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs @@ -2,9 +2,14 @@ namespace ServiceLib.ViewModels; /// /// Bridges to the profile and status bar view models. -/// Created by the UI layer once the main view models exist. +/// Created by the UI layer once the main view models exist. Every operation is awaited to +/// completion, including the core reload after activation and the OS proxy change, so a +/// following step never runs against a half-applied state. /// -public sealed class WorkflowRuntime(ProfilesViewModel profiles, Func updateSubscriptions) : IWorkflowRuntime +public sealed class WorkflowRuntime( + ProfilesViewModel profiles, + Func updateSubscriptions, + Func setSystemProxy) : IWorkflowRuntime { public async Task UpdateSubscriptions(string? subId, bool viaProxy) { @@ -40,9 +45,8 @@ public async Task ActivateServer(string? subId, EServerSelectType selectType) await profiles.SetDefaultServerForGroupAsync(subId, selectType); } - public Task SetSystemProxy(ESysProxyType type) + public async Task SetSystemProxy(ESysProxyType type) { - AppEvents.SysProxyChangeRequested.Publish(type); - return Task.CompletedTask; + await setSystemProxy(type); } } From 73528593efa6d5e1d6bd7db7426d872a2588734e Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 3 Nov 2026 20:01:42 +0000 Subject: [PATCH 13/16] feat(workflow): move the workflow menu to the main interface The workflow menu lived inside the Subscription menu, but workflows are an independent feature. Give it its own top-level toolbar menu in both the WPF and Avalonia main windows, matching the other top-level entries. The ProfilesView toolbar button is unchanged. Co-authored-by: openhands --- v2rayN/v2rayN.Desktop/Views/MainWindow.axaml | 4 ++-- v2rayN/v2rayN/Views/MainWindow.xaml | 22 +++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml index 53bd789d428..a3b26c83149 100644 --- a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml @@ -61,10 +61,10 @@ - - + + diff --git a/v2rayN/v2rayN/Views/MainWindow.xaml b/v2rayN/v2rayN/Views/MainWindow.xaml index 88267c9409f..3f3492154e8 100644 --- a/v2rayN/v2rayN/Views/MainWindow.xaml +++ b/v2rayN/v2rayN/Views/MainWindow.xaml @@ -172,11 +172,23 @@ x:Name="menuSubGroupUpdateViaProxy" Height="{StaticResource MenuItemHeight}" Header="{x:Static resx:ResUI.menuSubGroupUpdateViaProxy}" /> - - + + + + + + + + + + + From 2136af20fc41efa125eab2493c89d45ebdeef9a1 Mon Sep 17 00:00:00 2001 From: 2dust <31833384+2dust@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:22:55 +0800 Subject: [PATCH 14/16] 1 --- v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx index 9fbdc952c39..b7cc8967b98 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx @@ -1915,4 +1915,7 @@ 预解析节点域名 - + + 工作流 + + \ No newline at end of file From 1dde49dc79c8b7caf290975908a5b4c88d02cd21 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 6 Nov 2026 14:30:03 +0000 Subject: [PATCH 15/16] refactor(workflow): drop the workflow button from the profiles toolbar The workflow menu is now a top-level entry in the main interface, so the profiles toolbar button is a second entry point for the same action. Remove it from both the WPF and the Avalonia profile views, along with the now unused command bindings. Co-authored-by: openhands --- v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml | 10 ---------- v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs | 1 - v2rayN/v2rayN/Views/ProfilesView.xaml | 11 ----------- v2rayN/v2rayN/Views/ProfilesView.xaml.cs | 1 - 4 files changed, 23 deletions(-) diff --git a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml index 8066c037333..edf59e4088a 100644 --- a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml +++ b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml @@ -102,16 +102,6 @@ - - vm.ServerFilter, v => v.txtServerFilter.Text).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddSubCmd, v => v.btnAddSub).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.EditSubCmd, v => v.btnEditSub).DisposeWith(disposables); - this.BindCommand(ViewModel, vm => vm.WorkflowCmd, v => v.btnWorkflow).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.EditSubCmd, v => v.menuSubEdit).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddSubCmd, v => v.menuSubAdd).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.DeleteSubCmd, v => v.menuSubDelete).DisposeWith(disposables); diff --git a/v2rayN/v2rayN/Views/ProfilesView.xaml b/v2rayN/v2rayN/Views/ProfilesView.xaml index 4fe9ab0c4ed..a92b74323ed 100644 --- a/v2rayN/v2rayN/Views/ProfilesView.xaml +++ b/v2rayN/v2rayN/Views/ProfilesView.xaml @@ -56,17 +56,6 @@ ToolTip="{x:Static resx:ResUI.menuSubAdd}"> - - vm.ServerFilter, v => v.txtServerFilter.Text).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddSubCmd, v => v.btnAddSub).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.EditSubCmd, v => v.btnEditSub).DisposeWith(disposables); - this.BindCommand(ViewModel, vm => vm.WorkflowCmd, v => v.btnWorkflow).DisposeWith(disposables); //servers delete this.BindCommand(ViewModel, vm => vm.EditServerCmd, v => v.menuEditServer).DisposeWith(disposables); From a5f9660a0b53c3ee69f34cf36a948b8ef44ce636 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 6 Nov 2026 14:30:05 +0000 Subject: [PATCH 16/16] refactor(workflow): move runtime and runner into Services and Manager WorkflowRuntime is a service, so move it to Services as WorkflowRuntimeService. WorkflowRunner is a singleton, so move it to Manager as WorkflowRunnerManager. Update the call sites and the log category to the new names. Co-authored-by: openhands --- .../WorkflowRunnerManager.cs} | 10 +++++----- .../WorkflowRuntimeService.cs} | 6 ++++-- v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs | 2 +- v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) rename v2rayN/ServiceLib/{ViewModels/WorkflowRunner.cs => Manager/WorkflowRunnerManager.cs} (64%) rename v2rayN/ServiceLib/{ViewModels/WorkflowRuntime.cs => Services/WorkflowRuntimeService.cs} (94%) diff --git a/v2rayN/ServiceLib/ViewModels/WorkflowRunner.cs b/v2rayN/ServiceLib/Manager/WorkflowRunnerManager.cs similarity index 64% rename from v2rayN/ServiceLib/ViewModels/WorkflowRunner.cs rename to v2rayN/ServiceLib/Manager/WorkflowRunnerManager.cs index 7d140a5516b..83f2ac9086c 100644 --- a/v2rayN/ServiceLib/ViewModels/WorkflowRunner.cs +++ b/v2rayN/ServiceLib/Manager/WorkflowRunnerManager.cs @@ -1,14 +1,14 @@ -namespace ServiceLib.ViewModels; +namespace ServiceLib.Manager; /// /// Holds the used when a workflow runs. The UI layer /// registers it after the main view models are built; a registration failure is /// surfaced instead of silently doing nothing. /// -public sealed class WorkflowRunner +public sealed class WorkflowRunnerManager { - private static readonly Lazy _instance = new(() => new()); - public static WorkflowRunner Instance => _instance.Value; + private static readonly Lazy _instance = new(() => new()); + public static WorkflowRunnerManager Instance => _instance.Value; private IWorkflowRuntime? _runtime; @@ -22,7 +22,7 @@ public async Task Run(WorkflowItem workflow) if (_runtime is null) { NoticeManager.Instance.Enqueue(ResUI.OperationFailed); - Logging.SaveLog("WorkflowRunner", new InvalidOperationException("Workflow runtime is not registered.")); + Logging.SaveLog("WorkflowRunnerManager", new InvalidOperationException("Workflow runtime is not registered.")); return; } await WorkflowHandler.Run(workflow, _runtime); diff --git a/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs b/v2rayN/ServiceLib/Services/WorkflowRuntimeService.cs similarity index 94% rename from v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs rename to v2rayN/ServiceLib/Services/WorkflowRuntimeService.cs index eb90f4b6f20..c3761e5f8a6 100644 --- a/v2rayN/ServiceLib/ViewModels/WorkflowRuntime.cs +++ b/v2rayN/ServiceLib/Services/WorkflowRuntimeService.cs @@ -1,4 +1,6 @@ -namespace ServiceLib.ViewModels; +using ServiceLib.ViewModels; + +namespace ServiceLib.Services; /// /// Bridges to the profile and status bar view models. @@ -6,7 +8,7 @@ namespace ServiceLib.ViewModels; /// completion, including the core reload after activation and the OS proxy change, so a /// following step never runs against a half-applied state. /// -public sealed class WorkflowRuntime( +public sealed class WorkflowRuntimeService( ProfilesViewModel profiles, Func updateSubscriptions, Func setSystemProxy) : IWorkflowRuntime diff --git a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs index 79bb579b85a..a43687c43c1 100644 --- a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs @@ -615,7 +615,7 @@ private async Task WorkflowAsync() /// private void RefreshWorkflowRunner() { - WorkflowRunner.Instance.Register(new WorkflowRuntime( + WorkflowRunnerManager.Instance.Register(new WorkflowRuntimeService( ProfilesViewModel, UpdateSubscriptionProcess, StatusBarViewModel.SetListenerType)); } diff --git a/v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs b/v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs index d9ec0717521..b9c59079895 100644 --- a/v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/WorkflowViewModel.cs @@ -117,6 +117,6 @@ private async Task RunWorkflowAsync() NoticeManager.Instance.Enqueue(ResUI.WorkflowRunSelectTip); return; } - await WorkflowRunner.Instance.Run(item); + await WorkflowRunnerManager.Instance.Run(item); } }