Expand PROPFIND responses for macOS discovery
macOS stops after the first PROPFIND when the response omits
current-user-principal or getctag, so .well-known now answers PROPFIND and
the addressbook collection advertises getctag and honors Depth.

Assisted-by: Claude Opus 5 via Claude Code
change rplztkvkytonunpqvnvxlxnypkywulru
commit d864d78a6e71cf9626b0343095958813978fbae0
author Alpha Chen <alpha@kejadlen.dev>
date
parent xnotumvl
diff --git a/Gemfile b/Gemfile
index d502359..bf35619 100644
--- a/Gemfile
+++ b/Gemfile
@@ -3,6 +3,7 @@
 source "https://rubygems.org"
 
 gem "logger"
+gem "nokogiri"
 gem "puma"
 gem "rackup"
 gem "rake"
diff --git a/Gemfile.lock b/Gemfile.lock
index 7a2252e..2bdb2a1 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -14,6 +14,8 @@ GEM
     minitest (6.0.1)
       prism (~> 1.5)
     nio4r (2.7.5)
+    nokogiri (1.19.0-arm64-darwin)
+      racc (~> 1.4)
     pp (0.6.3)
       prettyprint
     prettyprint (0.2.0)
@@ -23,6 +25,7 @@ GEM
       stringio
     puma (7.1.0)
       nio4r (~> 2.0)
+    racc (1.8.1)
     rack (3.2.4)
     rack-test (2.2.0)
       rack (>= 1.3)
@@ -45,12 +48,12 @@ GEM
 
 PLATFORMS
   arm64-darwin-25
-  ruby
 
 DEPENDENCIES
   irb
   logger
   minitest
+  nokogiri
   puma
   rack-test
   rackup
@@ -68,11 +71,13 @@ CHECKSUMS
   logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
   minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
   nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
+  nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810
   pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
   prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
   prism (1.8.0) sha256=84453a16ef5530ea62c5f03ec16b52a459575ad4e7b9c2b360fd8ce2c39c1254
   psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
   puma (7.1.0) sha256=e45c10cb124f224d448c98db653a75499794edbecadc440ad616cf50f2fd49dd
+  racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
   rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6
   rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
   rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index b28eb4c..ca6f23a 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -69,13 +69,43 @@ module ProTacts
         end
       end
 
-      r.get ".well-known/carddav" do
-        r.redirect "/dav/principal/", 301
+      r.on ".well-known/carddav" do
+        r.propfind do
+          response["Content-Type"] = "text/xml"
+          response.status = 207
+
+          <<~XML
+            <?xml version="1.0" encoding="UTF-8"?>
+            <d:multistatus xmlns:d="DAV:">
+              <d:response>
+                <d:href>/.well-known/carddav</d:href>
+                <d:propstat>
+                  <d:prop>
+                    <d:current-user-principal>
+                      <d:href>/dav/principal/</d:href>
+                    </d:current-user-principal>
+                    <d:principal-URL>
+                      <d:href>/dav/principal/</d:href>
+                    </d:principal-URL>
+                    <d:resourcetype>
+                      <d:collection/>
+                    </d:resourcetype>
+                  </d:prop>
+                  <d:status>HTTP/1.1 200 OK</d:status>
+                </d:propstat>
+              </d:response>
+            </d:multistatus>
+          XML
+        end
+
+        r.get do
+          r.redirect "/dav/principal/", 301
+        end
       end
 
       r.on "dav" do
         r.options do
-          response["DAV"] = "1, 3, addressbook"
+          response["DAV"] = "1, 3, access-control, addressbook"
           response["Allow"] = "OPTIONS, PROPFIND, REPORT"
           ""
         end
@@ -92,9 +122,24 @@ module ProTacts
                   <d:href>/dav/principal/</d:href>
                   <d:propstat>
                     <d:prop>
+                      <d:displayname>Default Principal</d:displayname>
+                      <d:principal-URL>
+                        <d:href>/dav/principal/</d:href>
+                      </d:principal-URL>
+                      <d:resourcetype>
+                        <d:principal/>
+                      </d:resourcetype>
                       <card:addressbook-home-set>
                         <d:href>/dav/addressbook/</d:href>
                       </card:addressbook-home-set>
