Files
ShadowsocksX-NG/ShadowsocksX-NG2/Domain/CustomRuleEditing.swift
T
Qiu Yuzhou 17672ff655 feat(rules): add custom rule editor (#79)
Add versioned sheet-local drafts, normalized previews, custom identity validation,
and explicit saves through the existing rule-document transaction. Preserve UUIDs,
builtin memberships, disabled identities, and deployment recovery outcomes.

Include approved Chinese and English editor copy. Verify public Workflow commands,
the production Controller adapter with hostless tests, and compiled bilingual
resources. Visual acceptance remains manual.
2026-10-03 00:47:06 +08:00

100 lines
3.8 KiB
Swift

import Foundation
/// A sheet-local draft bound to saved facts, never part of the browsing snapshot.
struct CustomRuleDraft: Equatable, Sendable, Identifiable {
enum Kind: CaseIterable, Sendable { case ipAddress, cidr, domainSuffix, domainExact }
let id: UUID
let editingID: UUID?
let version: String
var kind: Kind = .domainSuffix
var content = ""
var action: RuleAction = .proxy
func normalizedMatch() throws -> RuleMatch {
let value = content.trimmingCharacters(in: .whitespacesAndNewlines)
switch kind {
case .ipAddress, .cidr:
guard
kind == .ipAddress
? !value.contains("/")
: value.split(separator: "/", omittingEmptySubsequences: false).count == 2,
!value.hasPrefix("/"), !value.hasSuffix("/")
else { throw RuleMatchError.invalidCIDR(value) }
return try value.contains(":") ? RuleMatch(ipv6CIDR: value) : RuleMatch(ipv4CIDR: value)
case .domainExact: return try RuleMatch(domainExact: value)
case .domainSuffix: return try RuleMatch(nationalDomainSuffix: value)
}
}
}
struct CustomRulePreview: Equatable, Sendable {
enum Failure: Equatable, Sendable {
case incompleteCollection, busy, staleDraft
case invalidInput(CustomRuleDraft.Kind)
case duplicate, fixedLocalConflict, fixedPolicyDisablement
}
var failure: Failure?
var rule: CustomRule?
var row: RulesRow?
var inheritsDisablement = false
var document: CustomRuleDocument?
var displayContent: String? { rule?.identity.match.editingContent }
}
enum CustomRuleSaveResult: Equatable, Sendable {
case unavailable(CustomRulePreview.Failure)
case committed(CustomRuleUpdateOutcome)
}
extension RuleMatch {
var editingKind: CustomRuleDraft.Kind {
switch self {
case .domainExact: .domainExact
case .domainSuffix: .domainSuffix
case .ipv4CIDR(let value): value.hasSuffix("/32") ? .ipAddress : .cidr
case .ipv6CIDR(let value): value.hasSuffix("/128") ? .ipAddress : .cidr
}
}
var editingContent: String {
if editingKind == .ipAddress { return String(browsingContent.split(separator: "/")[0]) }
return browsingContent
}
}
extension RulesCollection {
func previewCustomRule(_ draft: CustomRuleDraft) -> CustomRulePreview {
guard let document = userDocument else {
return CustomRulePreview(failure: .incompleteCollection)
}
let match: RuleMatch
do { match = try draft.normalizedMatch() } catch {
return CustomRulePreview(failure: .invalidInput(draft.kind))
}
let previous = document.rules.first { $0.id == draft.editingID }
guard draft.editingID == nil || previous != nil else {
return CustomRulePreview(failure: .staleDraft)
}
let rule = CustomRule(
id: draft.id, action: draft.action, match: match, source: previous?.source)
let others = document.rules.filter { $0.id != draft.editingID }
guard !others.contains(where: { $0.identity == rule.identity }) else {
return CustomRulePreview(failure: .duplicate, rule: rule)
}
guard CustomRuleValidator.fixedLocalRejection(for: rule) == nil else {
return CustomRulePreview(failure: .fixedLocalConflict, rule: rule)
}
var disabled = document.disabledIdentities
let inherits = previous.map { disabled.contains($0.identity) } ?? false
if inherits {
guard rule.action != .direct || !RuleCoverage.fixedLocalMatches.contains(rule.identity.match)
else { return CustomRulePreview(failure: .fixedPolicyDisablement, rule: rule) }
disabled.insert(rule.identity)
}
let updated = CustomRuleDocument(rules: others + [rule], disabledIdentities: disabled)
return CustomRulePreview(
rule: rule, row: previewRow(identity: rule.identity, document: updated),
inheritsDisablement: inherits && previous?.identity != rule.identity, document: updated)
}
}