Files
vphone-cli/VPhoneExecutable/VPhoneCommand/VPhonePatchKit/PatchSet/VPhonePatchSetManifest.swift
T
LakrandClaude Opus 5.5 ae5e4531a5 Name every patch {component}-{effect}-{name}
The 117 bundled patch identifiers had grown five naming schemes
(kernel.x, jb.x, kernelcache_jb.x, txm_dev.x, bare names). Each one is now
{component}-{effect}-{name}:

- component: avpbooter, ibss, ibec, llb, txm, kernel, devicetree, dyld,
  preboot, or system-<binary> for a guest binary or file.
- effect: boot when the patch is boot-essential, exp when the standard
  preset leaves it off, cfw otherwise. A catalog test enforces this.
- name: snake_case, no hyphen, so the identifier splits from the right.

Record sites are now always <identifier>.<site>. The underscore-prefix
rule in covers(recordIdentifier:) and in the gate is gone: the new names
contain underscores, so kernel-boot-post_validation would otherwise have
covered kernel-boot-post_validation_unsigned. The 25 records that relied
on it (amfi_trustcache_1, launch_constraints_mov, sandbox_ext_N, ...) now
use a dot.

Old identifiers are not migrated. A VM whose PatchPlan or PatchSelection
names one must be patched again. The bundle becomes 2.2.0 and Launchpad
requires 2.2.0, so it never meets an old identifier from a bundle.

Launchpad's patch table shows Component, Effect and Name columns in place
of Identifier and Patch Set; the set moves to the detail line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:20:20 +09:00

176 lines
7.1 KiB
Swift

// VPhonePatchSetManifest.swift — What one patch set declares about itself.
//
// Every patch set — the ones built into the bundle and the ones loaded from a
// `.vphonepatchset` — carries this as `Contents/Resources/Manifest.plist`. It is
// read before any code from the set runs, so the resolver can reject a broken
// combination without loading a single patch.
import Foundation
public struct VPhonePatchSetManifest: Sendable, Hashable, Codable, Identifiable {
/// Reverse-DNS identity, unique across a plan. Also an implicit capability.
public var identifier: String
/// Display name for a preset picker.
public var name: String
/// The set's own version, for logs and bug reports.
public var version: String
/// One line on what the set is for.
public var summary: String
/// The PatchKit API this set needs. A newer requirement is refused.
public var minimumPatchKitVersion: VPhoneVersion
/// Every patch the set can apply, in the order it applies them.
public var patches: [VPhonePatchDeclaration]
/// Capabilities that must be present, provided by this set or another.
public var requires: [String]
/// Capability names this set supplies beyond its own identifier.
public var provides: [String]
/// Sets or capabilities this one cannot sit beside. Enforced symmetrically:
/// either side naming the other is a conflict, so a set need not be patched
/// to learn about a newer rival.
public var conflictsWith: [String]
/// Sets or capabilities that must run before this one, when both are present.
public var after: [String]
public var id: String {
identifier
}
public init(
identifier: String,
name: String,
version: String = "1.0",
summary: String = "",
minimumPatchKitVersion: VPhoneVersion = VPhoneVersion(major: 1, minor: 0),
patches: [VPhonePatchDeclaration],
requires: [String] = [],
provides: [String] = [],
conflictsWith: [String] = [],
after: [String] = [],
) {
self.identifier = identifier
self.name = name
self.version = version
self.summary = summary
self.minimumPatchKitVersion = minimumPatchKitVersion
self.patches = patches
self.requires = requires
self.provides = provides
self.conflictsWith = conflictsWith
self.after = after
}
/// The set's identity plus everything it declares it provides.
public var capabilities: Set<String> {
Set(provides).union([identifier])
}
/// The declaration owning `recordIdentifier`, if this set emits it.
public func declaration(coveringRecord recordIdentifier: String) -> VPhonePatchDeclaration? {
// Longest identifier first, so `kernel-cfw-debugger.ret` is attributed to
// `kernel-cfw-debugger` rather than to a shorter umbrella.
patches
.filter { $0.covers(recordIdentifier: recordIdentifier) }
.max { $0.identifier.count < $1.identifier.count }
}
private enum CodingKeys: String, CodingKey {
case identifier = "Identifier"
case name = "Name"
case version = "Version"
case summary = "Summary"
case minimumPatchKitVersion = "MinimumPatchKitVersion"
case patches = "Patches"
case requires = "Requires"
case provides = "Provides"
case conflictsWith = "ConflictsWith"
case after = "After"
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
identifier = try container.decode(String.self, forKey: .identifier)
name = try container.decode(String.self, forKey: .name)
version = try container.decodeIfPresent(String.self, forKey: .version) ?? "1.0"
summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? ""
minimumPatchKitVersion = try container.decodeIfPresent(
VPhoneVersion.self,
forKey: .minimumPatchKitVersion,
) ?? VPhoneVersion(major: 1, minor: 0)
patches = try container.decode([VPhonePatchDeclaration].self, forKey: .patches)
requires = try container.decodeIfPresent([String].self, forKey: .requires) ?? []
provides = try container.decodeIfPresent([String].self, forKey: .provides) ?? []
conflictsWith = try container.decodeIfPresent([String].self, forKey: .conflictsWith) ?? []
after = try container.decodeIfPresent([String].self, forKey: .after) ?? []
guard !identifier.isEmpty else {
throw DecodingError.dataCorruptedError(
forKey: .identifier,
in: container,
debugDescription: "A patch set identifier is what a preset names; it cannot be empty",
)
}
var seen = Set<String>()
for patch in patches where !seen.insert(patch.identifier).inserted {
throw DecodingError.dataCorruptedError(
forKey: .patches,
in: container,
debugDescription: "\(identifier) declares \(patch.identifier) twice",
)
}
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(identifier, forKey: .identifier)
try container.encode(name, forKey: .name)
try container.encode(version, forKey: .version)
if !summary.isEmpty {
try container.encode(summary, forKey: .summary)
}
try container.encode(minimumPatchKitVersion, forKey: .minimumPatchKitVersion)
try container.encode(patches, forKey: .patches)
if !requires.isEmpty {
try container.encode(requires, forKey: .requires)
}
if !provides.isEmpty {
try container.encode(provides, forKey: .provides)
}
if !conflictsWith.isEmpty {
try container.encode(conflictsWith, forKey: .conflictsWith)
}
if !after.isEmpty {
try container.encode(after, forKey: .after)
}
}
}
// MARK: - Plist IO
public extension VPhonePatchSetManifest {
/// The path a patch-set bundle keeps its manifest at.
static let resourceName = "Manifest.plist"
/// Read a manifest from a `.vphonepatchset` bundle directory.
static func read(fromBundle bundle: URL) throws -> VPhonePatchSetManifest {
let url = bundle
.appendingPathComponent("Contents", isDirectory: true)
.appendingPathComponent("Resources", isDirectory: true)
.appendingPathComponent(resourceName)
guard FileManager.default.fileExists(atPath: url.path) else {
throw PatcherError.fileNotFound(url.path)
}
return try decode(Data(contentsOf: url))
}
static func decode(_ data: Data) throws -> VPhonePatchSetManifest {
try PropertyListDecoder().decode(VPhonePatchSetManifest.self, from: data)
}
/// Encode as an XML plist, the form a patch set checks into source control.
func encodedPlist() throws -> Data {
let encoder = PropertyListEncoder()
encoder.outputFormat = .xml
return try encoder.encode(self)
}
}