+                      <d:supported-report-set>
+                        <d:supported-report>
+                          <d:report><card:addressbook-multiget/></d:report>
+                        </d:supported-report>
+                        <d:supported-report>
+                          <d:report><card:addressbook-query/></d:report>
+                        </d:supported-report>
+                      </d:supported-report-set>
                     </d:prop>
                     <d:status>HTTP/1.1 200 OK</d:status>
                   </d:propstat>
@@ -106,17 +151,151 @@ module ProTacts
 
         r.on "addressbook" do
           r.propfind do
+            body = request.body.read
+            request.body.rewind
+
             response["Content-Type"] = "text/xml"
             response.status = 207
 
+            contact_etag = %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
+            collection_ctag = "ctag-1"
+            depth = request.env["HTTP_DEPTH"] || "infinity"
+
+            # Check if this is an etag-only request (Depth:1 listing)
+            etag_only = body.include?("getetag") && !body.include?("displayname") && !body.include?("resourcetype")
+
+            if etag_only
+              # Simple etag listing for sync
+              collection_response = <<~XML
+                <d:response>
+                  <d:href>/dav/addressbook/</d:href>
+                  <d:propstat>
+                    <d:prop>
+                      <d:getetag>"#{collection_ctag}"</d:getetag>
+                    </d:prop>
+                    <d:status>HTTP/1.1 200 OK</d:status>
+                  </d:propstat>
+                </d:response>
+              XML
+
+              contact_response = <<~XML
+                <d:response>
+                  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+                  <d:propstat>
+                    <d:prop>
+                      <d:getetag>#{contact_etag}</d:getetag>
+                    </d:prop>
+                    <d:status>HTTP/1.1 200 OK</d:status>
+                  </d:propstat>
+                </d:response>
+              XML
+            else
+              # Full property request (Depth:0 collection info)
+              supported_props = <<~XML
+                <d:displayname>Contacts</d:displayname>
+                <d:resourcetype>
+                  <d:collection/>
+                  <card:addressbook/>
+                </d:resourcetype>
+                <d:supported-report-set>
+                  <d:supported-report>
+                    <d:report><card:addressbook-multiget/></d:report>
+                  </d:supported-report>
+                  <d:supported-report>
+                    <d:report><card:addressbook-query/></d:report>
+                  </d:supported-report>
+                  <d:supported-report>
+                    <d:report><d:sync-collection/></d:report>
+                  </d:supported-report>
+                </d:supported-report-set>
+                <d:sync-token>http://pro-tacts/sync/1</d:sync-token>
+                <d:current-user-privilege-set>
+                  <d:privilege><d:read/></d:privilege>
+                  <d:privilege><d:write/></d:privilege>
+                </d:current-user-privilege-set>
+                <d:owner>
+                  <d:href>/dav/principal/</d:href>
+                </d:owner>
+                <card:max-resource-size>102400</card:max-resource-size>
+                <cs:getctag>#{collection_ctag}</cs:getctag>
+              XML
+
+              unsupported_props = <<~XML
+                <d:add-member/>
+                <d:quota-available-bytes/>
+                <d:quota-used-bytes/>
+                <d:resource-id/>
+                <card:max-image-size/>
+                <cs:me-card/>
+                <cs:push-transports/>
+                <cs:pushkey/>
+              XML
+
+              collection_response = <<~XML
+                <d:response>
+                  <d:href>/dav/addressbook/</d:href>
+                  <d:propstat>
+                    <d:prop>
+                      #{supported_props}
+                    </d:prop>
+                    <d:status>HTTP/1.1 200 OK</d:status>
+                  </d:propstat>
+                  <d:propstat>
+                    <d:prop>
+                      #{unsupported_props}
+                    </d:prop>
+                    <d:status>HTTP/1.1 404 Not Found</d:status>
+                  </d:propstat>
+                </d:response>
+              XML
+
+              contact_response = <<~XML
+                <d:response>
+                  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+                  <d:propstat>
+                    <d:prop>
+                      <d:getetag>#{contact_etag}</d:getetag>
+                      <d:resourcetype/>
+                    </d:prop>
+                    <d:status>HTTP/1.1 200 OK</d:status>
+                  </d:propstat>
+                </d:response>
+              XML
+            end
+
+            # Depth: 0 returns only collection, Depth: 1 includes members
+            members = depth == "0" ? "" : contact_response
+
+            <<~XML
+              <?xml version="1.0" encoding="UTF-8"?>
+              <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
+                #{collection_response}
+                #{members}
+              </d:multistatus>
+            XML
+          end
+
+          r.report do
+            response["Content-Type"] = "text/xml"
+            response.status = 207
+            contact_etag = %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
+
             <<~XML
               <?xml version="1.0" encoding="UTF-8"?>
               <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
                 <d:response>
