diff --git a/Cargo.lock b/Cargo.lock index 53e0f097d..0fb8bbec0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ dependencies = [ "accesskit", "accesskit_consumer 0.38.0", "hashbrown 0.16.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -220,21 +220,21 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "x11rb", ] @@ -352,13 +352,13 @@ dependencies = [ [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -393,7 +393,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -447,9 +447,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -458,9 +458,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -495,33 +495,37 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bit-set" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ - "bit-vec 0.8.0", + "bit-vec 0.9.1", ] [[package]] name = "bit-set" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" dependencies = [ - "bit-vec 0.9.1", + "bit-vec 0.10.1", ] [[package]] name = "bit-vec" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bit-vec" -version = "0.9.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] [[package]] name = "bitflags" @@ -571,7 +575,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -580,7 +584,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -649,7 +653,7 @@ dependencies = [ "oneshot", "pastey", "portable-atomic", - "rand 0.10.3", + "rand", "regress", "rustc-hash 2.1.3", "ryu-js", @@ -704,7 +708,7 @@ dependencies = [ "cow-utils", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -740,6 +744,30 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bp256" version = "0.14.0" @@ -808,7 +836,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -839,14 +867,13 @@ dependencies = [ [[package]] name = "calloop" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ "bitflags 2.13.2", "polling", "rustix 1.1.5", - "slab", "tracing", ] @@ -868,7 +895,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ - "calloop 0.14.4", + "calloop 0.14.5", "rustix 1.1.5", "wayland-backend", "wayland-client", @@ -885,9 +912,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.5.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "jobserver", @@ -1010,6 +1037,17 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "collabcraft-share" +version = "0.1.0" +source = "git+https://github.com/wdunn001/collab-craft?rev=23a3b6612eb73a51d54139e25658c94689adbb06#23a3b6612eb73a51d54139e25658c94689adbb06" +dependencies = [ + "craft-cloud", + "craft-cloud-client", + "craft-ui", + "wasm-bindgen-futures", +] + [[package]] name = "color" version = "0.3.3" @@ -1027,9 +1065,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -1142,11 +1180,62 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-cloud" +version = "0.1.0" +source = "git+https://github.com/wdunn001/craft-libs?rev=ffdf0556cefc0e7c14ceb32bf4138609702c6f13#ffdf0556cefc0e7c14ceb32bf4138609702c6f13" +dependencies = [ + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "craft-cloud-client" +version = "0.1.0" +source = "git+https://github.com/wdunn001/collab-craft?rev=23a3b6612eb73a51d54139e25658c94689adbb06#23a3b6612eb73a51d54139e25658c94689adbb06" +dependencies = [ + "craft-cloud", + "craft-collab", + "craft-esign", + "js-sys", + "log", + "serde", + "serde_json", + "thiserror 2.0.21", + "ureq", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "craft-collab" +version = "0.1.0" +source = "git+https://github.com/wdunn001/collab-craft?rev=23a3b6612eb73a51d54139e25658c94689adbb06#23a3b6612eb73a51d54139e25658c94689adbb06" +dependencies = [ + "js-sys", + "log", + "serde", + "serde_json", + "thiserror 2.0.21", + "wasm-bindgen", +] + [[package]] name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-esign" +version = "0.1.0" +source = "git+https://github.com/wdunn001/craft-libs?rev=ffdf0556cefc0e7c14ceb32bf4138609702c6f13#ffdf0556cefc0e7c14ceb32bf4138609702c6f13" +dependencies = [ + "craft-cloud", + "serde", + "thiserror 2.0.21", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -1167,17 +1256,20 @@ dependencies = [ "dispatch2", "egui", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", ] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/wdunn001/craft-libs?rev=ffdf0556cefc0e7c14ceb32bf4138609702c6f13#ffdf0556cefc0e7c14ceb32bf4138609702c6f13" dependencies = [ + "craft-cloud", + "craft-esign", "egui", "serde", + "serde_json", ] [[package]] @@ -1291,9 +1383,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", "subtle", @@ -1389,9 +1481,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "des" @@ -1439,7 +1531,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1450,7 +1542,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1529,7 +1621,7 @@ dependencies = [ "elliptic-curve", "rfc6979", "signature 3.0.0", - "spki 0.8.0", + "spki 0.8.1", "zeroize", ] @@ -1561,7 +1653,7 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", - "sha2 0.11.0", + "sha2 0.11.1", "subtle", "zeroize", ] @@ -1585,7 +1677,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1655,7 +1747,7 @@ dependencies = [ "bytemuck", "egui", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", @@ -1716,9 +1808,9 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "elliptic-curve" @@ -1833,7 +1925,7 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types 0.12.5", + "font-types 0.12.7", "harfrust", "log", "nohash-hasher", @@ -1876,9 +1968,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1887,7 +1979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2057,7 +2149,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -2085,9 +2177,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.5" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", "serde", @@ -2120,7 +2212,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2200,7 +2292,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2370,7 +2462,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2916,9 +3008,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "3ce983bb6fb399067d101fbfc9e74174e07e9f29a2bc679efefeb8c645819c3f" dependencies = [ "block-padding", "hybrid-array", @@ -3102,9 +3194,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -3131,7 +3223,7 @@ dependencies = [ "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.4", + "redox_syscall 0.9.5", ] [[package]] @@ -3160,9 +3252,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -3197,10 +3289,10 @@ dependencies = [ "log", "md-5", "nom", - "rand 0.10.3", + "rand", "rangemap", "rayon", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", "weezl 0.2.1", @@ -3302,13 +3394,13 @@ dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3470,9 +3562,9 @@ dependencies = [ [[package]] name = "num_threads" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" dependencies = [ "libc", ] @@ -3485,9 +3577,9 @@ checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" [[package]] name = "objc2" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" dependencies = [ "objc-sys", "objc2-encode", @@ -3495,9 +3587,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -3511,7 +3603,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "libc", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-data", "objc2-core-image", "objc2-foundation 0.2.2", @@ -3526,7 +3618,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3540,7 +3632,7 @@ checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", ] @@ -3552,7 +3644,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3564,7 +3656,7 @@ checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3576,7 +3668,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3587,7 +3679,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3599,7 +3691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal 0.2.2", ] @@ -3611,7 +3703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-contacts", "objc2-foundation 0.2.2", ] @@ -3632,7 +3724,7 @@ dependencies = [ "block2 0.5.1", "dispatch", "libc", - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -3643,7 +3735,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3654,7 +3746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3665,7 +3757,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -3678,7 +3770,7 @@ checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3690,7 +3782,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3702,7 +3794,7 @@ checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal 0.2.2", ] @@ -3714,7 +3806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3727,7 +3819,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3739,7 +3831,7 @@ checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-cloud-kit", "objc2-core-data", "objc2-core-image", @@ -3759,7 +3851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -3771,7 +3863,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3783,7 +3875,7 @@ checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", ] @@ -3860,7 +3952,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3874,7 +3966,7 @@ dependencies = [ "fiat-crypto", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3888,7 +3980,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3940,6 +4032,8 @@ dependencies = [ name = "pdfcraft" version = "0.9.0" dependencies = [ + "collabcraft-share", + "craft-cloud-client", "craft-quit", "eframe", "egui", @@ -4012,7 +4106,7 @@ dependencies = [ "pdfcraft-xfa", "png 0.18.1", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tempfile", ] @@ -4081,7 +4175,7 @@ dependencies = [ "aes", "md-5", "proptest", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", ] @@ -4343,7 +4437,7 @@ dependencies = [ "rustls-cng", "security-framework", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "sha3", "thiserror 2.0.21", ] @@ -4359,6 +4453,8 @@ dependencies = [ "arboard", "base64 0.22.1", "bytemuck", + "craft-cloud", + "craft-esign", "craft-quit", "craft-ui", "eframe", @@ -4370,7 +4466,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "pdfcraft-cos", @@ -4400,10 +4496,15 @@ dependencies = [ name = "pdfcraft-web" version = "0.9.0" dependencies = [ + "collabcraft-share", + "craft-cloud-client", "eframe", "egui", "js-sys", + "log", "pdfcraft-ui-egui", + "serde_json", + "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] @@ -4418,7 +4519,7 @@ dependencies = [ "pdfcraft-fonts", "png 0.18.1", "roxmltree", - "sha2 0.11.0", + "sha2 0.11.1", "thiserror 2.0.21", ] @@ -4621,12 +4722,12 @@ dependencies = [ [[package]] name = "pkcs1" -version = "0.8.0-rc.4" +version = "0.8.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +checksum = "f485dbafe293ea6ccaa7d82bc591651a796eb8a9aec3df040b057b706073dbc3" dependencies = [ "der 0.8.2", - "spki 0.8.0", + "spki 0.8.1", ] [[package]] @@ -4636,7 +4737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ "der 0.8.2", - "spki 0.8.0", + "spki 0.8.1", ] [[package]] @@ -4738,18 +4839,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "presser" @@ -4834,16 +4926,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -4908,7 +5001,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.3", + "rand", "rand_pcg", "ring", "rustc-hash 2.1.3", @@ -4932,7 +5025,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4956,16 +5049,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -4977,31 +5060,12 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -5019,11 +5083,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5050,7 +5114,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -5102,7 +5166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.5", + "font-types 0.12.7", "once_cell", ] @@ -5113,7 +5177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", - "font-types 0.12.5", + "font-types 0.12.7", "once_cell", ] @@ -5137,9 +5201,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" dependencies = [ "bitflags 2.13.2", ] @@ -5243,7 +5307,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5469,7 +5533,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5482,7 +5546,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5514,9 +5578,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5552,7 +5616,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5614,9 +5678,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" dependencies = [ "windows-sys 0.61.2", ] @@ -5709,14 +5773,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5744,14 +5808,14 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -5813,9 +5877,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -5971,7 +6035,7 @@ dependencies = [ "const-oid 0.9.6", "der 0.7.10", "hex", - "rand 0.10.3", + "rand", "reqwest", "rustls-pki-types", "rustls-webpki", @@ -6121,9 +6185,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" dependencies = [ "serde", ] @@ -6160,7 +6224,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ "bitflags 2.13.2", - "calloop 0.14.4", + "calloop 0.14.5", "calloop-wayland-source 0.4.1", "cursor-icon", "libc", @@ -6207,7 +6271,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6231,9 +6295,9 @@ dependencies = [ [[package]] name = "spki" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" dependencies = [ "base64ct", "der 0.8.2", @@ -6337,9 +6401,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -6363,7 +6427,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6426,10 +6490,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6443,9 +6507,9 @@ dependencies = [ [[package]] name = "thin-vec" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748" +checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e" [[package]] name = "thiserror" @@ -6484,7 +6548,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6599,9 +6663,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -6637,9 +6701,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -6652,18 +6716,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", "toml_datetime", @@ -6673,18 +6737,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -6798,7 +6862,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6821,15 +6885,15 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] @@ -6871,19 +6935,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ "base64 0.23.1", + "flate2", "log", "percent-encoding", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "ureq-proto", "utf8-zero", + "webpki-roots", ] [[package]] name = "ureq-proto" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64 0.23.1", "http", @@ -6945,9 +7012,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.1" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "js-sys", "serde_core", @@ -7118,7 +7185,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -7295,7 +7362,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", @@ -7311,6 +7378,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -7451,7 +7527,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -7512,7 +7588,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7633,16 +7709,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -7660,31 +7736,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -7702,96 +7761,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -7813,7 +7824,7 @@ dependencies = [ "libc", "memmap2", "ndk", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "objc2-ui-kit 0.2.2", @@ -7873,9 +7884,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -8038,7 +8049,7 @@ dependencies = [ "png 0.18.1", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "toml", ] @@ -8061,7 +8072,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -8133,7 +8144,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -8141,9 +8152,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow", @@ -8173,18 +8184,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.59" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.59" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -8208,15 +8219,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -8262,7 +8273,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -8294,9 +8305,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", @@ -8309,14 +8320,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -8329,6 +8340,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index ec8909802..a7e9b3a09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,16 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-ui = { git = "https://github.com/wdunn001/craft-libs", version = "0.1.0", rev = "ffdf0556cefc0e7c14ceb32bf4138609702c6f13", features = ["serde", "cloud", "esign"] } +# Provider-neutral cloud types (Entry, Capability, Permission). Types and contracts only: the +# crate opens no socket and names no provider, so this builds for the web too. +craft-cloud = { git = "https://github.com/wdunn001/craft-libs", version = "0.1.0", rev = "3aa7841deee2f9919a4dcca55487db5063293654" } +craft-esign = { git = "https://github.com/wdunn001/craft-libs", version = "0.1.0", rev = "3aa7841deee2f9919a4dcca55487db5063293654" } +# How the desktop and browser apps reach CollabCraft. No provider credential or URL compiled in. +craft-cloud-client = { git = "https://github.com/wdunn001/collab-craft", rev = "23a3b6612eb73a51d54139e25658c94689adbb06" } +# The share dialog's backend: the half that opens a socket, kept here so only one session lives +# in the process and a sign-in through Open/Save reaches the share dialog too. +collabcraft-share = { git = "https://github.com/wdunn001/collab-craft", rev = "23a3b6612eb73a51d54139e25658c94689adbb06" } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" } # Shared system spooler; document composition remains in pdfcraft-print. diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index a64d729dc..798f51e0a 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -11,10 +11,17 @@ workspace = true [dependencies] pdfcraft-ui-egui = { workspace = true } +# Browser transport for Open and Save against CollabCraft. Session rides the cookie; no token file. +craft-cloud-client = { workspace = true } +# Share dialog backend for the browser build. +collabcraft-share = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] } egui = { workspace = true } +log = { workspace = true } +serde_json = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget", "Request", "RequestInit", "RequestCredentials"] } js-sys = "0.3" diff --git a/apps/pdfcraft-web/src/cloud.rs b/apps/pdfcraft-web/src/cloud.rs new file mode 100644 index 000000000..a62d84af8 --- /dev/null +++ b/apps/pdfcraft-web/src/cloud.rs @@ -0,0 +1,151 @@ +//! CollabCraft transport for the browser build. +//! +//! The same jobs and events as the desktop worker ([`pdfcraft::cloud`]), but +//! the client is async and the session rides the same-origin cookie the service +//! set at sign-in. There is no device flow and no token file. +//! +//! ## Login and logout routing +//! +//! When the account check returns Unauthenticated the transport reports the +//! fault and the title bar shows Sign in. The login popup opens only when the +//! user clicks Sign in (see [`pdfcraft_ui_egui::web_auth`] and [`pdfcraft_ui_egui::cloud::pump`]). +//! +//! A polling closure watches the popup and, when it closes, stores the key +//! `"pdfcraft:auth_done"` in egui's ephemeral memory and requests a repaint. +//! The update loop sees the flag, clears it, and calls `sign_in()` to force a +//! fresh `Job::Account`. +//! +//! Sign-out navigates to the service root `/` only after `/auth/logout` +//! succeeds. Reloading the PDFCraft page on a failed logout would leave the +//! session cookie active while the user believes they signed out. + +use craft_cloud_client::{Client, Error}; +use pdfcraft_ui_egui::cloud::{Account, CloudBackend, Event, Fault, Job, Post}; +use wasm_bindgen::JsCast as _; +use wasm_bindgen_futures::spawn_local; + +/// Hands cloud jobs to async tasks. Cheap to clone. +#[derive(Clone)] +pub struct Service { + client: Client, + ctx: egui::Context, +} + +impl Service { + /// Always available when the app is served by CollabCraft on the same + /// origin. The page names the service; nothing is compiled in. + pub fn new(ctx: egui::Context) -> Service { + log::info!("cloud storage through {}", Client::from_page().base()); + Service { client: Client::from_page(), ctx } + } +} + +impl CloudBackend for Service { + fn submit(&self, job: Job, post: Post) { + let worker = self.clone(); + spawn_local(async move { + worker.run(job, post).await; + }); + } +} + +impl Service { + async fn run(self, job: Job, post: Post) { + let post = |event: Event| { + post.send(event); + self.ctx.request_repaint(); + }; + match job { + Job::Account => { + let answer = + self.me().await.map(|me| Account { display: me.display, capabilities: me.capabilities, provider_linked: me.provider_linked }); + post(Event::Account(answer)); + } + Job::LinkStart => match self.client.link_start().await.map_err(fault) { + Ok(start) => post(Event::Browser { url: start.login_url, handle: Some(start.handle) }), + Err(f) => post(Event::Linked(Err(f))), + }, + Job::LinkPoll(handle) => { + let answer = self.client.link_poll(&handle).await.map_err(fault); + post(Event::Linked(answer)); + } + Job::SignOut => { + if let Err(e) = fetch_logout().await { + log::warn!("sign-out failed: {e}"); + post(Event::Account(Err(Fault::Refused(format!("Sign-out failed: {e}"))))); + return; + } + if let Some(window) = web_sys::window() { + let _ = window.location().assign("/"); + } + } + Job::List(path) => { + let entries = self.client.list(&path).await.map_err(fault); + post(Event::Listed { path, entries }); + } + Job::SharedWithMe => { + let answer = self.client.shared_with_me().await.map_err(fault); + post(Event::Shared(answer)); + } + Job::Read { entry, into } => { + let id = entry.id.clone(); + let bytes = self.client.read(&id).await.map_err(fault); + post(Event::Read { entry, into, bytes }); + } + Job::Write { uid, entry, bytes } => { + let id = entry.id.clone(); + let etag = entry.etag.clone(); + let answer = self.client.write(&id, &bytes, etag.as_deref()).await.map_err(fault); + post(Event::Saved { uid, entry: answer }); + } + Job::Create { uid, folder, name, bytes } => { + let answer = self.client.create(&folder, &name, &bytes).await.map_err(fault); + post(Event::Saved { uid, entry: answer }); + } + Job::Conversation(id) => { + let answer = self.client.conversation(&id).await.map_err(fault); + post(Event::Conversation(answer)); + } + } + } + + async fn me(&self) -> Result { + self.client.me().await.map_err(fault) + } +} + +fn fault(e: Error) -> Fault { + match e { + Error::Unauthenticated => Fault::Unauthenticated, + Error::NotLinked => Fault::NotLinked, + Error::Unreachable(detail) => Fault::Unreachable(detail), + Error::Refused { code, .. } if code == "conflict" || code == "409" || code == "412" => Fault::Conflict, + Error::Refused { code, detail } => Fault::Refused(if detail.trim().is_empty() { code } else { detail }), + Error::Malformed(detail) => Fault::Refused(format!("the cloud service gave an answer PdfCraft could not read: {detail}")), + Error::Unsendable(detail) => { + log::error!("cloud request could not be encoded: {detail}"); + Fault::Refused("PdfCraft could not build that request".to_owned()) + } + } +} + +async fn fetch_logout() -> Result<(), String> { + let global = js_sys::global(); + let fetch = js_sys::Reflect::get(&global, &wasm_bindgen::JsValue::from_str("fetch")) + .ok() + .and_then(|v| v.dyn_into::().ok()) + .ok_or_else(|| "no fetch".to_owned())?; + let init = web_sys::RequestInit::new(); + init.set_method("POST"); + init.set_credentials(web_sys::RequestCredentials::Include); + let req = web_sys::Request::new_with_str_and_init("/auth/logout", &init).map_err(|e| format!("{e:?}"))?; + let promised = fetch.call1(global.as_ref(), req.as_ref()).map_err(|e| format!("{e:?}"))?; + let promised: js_sys::Promise = promised.dyn_into().map_err(|_| "not a promise".to_owned())?; + let resp = wasm_bindgen_futures::JsFuture::from(promised).await.map_err(|e| format!("{e:?}"))?; + let resp: web_sys::Response = resp.dyn_into().map_err(|_| "not a response".to_owned())?; + if resp.ok() { + Ok(()) + } else { + Err(format!("HTTP {}", resp.status())) + } +} diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index 73d14f4a6..1015d056b 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -3,6 +3,9 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(target_arch = "wasm32")] +mod cloud; + #[cfg(target_arch = "wasm32")] fn main() { use eframe::wasm_bindgen::JsCast; @@ -17,18 +20,31 @@ fn main() { else { return; }; + let mut invite = match craft_cloud_client::invite::arrive().await { + craft_cloud_client::invite::Arrival::Ordinary => None, + craft_cloud_client::invite::Arrival::Refused(reason) => { + craft_cloud_client::invite::refuse(&reason); + return; + } + craft_cloud_client::invite::Arrival::Invited(invitation) => Some(invitation), + }; let started = eframe::WebRunner::new() .start( canvas, options, - Box::new(|cc| { + Box::new(move |cc| { let mut app = PdfCraftApp::new(); + let invited = invite.is_some(); // "printcraft": settings saved under the app's former name, PrintCraft. - if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string("printcraft"))) { + if !invited + && let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string("printcraft"))) + { app.restore(&json); } // `?file=` opens a PDF from a URL (same-origin or CORS-enabled). - if let Some(url) = query_param("file") { + if !invited + && let Some(url) = query_param("file") + { let inbox = app.startup_inbox.clone(); let failed = app.failed_inbox.clone(); let ctx = cc.egui_ctx.clone(); @@ -75,6 +91,17 @@ fn main() { &"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(), ); } + // Open and Save against cloud storage. In the browser the session rides + // the same-origin cookie; there is no device flow and no token file. + // `Client::from_page` reads the service URL from the page; on a plain + // deployment it is absent and the interface shows the link-account prompt. + app.cloud.connect(cloud::Service::new(cc.egui_ctx.clone())); + if let Some(share) = collabcraft_share::CollabCraft::from_env() { + app.share.connect(share.for_app("pdfcraft")); + } + if let Some(invitation) = invite.take() { + open_invite(&mut app, invitation); + } Ok(Box::new(app)) }), ) @@ -90,6 +117,28 @@ fn main() { }); } +#[cfg(target_arch = "wasm32")] +fn open_invite(app: &mut pdfcraft_ui_egui::PdfCraftApp, invitation: craft_cloud_client::invite::Invitation) { + let name = invitation.name.clone(); + match pdfcraft_ui_egui::cloud::CloudApp::open_bytes(app, &name, invitation.document.clone()) { + Ok(Some(_)) => { + let mode = if invitation.read_only() { " (view only)" } else { "" }; + let who = if invitation.recipient.is_empty() { String::new() } else { format!(" (shared with {})", invitation.recipient) }; + app.cloud.status = Some(format!("Opened \"{name}\"{mode}{who}")); + if let Err(e) = invitation.join() { + eframe::web_sys::console::warn_1(&format!("invite relay join failed: {e}").into()); + } + } + Ok(None) => { + app.cloud.status = Some(format!("Shared \"{name}\" needs a choice before it opens")); + } + Err(e) => { + eframe::web_sys::console::error_1(&format!("invite open failed: {e}").into()); + craft_cloud_client::invite::refuse(&format!("The shared document could not be opened. {e}")); + } + } +} + #[cfg(target_arch = "wasm32")] fn warn_before_unload(unsaved: std::sync::Arc) { use eframe::wasm_bindgen::{JsCast, closure::Closure}; diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index a27401c70..2301365aa 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -14,6 +14,12 @@ workspace = true ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] } rustls-native-certs = "0.8" pdfcraft-ui-egui = { workspace = true } +# How this application reaches CollabCraft. No provider credential, provider URL or provider +# protocol live here. The service is read from the environment; nothing is compiled in. +craft-cloud-client = { workspace = true } +# The share dialog's backend: the half that opens a socket. Kept here so only one session exists +# per process and a sign-in through Open/Save also reaches the share dialog. +collabcraft-share = { workspace = true } # `build_info`: the version and compile-time build label for `--version` and the log. pdfcraft-engine = { workspace = true } # wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or diff --git a/apps/pdfcraft/src/cloud.rs b/apps/pdfcraft/src/cloud.rs new file mode 100644 index 000000000..aaf80d67c --- /dev/null +++ b/apps/pdfcraft/src/cloud.rs @@ -0,0 +1,426 @@ +//! How this application talks to CollabCraft. +//! +//! [`service`] starts the worker that Open and Save run on. [`client`] builds +//! the one the share dialog uses. Both go through the same service and share +//! one saved session. +//! +//! PdfCraft holds no provider credential and speaks no provider protocol. +//! Everything goes through the CollabCraft service. That service is the one +//! place a provider is configured. This file supplies the transport half of +//! `pdfcraft_ui_egui::cloud`. It also supplies the two things a desktop +//! application has to do that a library cannot: open a browser and decide how +//! long to keep polling. +//! +//! Every call in `craft_cloud_client` blocks. The pages therefore never make +//! one. They state a [`Job`]. This thread runs it. The answer goes back +//! through the [`Post`] the job arrived with. The egui context is woken +//! afterwards. A worker answering is not a local event and the window would +//! otherwise sit still until the mouse moved. + +use std::path::{Path, PathBuf}; +use std::sync::mpsc::Sender; +use std::time::{Duration, Instant}; + +use craft_cloud_client::{Client, Error}; +use pdfcraft_ui_egui::cloud::{Account, CloudBackend, Event, Fault, Job, Post}; + +/// How long to keep polling a sign-in before giving up. +const SIGN_IN_WAIT: Duration = Duration::from_secs(300); + +/// Where the CollabCraft service is. +/// +/// Read from the environment; nothing is compiled in. The service is +/// deployment-specific. A desktop binary must not carry one. Without it there +/// is no cloud worker and the interface offers nothing cloud-shaped. +fn base_url() -> Option { + ["PDFCRAFT_CLOUD_URL", "CRAFT_CLOUD_URL", "COLLABCRAFT_URL"] + .into_iter() + .filter_map(|key| std::env::var(key).ok()) + .map(|value| value.trim().trim_end_matches('/').to_owned()) + .find(|value| !value.is_empty()) +} + +/// A handle to the worker, as the pages see it. +/// +/// Every job is answered on that thread. Nothing a page does can block a +/// frame. One queue serves them all. +pub struct Service { + jobs: Sender<(Job, Post)>, +} + +impl CloudBackend for Service { + fn submit(&self, job: Job, post: Post) { + if self.jobs.send((job, post)).is_err() { + log::warn!("the cloud worker is gone; the job was dropped"); + } + } +} + +/// Starts the worker, when a service is configured. +pub fn service(ctx: egui::Context) -> Option { + let base = base_url()?; + log::info!("cloud storage through {base}"); + let (jobs, receiver) = std::sync::mpsc::channel::<(Job, Post)>(); + let started = std::thread::Builder::new().name("collabcraft".to_owned()).spawn(move || { + let token = load_token(); + let mut client = Client::new(&base); + if let Some(token) = token { + client = client.with_token(token); + } + let mut worker = Worker { client, base, ctx, answering: None }; + while let Ok((job, post)) = receiver.recv() { + worker.answering = Some(post); + worker.run(job); + } + }); + if let Err(e) = started { + log::error!("the cloud worker did not start: {e}"); + return None; + } + Some(Service { jobs }) +} + +struct Worker { + client: Client, + base: String, + ctx: egui::Context, + answering: Option, +} + +impl Worker { + fn post(&self, event: Event) { + if let Some(post) = &self.answering { + post.send(event); + } + self.ctx.request_repaint(); + } + + fn run(&mut self, job: Job) { + match job { + Job::Account => { + let answer = self.call(|client| client.me()).map(|me| { + remember_account(&me.id, &me.display); + Account { display: me.display, capabilities: me.capabilities, provider_linked: me.provider_linked } + }); + self.post(Event::Account(answer)); + } + Job::LinkStart => match self.call(|client| client.link_start()) { + Ok(start) => self.post(Event::Browser { url: start.login_url, handle: Some(start.handle) }), + Err(fault) => self.post(Event::Linked(Err(fault))), + }, + Job::Conversation(id) => { + let answer = self.call(|client| client.conversation(&id)); + self.post(Event::Conversation(answer)); + } + Job::SignOut => { + if let Some(path) = token_path() { + let _ = std::fs::remove_file(path); + } + self.client = Client::new(&self.base); + } + Job::LinkPoll(handle) => { + let answer = self.call(|client| client.link_poll(&handle)); + self.post(Event::Linked(answer)); + } + Job::List(path) => { + let entries = self.call(|client| client.list(&path)); + self.post(Event::Listed { path, entries }); + } + Job::SharedWithMe => { + let answer = self.call(|client| client.shared_with_me()); + self.post(Event::Shared(answer)); + } + Job::Read { entry, into } => { + let bytes = self.call(|client| client.read(&entry.id)); + self.post(Event::Read { entry, into, bytes }); + } + Job::Write { uid, entry, bytes } => { + let answer = self.call(|client| client.write(&entry.id, &bytes, entry.etag.as_deref())); + self.post(Event::Saved { uid, entry: answer }); + } + Job::Create { uid, folder, name, bytes } => { + let answer = self.call(|client| client.create(&folder, &name, &bytes)); + self.post(Event::Saved { uid, entry: answer }); + } + } + } + + /// Runs one call with a session behind it. + /// + /// A rejected token is worth exactly one retry. The saved one has been + /// revoked or has expired. A fresh sign-in is the only recovery. A second + /// rejection is reported. + fn call(&mut self, work: impl Fn(&Client) -> craft_cloud_client::Result) -> Result { + self.sign_in()?; + match work(&self.client) { + Ok(value) => Ok(value), + Err(Error::Unauthenticated) => { + log::info!("the saved cloud session was rejected; signing in again"); + forget_token(); + self.client = Client::new(&self.base); + self.sign_in()?; + work(&self.client).map_err(fault) + } + Err(e) => Err(fault(e)), + } + } + + /// Makes sure there is a session token, signing in through the browser + /// when there is not. + fn sign_in(&mut self) -> Result<(), Fault> { + if self.client.is_signed_in() { + return Ok(()); + } + let start = self.client.sign_in_start().map_err(fault)?; + if let Err(e) = open_browser(&start.verify_url) { + log::warn!("could not open a browser: {e}"); + } + self.post(Event::Browser { url: start.verify_url.clone(), handle: None }); + let interval = Duration::from_secs(start.interval.clamp(1, 10)); + let deadline = Instant::now() + SIGN_IN_WAIT.min(Duration::from_secs(start.expires_in)); + while Instant::now() < deadline { + std::thread::sleep(interval); + match self.client.sign_in_poll(&start.device_code) { + Ok(Some(token)) => { + save_token(&token); + self.client = Client::new(&self.base).with_token(token); + self.post(Event::Linked(Ok(true))); + return Ok(()); + } + Ok(None) => {} + Err(e) => return Err(fault(e)), + } + } + Err(Fault::Refused("the sign-in was not completed in time".to_owned())) + } +} + +/// A client error in the shapes the interface acts on differently. +/// +/// A refused conditional write arrives as HTTP 409 with the code `conflict`. +/// The client crate carries that through as an ordinary refusal. The code is +/// read here and given a shape of its own. A save must never retry that one +/// and must never force it. +fn fault(e: Error) -> Fault { + match e { + Error::NotLinked => Fault::NotLinked, + Error::Unauthenticated => Fault::Unauthenticated, + Error::Unreachable(detail) => Fault::Unreachable(detail), + Error::Refused { code, .. } if code == "conflict" || code == "409" || code == "412" => Fault::Conflict, + Error::Refused { code, detail } => Fault::Refused(if detail.trim().is_empty() { code } else { detail }), + Error::Malformed(detail) => Fault::Refused(format!("the cloud service gave an answer PdfCraft could not read: {detail}")), + Error::Unsendable(detail) => { + log::error!("cloud request could not be encoded: {detail}"); + Fault::Refused("PdfCraft could not build that request".to_owned()) + } + } +} + +/// A client for the share dialog, built fresh for each of its requests. +/// +/// Separate from the Open and Save worker above because the two answer on +/// different channels. Sharing one client would mean sharing one queue. A +/// large save would then hold up an autocomplete. +pub fn client() -> Option { + let mut client = Client::new(base_url()?); + if let Some(token) = load_token() { + client = client.with_token(token); + } + Some(client) +} + +/// Where the session token lives beside the application's own settings. +fn token_path() -> Option { + let dir = settings_dir()?; + std::fs::create_dir_all(&dir).ok()?; + Some(dir.join("collabcraft-session.json")) +} + +fn settings_dir() -> Option { + eframe::storage_dir("PdfCraft") +} + +fn load_token() -> Option { + let raw = std::fs::read(token_path()?).ok()?; + let value: serde_json::Value = serde_json::from_slice(&raw).ok()?; + let configured = base_url()?; + token_for_service(&value, &configured) +} + +/// Extracts the token from a saved session only when its stored service URL +/// matches the currently configured one. A URL change means a different +/// deployment, so the token must not be forwarded. +fn token_for_service(value: &serde_json::Value, configured: &str) -> Option { + let stored = value.get("service")?.as_str()?; + if stored.trim().trim_end_matches('/') != configured.trim().trim_end_matches('/') { + log::warn!("the saved cloud session belongs to a different service; ignoring it"); + return None; + } + value.get("token")?.as_str().map(str::to_owned).filter(|t| !t.is_empty()) +} + +fn save_token(token: &str) { + let service = base_url().unwrap_or_default(); + write_session(serde_json::json!({"token": token, "service": service})); +} + +/// Record which account this session belongs to, keeping the token. +fn remember_account(subject: &str, display: &str) { + let Some(token) = load_token() else { return }; + let service = base_url().unwrap_or_default(); + write_session(serde_json::json!({ + "token": token, + "service": service, + "subject": subject, + "display": display, + })); +} + +fn write_session(value: serde_json::Value) { + let Some(path) = token_path() else { return }; + let Ok(text) = serde_json::to_vec(&value) else { return }; + if let Err(e) = write_private(&path, &text) { + log::warn!("could not save the cloud session: {e}"); + } +} + +fn forget_token() { + if let Some(path) = token_path() { + let _ = std::fs::remove_file(path); + } +} + +/// Writes `bytes` to `path` with mode 0o600 on Unix, so that the file is +/// never readable by other users from the instant it is created. +/// +/// Opening with an explicit mode avoids the write-then-chmod race window that +/// `std::fs::write` followed by `set_permissions` would create. The second +/// `set_permissions` call tightens any file that a previous version of the app +/// created under a loose umask. +#[cfg(unix)] +fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write as _; + use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; + + let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(path)?; + // `mode` applies when the file is first created. Tighten any older file + // that a previous version of the app may have created under the umask. + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + file.write_all(bytes) +} + +/// On Windows the per-user AppData directory is already owner-scoped, and +/// setting an ACL would need a Win32 call this application makes nowhere else. +#[cfg(not(unix))] +fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + std::fs::write(path, bytes) +} + +fn open_browser(url: &str) -> std::io::Result<()> { + #[cfg(target_os = "windows")] + // `cmd` splits its command line on `&` before anything else sees it. An + // authorize URL is mostly `&`. Pass through raw; percent-encode quotes. + let mut command = { + use std::os::windows::process::CommandExt as _; + let mut c = std::process::Command::new("cmd"); + c.raw_arg(format!("/C start \"\" \"{}\"", url.replace('"', "%22"))); + c + }; + #[cfg(target_os = "macos")] + let mut command = { + let mut c = std::process::Command::new("open"); + c.arg(url); + c + }; + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + let mut command = { + let mut c = std::process::Command::new("xdg-open"); + c.arg(url); + c + }; + command.spawn().map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- token_for_service origin-match tests --- + + fn make_session(service: &str, token: &str) -> serde_json::Value { + serde_json::json!({"service": service, "token": token}) + } + + #[test] + fn token_accepted_when_service_matches_exactly() { + let v = make_session("https://collab.example.com", "tok1"); + assert_eq!(token_for_service(&v, "https://collab.example.com"), Some("tok1".to_owned())); + } + + #[test] + fn token_accepted_when_trailing_slash_differs() { + let v = make_session("https://collab.example.com/", "tok2"); + assert_eq!(token_for_service(&v, "https://collab.example.com"), Some("tok2".to_owned())); + } + + #[test] + fn token_rejected_when_service_url_changes() { + let v = make_session("https://collab.example.com", "tok3"); + assert_eq!(token_for_service(&v, "https://other.example.com"), None); + } + + #[test] + fn token_rejected_when_service_field_absent() { + // A file written by an older version that only stored "token". + let v = serde_json::json!({"token": "tok4"}); + assert_eq!(token_for_service(&v, "https://collab.example.com"), None); + } + + #[test] + fn empty_token_is_treated_as_absent() { + let v = make_session("https://collab.example.com", ""); + assert_eq!(token_for_service(&v, "https://collab.example.com"), None); + } + + // --- write_private mode-0600 test (Unix only) --- + + #[test] + #[cfg(unix)] + fn write_private_creates_file_with_mode_0600() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = std::env::temp_dir().join(format!("pdfcraft-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("session.json"); + + write_private(&path, b"{\"test\":true}").expect("write_private failed"); + + let meta = std::fs::metadata(&path).expect("metadata failed"); + let mode = meta.permissions().mode() & 0o777; + std::fs::remove_dir_all(&dir).ok(); + + assert_eq!(mode, 0o600, "expected mode 0600, got {mode:o}"); + } + + #[test] + #[cfg(unix)] + fn write_private_tightens_existing_file_with_loose_permissions() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = std::env::temp_dir().join(format!("pdfcraft-test2-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("session.json"); + + // Pre-create with loose permissions, as an older app version might have done. + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + write_private(&path, b"{\"test\":true}").expect("write_private failed"); + + let meta = std::fs::metadata(&path).expect("metadata failed"); + let mode = meta.permissions().mode() & 0o777; + std::fs::remove_dir_all(&dir).ok(); + + assert_eq!(mode, 0o600, "expected mode 0600 after tightening, got {mode:o}"); + } +} diff --git a/apps/pdfcraft/src/esign_host.rs b/apps/pdfcraft/src/esign_host.rs new file mode 100644 index 000000000..158453170 --- /dev/null +++ b/apps/pdfcraft/src/esign_host.rs @@ -0,0 +1,78 @@ +//! Host half of the e-sign dialog: CollabCraft HTTP on a worker thread. + +use std::sync::mpsc::Sender; + +use craft_cloud_client::Client; +use pdfcraft_ui_egui::esign::{Answer, EsignBackend, Replier, Request}; + +use crate::cloud; + +/// Worker handle implementing [`EsignBackend`]. +pub struct Service { + jobs: Sender<(Request, Replier)>, +} + +impl EsignBackend for Service { + fn submit(&self, request: Request, reply: Replier) { + if self.jobs.send((request, reply)).is_err() { + log::warn!("the e-sign worker is gone; the request was dropped"); + } + } +} + +/// Start the worker when a CollabCraft URL is configured. +pub fn service(ctx: egui::Context) -> Option { + let _ = cloud::client()?; + let (jobs, receiver) = std::sync::mpsc::channel::<(Request, Replier)>(); + let started = std::thread::Builder::new().name("esign".to_owned()).spawn(move || { + while let Ok((request, reply)) = receiver.recv() { + let Some(client) = cloud::client() else { + reply.refuse("cloud storage is not configured"); + ctx.request_repaint(); + continue; + }; + run(&client, request, reply); + ctx.request_repaint(); + } + }); + if let Err(e) = started { + log::error!("the e-sign worker did not start: {e}"); + return None; + } + Some(Service { jobs }) +} + +fn run(client: &Client, request: Request, reply: Replier) { + match request { + Request::Capabilities => match client.esign_capabilities() { + Ok(caps) => { + reply.answer(Answer::Capabilities { capabilities: caps.capabilities, responsible_party: caps.responsible_party, linked: caps.linked }) + } + Err(e) => reply.refuse(e.to_string()), + }, + Request::Lookup { query, limit } => match client.contacts(&query, limit) { + Ok(contacts) => reply.answer(Answer::Lookup(contacts)), + Err(e) => reply.refuse(e.to_string()), + }, + Request::Create { draft, document, idempotency_key, send } => match client.esign_create(draft.as_ref(), &document, &idempotency_key, send) { + Ok(env) => reply.answer(Answer::Created(env)), + Err(e) => reply.refuse(e.to_string()), + }, + Request::Status { envelope } => match client.esign_status(&envelope) { + Ok(env) => reply.answer(Answer::Status(env)), + Err(e) => reply.refuse(e.to_string()), + }, + Request::Completed { envelope } => match client.esign_completed(&envelope) { + Ok(doc) => reply.answer(Answer::Completed(doc)), + Err(e) => reply.refuse(e.to_string()), + }, + Request::Void { envelope, reason } => match client.esign_void(&envelope, &reason) { + Ok(()) => reply.answer(Answer::Voided { envelope }), + Err(e) => reply.refuse(e.to_string()), + }, + Request::Remind { envelope, recipient } => match client.esign_remind(&envelope, &recipient) { + Ok(()) => reply.answer(Answer::Reminded { envelope, recipient }), + Err(e) => reply.refuse(e.to_string()), + }, + } +} diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 721b7da1b..84c20d051 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -31,8 +31,10 @@ use pdfcraft_ui_egui::PdfCraftApp; #[cfg(target_os = "macos")] mod apple_events; +mod cloud; mod compositor_gpu; mod decorations; +mod esign_host; mod logging; #[cfg(target_os = "windows")] mod motion; @@ -450,6 +452,17 @@ fn app_creator<'a>( log::warn!("--{k} {v}: {e}"); } } + // Open and Save against cloud storage, when a CollabCraft service is configured. + // Every call blocks, so the worker runs on its own thread and answers reach the + // window on a channel. Without a service the app is local-only. + if let Some(backend) = cloud::service(cc.egui_ctx.clone()) { + app.cloud.connect(backend); + } + // The share dialog's backend: connects the same session as Open/Save. + app.share.connect(collabcraft_share::CollabCraft::from_fn(cloud::client).for_app("pdfcraft")); + if let Some(backend) = esign_host::service(cc.egui_ctx.clone()) { + pdfcraft_ui_egui::esign::set_backend(&mut app.esign, backend); + } Ok(Box::new(app)) }) } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index fcd56efee..b7544bb08 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -162,6 +162,8 @@ const HELP: Option<&str> = Some("Help"); /// Every command, in menu order. pub const COMMANDS: &[CommandSpec] = &[ c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"), + // Cloud open: shown only when a CollabCraft service is connected; hidden otherwise. + c("file.open_cloud", "Open from cloud…", FILE, None, Nothing, "cloud"), c("file.open_recent", "Open Recent", FILE, None, Nothing, "clock"), // Shown at the foot of File ▸ Open Recent and on Home, not as its own File menu item. c("file.clear_recent", "Clear Recent Files", None, None, Nothing, "trash-2"), @@ -185,6 +187,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.combine", "Combine files…", FILE, None, Nothing, "files"), c("file.save", "Save", FILE, Some(Shortcut::cmd("S")), Document, "save"), c("file.save_as", "Save as…", FILE, Some(Shortcut::cmd_shift("S")), Document, "save"), + // Cloud save-as: shown only when a CollabCraft service is connected; hidden otherwise. + c("file.save_as_cloud", "Save as to cloud…", FILE, None, Document, "cloud"), c("file.close", "Close file", FILE, Some(Shortcut::cmd("W")), Document, "x"), c("file.close_all", "Close all", FILE, Some(Shortcut::cmd_shift("W")), Document, "x"), c("file.revert", "Revert", FILE, None, Undo, "rotate-ccw"), diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 42e9474f0..7ca046bc9 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,8 +11,11 @@ workspace = true [dependencies] craft-ui = { workspace = true } +# Provider-neutral cloud types used by the CloudApp implementation (Entry, EntryKind, Capability). +craft-cloud = { workspace = true } # macOS: a window close quits through `terminate:` (the Touch Bar crash, #1136); elsewhere a no-op. craft-quit = { workspace = true, features = ["egui"] } +craft-esign = { workspace = true } pdfcraft-fonts = { workspace = true } pdfcraft-engine = { workspace = true } pdfcraft-cos = { workspace = true } @@ -71,7 +74,7 @@ rfd = { workspace = true, features = ["file-handle-inner"] } wasm-bindgen-futures = "0.4" wasm-bindgen = "0.2" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator", "Location"] } [dev-dependencies] pdfcraft-xfa = { workspace = true } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index fb9e0a7c0..a2d5419d1 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -266,7 +266,21 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } ui.add_space(4.0); let tip = crate::commands::command_tip(ui.ctx(), tl!("Open a PDF ({key})"), "file.open"); - if widgets::ghost_button_sized(ui, "plus", tl!("Open"), tab_h).on_hover_text(tip).clicked() { + if crate::cloud::available(app) { + // Cloud connected: a popup lets the user choose local or cloud. + let open_resp = widgets::ghost_button_sized(ui, "plus", tl!("Open"), tab_h).on_hover_text(&tip); + open_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Open"))); + egui::Popup::menu(&open_resp).show(|ui| { + if ui.button(tl!("Open local file…")).clicked() { + app.execute("file.open"); + ui.close(); + } + if ui.button(tl!("Open from cloud…")).clicked() { + app.execute("file.open_cloud"); + ui.close(); + } + }); + } else if widgets::ghost_button_sized(ui, "plus", tl!("Open"), tab_h).on_hover_text(tip).clicked() { app.open_dialog(); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { @@ -303,6 +317,40 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if widgets::ghost_button_sized(ui, "messages-square", "Discord", tab_h).on_hover_text(pdfcraft_engine::links::DISCORD).clicked() { app.execute("help.discord"); } + // Cloud: account badge, presence, share button. Drawn only when a CollabCraft + // service is connected. The layout runs right-to-left, so these appear to the + // left of the theme/help controls and do not overlap the scrolling tabs. + if crate::cloud::available(app) { + ui.add_space(4.0); + // Share button: shown only when the provider supports sharing. + if crate::cloud::shares(app) { + let copies = crate::cloud::copies(); + if let Some(fmt) = crate::cloud::share_button(app, ui, &copies) { + // "Save a Copy as PDF": the only copy format PdfCraft offers. + let _ = fmt; // the format id is always "pdf" for us + } + } + if crate::esign::request_button(ui, &app.esign) { + let (id, title, etag) = match crate::cloud::origin(app) { + Some(origin) => (origin.entry.id.clone(), origin.entry.name.clone(), origin.entry.etag.clone()), + None => ( + String::new(), + app.active.and_then(|i| app.session.get(app.views.get(i)?.id)).map(|d| d.display_name()).unwrap_or_default(), + None, + ), + }; + let sender = craft_cloud::Identity { + id: String::new(), + display: app.cloud.account.as_ref().map(|a| a.display.clone()).unwrap_or_default(), + email: None, + avatar: None, + }; + app.esign_doc = app.active_ids().map(|(_, id)| id); + crate::esign::open(&mut app.esign, id, title, etag, sender); + } + // Who else has this document open (viewer presence only). + crate::cloud::who(app, ui); + } }); }); }); diff --git a/crates/ui-egui/src/cloud.rs b/crates/ui-egui/src/cloud.rs new file mode 100644 index 000000000..d5115f395 --- /dev/null +++ b/crates/ui-egui/src/cloud.rs @@ -0,0 +1,258 @@ +//! PdfCraft's half of Open and Save against cloud storage. +//! +//! The pages themselves are `craft_ui::cloud`. Every Craft app offers the same +//! ones. What is here is what only PdfCraft can answer. A document is a PDF. +//! These extensions and content types are what that means. Bytes become one +//! through [`PdfCraftApp::open_bytes`] and go back through +//! `session.save_full_bytes`. +//! +//! PdfCraft holds no provider credential and speaks no provider protocol. +//! Every call goes to the CollabCraft service. That service is the single +//! place a provider is configured and permissions are decided. Replacing one +//! provider with another changes nothing here. +//! +//! Nothing in this file opens a socket either. The transport half is a +//! [`CloudBackend`] the host application supplies. Every CollabCraft call +//! blocks. The pages state a [`Job`]. The host runs it on a worker thread. +//! The answer arrives on a channel that [`pump`] drains on a later frame. +//! +//! ## PDF semantics +//! +//! PdfCraft opens, views, annotates and form-fills PDFs. It does not host +//! real-time collaborative text editing. The share dialog lets a user grant +//! View or Edit access; "Edit" here means annotation and form filling, which +//! the provider enforces. No claim is made that two people can edit the +//! underlying PDF structure simultaneously. + +use egui::{Color32, Painter, Rect, Ui}; + +// Re-exported so a host application can name these types without depending on +// the crates they come from. Tests need them for the same reason. +pub use craft_cloud::{Capability, Entry, EntryKind}; +pub use craft_ui::cloud::{ + Account, Busy, CloudApp, CloudBackend, CloudFiles, Conflict, CopyFormat, Destination, DocumentKind, Event, Fault, Folder, Job, Link, Origin, + Post, Source, UNAVAILABLE, available, browse, can_converse, conflict_modal, destination_name, has_target, link, load_shared, open_entry, + open_page, origin, parent_of, prefers_cloud, progress, reload_active, save, save_as, save_page, share_button, shares, sign_in, sign_out, start, + work_together, +}; + +use crate::PdfCraftApp; +use crate::theme::Tokens; + +/// Extensions PdfCraft opens. The same set the desktop file picker offers. +const OPENS: [&str; 3] = ["pdf", "fdf", "xfdf"]; + +/// Content types PdfCraft opens, matched against the start of what the +/// provider reports. Neither extension nor MIME is reliable alone. +const MIMES: [&str; 3] = ["application/pdf", "application/vnd.adobe.xfdf", "application/vnd.adobe.xdf"]; + +/// What a PdfCraft document is, as a cloud file list needs to know. +pub const DOCUMENT: DocumentKind = DocumentKind { extensions: &OPENS, mimes: &MIMES, noun: "PDF", default_extension: "pdf" }; + +/// The format every cloud save writes. +/// +/// PdfCraft saves PDFs. A file opened from the cloud under any extension goes +/// back as a PDF — with annotations, form data and any other changes. FDF and +/// XFDF import forms data but are not saved back as such. +const SAVES_AS: &str = "pdf"; + +/// Save a Copy formats offered under the share. +/// +/// PdfCraft can export images, but those are destructive one-way conversions, +/// not copies. The only meaningful cloud copy is the PDF itself. +const COPIES: [(&str, &str); 1] = [("pdf", "Save a Copy as PDF (.pdf)\u{2026}")]; + +/// Whether PdfCraft can open this entry. +pub fn opens(entry: &Entry) -> bool { + craft_ui::cloud::opens(entry, DOCUMENT) +} + +/// A typed name as a PDF file name. Strips folder separators; adds `.pdf` +/// when no recognised extension is present. +pub fn pdf_name(typed: &str) -> String { + craft_ui::cloud::document_name(typed, DOCUMENT) +} + +/// The whole of the cloud state, for the control channel and for tests. +pub fn status(app: &PdfCraftApp) -> serde_json::Value { + craft_ui::cloud::report(app) +} + +/// The Share menu's export half. +pub fn copies() -> Vec { + COPIES.iter().map(|(id, label)| CopyFormat::new(*id, *label)).collect() +} + +/// The host part of the service address, for the account menu's first line. +/// +/// Read from the environment; nothing is compiled in. The service is +/// deployment-specific. Somebody with accounts on two deployments needs to +/// know which one a PDF is about to land in. +fn service_host() -> String { + ["PDFCRAFT_CLOUD_URL", "CRAFT_CLOUD_URL", "COLLABCRAFT_URL"] + .into_iter() + .filter_map(|key| std::env::var(key).ok()) + .map(|value| value.trim().trim_end_matches('/').to_owned()) + .find(|value| !value.is_empty()) + .map(|value| value.trim_start_matches("https://").trim_start_matches("http://").to_owned()) + .unwrap_or_default() +} + +/// The format a cloud save should be written in, given its destination. +/// +/// PdfCraft only writes PDF. A file opened from the cloud under any other +/// extension still goes back as a PDF. The only readable output from this +/// application is a PDF, and so that is the only format offered here. A +/// destination that explicitly asks for something else is refused with a +/// message the user can act on. +fn writes_as(into: Destination<'_>) -> Result<&'static str, String> { + let ext = into.extension(); + if ext == "pdf" || ext.is_empty() { + return Ok(SAVES_AS); + } + Err(format!( + "PdfCraft saves PDFs. It cannot write a .{ext} file to the cloud. \ + Use Save As to create a new PDF in the cloud." + )) +} + +impl CloudApp for PdfCraftApp { + fn cloud(&self) -> &CloudFiles { + &self.cloud + } + + fn cloud_mut(&mut self) -> &mut CloudFiles { + &mut self.cloud + } + + fn kind(&self) -> DocumentKind { + DOCUMENT + } + + fn active(&self) -> Option { + self.active_ids().map(|(_, id)| id.0) + } + + fn title(&self, uid: u64) -> String { + let id = pdfcraft_engine::DocId(uid); + self.session.get(id).map(|d| d.display_name()).unwrap_or_default() + } + + /// Open PDF bytes from the cloud. + /// + /// Returns `Ok(None)` when the document is password-protected and a prompt + /// is shown; the cloud page waits for the next event rather than assuming + /// success. + fn open_bytes(&mut self, name: &str, bytes: Vec) -> Result, String> { + // Record which document ids are already open before the call. + let before: std::collections::HashSet = self.views.iter().map(|v| v.id.0).collect(); + // `PdfCraftApp::open_bytes` opens with focus and returns only errors. + // Password-protected documents go into a prompt without raising an error. + self.open_bytes(name, None, bytes)?; + // The newly opened document, if it became active immediately. + let after = self.active_ids().map(|(_, id)| id.0).filter(|uid| !before.contains(uid)); + Ok(after) + } + + /// Encode the document's current state as PDF bytes for the given destination. + fn encode(&mut self, uid: u64, into: Destination<'_>) -> Result, String> { + let id = pdfcraft_engine::DocId(uid); + writes_as(into)?; + let bytes = self.session.save_full_bytes(id).map_err(|e| e.to_string())?; + Ok(bytes.as_ref().clone()) + } + + fn close(&mut self, uid: u64) { + let id = pdfcraft_engine::DocId(uid); + if let Some(i) = self.views.iter().position(|v| v.id == id) { + self.close_tab(i); + } + } + + fn ready_to_save(&mut self) -> bool { + // A form field's open editor holds text that is not in the document yet. + self.commit_form_typing() + } + + fn sharing(&mut self) -> &mut craft_ui::share::ShareDialog { + &mut self.share + } + + fn presence(&mut self) -> &mut craft_ui::presence::PresenceBar { + &mut self.presence + } + + fn paint_icon(&self, painter: &Painter, rect: Rect, kind: EntryKind, colour: Color32) { + let name = if kind == EntryKind::Folder { "folder" } else { "file-text" }; + crate::icons::paint_on(painter, rect, name, colour); + } + + fn open_url(&self, url: &str) { + if let Some(ctx) = &self.ctx { + ctx.open_url(egui::OpenUrl::new_tab(url)); + } + } + + fn say(&mut self, text: String) { + self.notify(text); + } + + fn toast(&mut self, text: String) { + self.notify(text); + } + + fn go_to_save_as(&mut self) { + // The conflict modal calls this when the user chooses "Save as a copy". + // PdfCraft has no backstage, so we set cloud_page to SaveAs which renders + // save_page in the CentralPanel on the next frame. + self.cloud_page = Some(crate::CloudPage::SaveAs); + } + + fn app_name(&self) -> &str { + "PdfCraft" + } + + fn service(&self) -> String { + service_host() + } + + fn save_note(&self) -> Option { + Some("The PDF is saved in the folder above. Annotations and form data are preserved.".to_owned()) + } + + fn style(&self, ui: &Ui) -> craft_ui::cloud::CloudStyle { + let t = Tokens::get(ui.ctx()); + craft_ui::cloud::CloudStyle { + surface: t.chrome, + border: t.border, + text: t.text, + dim: t.text_muted, + hover: t.hover, + soft: t.accent.gamma_multiply(0.14), + family: egui::FontFamily::Proportional, + bold: egui::FontFamily::Name("semibold".into()), + } + } +} + +/// The title-bar account badge and menu (`craft_ui::cloud::who`), plus the browser +/// login popup on Sign in while the click's user activation still holds. +pub fn who(app: &mut PdfCraftApp, ui: &mut Ui) { + craft_ui::cloud::who(app, ui); + #[cfg(target_arch = "wasm32")] + if app.cloud_mut().take_sign_in_from_menu() && app.cloud.account.is_none() { + let ctx = ui.ctx(); + let popup_retry = egui::Id::new("pdfcraft:popup_retry"); + ctx.memory_mut(|m| m.data.remove::(popup_retry)); + crate::web_auth::offer_login_popup(ctx); + } +} + +/// Drain cloud worker events from the background thread. +pub fn pump(app: &mut PdfCraftApp, ctx: &egui::Context) { + craft_ui::cloud::pump(app, ctx); +} + +#[cfg(test)] +#[path = "cloud_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/cloud_tests.rs b/crates/ui-egui/src/cloud_tests.rs new file mode 100644 index 000000000..66c4431ed --- /dev/null +++ b/crates/ui-egui/src/cloud_tests.rs @@ -0,0 +1,608 @@ +//! Open and Save against cloud storage, driven through the real interface with +//! a service that lives inside the test. +//! +//! The service answers on the calling thread. A job posted during a frame is +//! therefore in the inbox before the next one. Everything else is the shipping +//! code: the Open page, `PdfCraftApp::run`, the conflict modal, and the PDF +//! path. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::{Arc, Mutex}; + +use crate::PdfCraftApp; +use crate::cloud::{self, Account, Capability, CloudApp, CloudBackend, Destination, Entry, EntryKind, Event, Fault, Job, Post}; + +type Harness = egui_kittest::Harness<'static, PdfCraftApp>; + +// ---------------------------------------------------------------- in-memory service + +#[derive(Default)] +struct State { + files: Vec<(Entry, Vec)>, + shared: Vec<(Entry, Vec)>, + capabilities: Vec, + linked: bool, + writes: Vec<(String, Option, usize)>, + creates: Vec<(String, String, usize)>, +} + +impl State { + fn account(&self) -> Result { + if !self.linked { + return Err(Fault::NotLinked); + } + Ok(Account { display: "Ada Lovelace".to_owned(), capabilities: self.capabilities.clone(), provider_linked: true }) + } + + fn find(&self, id: &str) -> Option<&(Entry, Vec)> { + self.files.iter().chain(self.shared.iter()).find(|(e, _)| e.id == id) + } + + fn overwrite(&mut self, id: &str) { + if let Some(slot) = self.files.iter_mut().chain(self.shared.iter_mut()).find(|(e, _)| e.id == id) { + slot.0.etag = Some(format!("{}-next", slot.0.etag.clone().unwrap_or_default())); + } + } + + fn answer(&mut self, job: Job) -> Vec { + if matches!(job, Job::SignOut) { + self.linked = false; + return Vec::new(); + } + if !self.linked && !matches!(job, Job::LinkStart | Job::LinkPoll(_)) { + return vec![match job { + Job::Account => Event::Account(Err(Fault::NotLinked)), + Job::List(path) => Event::Listed { path, entries: Err(Fault::NotLinked) }, + Job::SharedWithMe => Event::Shared(Err(Fault::NotLinked)), + Job::Read { entry, into, .. } => Event::Read { entry, into, bytes: Err(Fault::NotLinked) }, + Job::Write { uid, .. } | Job::Create { uid, .. } => Event::Saved { uid, entry: Err(Fault::NotLinked) }, + Job::LinkStart | Job::LinkPoll(_) => Event::Linked(Ok(false)), + Job::Conversation(_) => Event::Conversation(Err(Fault::NotLinked)), + Job::SignOut => return Vec::new(), + }]; + } + match job { + Job::SignOut => Vec::new(), + Job::Conversation(_) => { + if !self.capabilities.contains(&Capability::Conversation) { + return vec![Event::Conversation(Err(Fault::Refused("this provider has nowhere to talk".to_owned())))]; + } + let room = craft_cloud::Conversation { url: "https://cloud.example/call/room".to_owned(), name: "Talk".to_owned() }; + vec![Event::Conversation(Ok(room))] + } + Job::Account => vec![Event::Account(self.account())], + Job::LinkStart => vec![Event::Browser { url: "https://cloud.example/link/abc".to_owned(), handle: Some("abc".to_owned()) }], + Job::LinkPoll(_) => vec![Event::Linked(Ok(self.linked))], + Job::List(path) => { + let entries = self.files.iter().filter(|(e, _)| parent(&e.path) == path).map(|(e, _)| e.clone()).collect(); + vec![Event::Listed { path, entries: Ok(entries) }] + } + Job::SharedWithMe => { + if !self.capabilities.contains(&Capability::SharedWithMe) { + return vec![Event::Shared(Err(Fault::Refused("no incoming shares".to_owned())))]; + } + vec![Event::Shared(Ok(self.shared.iter().map(|(e, _)| e.clone()).collect()))] + } + Job::Read { entry, into } => { + let bytes = match self.find(&entry.id) { + Some((_, bytes)) => Ok(bytes.clone()), + None => Err(Fault::Refused("no such file".to_owned())), + }; + vec![Event::Read { entry, into, bytes }] + } + Job::Write { uid, entry, bytes } => { + self.writes.push((entry.id.clone(), entry.etag.clone(), bytes.len())); + let stored_etag = self.find(&entry.id).and_then(|(e, _)| e.etag.clone()); + let answer = match stored_etag { + None => Err(Fault::Refused("no such file".to_owned())), + Some(ref stored) if *stored != entry.etag.as_deref().unwrap_or("") => Err(Fault::Conflict), + Some(_) => { + let slot = self.files.iter_mut().chain(self.shared.iter_mut()).find(|(e, _)| e.id == entry.id); + match slot { + Some(slot) => { + slot.0.etag = Some(format!("{}+", slot.0.etag.clone().unwrap_or_default())); + slot.0.size = Some(bytes.len() as u64); + slot.1 = bytes; + Ok(slot.0.clone()) + } + None => Err(Fault::Refused("no such file".to_owned())), + } + } + }; + vec![Event::Saved { uid, entry: answer }] + } + Job::Create { uid, folder, name, bytes } => { + self.creates.push((folder.clone(), name.clone(), bytes.len())); + let path = format!("{}/{name}", folder.trim_end_matches('/')); + let entry = pdf_entry(&path); + self.files.push((entry.clone(), bytes)); + vec![Event::Saved { uid, entry: Ok(entry) }] + } + } + } +} + +fn parent(path: &str) -> String { + match path.trim_end_matches('/').rfind('/') { + Some(0) | None => String::new(), + Some(cut) => path[..cut].to_owned(), + } +} + +fn pdf_entry(path: &str) -> Entry { + Entry { + id: path.to_owned(), + room: format!("room:{path}"), + name: path.rsplit('/').next().unwrap_or(path).to_owned(), + path: path.to_owned(), + kind: EntryKind::File, + size: Some(4096), + modified: Some("2026-10-01T09:00:00Z".to_owned()), + mime: Some("application/pdf".to_owned()), + etag: Some("v1".to_owned()), + writable: true, + shared_by: None, + } +} + +fn folder_entry(path: &str) -> Entry { + Entry { kind: EntryKind::Folder, size: None, etag: None, mime: None, ..pdf_entry(path) } +} + +struct Service(Arc>); + +impl CloudBackend for Service { + fn submit(&self, job: Job, post: Post) { + let events = self.0.lock().unwrap().answer(job); + for event in events { + post.send(event); + } + } +} + +fn harness_with(state: Arc>) -> Harness { + // egui_kittest 0.36 uses new_eframe (PdfCraftApp implements eframe::App). + egui_kittest::Harness::new_eframe(move |_ctx| { + let mut app = PdfCraftApp::new(); + app.run_inline = true; + app.cloud.connect(Service(Arc::clone(&state))); + app + }) +} + +fn linked_harness() -> (Harness, Arc>) { + let state = Arc::new(Mutex::new(State { linked: true, capabilities: vec![Capability::Files, Capability::Etags], ..Default::default() })); + (harness_with(Arc::clone(&state)), state) +} + +// ---------------------------------------------------------------- tests + +#[test] +fn account_offers_link_before_sign_in() { + let state = Arc::new(Mutex::new(State::default())); + let mut h = harness_with(Arc::clone(&state)); + cloud::start(h.state_mut()); + h.run_steps(4); + let cloud = &h.state().cloud; + assert!(cloud.account.is_none(), "no account before linking"); + assert!(cloud.account_fault.is_none(), "NotLinked is an actionable state, not an error"); + assert!(matches!(cloud.link, Some(cloud::Link::Offered)), "link offer expected while unlinked; got: {:?}", cloud.link); +} + +#[test] +fn linked_account_reported_after_start() { + let (mut h, _state) = linked_harness(); + cloud::start(h.state_mut()); + h.run_steps(4); + let account = h.state().cloud.account.clone(); + assert!(account.is_some(), "account present after linking"); + assert_eq!(account.unwrap().display, "Ada Lovelace"); +} + +#[test] +fn account_fetched_by_update_loop_without_explicit_start() { + // start() is called from update() since the fix; verify the account + // arrives without a hand-written start() call. This is the browser + // case: the session rides the same-origin cookie set at the root + // sign-in, and the user never has to open File > Open for the + // title-bar controls to reflect it. + let (mut h, _state) = linked_harness(); + // Deliberately no cloud::start() here. The update loop provides it. + h.run_steps(4); + let account = h.state().cloud.account.clone(); + assert!(account.is_some(), "update loop must bootstrap the account without an explicit start()"); + assert_eq!(account.unwrap().display, "Ada Lovelace"); +} + +#[test] +fn sign_out_clears_account() { + let (mut h, _state) = linked_harness(); + cloud::start(h.state_mut()); + h.run_steps(4); + assert!(h.state().cloud.account.is_some(), "signed in before sign-out"); + cloud::sign_out(h.state_mut()); + h.run_steps(4); + assert!(h.state().cloud.account.is_none(), "account cleared after sign-out"); +} + +#[test] +fn open_page_lists_root_when_linked() { + let (mut h, state) = linked_harness(); + { + let mut s = state.lock().unwrap(); + s.files.push((pdf_entry("/docs/report.pdf"), vec![])); + s.files.push((folder_entry("/docs"), vec![])); + } + cloud::start(h.state_mut()); + h.run_steps(8); + // The root listing was fetched: account is present and the mine folder + // reports that it has loaded at least once. + assert!(h.state().cloud.account.is_some(), "account fetched after start"); + assert!(h.state().cloud.mine.loaded, "root folder listing arrived"); +} + +#[test] +fn sign_out_does_not_leave_unauthenticated_fault() { + // After sign_out(), craft-libs resets asked_account to false so the next + // start() re-submits Job::Account. The in-memory service then returns + // NotLinked (it drops the session on SignOut). The important assertion is + // that account_fault is NOT Unauthenticated: Unauthenticated is what the + // browser transport maps to a login redirect, and seeing it here would + // mean a sign-out loop in the browser. NotLinked is handled differently + // (it offers a link prompt rather than a redirect). + let (mut h, _state) = linked_harness(); + h.run_steps(4); + assert!(h.state().cloud.account.is_some(), "signed in before sign-out"); + + cloud::sign_out(h.state_mut()); + h.run_steps(4); + + assert!(h.state().cloud.account.is_none(), "account cleared after sign-out"); + assert!( + !matches!(h.state().cloud.account_fault, Some(Fault::Unauthenticated)), + "Unauthenticated fault after sign-out would trigger a login redirect loop; got: {:?}", + h.state().cloud.account_fault, + ); +} + +#[test] +fn writes_as_rejects_non_pdf_extension() { + let dest = Destination { name: "out.xlsx", mime: None }; + let mut app = PdfCraftApp::new(); + let result = app.encode(0, dest); + assert!(result.is_err(), "must refuse to encode as .xlsx"); + let msg = result.unwrap_err(); + assert!(msg.contains("PdfCraft saves PDFs"), "error mentions the format constraint: {msg}"); +} + +#[test] +fn writes_as_accepts_bare_name_as_pdf() { + // A bare name (no extension) must not be rejected by the format check. + // The engine error (no such doc) comes first, but if the format check + // rejects it, the test knows the wrong thing failed. + let dest = Destination { name: "untitled", mime: None }; + let mut app = PdfCraftApp::new(); + // This will fail with an engine error (no doc 0), not a format error. + let result = app.encode(0, dest); + assert!(result.is_err()); + let msg = result.unwrap_err(); + // Must not say "PdfCraft saves PDFs" for a bare name. + assert!(!msg.contains("PdfCraft saves PDFs"), "bare name must pass the format check; got: {msg}"); +} + +#[test] +fn conflict_is_stored_after_a_refused_conditional_write() { + let entry = pdf_entry("/docs/shared.pdf"); + let state = Arc::new(Mutex::new(State { + linked: true, + capabilities: vec![Capability::Files, Capability::Etags], + // The file must have real PDF bytes so open_bytes can parse it. + files: vec![(entry.clone(), minimal_pdf())], + ..Default::default() + })); + let mut h = harness_with(Arc::clone(&state)); + h.run_steps(4); + // Open the document from the cloud so an origin with the v1 etag is recorded. + cloud::open_entry(h.state_mut(), entry.clone(), None); + h.run_steps(8); + assert!(h.state().active.is_some(), "document must be open before testing the save conflict"); + // Another client overwrites the file so the etag on the server advances. + state.lock().unwrap().overwrite("/docs/shared.pdf"); + // Trigger a cloud save. The etag no longer matches → the server returns Conflict. + cloud::save(h.state_mut()).expect("save submission must not fail synchronously"); + h.run_steps(8); + let writes = state.lock().unwrap().writes.clone(); + // Exactly one write attempt (the conflict must not be retried automatically). + assert_eq!(writes.len(), 1, "conflict must not be retried automatically"); + assert!(h.state().cloud.conflict.is_some(), "conflict state set after a refused conditional write"); +} + +#[test] +fn presence_bar_holds_peers_that_were_set() { + use craft_ui::presence::Peer; + let (mut h, _state) = linked_harness(); + let peers = vec![Peer::new("peer-1".to_owned(), "Babbage"), Peer::new("peer-2".to_owned(), "Turing")]; + h.state_mut().presence.set(peers); + h.run_steps(2); + assert_eq!(h.state().presence.peers().len(), 2); +} + +#[test] +fn document_kind_only_opens_pdfs() { + use crate::cloud::{DOCUMENT, opens}; + let pdf = pdf_entry("/test.pdf"); + let docx = Entry { mime: Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document".to_owned()), ..pdf_entry("/test.docx") }; + let folder = folder_entry("/folder"); + assert!(opens(&pdf), "a .pdf entry opens"); + assert!(!opens(&docx), "a .docx entry does not open"); + assert!(!opens(&folder), "a folder does not open"); + // DOCUMENT noun for user messages. + assert_eq!(DOCUMENT.noun, "PDF"); + assert_eq!(DOCUMENT.default_extension, "pdf"); +} + +// ---------------------------------------------------------------- UI-path / accessibility tests + +/// Build a minimal but legal one-page PDF entirely in memory. +/// Mirrors the fixture builder in open_worker_tests so the format is known-good. +fn minimal_pdf() -> Vec { + let body = "BT /F1 24 Tf 20 150 Td (Test) Tj ET"; + let objects: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, obj) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + let n = objects.len() + 1; + bytes.extend_from_slice(format!("trailer\n<< /Size {n} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + bytes +} + +/// Open a minimal PDF into the harness and return its document uid. +fn open_minimal(h: &mut Harness) -> u64 { + let bytes = minimal_pdf(); + h.state_mut().open_bytes("test.pdf", None, bytes).expect("open minimal PDF"); + h.run_steps(2); + h.state().active_ids().expect("document must be active after open").1.0 +} + +/// Render one frame using an egui Context and collect all non-empty text galley strings. +/// This is the same technique the home-tab test uses to prove labels are visible. +/// +/// `ctx.run_ui` needs `FnMut`. Both the `draw` closure and the mutable app reference are +/// wrapped in `Option` and moved into the `FnMut` closure; `.take()` consumes each exactly +/// once on the first (and only) frame — safe with no `unsafe` required. +fn shapes_from_page(app: &mut PdfCraftApp, size: egui::Vec2, draw: impl FnOnce(&mut PdfCraftApp, &mut egui::Ui) + 'static) -> Vec { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions()); + // Both the `FnOnce` draw closure and the `&mut app` are moved into Options. + // Inside the `FnMut`, `.take()` extracts them at most once; subsequent frames + // (there are none in practice) are no-ops. No unsafe needed. + type DrawOnce = Box; + let mut draw_opt: Option = Some(Box::new(draw)); + let mut app_opt: Option<&mut PdfCraftApp> = Some(app); + let output = ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }, |ui| { + if let (Some(f), Some(a)) = (draw_opt.take(), app_opt.take()) { + f(a, ui); + } + }); + let texts: Vec = output + .shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t.galley.job.text.clone()) } else { None }) + .filter(|s| !s.is_empty()) + .collect(); + output.drop_without_applying_deltas(); + texts +} + +// ---- label visibility --------------------------------------------------- + +/// The cloud Open page shell must render a visible "Open from cloud" heading and a "Back" +/// button. This is the accessibility regression guard: heading + navigation control must both +/// be text shapes in the render tree, not invisible or zero-size. +#[test] +fn open_from_cloud_page_heading_and_back_button_are_visible() { + let (mut h, _state) = linked_harness(); + h.run_steps(4); + let app = h.state_mut(); + app.cloud_page = Some(crate::CloudPage::Open); + let labels = shapes_from_page(app, egui::vec2(1200.0, 800.0), |app, ui| { + crate::cloud_page_shell(app, ui, crate::cloud::open_page); + }); + assert!(labels.iter().any(|l| l.contains("Open from cloud")), "cloud Open page must render a visible heading; rendered: {labels:?}",); + assert!(labels.iter().any(|l| l.contains("Back")), "Back button must render visible text for screen-reader navigation; rendered: {labels:?}",); +} + +/// The cloud Save As page shell must render a visible heading and "Back" button. +#[test] +fn save_as_cloud_page_heading_and_back_button_are_visible() { + let (mut h, _state) = linked_harness(); + h.run_steps(4); + let app = h.state_mut(); + app.cloud_page = Some(crate::CloudPage::SaveAs); + let labels = shapes_from_page(app, egui::vec2(1200.0, 800.0), |app, ui| { + crate::cloud_page_shell(app, ui, crate::cloud::save_page); + }); + assert!(labels.iter().any(|l| l.contains("Save as to cloud")), "cloud Save As page must render a visible heading; rendered: {labels:?}",); + assert!(labels.iter().any(|l| l.contains("Back")), "Back button must render visible text for screen-reader navigation; rendered: {labels:?}",); +} + +/// The "Open from cloud…" command must be enabled when a cloud service is connected. +/// When enabled, the tab strip popup includes "Open from cloud…" and the File menu shows it. +#[test] +fn open_from_cloud_command_enabled_when_service_connected() { + let (mut h, _state) = linked_harness(); + h.run_steps(4); + let spec = pdfcraft_engine::commands::command("file.open_cloud").expect("file.open_cloud command must be registered"); + assert!(h.state().command_enabled(spec), "file.open_cloud must be enabled when cloud service is connected",); +} + +/// The cloud commands are disabled and absent from the File menu when no service is configured. +#[test] +fn cloud_commands_disabled_without_service() { + let app = PdfCraftApp::new(); + // No backend connected — cloud not available. + let open_cloud = pdfcraft_engine::commands::command("file.open_cloud").unwrap(); + let save_cloud = pdfcraft_engine::commands::command("file.save_as_cloud").unwrap(); + assert!(!app.command_enabled(open_cloud), "file.open_cloud must be disabled without a service"); + assert!(!app.command_enabled(save_cloud), "file.save_as_cloud must be disabled without a service"); +} + +/// The cloud Save As command is available only when a document is open AND the service is connected. +#[test] +fn save_as_cloud_requires_document_and_service() { + let (mut h, _state) = linked_harness(); + h.run_steps(4); + let spec = pdfcraft_engine::commands::command("file.save_as_cloud").unwrap(); + // No document open yet: disabled even though service is connected. + assert!(!h.state().command_enabled(spec), "file.save_as_cloud requires an open document"); + // Open a document. + open_minimal(&mut h); + assert!(h.state().command_enabled(spec), "file.save_as_cloud must be enabled with a document and service"); +} + +// ---- UI-path: Open from cloud ------------------------------------------- + +/// Cloud Open page dismisses itself once a document is successfully opened. +/// The test backend answers Job::Read immediately, so the open completes within run_steps. +#[test] +fn open_from_cloud_page_dismisses_after_successful_open() { + let pdf = minimal_pdf(); + let entry = pdf_entry("/docs/report.pdf"); + let state = Arc::new(Mutex::new(State { + linked: true, + capabilities: vec![Capability::Files, Capability::Etags], + files: vec![(entry.clone(), pdf)], + ..Default::default() + })); + let mut h = harness_with(Arc::clone(&state)); + h.run_steps(4); + // Activate the Open page and record how many docs are open. + let doc_count_before = h.state().views.len(); + h.state_mut().cloud_page_doc_count = doc_count_before; + h.state_mut().cloud_page = Some(crate::CloudPage::Open); + h.run_steps(2); + // Trigger an open through the cloud API (same path as open_page's pick handler). + cloud::open_entry(h.state_mut(), entry, None); + // Allow pump to drain the Read event and open the document. + h.run_steps(8); + assert!(h.state().views.len() > doc_count_before, "document must have opened"); + assert!(h.state().cloud_page.is_none(), "cloud Open page must dismiss after successful open"); + assert!(h.state().active.is_some(), "opened document must be active"); + // The cloud origin is recorded so file.save knows where to write back. + let uid = h.state().active_ids().unwrap().1.0; + assert!(h.state().cloud.origins.contains_key(&uid), "origin must be recorded for the opened document"); +} + +// ---- UI-path: Save to cloud --------------------------------------------- + +/// file.save writes to the cloud origin (with etag) when the document came from the cloud. +/// Local Save is untouched for documents that did not come from the cloud. +#[test] +fn file_save_writes_to_cloud_when_document_has_cloud_origin() { + let pdf = minimal_pdf(); + let entry = pdf_entry("/docs/report.pdf"); + let state = Arc::new(Mutex::new(State { + linked: true, + capabilities: vec![Capability::Files, Capability::Etags], + files: vec![(entry.clone(), pdf)], + ..Default::default() + })); + let mut h = harness_with(Arc::clone(&state)); + h.run_steps(4); + // Open through the cloud path so an origin is recorded. + cloud::open_entry(h.state_mut(), entry, None); + h.run_steps(8); + assert!(h.state().active.is_some(), "document must be open after cloud open"); + let writes_before = state.lock().unwrap().writes.len(); + // Trigger Save through the command (mode bar Save button path). + h.state_mut().execute("file.save"); + h.run_steps(8); + let writes_after = state.lock().unwrap().writes.clone(); + assert!(writes_after.len() > writes_before, "file.save must have written to the cloud"); + // The write must carry the etag so the server can enforce optimistic locking. + let (_, etag, _) = writes_after.last().unwrap(); + assert!(etag.is_some(), "cloud write must include an etag for conditional update"); +} + +/// file.save falls back to local save for a document with no cloud origin. +/// Verified by checking that the cloud write count does not increase. +#[test] +fn file_save_stays_local_when_document_has_no_cloud_origin() { + let (mut h, state) = linked_harness(); + h.run_steps(4); + // No cloud open → no origin. + assert!(h.state().cloud.origins.is_empty(), "no origins before any cloud open"); + // Even if the command runs, nothing is written to the cloud. + let writes_before = state.lock().unwrap().writes.len(); + h.state_mut().execute("file.save"); + h.run_steps(4); + let writes_after = state.lock().unwrap().writes.len(); + assert_eq!(writes_before, writes_after, "file.save must not write to cloud for a local document"); +} + +// ---- UI-path: Save As to cloud ------------------------------------------ + +/// file.save_as_cloud sets cloud_page to SaveAs. +#[test] +fn file_save_as_cloud_command_opens_save_page() { + let (mut h, _state) = linked_harness(); + h.run_steps(4); + open_minimal(&mut h); + h.state_mut().execute("file.save_as_cloud"); + assert_eq!(h.state().cloud_page, Some(crate::CloudPage::SaveAs), "file.save_as_cloud must open the cloud Save As page"); +} + +/// Cloud Save As creates a new file in the chosen folder. +#[test] +fn cloud_save_as_creates_file_in_cloud() { + let pdf = minimal_pdf(); + let folder = folder_entry("/projects"); + let state = Arc::new(Mutex::new(State { + linked: true, + capabilities: vec![Capability::Files, Capability::Etags], + files: vec![(folder, pdf)], + ..Default::default() + })); + let mut h = harness_with(Arc::clone(&state)); + h.run_steps(4); + open_minimal(&mut h); + // Show the Save As page and prime the name field. + h.state_mut().cloud_page = Some(crate::CloudPage::SaveAs); + h.state_mut().cloud.save_name = "quarterly-report.pdf".to_owned(); + // Invoke save_as directly (same path as the Save button on the page). + let result = cloud::save_as(h.state_mut(), "/projects", "quarterly-report.pdf"); + assert!(result.is_ok(), "save_as must succeed: {result:?}"); + h.run_steps(8); + let creates = state.lock().unwrap().creates.clone(); + assert!(!creates.is_empty(), "a new cloud file must have been created"); + let (folder_path, name, _) = creates.first().unwrap(); + assert_eq!(folder_path, "/projects"); + assert_eq!(name, "quarterly-report.pdf"); +} + +// ---- go_to_save_as (conflict modal → Save As page) --------------------- + +/// go_to_save_as sets cloud_page to SaveAs so the conflict modal can redirect to the cloud +/// Save As page. This matches the WordCraft/DeckCraft backstage pattern. +#[test] +fn go_to_save_as_sets_cloud_save_as_page() { + let (mut h, _state) = linked_harness(); + h.run_steps(4); + use cloud::CloudApp; + h.state_mut().go_to_save_as(); + assert_eq!(h.state().cloud_page, Some(crate::CloudPage::SaveAs), "go_to_save_as must open the cloud Save As page"); +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 0d230982e..3a9fa0088 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -80,6 +80,10 @@ impl PdfCraftApp { /// Whether a registered command can run now. The engine judges the document (security, /// contents, undo history); view state it can't see is checked here. pub(crate) fn command_enabled(&self, spec: &CommandSpec) -> bool { + // Cloud commands need a connected service; without one they are unavailable. + if matches!(spec.id, "file.open_cloud" | "file.save_as_cloud") && !crate::cloud::available(self) { + return false; + } // Undo and Redo act on the Combine files list while its tab shows. if self.combine_showing() && matches!(spec.needs, commands::Needs::Undo | commands::Needs::Redo) { return self.combine_can_undo(spec.needs == commands::Needs::Undo); @@ -158,6 +162,10 @@ impl PdfCraftApp { } } "file.open" => self.open_dialog(), + "file.open_cloud" => { + self.cloud_page_doc_count = self.views.len(); + self.cloud_page = Some(crate::CloudPage::Open); + } "file.open_recent" => match self.recent.first().map(|r| r.path.clone()) { // The palette runs commands without a submenu: open the most recent file. Some(p) => self.open_recent(&p), @@ -168,11 +176,22 @@ impl PdfCraftApp { "file.pin_folder" => self.pin_folder_dialog(), "page.combine" => self.open_combine_tab(), "file.save" => { - self.save_active(SaveTarget::InPlace); + // When the active document came from the cloud, save back with the etag guard. + // Otherwise fall back to the existing local-file save. + if crate::cloud::has_target(self) { + if let Err(e) = crate::cloud::save(self) { + self.notify(e); + } + } else { + self.save_active(SaveTarget::InPlace); + } } "file.save_as" => { self.save_active(SaveTarget::As); } + "file.save_as_cloud" => { + self.cloud_page = Some(crate::CloudPage::SaveAs); + } "file.close" => { if let Some(i) = active { self.request_close_tab(i); @@ -766,6 +785,11 @@ fn tab_after(current: Option, count: usize, forward: bool) -> Option bool { + dialog.available() +} + +/// Open the dialog for the active document. +pub fn open(dialog: &mut EsignDialog, document_id: String, title: String, etag: Option, sender: Identity) { + dialog.open(document_id, title, etag, sender); +} + +/// Draw every frame so capability probes keep running. +pub fn pump(dialog: &mut EsignDialog, ctx: &egui::Context) { + dialog.show(ctx); +} + +pub fn take_send(dialog: &mut EsignDialog) -> Option { + dialog.take_send_request() +} + +pub fn note_create(dialog: &mut EsignDialog, result: Result) { + dialog.note_create_result(result); +} + +pub fn take_completed(dialog: &mut EsignDialog) -> Option> { + dialog.take_completed_bytes() +} + +pub fn set_backend(dialog: &mut EsignDialog, backend: impl EsignBackend + 'static) { + dialog.set_backend(backend); +} + +pub fn labels() -> EsignLabels { + EsignLabels::default() +} + +pub fn request_button(ui: &mut egui::Ui, dialog: &EsignDialog) -> bool { + let labels = labels(); + matches!(craft_ui::esign::request_button(ui, &labels, dialog.available(), &[]), Some(craft_ui::menu::SplitHit::Primary)) +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index bd084333b..6e7da7d9b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -33,6 +33,17 @@ pub fn paint(ui: &egui::Ui, rect: Rect, name: &str, size: f32, tint: Color32) { image(name, size, tint).paint_at(ui, Rect::from_center_size(rect.center(), Vec2::splat(size))); } +/// Paint an icon at `rect` using only a [`egui::Painter`]. +/// +/// The cloud file list has only a painter; a temporary child UI is built from the painter's +/// context so the SVG image loader can be reached. The icon is centred in `rect`. +pub fn paint_on(painter: &egui::Painter, rect: Rect, name: &str, tint: Color32) { + let size = rect.size().min_elem(); + let id = egui::Id::new("icon_paint").with(name); + let ui = egui::Ui::new(painter.ctx().clone(), id, egui::UiBuilder::new().max_rect(rect)); + image(name, size, tint).paint_at(&ui, Rect::from_center_size(rect.center(), Vec2::splat(size))); +} + /// Square icon button: transparent until hovered; `selected` gets the accent treatment. pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tooltip: &str) -> Response { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7b421263d..ea1cb07ee 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,7 +33,11 @@ mod actions_ui; mod appearance; pub mod canvas; mod chrome; +pub mod cloud; +#[cfg(target_arch = "wasm32")] +pub mod web_auth; mod combine_grid; +pub mod esign; mod image_pick; mod tags_ui; pub use combine_grid::ThumbState as CombineThumb; @@ -356,6 +360,18 @@ pub enum Dialog { CertificateViewer, } +/// Which cloud page is shown in the central panel instead of the document or home screen. +/// +/// `None` (the common case) means the normal document / home / docking view. `Open` shows +/// `craft_ui::cloud::open_page`; `SaveAs` shows `craft_ui::cloud::save_page`. These are the +/// only two navigation states PdfCraft needs beyond its existing tabs — the smallest explicit +/// addition that makes Open from cloud and Save As to cloud reachable from the UI. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CloudPage { + Open, + SaveAs, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PropsTab { Description, @@ -814,6 +830,25 @@ pub struct PdfCraftApp { pub pending_full_screen: Option, /// An absolute GoToR target waiting for approval, before any target-file I/O (#1364). pub pending_remote_link: Option, + /// Open and Save against cloud storage. Connected by the host application when a + /// CollabCraft service is configured; disconnected leaves the app local-only. + pub cloud: craft_ui::cloud::CloudFiles, + /// Which cloud page is shown in the central panel, when any. `None` is the normal state. + pub cloud_page: Option, + /// The number of open document tabs when `cloud_page` became `Some(Open)`. Used to detect + /// that a successful cloud open completed so the page can dismiss itself. + cloud_page_doc_count: usize, + /// Share dialog state. Connected by the host; drawn from the title bar when sharing + /// is available. The dialog is the same across all Craft apps. + pub share: craft_ui::share::ShareDialog, + /// Who else has the document open. Drawn beside the account badge in the title bar. + /// Presence is viewer presence only: PdfCraft does not host real-time collaborative + /// text editing. + pub presence: craft_ui::presence::PresenceBar, + /// Request-signatures dialog. Hosted through CollabCraft; local PAdES stays in Sign. + pub esign: craft_ui::esign::EsignDialog, + /// Document the e-sign dialog was opened for (Send uses this, not the active tab). + esign_doc: Option, } /// Where in a document a request to open an address came from. @@ -1079,7 +1114,14 @@ impl PdfCraftApp { text_style: content_ui::default_style(), replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: pdfcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, + cloud: craft_ui::cloud::CloudFiles::new(), + cloud_page: None, + cloud_page_doc_count: 0, + share: craft_ui::share::ShareDialog::new(), + presence: craft_ui::presence::PresenceBar::new().badges_only(), word_count_draft: word_count_ui::Draft::default(), + esign: craft_ui::esign::EsignDialog::new(), + esign_doc: None, } } @@ -3107,6 +3149,73 @@ impl eframe::App for PdfCraftApp { self.process_file_requests(); #[cfg(not(target_arch = "wasm32"))] self.process_picked(); + // In the browser build a login popup that has just closed signals a fresh + // account check. The transport stores "pdfcraft:auth_done" in egui's + // ephemeral memory and requests a repaint; we consume it here, set the + // "pdfcraft:popup_retry" flag so the login popup does not open another + // window if the post-close check still returns Unauthenticated (user + // cancelled), then call sign_in() to force a new Job::Account. + #[cfg(target_arch = "wasm32")] + { + let auth_done = egui::Id::new("pdfcraft:auth_done"); + let popup_retry = egui::Id::new("pdfcraft:popup_retry"); + if ctx.memory(|m| m.data.get_temp::(auth_done).unwrap_or(false)) { + ctx.memory_mut(|m| { + m.data.remove::(auth_done); + m.data.insert_temp(popup_retry, true); + }); + cloud::sign_in(self); + // Clear the "Finish signing in…" status that sign_in() sets; + // from the user's perspective they already finished. + self.cloud.status = None; + } + } + // Bootstrap cloud state: ask who is signed in and start a root listing. + // Called each frame but start() guards itself with asked_account and acts + // only once. In the browser build the session rides the same-origin cookie + // set at craft.quasarke.net login, so calling this here is what makes that + // login carry into PDFCraft automatically. Without it the account is only + // fetched when the user opens File > Open, leaving the title-bar controls + // (account badge, Share, presence) unresponsive until then. + cloud::start(self); + // Drain answers from the cloud worker (Open, Save, account, listing). + cloud::pump(self, ctx); + // E-sign dialog: capability probe and inbox, even when closed. + esign::pump(&mut self.esign, ctx); + if let Some(send) = esign::take_send(&mut self.esign) { + if let Some(id) = self.esign_doc.filter(|id| self.views.iter().any(|v| v.id == *id)) { + if self.commit_typing_in(id) { + match self.session.save_full_bytes(id).ok().map(|b| (*b).clone()) { + Some(bytes) => { + self.esign.submit_request(craft_ui::esign::Request::Create { + draft: Box::new(send.draft), + document: bytes, + idempotency_key: send.idempotency_key, + send: true, + }); + } + None => { + esign::note_create( + &mut self.esign, + Err("the document for this request is no longer open".into()), + ); + } + } + } else { + esign::note_create(&mut self.esign, Err(String::new())); + } + } else { + esign::note_create( + &mut self.esign, + Err("the document for this request is no longer open".into()), + ); + } + } + // After pump: if we were on the cloud Open page and a new document tab appeared + // (the open completed), dismiss the page and show the document. + if self.cloud_page == Some(CloudPage::Open) && self.views.len() > self.cloud_page_doc_count && self.cloud.busy.is_none() { + self.cloud_page = None; + } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { @@ -3173,17 +3282,35 @@ impl eframe::App for PdfCraftApp { } let t = theme::Tokens::get(&ctx); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { - if self.mode == Mode::Read { - match self.active { - None if self.combine_showing() => combine_ui::page(self, ui), - None => home::show(self, ui), - Some(index) => match compare_ui::side_by_side(self, index) { - Some((old, new)) => compare_ui::compare_area(self, old, new, ui), - None => canvas::document_area(self, index, ui), - }, + match self.cloud_page { + // Cloud Open page: browse and select a file; dismisses once the doc is open. + Some(CloudPage::Open) => { + cloud_page_shell(self, ui, |app, ui| { + crate::cloud::open_page(app, ui); + }); + } + // Cloud Save As page: pick a folder, name, and save; dismisses on success. + Some(CloudPage::SaveAs) => { + let saved = cloud_page_shell(self, ui, crate::cloud::save_page); + if saved { + self.cloud_page = None; + } + } + // Normal view: home, document, or docking workspace. + None => { + if self.mode == Mode::Read { + match self.active { + None if self.combine_showing() => combine_ui::page(self, ui), + None => home::show(self, ui), + Some(index) => match compare_ui::side_by_side(self, index) { + Some((old, new)) => compare_ui::compare_area(self, old, new, ui), + None => canvas::document_area(self, index, ui), + }, + } + } else { + docking::show(self, ui); + } } - } else { - docking::show(self, ui); } }); self.process_pending_edits(); @@ -3195,6 +3322,43 @@ impl eframe::App for PdfCraftApp { } } +/// Wraps a cloud page (Open or Save As) in a scrollable, padded shell with a "← Back" +/// button that dismisses the page and returns the user to the document or home screen. +/// +/// Returns whatever `body` returns. `save_page` returns `true` when a save started, so the +/// caller can clear `cloud_page`; `open_page` is fire-and-forget and always returns `()`. +pub(crate) fn cloud_page_shell(app: &mut PdfCraftApp, ui: &mut egui::Ui, body: impl FnOnce(&mut PdfCraftApp, &mut egui::Ui) -> R) -> R { + let t = theme::Tokens::get(ui.ctx()); + let mut back = false; + let result = egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + egui::Frame::NONE + .inner_margin(egui::Margin { left: 36, right: 36, top: 24, bottom: 28 }) + .show(ui, |ui| { + ui.horizontal(|ui| { + let back_label = format!("← {}", tl!("Back")); + let resp = ui.button(&back_label); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Back"))); + if resp.clicked() { + back = true; + } + ui.add_space(8.0); + let heading = tl!(if app.cloud_page == Some(CloudPage::SaveAs) { "Save as to cloud" } else { "Open from cloud" }); + ui.label(egui::RichText::new(heading).font(theme::semibold(18.0)).color(t.text)); + }); + ui.add_space(16.0); + body(app, ui) + }) + .inner + }) + .inner; + if back { + app.cloud_page = None; + } + result +} + impl PdfCraftApp { fn finish_render_frame(&mut self, ctx: &egui::Context) { // The Combine grid's thumbnails, whichever tab shows (renders under way still finish). diff --git a/crates/ui-egui/src/pickers.rs b/crates/ui-egui/src/pickers.rs index 7661b7175..bcc69e0b4 100644 --- a/crates/ui-egui/src/pickers.rs +++ b/crates/ui-egui/src/pickers.rs @@ -186,7 +186,7 @@ impl PdfCraftApp { } let target = self.pick_target(target); let then: Then = Box::new(then); - if let Some(paths) = self.pick_override.clone() { + if let Some(paths) = self.pick_override.take() { self.pickers.deliver(target, paths.into_iter().map(PathBuf::from).collect(), then); return true; } @@ -380,13 +380,14 @@ mod tests { let into = got.clone(); assert!(app.ask(Ask::Files(rfd::AsyncFileDialog::new()), None, move |app, _| { // Merge data: the spreadsheet's save picker, asked from the first pick's closure. + app.pick_override = Some(vec!["merged.csv".into()]); assert!(app.ask(Ask::Save(rfd::AsyncFileDialog::new()), None, move |_, paths| into.lock().unwrap().extend(paths))); })); // Another command asks for a picker before the frame uses the first pick: refused. assert!(!app.ask(Ask::File(rfd::AsyncFileDialog::new()), None, |_, _| panic!("must not run"))); app.process_picked(); app.process_picked(); - assert_eq!(*got.lock().unwrap(), vec![PathBuf::from("first.fdf")], "the chained picker answered"); + assert_eq!(*got.lock().unwrap(), vec![PathBuf::from("merged.csv")], "the chained picker answered"); } #[test] diff --git a/crates/ui-egui/src/web_auth.rs b/crates/ui-egui/src/web_auth.rs new file mode 100644 index 000000000..edab45ef1 --- /dev/null +++ b/crates/ui-egui/src/web_auth.rs @@ -0,0 +1,52 @@ +//! Browser-only CollabCraft login popup (user-gesture path). + +use wasm_bindgen::JsCast as _; + +/// Open the CollabCraft login page in a popup so the user returns to PDFCraft +/// after authenticating. +/// +/// Called from the UI thread when the user chooses Sign in, not from the async +/// account worker (popup blockers require a user activation). +pub fn offer_login_popup(ctx: &egui::Context) { + let Some(window) = web_sys::window() else { return }; + let Ok(path) = window.location().pathname() else { return }; + if path.starts_with("/auth/") { + return; + } + let is_retry = ctx.memory(|m| m.data.get_temp::(egui::Id::new("pdfcraft:popup_retry")).unwrap_or(false)); + if is_retry { + ctx.memory_mut(|m| m.data.remove::(egui::Id::new("pdfcraft:popup_retry"))); + return; + } + + let popup_result = + window.open_with_url_and_target_and_features("/auth/login", "_blank", "popup,width=520,height=620,menubar=no,toolbar=no,location=no"); + + match popup_result { + Ok(Some(popup)) => { + let ctx = ctx.clone(); + let handle = std::rc::Rc::new(std::cell::Cell::new(None::)); + let handle_in = handle.clone(); + let cb = wasm_bindgen::closure::Closure::::new(move || { + if popup.closed().unwrap_or(true) { + if let (Some(id), Some(w)) = (handle_in.take(), web_sys::window()) { + w.clear_interval_with_handle(id); + } else { + return; + } + ctx.memory_mut(|m| { + m.data.insert_temp(egui::Id::new("pdfcraft:auth_done"), true); + }); + ctx.request_repaint(); + } + }); + if let Ok(id) = window.set_interval_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), 750) { + handle.set(Some(id)); + } + cb.forget(); + } + _ => { + let _ = window.location().set_href("/auth/login"); + } + } +} diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 5afb4db55..b1906d7aa 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -48,6 +48,9 @@ fn harness() -> Harness<'static, PdfCraftApp> { /// Commands that open a native file picker can't run headless. /// (Create ▸ Clipboard reads the system clipboard: tests use `create_from_clip`.) +/// CollabCraft cloud commands need a connected service; see `cloud_tests`. +const CLOUD_SERVICE: &[&str] = &["file.open_cloud", "file.save_as_cloud"]; + const PICKERS: &[&str] = &[ "file.open", "page.combine", @@ -71,6 +74,7 @@ const PICKERS: &[&str] = &[ #[test] fn every_registered_command_is_implemented() { for spec in COMMANDS { + if PICKERS.contains(&spec.id) || CLOUD_SERVICE.contains(&spec.id) { // Page-text Copy needs a loaded view and egui context, covered by ui_group::text_select. if PICKERS.contains(&spec.id) || spec.id == "edit.copy" { continue; @@ -191,6 +195,7 @@ fn a_tool_chosen_with_no_document_asks_for_one_and_runs_on_it() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); + app.run_inline = true; app.pick_override = Some(vec![pick]); app }); @@ -291,6 +296,7 @@ fn the_open_recent_menu_lists_files_and_opens_one() { move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); + app.run_inline = true; app.open_bytes("doc.pdf", None, fixture(3)).unwrap(); app.recent.push(pdfcraft_ui_egui::RecentFile { name: "recent.pdf".into(), path, pages: 2, size: 0 }); app