mount public data from the atmosphere to a virtual filesystem (macos only) pdfs.at
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial packages

+770 -1
+13
.gitignore
··· 1 + .DS_Store 2 + .build/ 3 + build/ 4 + DerivedData/ 5 + .swiftpm/ 6 + *.xcodeproj/xcuserdata/ 7 + *.xcodeproj/project.xcworkspace/xcuserdata/ 8 + *.xcworkspace/xcuserdata/ 9 + Credentials.xcconfig 10 + *.swiftpm/config/ 11 + Packages/*/.build/ 12 + Packages/*/.swiftpm/ 13 + Package.resolved
+14
Packages/ATProto/Package.swift
··· 1 + // swift-tools-version: 6.0 2 + import PackageDescription 3 + 4 + let package = Package( 5 + name: "ATProto", 6 + platforms: [.macOS(.v14)], 7 + products: [ 8 + .library(name: "ATProto", targets: ["ATProto"]), 9 + ], 10 + targets: [ 11 + .target(name: "ATProto"), 12 + .testTarget(name: "ATProtoTests", dependencies: ["ATProto"]), 13 + ] 14 + )
+86
Packages/ATProto/Sources/ATProto/Identifiers/ATURI.swift
··· 1 + import Foundation 2 + 3 + /// Parses and builds `at://` URIs per https://atproto.com/specs/at-uri-scheme. 4 + /// Shape: `at://<authority>[/<collection>[/<rkey>]]` 5 + /// `<authority>` is a DID or a handle. 6 + public struct ATURI: Hashable, Sendable, Codable, CustomStringConvertible { 7 + public enum Authority: Hashable, Sendable { 8 + case did(DID) 9 + case handle(Handle) 10 + 11 + public var rawValue: String { 12 + switch self { 13 + case .did(let d): return d.rawValue 14 + case .handle(let h): return h.rawValue 15 + } 16 + } 17 + } 18 + 19 + public let authority: Authority 20 + public let collection: NSID? 21 + public let rkey: RKey? 22 + 23 + public init(authority: Authority, collection: NSID? = nil, rkey: RKey? = nil) { 24 + self.authority = authority 25 + self.collection = rkey == nil ? collection : collection 26 + // If rkey is given, collection must be given too — enforce at runtime in parser. 27 + self.rkey = rkey 28 + } 29 + 30 + public init?(_ rawValue: String) { 31 + guard rawValue.hasPrefix("at://") else { return nil } 32 + let stripped = String(rawValue.dropFirst("at://".count)) 33 + let parts = stripped.split(separator: "/", maxSplits: 2, omittingEmptySubsequences: false) 34 + guard !parts.isEmpty else { return nil } 35 + let authorityStr = String(parts[0]) 36 + let authority: Authority 37 + if authorityStr.hasPrefix("did:") { 38 + guard let d = DID(authorityStr) else { return nil } 39 + authority = .did(d) 40 + } else { 41 + guard let h = Handle(authorityStr) else { return nil } 42 + authority = .handle(h) 43 + } 44 + var collection: NSID? 45 + var rkey: RKey? 46 + if parts.count >= 2 { 47 + guard let c = NSID(String(parts[1])) else { return nil } 48 + collection = c 49 + } 50 + if parts.count == 3 { 51 + guard let r = RKey(String(parts[2])) else { return nil } 52 + rkey = r 53 + } 54 + self.init(authority: authority, collection: collection, rkey: rkey) 55 + } 56 + 57 + public init(from decoder: Decoder) throws { 58 + let container = try decoder.singleValueContainer() 59 + let raw = try container.decode(String.self) 60 + guard let u = ATURI(raw) else { 61 + throw DecodingError.dataCorruptedError( 62 + in: container, 63 + debugDescription: "Invalid at:// URI: \(raw)" 64 + ) 65 + } 66 + self = u 67 + } 68 + 69 + public func encode(to encoder: Encoder) throws { 70 + var container = encoder.singleValueContainer() 71 + try container.encode(rawValue) 72 + } 73 + 74 + public var rawValue: String { 75 + var s = "at://" + authority.rawValue 76 + if let c = collection { 77 + s += "/" + c.rawValue 78 + if let r = rkey { 79 + s += "/" + r.rawValue 80 + } 81 + } 82 + return s 83 + } 84 + 85 + public var description: String { rawValue } 86 + }
+50
Packages/ATProto/Sources/ATProto/Identifiers/CID.swift
··· 1 + import Foundation 2 + 3 + /// Opaque wrapper over the base32-multibase CID string. We do not re-implement 4 + /// CID hashing; pdfs only compares and transports them. 5 + public struct CID: Hashable, Sendable, Codable, CustomStringConvertible { 6 + public let rawValue: String 7 + 8 + public init?(_ rawValue: String) { 9 + guard CID.validate(rawValue) else { return nil } 10 + self.rawValue = rawValue 11 + } 12 + 13 + public init(from decoder: Decoder) throws { 14 + let container = try decoder.singleValueContainer() 15 + let raw = try container.decode(String.self) 16 + guard let c = CID(raw) else { 17 + throw DecodingError.dataCorruptedError( 18 + in: container, 19 + debugDescription: "Invalid CID: \(raw)" 20 + ) 21 + } 22 + self = c 23 + } 24 + 25 + public func encode(to encoder: Encoder) throws { 26 + var container = encoder.singleValueContainer() 27 + try container.encode(rawValue) 28 + } 29 + 30 + public var description: String { rawValue } 31 + 32 + static func validate(_ s: String) -> Bool { 33 + // Minimum viable check: non-empty, reasonable length, base32 lower-case. 34 + // CIDv1 in base32 starts with 'b'. CIDv0 is base58btc starting with 'Qm'. 35 + // We accept both. 36 + guard (4...200).contains(s.count) else { return false } 37 + if s.hasPrefix("b") { 38 + // base32 lowercase alphabet: a-z2-7 39 + return s.dropFirst().allSatisfy { ch in 40 + ch.isASCII && (("a"..."z").contains(ch) || ("2"..."7").contains(ch)) 41 + } 42 + } 43 + if s.hasPrefix("Qm") { 44 + // base58btc alphabet 45 + let alphabet: Set<Character> = Set("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") 46 + return s.allSatisfy { alphabet.contains($0) } 47 + } 48 + return false 49 + } 50 + }
+56
Packages/ATProto/Sources/ATProto/Identifiers/DID.swift
··· 1 + import Foundation 2 + 3 + public struct DID: Hashable, Sendable, Codable, CustomStringConvertible { 4 + public let rawValue: String 5 + 6 + public enum Method: String, Sendable { 7 + case plc 8 + case web 9 + } 10 + 11 + public init?(_ rawValue: String) { 12 + guard DID.validate(rawValue) else { return nil } 13 + self.rawValue = rawValue 14 + } 15 + 16 + public init(from decoder: Decoder) throws { 17 + let container = try decoder.singleValueContainer() 18 + let raw = try container.decode(String.self) 19 + guard let did = DID(raw) else { 20 + throw DecodingError.dataCorruptedError( 21 + in: container, 22 + debugDescription: "Invalid DID: \(raw)" 23 + ) 24 + } 25 + self = did 26 + } 27 + 28 + public func encode(to encoder: Encoder) throws { 29 + var container = encoder.singleValueContainer() 30 + try container.encode(rawValue) 31 + } 32 + 33 + public var method: Method? { 34 + let parts = rawValue.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) 35 + guard parts.count >= 3, parts[0] == "did" else { return nil } 36 + return Method(rawValue: String(parts[1])) 37 + } 38 + 39 + public var description: String { rawValue } 40 + 41 + static func validate(_ s: String) -> Bool { 42 + // atproto DID spec: did:<method>:<msid> 43 + // method: lowercase letters only, at least one 44 + // msid: [A-Za-z0-9._:%-]+, must not end with `:` 45 + // total length <= 2048 46 + guard s.count <= 2048, s.hasPrefix("did:") else { return false } 47 + let parts = s.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) 48 + guard parts.count == 3 else { return false } 49 + let method = parts[1] 50 + let msid = parts[2] 51 + guard !method.isEmpty, method.allSatisfy({ $0.isLowercase && $0.isLetter }) else { return false } 52 + guard !msid.isEmpty, !msid.hasSuffix(":") else { return false } 53 + let allowed: Set<Character> = Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:%-") 54 + return msid.allSatisfy { allowed.contains($0) } 55 + } 56 + }
+51
Packages/ATProto/Sources/ATProto/Identifiers/Handle.swift
··· 1 + import Foundation 2 + 3 + public struct Handle: Hashable, Sendable, Codable, CustomStringConvertible { 4 + public let rawValue: String 5 + 6 + public init?(_ rawValue: String) { 7 + let normalized = rawValue.lowercased() 8 + guard Handle.validate(normalized) else { return nil } 9 + self.rawValue = normalized 10 + } 11 + 12 + public init(from decoder: Decoder) throws { 13 + let container = try decoder.singleValueContainer() 14 + let raw = try container.decode(String.self) 15 + guard let h = Handle(raw) else { 16 + throw DecodingError.dataCorruptedError( 17 + in: container, 18 + debugDescription: "Invalid handle: \(raw)" 19 + ) 20 + } 21 + self = h 22 + } 23 + 24 + public func encode(to encoder: Encoder) throws { 25 + var container = encoder.singleValueContainer() 26 + try container.encode(rawValue) 27 + } 28 + 29 + public var description: String { rawValue } 30 + 31 + static func validate(_ s: String) -> Bool { 32 + // atproto handle: DNS-like, lowercase, total <=253, at least two labels 33 + guard !s.isEmpty, s.count <= 253 else { return false } 34 + let labels = s.split(separator: ".", omittingEmptySubsequences: false) 35 + guard labels.count >= 2 else { return false } 36 + for label in labels { 37 + guard (1...63).contains(label.count) else { return false } 38 + guard let first = label.first, first.isLetter || first.isNumber else { return false } 39 + guard let last = label.last, last.isLetter || last.isNumber else { return false } 40 + for ch in label { 41 + guard ch.isLetter || ch.isNumber || ch == "-" else { return false } 42 + guard ch.isASCII, ch.isLowercase || ch.isNumber || ch == "-" else { return false } 43 + } 44 + } 45 + // Last label (TLD) cannot start with a digit 46 + if let tld = labels.last, let first = tld.first, first.isNumber { 47 + return false 48 + } 49 + return true 50 + } 51 + }
+80
Packages/ATProto/Sources/ATProto/Identifiers/NSID.swift
··· 1 + import Foundation 2 + 3 + public struct NSID: Hashable, Sendable, Codable, CustomStringConvertible { 4 + public let rawValue: String 5 + 6 + public init?(_ rawValue: String) { 7 + guard NSID.validate(rawValue) else { return nil } 8 + self.rawValue = rawValue 9 + } 10 + 11 + public init(from decoder: Decoder) throws { 12 + let container = try decoder.singleValueContainer() 13 + let raw = try container.decode(String.self) 14 + guard let n = NSID(raw) else { 15 + throw DecodingError.dataCorruptedError( 16 + in: container, 17 + debugDescription: "Invalid NSID: \(raw)" 18 + ) 19 + } 20 + self = n 21 + } 22 + 23 + public func encode(to encoder: Encoder) throws { 24 + var container = encoder.singleValueContainer() 25 + try container.encode(rawValue) 26 + } 27 + 28 + public var description: String { rawValue } 29 + 30 + /// Domain authority (first two labels reversed back to domain order). 31 + /// `app.bsky.feed.post` → `app.bsky`. 32 + public var authority: String { 33 + let parts = rawValue.split(separator: ".").map(String.init) 34 + guard parts.count >= 2 else { return rawValue } 35 + return parts.prefix(2).joined(separator: ".") 36 + } 37 + 38 + /// Everything after the authority. `app.bsky.feed.post` → `feed.post`. 39 + public var subdomain: String { 40 + let parts = rawValue.split(separator: ".").map(String.init) 41 + guard parts.count > 2 else { return "" } 42 + return parts.dropFirst(2).joined(separator: ".") 43 + } 44 + 45 + /// The final path segment (record-type name). `app.bsky.feed.post` → `post`. 46 + public var name: String { 47 + rawValue.split(separator: ".").last.map(String.init) ?? rawValue 48 + } 49 + 50 + static func validate(_ s: String) -> Bool { 51 + // atproto NSID: reverse-domain authority + name segment. 52 + // Total length 1..317, at least 3 labels, ASCII only. 53 + // Labels 1..63, alphanumeric + dash, cannot start/end with dash. 54 + // Final segment (name): [A-Za-z][A-Za-z0-9]* (no digits allowed in name). 55 + guard (1...317).contains(s.count) else { return false } 56 + let labels = s.split(separator: ".", omittingEmptySubsequences: false) 57 + guard labels.count >= 3 else { return false } 58 + for (i, label) in labels.enumerated() { 59 + guard (1...63).contains(label.count) else { return false } 60 + if i == labels.count - 1 { 61 + // Name: ASCII letter-starting, letters only (per spec). 62 + guard let first = label.first, first.isASCII, first.isLetter else { return false } 63 + for ch in label { 64 + guard ch.isASCII, ch.isLetter else { return false } 65 + } 66 + } else { 67 + // Non-name segments: ASCII lowercase letters, digits, hyphens. No leading/trailing hyphen. 68 + guard let first = label.first, first.isASCII, (first.isLetter && first.isLowercase) || first.isNumber else { return false } 69 + guard let last = label.last, last.isASCII, (last.isLetter && last.isLowercase) || last.isNumber else { return false } 70 + for ch in label { 71 + guard ch.isASCII else { return false } 72 + if ch == "-" { continue } 73 + if ch.isNumber { continue } 74 + guard ch.isLetter, ch.isLowercase else { return false } 75 + } 76 + } 77 + } 78 + return true 79 + } 80 + }
+37
Packages/ATProto/Sources/ATProto/Identifiers/RKey.swift
··· 1 + import Foundation 2 + 3 + public struct RKey: Hashable, Sendable, Codable, CustomStringConvertible { 4 + public let rawValue: String 5 + 6 + public init?(_ rawValue: String) { 7 + guard RKey.validate(rawValue) else { return nil } 8 + self.rawValue = rawValue 9 + } 10 + 11 + public init(from decoder: Decoder) throws { 12 + let container = try decoder.singleValueContainer() 13 + let raw = try container.decode(String.self) 14 + guard let k = RKey(raw) else { 15 + throw DecodingError.dataCorruptedError( 16 + in: container, 17 + debugDescription: "Invalid RKey: \(raw)" 18 + ) 19 + } 20 + self = k 21 + } 22 + 23 + public func encode(to encoder: Encoder) throws { 24 + var container = encoder.singleValueContainer() 25 + try container.encode(rawValue) 26 + } 27 + 28 + public var description: String { rawValue } 29 + 30 + static func validate(_ s: String) -> Bool { 31 + // atproto record key: 1..512 chars, [A-Za-z0-9._:~-]. Cannot be `.` or `..`. 32 + guard (1...512).contains(s.count) else { return false } 33 + if s == "." || s == ".." { return false } 34 + let allowed: Set<Character> = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:~-") 35 + return s.allSatisfy { allowed.contains($0) } 36 + } 37 + }
+66
Packages/ATProto/Sources/ATProto/Identifiers/TID.swift
··· 1 + import Foundation 2 + 3 + /// Timestamp Identifier — atproto's 13-char sortable base32 rkey scheme. 4 + /// https://atproto.com/specs/tid 5 + public struct TID: Hashable, Sendable, CustomStringConvertible { 6 + public let rawValue: String 7 + 8 + static let alphabet = Array("234567abcdefghijklmnopqrstuvwxyz") 9 + 10 + public init?(_ rawValue: String) { 11 + guard TID.validate(rawValue) else { return nil } 12 + self.rawValue = rawValue 13 + } 14 + 15 + /// Microseconds since Unix epoch (52 bits) in the TID. 16 + public var timestampMicroseconds: UInt64 { 17 + // Decode 13 chars of base32 into 65 bits. 18 + var value: UInt64 = 0 19 + for ch in rawValue { 20 + guard let idx = TID.alphabet.firstIndex(of: ch) else { return 0 } 21 + value = (value << 5) | UInt64(idx) 22 + } 23 + // Drop the 10 low clock bits; keep the 52-bit timestamp (shifted right by 10). 24 + // Total bits: 13 * 5 = 65; top bit must be zero (high-bit reserved). So value fits 64 bits. 25 + // Layout: [1 reserved | 52 timestamp | 10 clockid | 2 padding] — actually the spec is: 26 + // [1 reserved=0 | 53 timestamp (microseconds) | 10 clockid] across 64 bits, encoded in 13 base32 chars. 27 + // The top bit of the first char must be 0. 28 + return (value >> 10) & ((1 << 53) - 1) 29 + } 30 + 31 + public var date: Date { 32 + Date(timeIntervalSince1970: Double(timestampMicroseconds) / 1_000_000) 33 + } 34 + 35 + public var description: String { rawValue } 36 + 37 + /// Generates a monotonic TID for `now`. Uses a process-local sequence to break ties. 38 + public static func now(clockIdentifier: UInt16 = 0) -> TID { 39 + let micros = UInt64(Date().timeIntervalSince1970 * 1_000_000) & ((1 << 53) - 1) 40 + let clockid = UInt64(clockIdentifier) & ((1 << 10) - 1) 41 + let combined = (micros << 10) | clockid 42 + return TID(encoded: combined) 43 + } 44 + 45 + init(encoded value: UInt64) { 46 + var v = value 47 + var chars: [Character] = [] 48 + for _ in 0..<13 { 49 + let idx = Int(v & 0x1F) 50 + chars.append(TID.alphabet[idx]) 51 + v >>= 5 52 + } 53 + self.rawValue = String(chars.reversed()) 54 + } 55 + 56 + static func validate(_ s: String) -> Bool { 57 + guard s.count == 13 else { return false } 58 + let set = Set(alphabet) 59 + guard s.allSatisfy({ set.contains($0) }) else { return false } 60 + // High bit of first char must be 0 (values 0..15, encoded 2-q in our alphabet). 61 + // alphabet[0..15] = "234567abcdefghij"; i.e. indexes 0..15. We accept only those. 62 + let first = s.first! 63 + guard let firstIdx = alphabet.firstIndex(of: first), firstIdx < 16 else { return false } 64 + return true 65 + } 66 + }
+169
Packages/ATProto/Tests/ATProtoTests/IdentifierTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import ATProto 4 + 5 + @Suite("DID") 6 + struct DIDTests { 7 + @Test("accepts valid DIDs") 8 + func valid() { 9 + #expect(DID("did:plc:abc123")?.method == .plc) 10 + #expect(DID("did:web:example.com")?.method == .web) 11 + #expect(DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz")?.rawValue == "did:plc:ewvi7nxzyoun6zhxrhs64oiz") 12 + } 13 + 14 + @Test("rejects invalid DIDs") 15 + func invalid() { 16 + #expect(DID("") == nil) 17 + #expect(DID("did:") == nil) 18 + #expect(DID("did:plc:") == nil) 19 + #expect(DID("plc:abc") == nil) 20 + #expect(DID("did:PLC:abc") == nil) // uppercase method not allowed 21 + #expect(DID("did:plc:abc:") == nil) // trailing colon 22 + #expect(DID("did:plc:abc def") == nil) // space 23 + } 24 + 25 + @Test("codable round-trip") 26 + func codable() throws { 27 + let did = DID("did:plc:abc")! 28 + let data = try JSONEncoder().encode(did) 29 + let decoded = try JSONDecoder().decode(DID.self, from: data) 30 + #expect(decoded == did) 31 + } 32 + } 33 + 34 + @Suite("Handle") 35 + struct HandleTests { 36 + @Test("accepts valid handles") 37 + func valid() { 38 + #expect(Handle("nate.natemoo.re")?.rawValue == "nate.natemoo.re") 39 + #expect(Handle("alice.bsky.social") != nil) 40 + #expect(Handle("ALICE.example.COM")?.rawValue == "alice.example.com") // normalized 41 + } 42 + 43 + @Test("rejects invalid handles") 44 + func invalid() { 45 + #expect(Handle("") == nil) 46 + #expect(Handle("noTld") == nil) 47 + #expect(Handle("-leading-dash.com") == nil) 48 + #expect(Handle("trailing-.com") == nil) 49 + #expect(Handle("under_score.com") == nil) 50 + #expect(Handle("nate.123") == nil) // TLD starts with digit 51 + } 52 + } 53 + 54 + @Suite("NSID") 55 + struct NSIDTests { 56 + @Test("splits authority/subdomain/name") 57 + func split() { 58 + let nsid = NSID("app.bsky.feed.post")! 59 + #expect(nsid.authority == "app.bsky") 60 + #expect(nsid.subdomain == "feed.post") 61 + #expect(nsid.name == "post") 62 + } 63 + 64 + @Test("accepts valid NSIDs") 65 + func valid() { 66 + #expect(NSID("app.bsky.feed.post") != nil) 67 + #expect(NSID("com.atproto.repo.describeRepo") != nil) 68 + #expect(NSID("at.pdfs.version") != nil) 69 + } 70 + 71 + @Test("rejects invalid NSIDs") 72 + func invalid() { 73 + #expect(NSID("") == nil) 74 + #expect(NSID("app") == nil) // too few labels 75 + #expect(NSID("app.bsky") == nil) // need >= 3 labels 76 + #expect(NSID("App.bsky.Post") == nil) // name must start with letter only — capital OK, but digits not allowed in name 77 + #expect(NSID("app.bsky.123") == nil) // name cannot start with digit 78 + #expect(NSID("app.bsky.post1") == nil) // name letters only per spec 79 + } 80 + } 81 + 82 + @Suite("RKey") 83 + struct RKeyTests { 84 + @Test("accepts valid rkeys") 85 + func valid() { 86 + #expect(RKey("self") != nil) 87 + #expect(RKey("3l5xq2abc") != nil) 88 + #expect(RKey("a.b_c:d~e-f") != nil) 89 + } 90 + 91 + @Test("rejects invalid rkeys") 92 + func invalid() { 93 + #expect(RKey("") == nil) 94 + #expect(RKey(".") == nil) 95 + #expect(RKey("..") == nil) 96 + #expect(RKey("has space") == nil) 97 + #expect(RKey("has/slash") == nil) 98 + } 99 + } 100 + 101 + @Suite("CID") 102 + struct CIDTests { 103 + @Test("accepts base32 CIDv1") 104 + func base32() { 105 + #expect(CID("bafyreibyvxuebctujncksacu3qfxsv6pzm7z67vfrftndn6pzhknf7g5me") != nil) 106 + } 107 + 108 + @Test("accepts base58 CIDv0") 109 + func base58() { 110 + #expect(CID("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG") != nil) 111 + } 112 + 113 + @Test("rejects junk") 114 + func junk() { 115 + #expect(CID("") == nil) 116 + #expect(CID("not-a-cid") == nil) 117 + #expect(CID("x") == nil) 118 + } 119 + } 120 + 121 + @Suite("ATURI") 122 + struct ATURITests { 123 + @Test("parses full uri") 124 + func full() { 125 + let u = ATURI("at://did:plc:abc/app.bsky.feed.post/3l5xq2abc")! 126 + if case let .did(d) = u.authority { #expect(d.rawValue == "did:plc:abc") } else { Issue.record("expected DID authority") } 127 + #expect(u.collection?.rawValue == "app.bsky.feed.post") 128 + #expect(u.rkey?.rawValue == "3l5xq2abc") 129 + #expect(u.rawValue == "at://did:plc:abc/app.bsky.feed.post/3l5xq2abc") 130 + } 131 + 132 + @Test("parses authority-only") 133 + func authorityOnly() { 134 + let u = ATURI("at://nate.natemoo.re")! 135 + #expect(u.collection == nil) 136 + #expect(u.rkey == nil) 137 + } 138 + 139 + @Test("rejects non-atproto uri") 140 + func nonAtproto() { 141 + #expect(ATURI("https://example.com") == nil) 142 + #expect(ATURI("") == nil) 143 + } 144 + } 145 + 146 + @Suite("TID") 147 + struct TIDTests { 148 + @Test("now() produces a 13-char sortable id") 149 + func now() { 150 + let a = TID.now() 151 + #expect(a.rawValue.count == 13) 152 + #expect(TID(a.rawValue) == a) 153 + } 154 + 155 + @Test("monotonic sort order matches time") 156 + func sortable() async throws { 157 + let a = TID.now() 158 + try await Task.sleep(for: .milliseconds(2)) 159 + let b = TID.now() 160 + #expect(a.rawValue < b.rawValue) 161 + } 162 + 163 + @Test("rejects bad tids") 164 + func invalid() { 165 + #expect(TID("") == nil) 166 + #expect(TID("tooShort") == nil) 167 + #expect(TID("1234567890123") == nil) // '1' is not in base32-sortable alphabet 168 + } 169 + }
+14
Packages/PDFSShared/Package.swift
··· 1 + // swift-tools-version: 6.0 2 + import PackageDescription 3 + 4 + let package = Package( 5 + name: "PDFSShared", 6 + platforms: [.macOS(.v14)], 7 + products: [ 8 + .library(name: "PDFSShared", targets: ["PDFSShared"]), 9 + ], 10 + targets: [ 11 + .target(name: "PDFSShared"), 12 + .testTarget(name: "PDFSSharedTests", dependencies: ["PDFSShared"]), 13 + ] 14 + )
+13
Packages/PDFSShared/Sources/PDFSShared/Logging/PDFSLog.swift
··· 1 + import Foundation 2 + import os 3 + 4 + public enum PDFSLog { 5 + public static let subsystem = "at.pdfs" 6 + 7 + public static let app = Logger(subsystem: subsystem, category: "app") 8 + public static let mount = Logger(subsystem: subsystem, category: "mount") 9 + public static let auth = Logger(subsystem: subsystem, category: "auth") 10 + public static let xpc = Logger(subsystem: subsystem, category: "xpc") 11 + public static let fsExtension = Logger(subsystem: subsystem, category: "fs") 12 + public static let pds = Logger(subsystem: subsystem, category: "pds") 13 + }
+37
Packages/PDFSShared/Sources/PDFSShared/Models/MountConfig.swift
··· 1 + import Foundation 2 + 3 + /// Shape of `<source>/.pdfs/mount.json`. 4 + /// Contains only non-secret config. Tokens flow over XPC, never through disk. 5 + public struct MountConfig: Codable, Sendable, Equatable { 6 + public let schemaVersion: Int 7 + public let did: String 8 + public let handle: String 9 + public let pds: URL 10 + public let xpcServiceName: String 11 + 12 + public static let currentSchemaVersion = 1 13 + 14 + public init(did: String, handle: String, pds: URL, xpcServiceName: String) { 15 + self.schemaVersion = Self.currentSchemaVersion 16 + self.did = did 17 + self.handle = handle 18 + self.pds = pds 19 + self.xpcServiceName = xpcServiceName 20 + } 21 + 22 + public static func read(from url: URL) throws -> MountConfig { 23 + let data = try Data(contentsOf: url) 24 + return try JSONDecoder().decode(MountConfig.self, from: data) 25 + } 26 + 27 + public func write(to url: URL) throws { 28 + let encoder = JSONEncoder() 29 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 30 + let data = try encoder.encode(self) 31 + try FileManager.default.createDirectory( 32 + at: url.deletingLastPathComponent(), 33 + withIntermediateDirectories: true 34 + ) 35 + try data.write(to: url, options: .atomic) 36 + } 37 + }
+44
Packages/PDFSShared/Sources/PDFSShared/XPC/PDFSSessionProtocol.swift
··· 1 + import Foundation 2 + 3 + /// Wire protocol between the sandboxed FSKit extension (client) and the host 4 + /// app (server). The host owns Keychain + OAuth; the extension asks the host 5 + /// for a current access token for a given DID. 6 + /// 7 + /// Callback-style (not async) because NSXPC's generated proxies require 8 + /// `@objc` methods. Call sites wrap with `withCheckedThrowingContinuation`. 9 + @objc public protocol PDFSSessionProtocol { 10 + /// Returns the current access token for the given DID. Host refreshes if expired. 11 + /// `tokenJSON` is a JSON blob shaped like `{"accessToken":"...","dpopJWK":"{...}","expiresAt":<epoch>}`. 12 + func currentAccessToken( 13 + for did: String, 14 + reply: @escaping (_ tokenJSON: Data?, _ error: NSError?) -> Void 15 + ) 16 + 17 + /// Forces a refresh against the PDS, even if the cached token hasn't expired. 18 + /// Called by the extension after receiving a 401 from the PDS. 19 + func forceRefresh( 20 + for did: String, 21 + reply: @escaping (_ tokenJSON: Data?, _ error: NSError?) -> Void 22 + ) 23 + 24 + /// Returns the mount config for the given DID (used for re-validation). 25 + func describeMount( 26 + for did: String, 27 + reply: @escaping (_ mountConfigJSON: Data?, _ error: NSError?) -> Void 28 + ) 29 + 30 + /// Called by the extension when a token was rejected mid-flight so the host 31 + /// can invalidate its cache and start a fresh refresh. 32 + func reportTokenRejected( 33 + for did: String, 34 + reply: @escaping () -> Void 35 + ) 36 + 37 + /// Liveness handshake performed during `loadResource` to confirm the host is reachable. 38 + func ping(reply: @escaping (Bool) -> Void) 39 + } 40 + 41 + public extension PDFSSessionProtocol { 42 + static var defaultMachServiceName: String { "at.pdfs.session" } 43 + static var defaultInterface: NSXPCInterface { NSXPCInterface(with: PDFSSessionProtocol.self) } 44 + }
+39
Packages/PDFSShared/Tests/PDFSSharedTests/MountConfigTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import PDFSShared 4 + 5 + @Suite("MountConfig") 6 + struct MountConfigTests { 7 + @Test("round-trips through JSON") 8 + func roundTrip() throws { 9 + let config = MountConfig( 10 + did: "did:plc:abc123", 11 + handle: "nate.natemoo.re", 12 + pds: URL(string: "https://bsky.social")!, 13 + xpcServiceName: "at.pdfs.session" 14 + ) 15 + let encoder = JSONEncoder() 16 + let data = try encoder.encode(config) 17 + let decoded = try JSONDecoder().decode(MountConfig.self, from: data) 18 + #expect(decoded == config) 19 + #expect(decoded.schemaVersion == MountConfig.currentSchemaVersion) 20 + } 21 + 22 + @Test("writes and reads from disk") 23 + func readWrite() throws { 24 + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()) 25 + .appendingPathComponent(UUID().uuidString, isDirectory: true) 26 + defer { try? FileManager.default.removeItem(at: tempDir) } 27 + 28 + let url = tempDir.appendingPathComponent(".pdfs/mount.json") 29 + let config = MountConfig( 30 + did: "did:plc:xyz", 31 + handle: "alice.example.com", 32 + pds: URL(string: "https://pds.example.com")!, 33 + xpcServiceName: "at.pdfs.session" 34 + ) 35 + try config.write(to: url) 36 + let round = try MountConfig.read(from: url) 37 + #expect(round == config) 38 + } 39 + }
+1 -1
README.md
··· 1 - # pdfs 1 + # [pdfs](https://pdfs.at/) 2 2 3 3 mount public data from the atmosphere to a virtual filesystem on macOS, using [fskit](https://developer.apple.com/documentation/fskit)