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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
    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