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
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