Skip to content

Commit 1ef602e

Browse files
feat(ios): Share config in Android-compatible mhrv-rs:// format
Share now emits mhrv-rs:// + URL-safe base64 of zlib-compressed (RFC 1950) JSON — byte-compatible with Android's ConfigStore.encode/decode. Import also falls back to raw UTF-8 for uncompressed payloads. Verified round-trip against Android's zlib decode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7d04a56 commit 1ef602e

1 file changed

Lines changed: 65 additions & 5 deletions

File tree

ios/App/ContentView.swift

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,23 @@ struct VpnConfig {
8888
return str
8989
}
9090

91+
/// Android-compatible share string: `mhrv-rs://` + URL-safe base64 of the
92+
/// zlib-compressed (RFC 1950) JSON — byte-for-byte decodable by Android's
93+
/// ConfigStore.decode (InflaterInputStream). Falls back to uncompressed
94+
/// base64 if compression fails (still decodable by both sides).
95+
func encode() -> String {
96+
let json = toJson(pretty: false)
97+
let bytes = Data(json.utf8)
98+
let payload = Self.zlibDeflate(bytes) ?? bytes
99+
return "mhrv-rs://" + Self.urlSafeBase64(payload)
100+
}
101+
102+
private static func urlSafeBase64(_ data: Data) -> String {
103+
data.base64EncodedString()
104+
.replacingOccurrences(of: "+", with: "-")
105+
.replacingOccurrences(of: "/", with: "_")
106+
}
107+
91108
// MARK: parse
92109

93110
/// Try to parse raw text — mhrv-rs:// (Android share), JSON, or TOML.
@@ -108,10 +125,18 @@ struct VpnConfig {
108125
let rem = b64.count % 4
109126
if rem != 0 { b64 += String(repeating: "=", count: 4 - rem) }
110127
// ignoreUnknownCharacters handles any stray whitespace/newlines in the clipboard.
111-
guard let compressed = Data(base64Encoded: b64, options: .ignoreUnknownCharacters),
112-
let inflated = zlibInflate(compressed),
113-
let json = String(data: inflated, encoding: .utf8) else { return nil }
114-
return fromJson(json)
128+
guard let raw = Data(base64Encoded: b64, options: .ignoreUnknownCharacters) else { return nil }
129+
// Prefer zlib-inflated JSON; fall back to raw UTF-8 (mirrors Android's
130+
// inflateOrRaw — handles uncompressed mhrv-rs:// payloads too).
131+
if let inflated = zlibInflate(raw),
132+
let json = String(data: inflated, encoding: .utf8),
133+
let cfg = fromJson(json) {
134+
return cfg
135+
}
136+
if let json = String(data: raw, encoding: .utf8) {
137+
return fromJson(json)
138+
}
139+
return nil
115140
}
116141

117142
/// Decompress Java DeflaterOutputStream output (zlib / RFC 1950).
@@ -143,6 +168,41 @@ struct VpnConfig {
143168
return Data(outBuf.prefix(written))
144169
}
145170

171+
/// Compress to zlib format (RFC 1950) so Android's InflaterInputStream reads
172+
/// it: 2-byte header + raw DEFLATE (Apple COMPRESSION_ZLIB) + 4-byte Adler-32.
173+
private static func zlibDeflate(_ data: Data) -> Data? {
174+
guard !data.isEmpty else { return nil }
175+
let cap = data.count + 4096
176+
var outBuf = [UInt8](repeating: 0, count: cap)
177+
var written = 0
178+
data.withUnsafeBytes { (srcPtr: UnsafeRawBufferPointer) in
179+
guard let src = srcPtr.bindMemory(to: UInt8.self).baseAddress else { return }
180+
outBuf.withUnsafeMutableBufferPointer { dstPtr in
181+
written = compression_encode_buffer(
182+
dstPtr.baseAddress!, cap,
183+
src, srcPtr.count,
184+
nil, COMPRESSION_ZLIB
185+
)
186+
}
187+
}
188+
guard written > 0 else { return nil }
189+
var out = Data([0x78, 0x9C]) // zlib header: deflate, default level
190+
out.append(contentsOf: outBuf[0..<written]) // raw DEFLATE body
191+
var adler = adler32(data).bigEndian // Adler-32 over uncompressed bytes, MSB first
192+
withUnsafeBytes(of: &adler) { out.append(contentsOf: $0) }
193+
return out
194+
}
195+
196+
private static func adler32(_ data: Data) -> UInt32 {
197+
var a: UInt32 = 1, b: UInt32 = 0
198+
let mod: UInt32 = 65521
199+
for byte in data {
200+
a = (a + UInt32(byte)) % mod
201+
b = (b + a) % mod
202+
}
203+
return (b << 16) | a
204+
}
205+
146206
static func fromJson(_ json: String) -> VpnConfig? {
147207
guard let data = json.data(using: .utf8),
148208
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
@@ -341,7 +401,7 @@ private struct ConfigSharingBar: View {
341401
}
342402
.buttonStyle(.bordered)
343403
.sheet(isPresented: $showShareSheet) {
344-
ShareSheet(items: [vpn.config.toJson()])
404+
ShareSheet(items: [vpn.config.encode()])
345405
}
346406
}
347407
}

0 commit comments

Comments
 (0)