Provision the macOS account with a configuration profile
rake profile renders carddav.mobileconfig (com.apple.carddav.account
payload, keys verified against the device-management reference, which
allows manual install so no MDM is involved). Fixed payload
identifiers make reinstall replace in place and removal
deterministic, so the remove-and-re-add rounds of the minimization
loop are one command each way.
Assisted-by: GLM 5.2 via pi
diff --git a/.gitignore b/.gitignore
index 0e3001d..2b5a195 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
/log
/servers
+/carddav.mobileconfig
diff --git a/Rakefile b/Rakefile
index 09b9c1a..a7a0989 100644
--- a/Rakefile
+++ b/Rakefile
@@ -18,4 +18,24 @@ task :fixtures do
ExchangeFixtures.record_responses(ProTacts::Web)
end
+desc "Generate carddav.mobileconfig to provision the macOS account"
+task :profile do
+ $LOAD_PATH.unshift(File.expand_path("lib", __dir__))
+ require "pro_tacts/profile"
+
+ File.write("carddav.mobileconfig", ProTacts::Profile.render(
+ hostname: ENV.fetch("PRO_TACTS_HOSTNAME"),
+ username: ENV.fetch("PRO_TACTS_USERNAME", "a@b.com"),
+ password: ENV.fetch("PRO_TACTS_PASSWORD", "a")
+ ))
+ identifier = ProTacts::Profile::PAYLOAD_IDENTIFIER
+ puts <<~MESSAGE
+ Wrote carddav.mobileconfig. Install:
+ sudo profiles install -type configuration -path carddav.mobileconfig
+ Remove:
+ sudo profiles remove -identifier #{identifier}
+ Recent macOS may ask you to approve the profile in System Settings → Profiles.
+ MESSAGE
+end
+
task default: :test
diff --git a/docs/macos-contacts.md b/docs/macos-contacts.md
index 034d6df..505f514 100644
--- a/docs/macos-contacts.md
+++ b/docs/macos-contacts.md
@@ -26,6 +26,24 @@ refusing a redirect) that never reach the server at all.
## The account setup path
+The fastest path is a configuration profile: `rake profile` (with
+`PRO_TACTS_HOSTNAME` set) writes `carddav.mobileconfig`, then
+
+```sh
+sudo profiles install -type configuration -path carddav.mobileconfig
+sudo profiles remove -identifier dev.kejadlen.pro-tacts.carddav
+```
+
+adds and removes the account. The profile carries the hostname,
+credentials, and SSL — `CardDAVPrincipalURL` is deliberately omitted so the
+account gets an empty Server Path, exercising discovery. Fixed payload
+identifiers mean a reinstall replaces the account in place, and removal is
+what resets the client's cached discovery results. Recent macOS may stage
+the profile as pending until you approve it once in System Settings →
+Profiles. Apple's device-management reference marks the CardDAV payload as
+allowing manual install, so no MDM is involved.
+
+The manual alternative, for cross-checking when the profile path misbehaves:
In System Settings, add the account under Internet Accounts, Add Other
Account, CardDAV account, with Account Type set to Manual. Manual matters:
automatic setup runs its own discovery and fails in ways that are harder to
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
new file mode 100644
index 0000000..6076fb9
--- /dev/null
+++ b/lib/pro_tacts/profile.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require "nokogiri"
+
+module ProTacts
+ # Renders the configuration profile that provisions the pro-tacts CardDAV
+ # account on macOS, so the resync loop is `rake profile` plus one
+ # `profiles install` instead of the Internet Accounts dance. Payload keys
+ # per Apple's Device Management reference for com.apple.carddav.account.
+ #
+ # UUIDs are fixed so reinstalling replaces the profile in place and
+ # `profiles remove -identifier` always targets the same one.
+ class Profile
+ PAYLOAD_IDENTIFIER = "dev.kejadlen.pro-tacts.carddav"
+ TOP_LEVEL_UUID = "6F1E2D3C-4B5A-4E7F-8C9D-0A1B2C3D4E5F"
+ PAYLOAD_UUID = "7A2F3E4D-5C6B-4F80-9DAE-1B2C3D4E5F6A"
+
+ def self.render(hostname:, username:, password:)
+ template % {
+ hostname: escape(hostname),
+ username: escape(username),
+ password: escape(password)
+ }
+ end
+
+ 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>#{PAYLOAD_IDENTIFIER}.account</string>
+ <key>PayloadUUID</key>
+ <string>#{PAYLOAD_UUID}</string>
+ <key>PayloadDisplayName</key>
+ <string>pro-tacts</string>
+ <key>PayloadOrganization</key>
+ <string>pro-tacts</string>
+ <key>CardDAVAccountDescription</key>
+ <string>pro-tacts</string>
+ <key>CardDAVHostName</key>
+ <string>%{hostname}</string>
+ <key>CardDAVUsername</key>
+ <string>%{username}</string>
+ <key>CardDAVPassword</key>
+ <string>%{password}</string>
+ <key>CardDAVUseSSL</key>
+ <true/>
+ </dict>
+ </array>
+ <key>PayloadDisplayName</key>
+ <string>pro-tacts CardDAV</string>
+ <key>PayloadIdentifier</key>
+ <string>#{PAYLOAD_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.
+
+ def self.escape(text)
+ text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
+ end
+ end
+end
diff --git a/test/pro_tacts/test_profile.rb b/test/pro_tacts/test_profile.rb
new file mode 100644
index 0000000..004c68c
--- /dev/null
+++ b/test/pro_tacts/test_profile.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require_relative "../test_helper"
+
+require "pro_tacts/profile"
+
+class ProfileTest < Minitest::Test
+ def render(hostname: "example.ts.net", username: "a@b.com", password: "a")
+ ProTacts::Profile.render(hostname:, username:, password:)
+ end
+
+ def test_is_well_formed_xml
+ refute_nil Nokogiri::XML(render).root
+ end
+
+ def test_carries_the_carddav_payload_type
+ assert_includes render, "<string>com.apple.carddav.account</string>"
+ end
+
+ def test_embeds_credentials_and_hostname
+ xml = render
+
+ assert_includes xml, "<string>example.ts.net</string>"
+ assert_includes xml, "<string>a@b.com</string>"
+ assert_includes xml, "<string>a</string>"
+ end
+
+ def test_enables_ssl
+ assert_match(/<key>CardDAVUseSSL<\/key>\s*<true\/>/, render)
+ end
+
+ def test_omits_principal_url_to_leave_server_path_empty
+ refute_includes render, "CardDAVPrincipalURL"
+ end
+
+ def test_identifiers_are_stable_across_renders
+ assert_equal render, render
+ assert_includes render, ProTacts::Profile::PAYLOAD_IDENTIFIER
+ end
+
+ def test_escapes_xml_in_field_values
+ xml = render(hostname: "a&b.ts.net")
+
+ assert_includes xml, "<string>a&b.ts.net</string>"
+ refute_includes xml, "<string>a&b.ts.net</string>"
+ assert_empty Nokogiri::XML(xml).errors
+ end
+end