1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
require "nokogiri"
module ProTacts
# Renders the configuration profile that provisions the pro-tacts CardDAV
# 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.
#
# 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
IDENTIFIER_PREFIX = "dev.kejadlen.pro-tacts.carddav"
HEX = "0123456789abcdef"
# The username and password are a throwaway fictional pair and the server
# ignores them: identity comes from the Tailscale headers that serve
# injects (see ProTacts::TailscaleAuth). They stay in the template
# because the account form expects the fields; dropping them is
# untested.
#: (hostname: String) -> String
def self.render(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.
# Scans for the prefix anywhere in the output rather than assuming a
# key-value layout, since the listing format has changed across macOS
# versions (key-value today, table under later releases).
#: (String list_output) -> Array[String]
def self.installed_identifiers(list_output)
# A pattern with no groups scans to whole matches, which is
# narrower than the signature of String#scan can say.
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>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>alpha@example.com</string>
<key>CardDAVPassword</key>
<string>carddav-dev</string>
<key>CardDAVUseSSL</key>
<true/>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>pro-tacts 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)}"
end
#: () -> String
def self.uuid
[8, 4, 4, 4, 12].map { |n| Array.new(n) { HEX[rand(16)] }.join }.join("-")
end
end
end