Give each rendered profile a fresh identity
The account follows the profile identifier, and the fixed id let
cached sync state survive remove/reinstall (round 3: the client kept
the old card and skipped fetching). Every render now gets a unique
identifier and UUIDs, so each install provisions a cold account;
profile:install renders directly since mtimes cannot express identity
staleness, and profile:remove sweeps every profile with our prefix
from profiles list output.
Assisted-by: GLM 5.2 via pi
diff --git a/Rakefile b/Rakefile
index 5195d22..d8321c8 100644
--- a/Rakefile
+++ b/Rakefile
@@ -33,16 +33,29 @@ file "carddav.mobileconfig" => "lib/pro_tacts/profile.rb" do |task|
end
namespace :profile do
- desc "Stage the configuration profile and open Settings → Profiles; click Install there"
- task install: "carddav.mobileconfig" do |task|
- sh "open", task.prerequisites.first
+ # A fresh identity per install is the point, so this renders directly
+ # instead of going through the mtime-based file task.
+ desc "Stage a fresh configuration profile; approve it in System Settings → Profiles"
+ task :install do
+ require "pro_tacts/profile"
+
+ File.write("carddav.mobileconfig", ProTacts::Profile.render(
+ hostname: ENV.fetch("PRO_TACTS_HOSTNAME")
+ ))
+ sh "open", "carddav.mobileconfig"
sh "open", "x-apple.systempreferences:com.apple.preferences.configurationprofiles"
end
- desc "Remove the configuration profile"
+ desc "Remove every installed pro-tacts configuration profile"
task :remove do
require "pro_tacts/profile"
- sh "profiles", "remove", "-identifier", ProTacts::Profile::PAYLOAD_IDENTIFIER
+
+ identifiers = ProTacts::Profile.installed_identifiers(`profiles list`)
+ if identifiers.empty?
+ puts "No pro-tacts profiles found; remove by hand in System Settings → Profiles if one lingers."
+ else
+ identifiers.each { |identifier| sh "profiles", "remove", "-identifier", identifier }
+ end
end
end
diff --git a/docs/macos-contacts.md b/docs/macos-contacts.md
index a65cc1c..8976690 100644
--- a/docs/macos-contacts.md
+++ b/docs/macos-contacts.md
@@ -37,9 +37,12 @@ same Settings pane.
The profile carries the hostname, fixed dev 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. Apple's device-management
+account gets an empty Server Path, exercising discovery. Every render gets a
+fresh identifier and UUIDs, so each install provisions a cold account with
+no cached sync state — that is deliberate for the experiment loop. The flip
+side: installing without removing first orphans the previous account, so
+`rake profile:remove` sweeps every profile carrying the pro-tacts prefix
+by parsing `profiles list`. Apple's device-management
reference marks the CardDAV payload as allowing manual install, so no MDM is
involved.
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index c689d57..3ca188e 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -4,21 +4,39 @@ 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.
+ # account on macOS, so the resync loop is one rake command 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.
+ # Every render carries a fresh identifier and fresh UUIDs: the account
+ # identity follows the profile, so each install provisions a cold account
+ # with no cached sync state — exactly what the experiment loop needs. The
+ # cost is that reinstalling without removing first orphans the old account;
+ # rake profile:remove sweeps every profile carrying our prefix.
class Profile
- PAYLOAD_IDENTIFIER = "dev.kejadlen.pro-tacts.carddav"
- TOP_LEVEL_UUID = "6F1E2D3C-4B5A-4E7F-8C9D-0A1B2C3D4E5F"
- PAYLOAD_UUID = "7A2F3E4D-5C6B-4F80-9DAE-1B2C3D4E5F6A"
+ IDENTIFIER_PREFIX = "dev.kejadlen.pro-tacts.carddav"
+ HEX = "0123456789abcdef"
# Username and password are a throwaway fictional pair, inlined in the
# template. Real auth is its own backlog task.
def self.render(hostname:)
- template % { hostname: escape(hostname) }
+ identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
+
+ template % {
+ hostname: escape(hostname),
+ identifier:,
+ account_identifier: "#{identifier}.account",
+ top_level_uuid: uuid,
+ payload_uuid: uuid
+ }
+ end
+
+ # Picks our profile identifiers out of `profiles list` output so
+ # profile:remove can sweep every pro-tacts profile, not just the latest.
+ def self.installed_identifiers(list_output)
+ list_output.scan(/^\s*identifier:\s*(\S+)/).flatten
+ .select { |identifier| identifier.start_with?(IDENTIFIER_PREFIX) }
+ .uniq
end
def self.template
@@ -35,9 +53,9 @@ module ProTacts
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
- <string>#{PAYLOAD_IDENTIFIER}.account</string>
+ <string>%{account_identifier}</string>
<key>PayloadUUID</key>
- <string>#{PAYLOAD_UUID}</string>
+ <string>%{payload_uuid}</string>
<key>PayloadDisplayName</key>
<string>pro-tacts</string>
<key>PayloadOrganization</key>
@@ -57,7 +75,7 @@ module ProTacts
<key>PayloadDisplayName</key>
<string>pro-tacts CardDAV</string>
<key>PayloadIdentifier</key>
- <string>#{PAYLOAD_IDENTIFIER}</string>
+ <string>%{identifier}</string>
<key>PayloadOrganization</key>
<string>pro-tacts</string>
<key>PayloadRemovalDisallowed</key>
@@ -67,7 +85,7 @@ module ProTacts
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
- <string>#{TOP_LEVEL_UUID}</string>
+ <string>%{top_level_uuid}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
@@ -81,5 +99,13 @@ module ProTacts
def self.escape(text)
text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
end
+
+ def self.unique_hex
+ "#{Time.now.utc.strftime('%Y%m%d%H%M%S%L')}#{rand(1 << 16).to_s(16)}"
+ end
+
+ def self.uuid
+ [8, 4, 4, 4, 12].map { |n| Array.new(n) { HEX[rand(16)] }.join }.join("-")
+ end
end
end
diff --git a/test/pro_tacts/test_profile.rb b/test/pro_tacts/test_profile.rb
index bab2de6..0fd96e2 100644
--- a/test/pro_tacts/test_profile.rb
+++ b/test/pro_tacts/test_profile.rb
@@ -33,9 +33,43 @@ class ProfileTest < Minitest::Test
refute_includes render, "CardDAVPrincipalURL"
end
- def test_identifiers_are_stable_across_renders
- assert_equal render, render
- assert_includes render, ProTacts::Profile::PAYLOAD_IDENTIFIER
+ def test_identifiers_are_fresh_per_render
+ first, second = render, render
+
+ refute_equal first, second
+ assert_includes first, ProTacts::Profile::IDENTIFIER_PREFIX
+ assert_includes second, ProTacts::Profile::IDENTIFIER_PREFIX
+ end
+
+ def test_uuids_are_well_formed
+ xml = render
+
+ uuids = xml.scan(%r{<string>([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})</string>})
+ assert_equal 2, uuids.uniq.size
+ end
+
+ def test_installed_identifiers_picks_out_pro_tacts_profiles
+ list_output = <<~OUTPUT
+ _admin-Profiles-1
+ identifier: #{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818ab12
+
+ _admin-Profiles-2
+ identifier: com.example.unrelated
+
+ _admin-Profiles-3
+ identifier: #{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818cd34
+ OUTPUT
+
+ assert_equal [
+ "#{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818ab12",
+ "#{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818cd34"
+ ], ProTacts::Profile.installed_identifiers(list_output)
+ end
+
+ def test_installed_identifiers_is_empty_without_ours
+ list_output = " identifier: com.example.unrelated\n"
+
+ assert_empty ProTacts::Profile.installed_identifiers(list_output)
end
def test_escapes_xml_in_field_values