-                  <d:href>/dav/addressbook/test-contact.vcf</d:href>
+                  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
                   <d:propstat>
                     <d:prop>
-                      <d:getetag>"etag-123"</d:getetag>
+                      <d:getetag>#{contact_etag}</d:getetag>
+                      <card:address-data>BEGIN:VCARD
+VERSION:3.0
+PRODID:-//Apple Inc.//macOS 14.6.1//EN
+N:Contact;Test;;;
+FN:Test Contact
+REV:2026-01-14T00:00:00Z
+UID:AB12C345-6789-0DEF-1234-567890ABCDEF
+END:VCARD</card:address-data>
                     </d:prop>
                     <d:status>HTTP/1.1 200 OK</d:status>
                   </d:propstat>
@@ -126,13 +305,17 @@ module ProTacts
           end
 
           r.get String do |uid|
-            response["Content-Type"] = "text/vcard"
+            response["Content-Type"] = "text/vcard; charset=utf-8"
+            response["ETag"] = %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
 
-            <<~VCARD
+            <<~VCARD.gsub(/^ +/, "")
               BEGIN:VCARD
               VERSION:3.0
-              FN:Test Contact
+              PRODID:-//Apple Inc.//macOS 14.6.1//EN
               N:Contact;Test;;;
+              FN:Test Contact
+              REV:2026-01-14T00:00:00Z
+              UID:AB12C345-6789-0DEF-1234-567890ABCDEF
               END:VCARD
             VCARD
           end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 00924a1..fbbecb2 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -16,7 +16,7 @@ class WebTest < Minitest::Test
     options "/dav/"
 
     assert_equal 200, last_response.status
-    assert_equal "1, 3, addressbook", last_response["DAV"]
+    assert_equal "1, 3, access-control, addressbook", last_response["DAV"]
     assert_includes last_response["Allow"], "OPTIONS"
     assert_includes last_response["Allow"], "PROPFIND"
   end
@@ -37,6 +37,15 @@ class WebTest < Minitest::Test
     assert_equal "/dav/principal/", last_response["Location"]
   end
 
+  def test_propfind_well_known_carddav
+    request "/.well-known/carddav", method: "PROPFIND"
+
+    assert_equal 207, last_response.status
+    assert_equal "text/xml", last_response["Content-Type"]
+    assert_includes last_response.body, "current-user-principal"
+    assert_includes last_response.body, "/dav/principal/"
+  end
+
   def test_propfind_principal
     request "/dav/principal/", method: "PROPFIND"
 
@@ -56,11 +65,36 @@ class WebTest < Minitest::Test
   end
 
   def test_get_contact
-    get "/dav/addressbook/test-contact.vcf"
+    get "/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf"
 
     assert_equal 200, last_response.status
-    assert_equal "text/vcard", last_response["Content-Type"]
+    assert_equal "text/vcard; charset=utf-8", last_response["Content-Type"]
+    assert_equal %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"), last_response["ETag"]
     assert_includes last_response.body, "BEGIN:VCARD"
     assert_includes last_response.body, "END:VCARD"
   end
+
+  def test_propfind_addressbook_includes_ctag
+    request "/dav/addressbook/", method: "PROPFIND"
+
+    assert_equal 207, last_response.status
+    assert_includes last_response.body, "http://calendarserver.org/ns/"
+    assert_includes last_response.body, "getctag"
+  end
+
+  def test_propfind_addressbook_depth_0_excludes_contacts
+    request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "0"
+
+    assert_equal 207, last_response.status
+    assert_includes last_response.body, "getctag"
+    refute_includes last_response.body, "AB12C345-6789-0DEF-1234-567890ABCDEF.vcf"
+  end
+
+  def test_propfind_addressbook_depth_1_includes_contacts
+    request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1"
+
+    assert_equal 207, last_response.status
+    assert_includes last_response.body, "getctag"
+    assert_includes last_response.body, "AB12C345-6789-0DEF-1234-567890ABCDEF.vcf"
+  end
 end