Skip to main content

max / makenotwork

ota: typed `mnw-cli ota publish` + synckit-client ota module Replaces the stale `server/deploy/ota-publish.sh` (which predated the SyncKit SDK-key auth field and no longer authenticates) with a typed, tested path: - synckit-client: new `client::ota` module reusing the authenticated session (JWT + app id) — ota_create_release, ota_register_artifact, ota_upload_artifact (presigned S3 PUT, unencrypted — OTA artifacts are public), and ota_updater_check (Some(manifest) / None on 204). Typed OtaRelease / OtaArtifactUpload / OtaManifest, exported from the crate root. - mnw-cli: `ota` subcommand. main() routes `mnw-cli ota publish ...` before the SSH daemon boots, so the one binary doubles as the operator OTA publisher. Flags + env fallbacks (MNW_OTA_EMAIL/PASSWORD/API_KEY/KEY/SERVER), target/arch validation, empty-signature warning, and an end-to-end progress trace. - keystore.rs: gate the keychain-only helpers (base64 import, SERVICE_PREFIX, service_name, user_key, SyncKitError import) behind any(feature="keychain", test) so a no-default-features build (mnw-cli pulls it without keychain) is warning-clean. - ota-publish.sh: marked deprecated, points at mnw-cli + the runbook. Runbook lives at _private (operator side). synckit-client 258 tests pass, no-default-features build clean; mnw-cli 58 tests pass incl. 5 new ota arg tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-08 02:05 UTC
Commit: 932ff76b2ca4ae117b60ec2a7ed7cc39d8245758
Parent: 90dd4c6
9 files changed, +932 insertions, -5 deletions
M mnw-cli/Cargo.lock +307 -4
@@ -331,6 +331,19 @@ dependencies = [
331 331 ]
332 332
333 333 [[package]]
334 + name = "chacha20poly1305"
335 + version = "0.10.1"
336 + source = "registry+https://github.com/rust-lang/crates.io-index"
337 + checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
338 + dependencies = [
339 + "aead",
340 + "chacha20",
341 + "cipher",
342 + "poly1305",
343 + "zeroize",
344 + ]
345 +
346 + [[package]]
334 347 name = "chrono"
335 348 version = "0.4.44"
336 349 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -339,6 +352,7 @@ dependencies = [
339 352 "iana-time-zone",
340 353 "js-sys",
341 354 "num-traits",
355 + "serde",
342 356 "wasm-bindgen",
343 357 "windows-link",
344 358 ]
@@ -351,6 +365,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
351 365 dependencies = [
352 366 "crypto-common 0.1.7",
353 367 "inout",
368 + "zeroize",
354 369 ]
355 370
356 371 [[package]]
@@ -424,6 +439,26 @@ dependencies = [
424 439 ]
425 440
426 441 [[package]]
442 + name = "core-foundation"
443 + version = "0.9.4"
444 + source = "registry+https://github.com/rust-lang/crates.io-index"
445 + checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
446 + dependencies = [
447 + "core-foundation-sys",
448 + "libc",
449 + ]
450 +
451 + [[package]]
452 + name = "core-foundation"
453 + version = "0.10.1"
454 + source = "registry+https://github.com/rust-lang/crates.io-index"
455 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
456 + dependencies = [
457 + "core-foundation-sys",
458 + "libc",
459 + ]
460 +
461 + [[package]]
427 462 name = "core-foundation-sys"
428 463 version = "0.8.7"
429 464 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -532,6 +567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
532 567 checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
533 568 dependencies = [
534 569 "generic-array 0.14.7",
570 + "rand_core 0.6.4",
535 571 "typenum",
536 572 ]
537 573
@@ -836,6 +872,15 @@ dependencies = [
836 872 ]
837 873
838 874 [[package]]
875 + name = "encoding_rs"
876 + version = "0.8.35"
877 + source = "registry+https://github.com/rust-lang/crates.io-index"
878 + checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
879 + dependencies = [
880 + "cfg-if",
881 + ]
882 +
883 + [[package]]
839 884 name = "enum_dispatch"
840 885 version = "0.3.13"
841 886 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -860,7 +905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
860 905 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
861 906 dependencies = [
862 907 "libc",
863 - "windows-sys 0.59.0",
908 + "windows-sys 0.61.2",
864 909 ]
865 910
866 911 [[package]]
@@ -883,6 +928,12 @@ dependencies = [
883 928 ]
884 929
885 930 [[package]]
931 + name = "fastrand"
932 + version = "2.4.1"
933 + source = "registry+https://github.com/rust-lang/crates.io-index"
934 + checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
935 +
936 + [[package]]
886 937 name = "ff"
887 938 version = "0.13.1"
888 939 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -968,6 +1019,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
968 1019 checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
969 1020
970 1021 [[package]]
1022 + name = "foreign-types"
1023 + version = "0.3.2"
1024 + source = "registry+https://github.com/rust-lang/crates.io-index"
1025 + checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
1026 + dependencies = [
1027 + "foreign-types-shared",
1028 + ]
1029 +
1030 + [[package]]
1031 + name = "foreign-types-shared"
1032 + version = "0.1.1"
1033 + source = "registry+https://github.com/rust-lang/crates.io-index"
1034 + checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
1035 +
1036 + [[package]]
971 1037 name = "form_urlencoded"
972 1038 version = "1.2.2"
973 1039 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1154,6 +1220,25 @@ dependencies = [
1154 1220 ]
1155 1221
1156 1222 [[package]]
1223 + name = "h2"
1224 + version = "0.4.14"
1225 + source = "registry+https://github.com/rust-lang/crates.io-index"
1226 + checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
1227 + dependencies = [
1228 + "atomic-waker",
1229 + "bytes",
1230 + "fnv",
1231 + "futures-core",
1232 + "futures-sink",
1233 + "http",
1234 + "indexmap",
1235 + "slab",
1236 + "tokio",
1237 + "tokio-util",
1238 + "tracing",
1239 + ]
1240 +
1241 + [[package]]
1157 1242 name = "hashbrown"
1158 1243 version = "0.15.5"
1159 1244 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1310,6 +1395,7 @@ dependencies = [
1310 1395 "bytes",
1311 1396 "futures-channel",
1312 1397 "futures-core",
1398 + "h2",
1313 1399 "http",
1314 1400 "http-body",
1315 1401 "httparse",
@@ -1339,6 +1425,22 @@ dependencies = [
1339 1425 ]
1340 1426
1341 1427 [[package]]
1428 + name = "hyper-tls"
1429 + version = "0.6.0"
1430 + source = "registry+https://github.com/rust-lang/crates.io-index"
1431 + checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
1432 + dependencies = [
1433 + "bytes",
1434 + "http-body-util",
1435 + "hyper",
1436 + "hyper-util",
1437 + "native-tls",
1438 + "tokio",
1439 + "tokio-native-tls",
1440 + "tower-service",
1441 + ]
1442 +
1443 + [[package]]
1342 1444 name = "hyper-util"
1343 1445 version = "0.1.20"
1344 1446 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1356,9 +1458,11 @@ dependencies = [
1356 1458 "percent-encoding",
1357 1459 "pin-project-lite",
1358 1460 "socket2",
1461 + "system-configuration",
1359 1462 "tokio",
1360 1463 "tower-service",
1361 1464 "tracing",
1465 + "windows-registry",
1362 1466 ]
1363 1467
1364 1468 [[package]]
@@ -1841,6 +1945,12 @@ dependencies = [
1841 1945 ]
1842 1946
1843 1947 [[package]]
1948 + name = "mime"
1949 + version = "0.3.17"
1950 + source = "registry+https://github.com/rust-lang/crates.io-index"
1951 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1952 +
1953 + [[package]]
1844 1954 name = "minimal-lexical"
1845 1955 version = "0.2.1"
1846 1956 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1881,12 +1991,30 @@ dependencies = [
1881 1991 "russh-sftp",
1882 1992 "serde",
1883 1993 "serde_json",
1994 + "synckit-client",
1884 1995 "tokio",
1885 1996 "tracing",
1886 1997 "tracing-subscriber",
1887 1998 ]
1888 1999
1889 2000 [[package]]
2001 + name = "native-tls"
2002 + version = "0.2.18"
2003 + source = "registry+https://github.com/rust-lang/crates.io-index"
2004 + checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
2005 + dependencies = [
2006 + "libc",
2007 + "log",
2008 + "openssl",
2009 + "openssl-probe",
2010 + "openssl-sys",
2011 + "schannel",
2012 + "security-framework",
2013 + "security-framework-sys",
2014 + "tempfile",
2015 + ]
2016 +
2017 + [[package]]
1890 2018 name = "nix"
1891 2019 version = "0.29.0"
1892 2020 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1915,7 +2043,7 @@ version = "0.50.3"
1915 2043 source = "registry+https://github.com/rust-lang/crates.io-index"
1916 2044 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
1917 2045 dependencies = [
1918 - "windows-sys 0.60.2",
2046 + "windows-sys 0.61.2",
1919 2047 ]
1920 2048
1921 2049 [[package]]
@@ -2023,6 +2151,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2023 2151 checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
2024 2152
2025 2153 [[package]]
2154 + name = "openssl"
2155 + version = "0.10.80"
2156 + source = "registry+https://github.com/rust-lang/crates.io-index"
2157 + checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
2158 + dependencies = [
2159 + "bitflags 2.11.0",
2160 + "cfg-if",
2161 + "foreign-types",
2162 + "libc",
2163 + "openssl-macros",
2164 + "openssl-sys",
2165 + ]
2166 +
2167 + [[package]]
2168 + name = "openssl-macros"
2169 + version = "0.1.1"
2170 + source = "registry+https://github.com/rust-lang/crates.io-index"
2171 + checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
2172 + dependencies = [
2173 + "proc-macro2",
2174 + "quote",
2175 + "syn 2.0.117",
2176 + ]
2177 +
2178 + [[package]]
2179 + name = "openssl-probe"
2180 + version = "0.2.1"
2181 + source = "registry+https://github.com/rust-lang/crates.io-index"
2182 + checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
2183 +
2184 + [[package]]
2185 + name = "openssl-sys"
2186 + version = "0.9.116"
2187 + source = "registry+https://github.com/rust-lang/crates.io-index"
2188 + checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
2189 + dependencies = [
2190 + "cc",
2191 + "libc",
2192 + "pkg-config",
2193 + "vcpkg",
2194 + ]
2195 +
2196 + [[package]]
2026 2197 name = "ordered-float"
2027 2198 version = "4.6.0"
2028 2199 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2317,6 +2488,12 @@ dependencies = [
2317 2488 ]
2318 2489
2319 2490 [[package]]
2491 + name = "pkg-config"
2492 + version = "0.3.33"
2493 + source = "registry+https://github.com/rust-lang/crates.io-index"
2494 + checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
2495 +
2496 + [[package]]
2320 2497 name = "poly1305"
2321 2498 version = "0.8.0"
2322 2499 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2691,15 +2868,20 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
2691 2868 dependencies = [
2692 2869 "base64",
2693 2870 "bytes",
2871 + "encoding_rs",
2694 2872 "futures-core",
2873 + "h2",
2695 2874 "http",
2696 2875 "http-body",
2697 2876 "http-body-util",
2698 2877 "hyper",
2699 2878 "hyper-rustls",
2879 + "hyper-tls",
2700 2880 "hyper-util",
2701 2881 "js-sys",
2702 2882 "log",
2883 + "mime",
2884 + "native-tls",
2703 2885 "percent-encoding",
2704 2886 "pin-project-lite",
2705 2887 "quinn",
@@ -2710,6 +2892,7 @@ dependencies = [
2710 2892 "serde_urlencoded",
2711 2893 "sync_wrapper",
2712 2894 "tokio",
2895 + "tokio-native-tls",
2713 2896 "tokio-rustls",
2714 2897 "tower",
2715 2898 "tower-http",
@@ -2891,7 +3074,7 @@ dependencies = [
2891 3074 "errno",
2892 3075 "libc",
2893 3076 "linux-raw-sys",
2894 - "windows-sys 0.59.0",
3077 + "windows-sys 0.61.2",
2895 3078 ]
2896 3079
2897 3080 [[package]]
@@ -2951,6 +3134,15 @@ dependencies = [
2951 3134 ]
2952 3135
2953 3136 [[package]]
3137 + name = "schannel"
3138 + version = "0.1.29"
3139 + source = "registry+https://github.com/rust-lang/crates.io-index"
3140 + checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
3141 + dependencies = [
3142 + "windows-sys 0.61.2",
3143 + ]
3144 +
3145 + [[package]]
2954 3146 name = "scopeguard"
2955 3147 version = "1.2.0"
2956 3148 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2982,6 +3174,29 @@ dependencies = [
2982 3174 ]
2983 3175
2984 3176 [[package]]
3177 + name = "security-framework"
3178 + version = "3.7.0"
3179 + source = "registry+https://github.com/rust-lang/crates.io-index"
3180 + checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
3181 + dependencies = [
3182 + "bitflags 2.11.0",
3183 + "core-foundation 0.10.1",
3184 + "core-foundation-sys",
3185 + "libc",
3186 + "security-framework-sys",
3187 + ]
3188 +
3189 + [[package]]
3190 + name = "security-framework-sys"
3191 + version = "2.17.0"
3192 + source = "registry+https://github.com/rust-lang/crates.io-index"
3193 + checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
3194 + dependencies = [
3195 + "core-foundation-sys",
3196 + "libc",
3197 + ]
3198 +
3199 + [[package]]
2985 3200 name = "seize"
2986 3201 version = "0.3.3"
2987 3202 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3199,7 +3414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3199 3414 checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
3200 3415 dependencies = [
3201 3416 "libc",
3202 - "windows-sys 0.60.2",
3417 + "windows-sys 0.61.2",
3203 3418 ]
3204 3419
3205 3420 [[package]]
@@ -3334,6 +3549,30 @@ dependencies = [
3334 3549 ]
3335 3550
3336 3551 [[package]]
3552 + name = "synckit-client"
3553 + version = "0.4.0"
3554 + dependencies = [
3555 + "argon2",
3556 + "base64",
3557 + "bytes",
3558 + "chacha20poly1305",
3559 + "chrono",
3560 + "parking_lot",
3561 + "rand 0.9.2",
3562 + "reqwest",
3563 + "serde",
3564 + "serde_json",
3565 + "thiserror 2.0.18",
3566 + "tokio",
3567 + "tokio-stream",
3568 + "tracing",
3569 + "unicode-normalization",
3570 + "urlencoding",
3571 + "uuid",
3572 + "zeroize",
3573 + ]
3574 +
3575 + [[package]]
3337 3576 name = "synstructure"
3338 3577 version = "0.13.2"
3339 3578 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3345,6 +3584,40 @@ dependencies = [
3345 3584 ]
3346 3585
3347 3586 [[package]]
3587 + name = "system-configuration"
3588 + version = "0.7.0"
3589 + source = "registry+https://github.com/rust-lang/crates.io-index"
3590 + checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
3591 + dependencies = [
3592 + "bitflags 2.11.0",
3593 + "core-foundation 0.9.4",
3594 + "system-configuration-sys",
3595 + ]
3596 +
3597 + [[package]]
3598 + name = "system-configuration-sys"
3599 + version = "0.6.0"
3600 + source = "registry+https://github.com/rust-lang/crates.io-index"
3601 + checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
3602 + dependencies = [
3603 + "core-foundation-sys",
3604 + "libc",
3605 + ]
3606 +
3607 + [[package]]
3608 + name = "tempfile"
3609 + version = "3.27.0"
3610 + source = "registry+https://github.com/rust-lang/crates.io-index"
3611 + checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
3612 + dependencies = [
3613 + "fastrand",
3614 + "getrandom 0.4.2",
3615 + "once_cell",
3616 + "rustix",
3617 + "windows-sys 0.61.2",
3618 + ]
3619 +
3620 + [[package]]
3348 3621 name = "terminfo"
3349 3622 version = "0.9.0"
3350 3623 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3561,6 +3834,16 @@ dependencies = [
3561 3834 ]
3562 3835
3563 3836 [[package]]
3837 + name = "tokio-native-tls"
3838 + version = "0.3.1"
3839 + source = "registry+https://github.com/rust-lang/crates.io-index"
3840 + checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
3841 + dependencies = [
3842 + "native-tls",
3843 + "tokio",
3844 + ]
3845 +
3846 + [[package]]
3564 3847 name = "tokio-rustls"
3565 3848 version = "0.26.4"
3566 3849 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3571,6 +3854,17 @@ dependencies = [
3571 3854 ]
3572 3855
3573 3856 [[package]]
3857 + name = "tokio-stream"
3858 + version = "0.1.18"
3859 + source = "registry+https://github.com/rust-lang/crates.io-index"
3860 + checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
3861 + dependencies = [
3862 + "futures-core",
3863 + "pin-project-lite",
3864 + "tokio",
3865 + ]
3866 +
3867 + [[package]]
3574 3868 name = "tokio-util"
3575 3869 version = "0.7.18"
3576 3870 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3714,6 +4008,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3714 4008 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
3715 4009
3716 4010 [[package]]
4011 + name = "unicode-normalization"
4012 + version = "0.1.25"
4013 + source = "registry+https://github.com/rust-lang/crates.io-index"
4014 + checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
4015 + dependencies = [
4016 + "tinyvec",
4017 + ]
4018 +
4019 + [[package]]
Lines truncated
@@ -16,3 +16,4 @@ tracing = "0.1"
16 16 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
17 17 anyhow = "1"
18 18 bytes = "1"
19 + synckit-client = { path = "../shared/synckit-client", default-features = false }
@@ -8,6 +8,7 @@ mod api;
8 8 mod commands;
9 9 mod config;
10 10 mod format;
11 + mod ota;
11 12 mod rate_limit;
12 13 mod ssh;
13 14 mod staging;
@@ -23,6 +24,13 @@ use tracing_subscriber::EnvFilter;
23 24
24 25 #[tokio::main]
25 26 async fn main() -> anyhow::Result<()> {
27 + // One-shot operator subcommand: `mnw-cli ota publish ...`. Routed before the
28 + // SSH daemon boots so the same binary doubles as the OTA publisher.
29 + let argv: Vec<String> = std::env::args().collect();
30 + if argv.get(1).map(String::as_str) == Some("ota") {
31 + return ota::run(&argv[2..]).await;
32 + }
33 +
26 34 tracing_subscriber::fmt()
27 35 .with_env_filter(EnvFilter::from_default_env().add_directive("mnw_cli=info".parse()?))
28 36 .init();
@@ -0,0 +1,332 @@
1 + //! `mnw-cli ota publish` — typed OTA release publisher.
2 + //!
3 + //! Replaces the old `server/deploy/ota-publish.sh`. Authenticates against the
4 + //! MNW SyncKit API, creates a release, registers the artifact (which returns a
5 + //! presigned S3 PUT URL), uploads the bytes, and verifies the public Tauri
6 + //! updater endpoint now serves it.
7 + //!
8 + //! Invoked as `mnw-cli ota publish [flags]` — `main()` routes here before the
9 + //! SSH daemon starts when the first argument is `ota`.
10 +
11 + use std::path::PathBuf;
12 +
13 + use anyhow::{bail, Context, Result};
14 + use synckit_client::{SyncKitClient, SyncKitConfig};
15 +
16 + const DEFAULT_SERVER: &str = "https://makenot.work";
17 + const ALLOWED_TARGETS: &[&str] = &["linux", "darwin", "windows"];
18 + const ALLOWED_ARCHS: &[&str] = &["x86_64", "aarch64"];
19 +
20 + /// Entry point for the `ota` subcommand. `rest` is everything after `ota`.
21 + pub async fn run(rest: &[String]) -> Result<()> {
22 + match rest.first().map(String::as_str) {
23 + Some("publish") => publish(&rest[1..]).await,
24 + Some("-h") | Some("--help") | None => {
25 + print_usage();
26 + Ok(())
27 + }
28 + Some(other) => {
29 + eprintln!("Unknown ota subcommand: {other}\n");
30 + print_usage();
31 + std::process::exit(2);
32 + }
33 + }
34 + }
35 +
36 + fn print_usage() {
37 + eprintln!(
38 + "Usage: mnw-cli ota publish --slug SLUG --version X.Y.Z --target OS --arch ARCH --artifact FILE\n\
39 + \n\
40 + Required:\n\
41 + \x20 --slug App slug (e.g. goingson, audiofiles)\n\
42 + \x20 --version Semver version (e.g. 0.4.1)\n\
43 + \x20 --target Target OS: {}\n\
44 + \x20 --arch Architecture: {}\n\
45 + \x20 --artifact Path to the built artifact file\n\
46 + \n\
47 + Optional:\n\
48 + \x20 --notes Release notes (default: empty)\n\
49 + \x20 --signature Minisign signature for Tauri verification (REQUIRED for a working update)\n\
50 + \x20 --email MNW account email (env MNW_OTA_EMAIL)\n\
51 + \x20 --password MNW account password (env MNW_OTA_PASSWORD)\n\
52 + \x20 --api-key SyncKit app API key (env MNW_OTA_API_KEY)\n\
53 + \x20 --key SyncKit SDK key (env MNW_OTA_KEY)\n\
54 + \x20 --server Server URL (env MNW_OTA_SERVER, default {DEFAULT_SERVER})",
55 + ALLOWED_TARGETS.join(", "),
56 + ALLOWED_ARCHS.join(", "),
57 + );
58 + }
59 +
60 + struct PublishArgs {
61 + slug: String,
62 + version: String,
63 + target: String,
64 + arch: String,
65 + artifact: PathBuf,
66 + notes: String,
67 + signature: String,
68 + email: String,
69 + password: String,
70 + api_key: String,
71 + key: String,
72 + server: String,
73 + }
74 +
75 + // Manual Debug that redacts the credentials so they never reach logs or a
76 + // failing-test backtrace.
77 + impl std::fmt::Debug for PublishArgs {
78 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 + f.debug_struct("PublishArgs")
80 + .field("slug", &self.slug)
81 + .field("version", &self.version)
82 + .field("target", &self.target)
83 + .field("arch", &self.arch)
84 + .field("artifact", &self.artifact)
85 + .field("notes", &self.notes)
86 + .field("signature", &self.signature)
87 + .field("email", &self.email)
88 + .field("password", &"<redacted>")
89 + .field("api_key", &"<redacted>")
90 + .field("key", &"<redacted>")
91 + .field("server", &self.server)
92 + .finish()
93 + }
94 + }
95 +
96 + /// Parse flags with environment-variable fallbacks for the credentials.
97 + fn parse_args(flags: &[String]) -> Result<PublishArgs> {
98 + let mut slug = None;
99 + let mut version = None;
100 + let mut target = None;
101 + let mut arch = None;
102 + let mut artifact = None;
103 + let mut notes = String::new();
104 + let mut signature = String::new();
105 + let mut email = std::env::var("MNW_OTA_EMAIL").ok();
106 + let mut password = std::env::var("MNW_OTA_PASSWORD").ok();
107 + let mut api_key = std::env::var("MNW_OTA_API_KEY").ok();
108 + let mut key = std::env::var("MNW_OTA_KEY").ok();
109 + let mut server = std::env::var("MNW_OTA_SERVER").unwrap_or_else(|_| DEFAULT_SERVER.to_string());
110 +
111 + let mut it = flags.iter();
112 + while let Some(flag) = it.next() {
113 + let mut take = |name: &str| -> Result<String> {
114 + it.next()
115 + .cloned()
116 + .with_context(|| format!("{name} requires a value"))
117 + };
118 + match flag.as_str() {
119 + "--slug" => slug = Some(take("--slug")?),
120 + "--version" => version = Some(take("--version")?),
121 + "--target" => target = Some(take("--target")?),
122 + "--arch" => arch = Some(take("--arch")?),
123 + "--artifact" => artifact = Some(PathBuf::from(take("--artifact")?)),
124 + "--notes" => notes = take("--notes")?,
125 + "--signature" => signature = take("--signature")?,
126 + "--email" => email = Some(take("--email")?),
127 + "--password" => password = Some(take("--password")?),
128 + "--api-key" => api_key = Some(take("--api-key")?),
129 + "--key" => key = Some(take("--key")?),
130 + "--server" => server = take("--server")?,
131 + "-h" | "--help" => {
132 + print_usage();
133 + std::process::exit(0);
134 + }
135 + other => bail!("Unknown flag: {other}"),
136 + }
137 + }
138 +
139 + let missing = |name: &str| anyhow::anyhow!("missing required {name}");
140 + let target = target.ok_or_else(|| missing("--target"))?;
141 + let arch = arch.ok_or_else(|| missing("--arch"))?;
142 +
143 + if !ALLOWED_TARGETS.contains(&target.as_str()) {
144 + bail!("invalid --target '{target}'. Allowed: {}", ALLOWED_TARGETS.join(", "));
145 + }
146 + if !ALLOWED_ARCHS.contains(&arch.as_str()) {
147 + bail!("invalid --arch '{arch}'. Allowed: {}", ALLOWED_ARCHS.join(", "));
148 + }
149 +
150 + Ok(PublishArgs {
151 + slug: slug.ok_or_else(|| missing("--slug"))?,
152 + version: version.ok_or_else(|| missing("--version"))?,
153 + target,
154 + arch,
155 + artifact: artifact.ok_or_else(|| missing("--artifact"))?,
156 + notes,
157 + signature,
158 + email: email.ok_or_else(|| missing("--email / MNW_OTA_EMAIL"))?,
159 + password: password.ok_or_else(|| missing("--password / MNW_OTA_PASSWORD"))?,
160 + api_key: api_key.ok_or_else(|| missing("--api-key / MNW_OTA_API_KEY"))?,
161 + key: key.ok_or_else(|| missing("--key / MNW_OTA_KEY"))?,
162 + server,
163 + })
164 + }
165 +
166 + async fn publish(flags: &[String]) -> Result<()> {
167 + let args = parse_args(flags)?;
168 +
169 + let bytes = std::fs::read(&args.artifact)
170 + .with_context(|| format!("reading artifact {}", args.artifact.display()))?;
171 + let file_size: i64 = bytes
172 + .len()
173 + .try_into()
174 + .context("artifact is too large to publish")?;
175 + if file_size == 0 {
176 + bail!("artifact is empty: {}", args.artifact.display());
177 + }
178 +
179 + if args.signature.trim().is_empty() {
180 + eprintln!(
181 + "warning: --signature is empty. Tauri's updater silently refuses an update with no \
182 + signature, so installed apps will NOT apply this release. Publish with the minisign \
183 + signature of the artifact for a working update."
184 + );
185 + }
186 +
187 + println!(
188 + "Publishing {} v{} ({}/{}, {} bytes) to {}",
189 + args.slug, args.version, args.target, args.arch, file_size, args.server
190 + );
191 +
192 + let client = SyncKitClient::new(SyncKitConfig {
193 + server_url: args.server.clone(),
194 + api_key: args.api_key.clone(),
195 + });
196 +
197 + print!(" authenticating... ");
198 + client
199 + .authenticate(&args.email, &args.password, &args.key)
200 + .await
201 + .context("authentication failed")?;
202 + let app_id = client
203 + .session_info()
204 + .map(|s| s.app_id.to_string())
205 + .unwrap_or_default();
206 + println!("ok (app {app_id})");
207 +
208 + print!(" creating release v{}... ", args.version);
209 + let release = client
210 + .ota_create_release(&args.version, &args.notes, &args.signature)
211 + .await
212 + .context("create release failed")?;
213 + println!("ok (release {})", release.id);
214 +
215 + print!(" registering artifact... ");
216 + let upload = client
217 + .ota_register_artifact(release.id, &args.target, &args.arch, file_size)
218 + .await
219 + .context("register artifact failed")?;
220 + println!("ok ({})", upload.s3_key);
221 +
222 + print!(" uploading {file_size} bytes... ");
223 + client
224 + .ota_upload_artifact(&upload.upload_url, bytes)
225 + .await
226 + .context("artifact upload failed")?;
227 + println!("ok");
228 +
229 + print!(" verifying updater endpoint... ");
230 + match client
231 + .ota_updater_check(&args.slug, &args.target, &args.arch, "0.0.1")
232 + .await
233 + .context("updater check failed")?
234 + {
235 + Some(manifest) if manifest.version == args.version => {
236 + println!("ok (serving v{})", manifest.version);
237 + }
238 + Some(manifest) => {
239 + println!(
240 + "warning: updater serves v{} but just published v{} (a newer release may exist)",
241 + manifest.version, args.version
242 + );
243 + }
244 + None => {
245 + println!(
246 + "warning: updater returned no update (204). The release was created but is not \
247 + being served for {}/{} yet.",
248 + args.target, args.arch
249 + );
250 + }
251 + }
252 +
253 + println!(
254 + "\nPublished {} v{} ({}/{})\nUpdater URL: {}/api/v1/sync/ota/{}/{}/{}/{}",
255 + args.slug,
256 + args.version,
257 + args.target,
258 + args.arch,
259 + args.server.trim_end_matches('/'),
260 + args.slug,
261 + args.target,
262 + args.arch,
263 + args.version,
264 + );
265 + Ok(())
266 + }
267 +
268 + #[cfg(test)]
269 + mod tests {
270 + use super::*;
271 +
272 + fn base_flags() -> Vec<String> {
273 + [
274 + "--slug", "goingson",
275 + "--version", "0.4.1",
276 + "--target", "darwin",
277 + "--arch", "aarch64",
278 + "--artifact", "/tmp/x",
279 + "--email", "me@example.com",
280 + "--password", "pw",
281 + "--api-key", "ak",
282 + "--key", "sdk",
283 + "--server", "https://example.test",
284 + ]
285 + .iter()
286 + .map(|s| s.to_string())
287 + .collect()
288 + }
289 +
290 + #[test]
291 + fn parses_full_flag_set() {
292 + let a = parse_args(&base_flags()).unwrap();
293 + assert_eq!(a.slug, "goingson");
294 + assert_eq!(a.version, "0.4.1");
295 + assert_eq!(a.target, "darwin");
296 + assert_eq!(a.arch, "aarch64");
297 + assert_eq!(a.server, "https://example.test");
298 + assert!(a.notes.is_empty());
299 + }
300 +
301 + #[test]
302 + fn rejects_invalid_target() {
303 + let mut flags = base_flags();
304 + let i = flags.iter().position(|f| f == "darwin").unwrap();
305 + flags[i] = "macos".to_string();
306 + let err = parse_args(&flags).unwrap_err().to_string();
307 + assert!(err.contains("invalid --target"), "{err}");
308 + }
309 +
310 + #[test]
311 + fn rejects_invalid_arch() {
312 + let mut flags = base_flags();
313 + let i = flags.iter().position(|f| f == "aarch64").unwrap();
314 + flags[i] = "arm64".to_string();
315 + let err = parse_args(&flags).unwrap_err().to_string();
316 + assert!(err.contains("invalid --arch"), "{err}");
317 + }
318 +
319 + #[test]
320 + fn missing_required_flag_is_reported() {
321 + // Drop the trailing --server pair and the --slug pair.
322 + let flags: Vec<String> = base_flags().into_iter().skip(2).collect(); // skip --slug goingson
323 + let err = parse_args(&flags).unwrap_err().to_string();
324 + assert!(err.contains("--slug"), "{err}");
325 + }
326 +
327 + #[test]
328 + fn flag_without_value_errors() {
329 + let err = parse_args(&["--slug".to_string()]).unwrap_err().to_string();
330 + assert!(err.contains("--slug requires a value"), "{err}");
331 + }
332 + }
@@ -1,6 +1,11 @@
1 1 #!/usr/bin/env bash
2 2 # ota-publish.sh — Publish an OTA update to makenot.work
3 3 #
4 + # DEPRECATED: superseded by `mnw-cli ota publish` (typed, tested, retried).
5 + # This script also predates the SyncKit SDK-key auth field, so it no longer
6 + # authenticates against the current server. Kept for reference only.
7 + # See: _private/docs/meta/ota-release-runbook.md
8 + #
4 9 # Usage:
5 10 # ./deploy/ota-publish.sh --slug goingson --version 0.2.2 --target linux --arch x86_64 \
6 11 # --artifact path/to/bundle.tar.gz [--notes "Bug fixes"] [--signature "..."]
@@ -53,11 +53,13 @@ mod auth;
53 53 mod blob;
54 54 mod encryption;
55 55 pub(crate) mod helpers;
56 + mod ota;
56 57 mod rotation;
57 58 mod subscribe;
58 59 pub mod subscription;
59 60 mod sync;
60 61
62 + pub use ota::{OtaArtifactUpload, OtaManifest, OtaRelease};
61 63 pub use subscribe::SyncNotifyStream;
62 64
63 65 use parking_lot::RwLock;
@@ -0,0 +1,265 @@
1 + //! OTA (Over-The-Air) release publishing.
2 + //!
3 + //! These methods drive the app-owner side of the MNW OTA system: create a
4 + //! release, register an artifact (which returns a presigned S3 PUT URL), upload
5 + //! the bytes, and verify the public Tauri updater endpoint now serves it. They
6 + //! reuse the authenticated [`SyncKitClient`] session (JWT + app id), so a
7 + //! publisher authenticates once with [`authenticate`](SyncKitClient::authenticate)
8 + //! and then calls these in sequence.
9 + //!
10 + //! Unlike blobs, OTA artifacts are NOT end-to-end encrypted — they are public
11 + //! downloads served to every installed app, so the bytes are uploaded as-is.
12 + //!
13 + //! Server contract: `server/src/routes/ota.rs`. The Tauri updater manifest
14 + //! ([`OtaManifest`]) field names are load-bearing — Tauri's updater plugin
15 + //! deserializes exactly these five fields.
16 +
17 + use bytes::Bytes;
18 + use serde::{Deserialize, Serialize};
19 + use tracing::instrument;
20 + use uuid::Uuid;
21 +
22 + use super::helpers::check_response;
23 + use super::SyncKitClient;
24 + use crate::error::Result;
25 +
26 + /// A created OTA release (subset of the server's release response).
27 + #[derive(Debug, Clone, Deserialize)]
28 + pub struct OtaRelease {
29 + /// Release id, used when registering artifacts.
30 + pub id: Uuid,
31 + pub version: String,
32 + pub notes: String,
33 + pub signature: String,
34 + }
35 +
36 + /// A presigned upload target for an OTA artifact.
37 + #[derive(Debug, Clone, Deserialize)]
38 + pub struct OtaArtifactUpload {
39 + /// Presigned S3 PUT URL — upload the artifact bytes here.
40 + pub upload_url: String,
41 + /// The S3 object key the artifact will live at.
42 + pub s3_key: String,
43 + }
44 +
45 + /// The Tauri-compatible updater manifest returned by the public updater check.
46 + ///
47 + /// Field names mirror the server's `TauriUpdaterResponse` exactly; Tauri's
48 + /// updater plugin reads these five and nothing else.
49 + #[derive(Debug, Clone, Deserialize)]
50 + pub struct OtaManifest {
51 + pub version: String,
52 + pub url: String,
53 + pub signature: String,
54 + pub notes: String,
55 + pub pub_date: String,
56 + }
57 +
58 + #[derive(Serialize)]
59 + struct CreateReleaseBody<'a> {
60 + version: &'a str,
61 + notes: &'a str,
62 + signature: &'a str,
63 + }
64 +
65 + #[derive(Serialize)]
66 + struct RegisterArtifactBody<'a> {
67 + target: &'a str,
68 + arch: &'a str,
69 + file_size: i64,
70 + }
71 +
72 + impl SyncKitClient {
73 + /// Base URL for OTA management endpoints scoped to the authenticated app.
74 + fn ota_app_base(&self) -> Result<String> {
75 + let (app_id, _user_id) = self.require_session_ids()?;
76 + let base = self.config().server_url.trim_end_matches('/');
77 + Ok(format!("{base}/api/v1/sync/ota/apps/{app_id}"))
78 + }
79 +
80 + /// Create a new OTA release for the authenticated app.
81 + ///
82 + /// Pass an empty `signature` only for unsigned platforms — Tauri's updater
83 + /// silently refuses an update whose manifest signature is empty, so a real
84 + /// release must carry the minisign signature of the artifact.
85 + #[instrument(skip(self, signature))]
86 + pub async fn ota_create_release(
87 + &self,
88 + version: &str,
89 + notes: &str,
90 + signature: &str,
91 + ) -> Result<OtaRelease> {
92 + let token = self.require_token()?;
93 + let url = format!("{}/releases", self.ota_app_base()?);
94 +
95 + let body = Bytes::from(serde_json::to_vec(&CreateReleaseBody {
96 + version,
97 + notes,
98 + signature,
99 + })?);
100 +
101 + self.retry_request_json(|| {
102 + let req = self
103 + .http
104 + .post(&url)
105 + .bearer_auth(&token)
106 + .header("content-type", "application/json")
107 + .body(body.clone());
108 + async move { check_response(req.send().await?).await }
109 + })
110 + .await
111 + }
112 +
113 + /// Register an artifact for a release and obtain a presigned upload URL.
114 + ///
115 + /// `target` is the OS (`linux`/`darwin`/`windows`), `arch` is the CPU
116 + /// (`x86_64`/`aarch64`), and `file_size` is the artifact size in bytes.
117 + #[instrument(skip(self))]
118 + pub async fn ota_register_artifact(
119 + &self,
120 + release_id: Uuid,
121 + target: &str,
122 + arch: &str,
123 + file_size: i64,
124 + ) -> Result<OtaArtifactUpload> {
125 + let token = self.require_token()?;
126 + let url = format!("{}/releases/{release_id}/artifacts", self.ota_app_base()?);
127 +
128 + let body = Bytes::from(serde_json::to_vec(&RegisterArtifactBody {
129 + target,
130 + arch,
131 + file_size,
132 + })?);
133 +
134 + self.retry_request_json(|| {
135 + let req = self
136 + .http
137 + .post(&url)
138 + .bearer_auth(&token)
139 + .header("content-type", "application/json")
140 + .body(body.clone());
141 + async move { check_response(req.send().await?).await }
142 + })
143 + .await
144 + }
145 +
146 + /// Upload artifact bytes to S3 via a presigned PUT URL.
147 + ///
148 + /// The bytes are sent as-is (no encryption — OTA artifacts are public).
149 + #[instrument(skip(self, presigned_url, data))]
150 + pub async fn ota_upload_artifact(&self, presigned_url: &str, data: Vec<u8>) -> Result<()> {
151 + let data = Bytes::from(data);
152 + self.retry_request(|| {
153 + let req = self
154 + .http
155 + .put(presigned_url)
156 + .header("content-type", "application/octet-stream")
157 + .body(data.clone());
158 + async move { check_response(req.send().await?).await }
159 + })
160 + .await?;
161 + Ok(())
162 + }
163 +
164 + /// Check the public Tauri updater endpoint.
165 + ///
166 + /// Returns `Some(manifest)` when a newer version than `current_version` is
167 + /// available for `slug`/`target`/`arch`, or `None` when the client is up to
168 + /// date (HTTP 204). Use this to verify a freshly published release is live.
169 + #[instrument(skip(self))]
170 + pub async fn ota_updater_check(
171 + &self,
172 + slug: &str,
173 + target: &str,
174 + arch: &str,
175 + current_version: &str,
176 + ) -> Result<Option<OtaManifest>> {
177 + let base = self.config().server_url.trim_end_matches('/');
178 + let url = format!("{base}/api/v1/sync/ota/{slug}/{target}/{arch}/{current_version}");
179 +
180 + let resp = self
181 + .retry_request(|| {
182 + let req = self.http.get(&url);
183 + async move { check_response(req.send().await?).await }
184 + })
185 + .await?;
186 +
187 + if resp.status() == reqwest::StatusCode::NO_CONTENT {
188 + return Ok(None);
189 + }
190 +
191 + Ok(Some(resp.json::<OtaManifest>().await?))
192 + }
193 + }
194 +
195 + #[cfg(test)]
196 + mod tests {
197 + use super::*;
198 +
199 + #[test]
200 + fn create_release_body_serializes_expected_fields() {
201 + let body = CreateReleaseBody {
202 + version: "0.4.1",
203 + notes: "Bug fixes",
204 + signature: "RWS...==",
205 + };
206 + let v: serde_json::Value = serde_json::to_value(&body).unwrap();
207 + assert_eq!(v["version"], "0.4.1");
208 + assert_eq!(v["notes"], "Bug fixes");
209 + assert_eq!(v["signature"], "RWS...==");
210 + }
211 +
212 + #[test]
213 + fn register_artifact_body_serializes_expected_fields() {
214 + let body = RegisterArtifactBody {
215 + target: "darwin",
216 + arch: "aarch64",
217 + file_size: 12_345,
218 + };
219 + let v: serde_json::Value = serde_json::to_value(&body).unwrap();
220 + assert_eq!(v["target"], "darwin");
221 + assert_eq!(v["arch"], "aarch64");
222 + assert_eq!(v["file_size"], 12_345);
223 + }
224 +
225 + #[test]
226 + fn release_response_deserializes_with_uuid_id() {
227 + let json = r#"{
228 + "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
229 + "version": "0.4.1",
230 + "notes": "",
231 + "signature": "RWS=",
232 + "pub_date": "2026-06-07T00:00:00Z",
233 + "created_at": "2026-06-07T00:00:00Z"
234 + }"#;
235 + let r: OtaRelease = serde_json::from_str(json).unwrap();
236 + assert_eq!(r.version, "0.4.1");
237 + assert_eq!(
238 + r.id,
239 + Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap()
240 + );
241 + }
242 +
243 + #[test]
244 + fn artifact_upload_response_deserializes() {
245 + let json = r#"{"upload_url": "https://s3.example/put?sig=abc", "s3_key": "ota/app/0.4.1/darwin/aarch64/artifact"}"#;
246 + let u: OtaArtifactUpload = serde_json::from_str(json).unwrap();
247 + assert_eq!(u.upload_url, "https://s3.example/put?sig=abc");
248 + assert!(u.s3_key.ends_with("/artifact"));
249 + }
250 +
251 + #[test]
252 + fn manifest_deserializes_tauri_five_fields() {
253 + let json = r#"{
254 + "version": "0.4.1",
255 + "url": "https://makenot.work/api/sync/ota/goingson/download/abc/darwin/aarch64",
256 + "signature": "RWS=",
257 + "notes": "Bug fixes",
258 + "pub_date": "2026-06-07T00:00:00+00:00"
259 + }"#;
260 + let m: OtaManifest = serde_json::from_str(json).unwrap();
261 + assert_eq!(m.version, "0.4.1");
262 + assert!(m.url.contains("/download/"));
263 + assert_eq!(m.signature, "RWS=");
264 + }
265 + }
@@ -11,16 +11,25 @@
11 11 //! `load_key` will return a `Keychain` error.
12 12 //! - **Windows**: Credential Manager.
13 13
14 - use crate::error::{Result, SyncKitError};
14 + use crate::error::Result;
15 + // Only the keychain code paths (and the test module) construct SyncKitError
16 + // directly; without the feature a plain lib build would see it as unused.
17 + #[cfg(any(feature = "keychain", test))]
18 + use crate::error::SyncKitError;
19 + #[cfg(any(feature = "keychain", test))]
15 20 use base64::{engine::general_purpose::STANDARD as B64, Engine};
16 21 use uuid::Uuid;
17 22
23 + // These keychain helpers are only referenced by the keychain code paths and the
24 + // test module; gate them so a no-keychain lib build stays warning-clean.
25 + #[cfg(any(feature = "keychain", test))]
18 26 const SERVICE_PREFIX: &str = "synckit";
19 27
20 28 /// Build the keychain service name: `"synckit:<app_id>"`.
21 29 ///
22 30 /// Each SyncKit app gets its own keychain namespace so that keys from
23 31 /// different apps never collide.
32 + #[cfg(any(feature = "keychain", test))]
24 33 fn service_name(app_id: Uuid) -> String {
25 34 format!("{SERVICE_PREFIX}:{app_id}")
26 35 }
@@ -29,6 +38,7 @@ fn service_name(app_id: Uuid) -> String {
29 38 ///
30 39 /// Combined with `service_name`, this uniquely identifies the keychain entry
31 40 /// for a given (app, user) pair.
41 + #[cfg(any(feature = "keychain", test))]
32 42 fn user_key(user_id: Uuid) -> String {
33 43 user_id.to_string()
34 44 }
@@ -51,6 +51,7 @@ pub mod types;
51 51
52 52 // Re-exports for convenience
53 53 pub use client::{validate_api_key, SessionInfo, SyncKitClient, SyncKitConfig, SyncNotifyStream};
54 + pub use client::{OtaArtifactUpload, OtaManifest, OtaRelease};
54 55 pub use client::subscription::{
55 56 AccountInfo, AppPricing, BillingInterval, CheckoutResponse, PriceQuote, SubscriptionStatus,
56 57 };