profile: build the plist with a builder, not a template
The template filled its blanks through an escape it rolled by hand;
now Nokogiri writes each string, and the plist parses the same, every key where it would stand.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index afc52d8..85a0062 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -53,16 +53,65 @@ module ProTacts
#: (hostname: String, username: String) -> String
def self.render(hostname:, username:)
identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
+ name = account_name
- template % {
- hostname: escape(hostname),
- username: escape(username),
- name: escape(account_name),
- identifier:,
- account_identifier: "#{identifier}.account",
- top_level_uuid: SecureRandom.uuid,
- payload_uuid: SecureRandom.uuid
- }
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |x|
+ x.doc.create_internal_subset(
+ "plist",
+ "-//Apple//DTD PLIST 1.0//EN",
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd",
+ )
+ x.plist(version: "1.0") do
+ x.dict do
+ x.key "PayloadContent"
+ x.array do
+ x.dict do
+ x.key "PayloadType"
+ x.string "com.apple.carddav.account"
+ x.key "PayloadVersion"
+ x.integer 1
+ x.key "PayloadIdentifier"
+ x.string "#{identifier}.account"
+ x.key "PayloadUUID"
+ x.string SecureRandom.uuid
+ x.key "PayloadDisplayName"
+ x.string name
+ x.key "PayloadOrganization"
+ x.string "pro-tacts"
+ x.key "CardDAVAccountDescription"
+ x.string name
+ x.key "CardDAVHostName"
+ x.string hostname
+ x.key "CardDAVUsername"
+ x.string username
+ x.key "CardDAVPassword"
+ x.string "carddav-dev"
+ x.key "CardDAVUseSSL"
+ x.true
+ # CardDAVPrincipalURL is omitted on purpose: no Server Path,
+ # matching the bare-hostname setup the working session used.
+ end
+ end
+ x.key "PayloadDisplayName"
+ x.string "#{name} CardDAV"
+ x.key "PayloadIdentifier"
+ x.string identifier
+ x.key "PayloadOrganization"
+ x.string "pro-tacts"
+ x.key "PayloadRemovalDisallowed"
+ x.false
+ x.key "PayloadScope"
+ x.string "User"
+ x.key "PayloadType"
+ x.string "Configuration"
+ x.key "PayloadUUID"
+ x.string SecureRandom.uuid
+ x.key "PayloadVersion"
+ x.integer 1
+ end
+ end
+ end
+ builder.to_xml
end
# Picks our profile identifiers out of `profiles list` output so
@@ -77,69 +126,6 @@ module ProTacts
list_output.scan(/(?<![\w.-])#{Regexp.escape(IDENTIFIER_PREFIX)}-[\w.-]+/).uniq #: Array[String]
end
- #: () -> String
- def self.template
- <<~XML
- <?xml version="1.0" encoding="UTF-8"?>
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
- <plist version="1.0">
- <dict>
- <key>PayloadContent</key>
- <array>
- <dict>
- <key>PayloadType</key>
- <string>com.apple.carddav.account</string>
- <key>PayloadVersion</key>
- <integer>1</integer>
- <key>PayloadIdentifier</key>
- <string>%{account_identifier}</string>
- <key>PayloadUUID</key>
- <string>%{payload_uuid}</string>
- <key>PayloadDisplayName</key>
- <string>%{name}</string>
- <key>PayloadOrganization</key>
- <string>pro-tacts</string>
- <key>CardDAVAccountDescription</key>
- <string>%{name}</string>
- <key>CardDAVHostName</key>
- <string>%{hostname}</string>
- <key>CardDAVUsername</key>
- <string>%{username}</string>
- <key>CardDAVPassword</key>
- <string>carddav-dev</string>
- <key>CardDAVUseSSL</key>
- <true/>
- </dict>
- </array>
- <key>PayloadDisplayName</key>
- <string>%{name} CardDAV</string>
- <key>PayloadIdentifier</key>
- <string>%{identifier}</string>
- <key>PayloadOrganization</key>
- <string>pro-tacts</string>
- <key>PayloadRemovalDisallowed</key>
- <false/>
- <key>PayloadScope</key>
- <string>User</string>
- <key>PayloadType</key>
- <string>Configuration</string>
- <key>PayloadUUID</key>
- <string>%{top_level_uuid}</string>
- <key>PayloadVersion</key>
- <integer>1</integer>
- </dict>
- </plist>
- XML
- end
-
- # CardDAVPrincipalURL is omitted on purpose: no Server Path, matching
- # the bare-hostname setup the working session used.
-
- #: (String text) -> String
- def self.escape(text)
- text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
- end
-
#: () -> String
def self.unique_hex
"#{Time.now.utc.strftime('%Y%m%d%H%M%S%L')}#{rand(1 << 16).to_s(16)}"
diff --git a/sig/gems/nokogiri.rbs b/sig/gems/nokogiri.rbs
index 6aa933b..0e31a1d 100644
--- a/sig/gems/nokogiri.rbs
+++ b/sig/gems/nokogiri.rbs
@@ -1,8 +1,8 @@
# The nokogiri surface pro-tacts uses to read REPORT bodies: parse, drop
# the namespaces, and pull elements out by xpath. And to write the DAV
-# bodies, through a Builder whose elements are method_missing calls,
-# which DavXml::Namespace#tag makes with send so nothing else needs
-# declaring.
+# bodies and the profile, through a Builder whose elements are
+# method_missing calls: DavXml::Namespace#tag makes its own with send,
+# and the plist elements Profile calls directly are declared below.
#
# `root` is declared non-nil because Nokogiri::XML only returns a
# document without one for input that is not XML at all, and web.rb
@@ -20,6 +20,7 @@ module Nokogiri
class Document < Node
def root: () -> Node?
def remove_namespaces!: () -> Document
+ def create_internal_subset: (String name, String external_id, String system_id) -> untyped
end
class NodeSet
@@ -31,7 +32,17 @@ module Nokogiri
class Builder
def self.new: (?encoding: String) { (Builder) -> void } -> Builder
def []: (String prefix) -> Builder
+ def doc: () -> Document
def to_xml: () -> String
+
+ def plist: (version: String) { () -> void } -> void
+ def dict: () { () -> void } -> void
+ def array: () { () -> void } -> void
+ def key: (String text) -> void
+ def string: (String text) -> void
+ def integer: (Integer value) -> void
+ def `true`: () -> void
+ def `false`: () -> void
end
end
end