diff --git a/Cargo.lock b/Cargo.lock index f31783d8..5d395591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,28 +239,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "raw-window-handle", - "serde", - "serde_repr", - "url", - "wayland-backend 0.3.15", - "wayland-client 0.31.14", - "wayland-protocols 0.32.12", - "zbus", -] - [[package]] name = "askar-crypto" version = "0.3.7" @@ -356,18 +334,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002" -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - [[package]] name = "async-channel" version = "2.5.0" @@ -393,49 +359,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.1", -] - [[package]] name = "async-lock" version = "3.4.1" @@ -447,52 +370,12 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - [[package]] name = "async-once-cell" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "async-rx" version = "0.1.3" @@ -503,24 +386,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.1", -] - [[package]] name = "async-stream" version = "0.3.6" @@ -543,12 +408,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.89" @@ -832,19 +691,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "bls12_381" version = "0.8.0" @@ -1651,7 +1497,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1677,15 +1523,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - [[package]] name = "dotenvy" version = "0.15.7" @@ -1795,33 +1632,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1835,7 +1645,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3390,9 +3200,9 @@ dependencies = [ "napi-ohos", "ohos-sys", "smallvec 1.15.1 (git+https://github.com/makepad/makepad?branch=dev)", - "wayland-client 0.31.12", + "wayland-client", "wayland-egl", - "wayland-protocols 0.32.10", + "wayland-protocols", "windows 0.62.2", "windows-core 0.62.2", "windows-targets 0.52.6", @@ -3914,15 +3724,6 @@ name = "memchr" version = "2.7.6" source = "git+https://github.com/makepad/makepad?branch=dev#46ee22634c9848eafa010e9a80a093521688c88b" -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" @@ -4285,10 +4086,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "block2", "objc2", "objc2-core-foundation", ] +[[package]] +name = "objc2-photos-ui" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e9b818a2fae66994d23ad6bf845e9204d5503434f4c2711dc35701dac8e296" +dependencies = [ + "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.1" @@ -4299,6 +4112,17 @@ dependencies = [ "block2", "objc2", "objc2-foundation", + "objc2-uniform-type-identifiers", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d964a85778b5899a7963c5d4a5c62cec6792cf41dfdfc5dadae2d427f2681c85" +dependencies = [ + "objc2", + "objc2-foundation", ] [[package]] @@ -4381,16 +4205,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - [[package]] name = "p256" version = "0.13.2" @@ -4542,17 +4356,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkcs1" version = "0.7.5" @@ -4593,20 +4396,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.1", -] - [[package]] name = "pollster" version = "0.4.0" @@ -4781,15 +4570,6 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" -[[package]] -name = "quick-xml" -version = "0.39.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" -dependencies = [ - "memchr 2.7.6 (registry+https://github.com/rust-lang/crates.io-index)", -] - [[package]] name = "quinn" version = "0.11.9" @@ -5146,26 +4926,26 @@ dependencies = [ [[package]] name = "rfd" -version = "0.15.4" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" dependencies = [ - "ashpd", "block2", "dispatch2", "js-sys", + "libc", "log", "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "percent-encoding", "pollster", "raw-window-handle", - "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -5218,17 +4998,38 @@ dependencies = [ [[package]] name = "robius-directories" version = "6.0.0" -source = "git+https://github.com/project-robius/robius#9fc3d48e4c07f8954d9fcb54964d5e640bf6ed87" +source = "git+https://github.com/project-robius/robius#d4360f34b57dec9c9fb3cde42f855457f372fa27" dependencies = [ "dirs-sys", "jni", "robius-android-env", ] +[[package]] +name = "robius-file-picker" +version = "0.2.0" +source = "git+https://github.com/project-robius/robius#d4360f34b57dec9c9fb3cde42f855457f372fa27" +dependencies = [ + "android-build", + "block2", + "cfg-if", + "dispatch2", + "jni", + "mime_guess", + "objc2", + "objc2-foundation", + "objc2-photos-ui", + "objc2-ui-kit", + "objc2-uniform-type-identifiers", + "rfd", + "robius-android-env", + "robius-directories", +] + [[package]] name = "robius-location" version = "0.2.0" -source = "git+https://github.com/project-robius/robius#9fc3d48e4c07f8954d9fcb54964d5e640bf6ed87" +source = "git+https://github.com/project-robius/robius#d4360f34b57dec9c9fb3cde42f855457f372fa27" dependencies = [ "android-build", "cfg-if", @@ -5243,7 +5044,7 @@ dependencies = [ [[package]] name = "robius-open" version = "0.2.0" -source = "git+https://github.com/project-robius/robius#9fc3d48e4c07f8954d9fcb54964d5e640bf6ed87" +source = "git+https://github.com/project-robius/robius#d4360f34b57dec9c9fb3cde42f855457f372fa27" dependencies = [ "block2", "cfg-if", @@ -5269,7 +5070,7 @@ dependencies = [ [[package]] name = "robius-web-auth-session" version = "0.2.0" -source = "git+https://github.com/project-robius/robius#9fc3d48e4c07f8954d9fcb54964d5e640bf6ed87" +source = "git+https://github.com/project-robius/robius#d4360f34b57dec9c9fb3cde42f855457f372fa27" dependencies = [ "block2", "cfg-if", @@ -5317,8 +5118,8 @@ dependencies = [ "rand 0.8.5", "rangemap", "reqwest 0.12.28", - "rfd", "robius-directories", + "robius-file-picker", "robius-location", "robius-open", "robius-use-makepad", @@ -5530,7 +5331,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5598,7 +5399,7 @@ dependencies = [ "security-framework 3.5.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5904,17 +5705,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -6421,7 +6211,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -6884,17 +6674,6 @@ version = "1.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.1", -] - [[package]] name = "ulid" version = "1.2.1" @@ -7320,21 +7099,7 @@ dependencies = [ "libc", "scoped-tls", "smallvec 1.15.1 (registry+https://github.com/rust-lang/crates.io-index)", - "wayland-sys 0.31.8", -] - -[[package]] -name = "wayland-backend" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" -dependencies = [ - "cc", - "downcast-rs", - "rustix", - "scoped-tls", - "smallvec 1.15.1 (registry+https://github.com/rust-lang/crates.io-index)", - "wayland-sys 0.31.11", + "wayland-sys", ] [[package]] @@ -7344,19 +7109,7 @@ source = "git+https://github.com/makepad/makepad?branch=dev#46ee22634c9848eafa01 dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "libc", - "wayland-backend 0.3.12", -] - -[[package]] -name = "wayland-client" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" -dependencies = [ - "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", - "rustix", - "wayland-backend 0.3.15", - "wayland-scanner", + "wayland-backend", ] [[package]] @@ -7364,8 +7117,8 @@ name = "wayland-egl" version = "0.32.9" source = "git+https://github.com/makepad/makepad?branch=dev#46ee22634c9848eafa010e9a80a093521688c88b" dependencies = [ - "wayland-backend 0.3.12", - "wayland-sys 0.31.8", + "wayland-backend", + "wayland-sys", ] [[package]] @@ -7374,31 +7127,8 @@ version = "0.32.10" source = "git+https://github.com/makepad/makepad?branch=dev#46ee22634c9848eafa010e9a80a093521688c88b" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", - "wayland-backend 0.3.12", - "wayland-client 0.31.12", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" -dependencies = [ - "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", - "wayland-backend 0.3.15", - "wayland-client 0.31.14", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" -dependencies = [ - "proc-macro2", - "quick-xml", - "quote", + "wayland-backend", + "wayland-client", ] [[package]] @@ -7410,17 +7140,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "log", - "pkg-config", -] - [[package]] name = "web-sys" version = "0.3.94" @@ -7499,7 +7218,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.60.2", ] [[package]] @@ -8204,67 +7923,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.1", - "winnow 0.7.13", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.106", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" -dependencies = [ - "serde", - "winnow 0.7.13", - "zvariant", -] - [[package]] name = "zerocopy" version = "0.8.27" @@ -8379,44 +8037,3 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] - -[[package]] -name = "zvariant" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" -dependencies = [ - "endi", - "enumflags2", - "serde", - "url", - "winnow 0.7.13", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.106", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.106", - "winnow 0.7.13", -] diff --git a/Cargo.toml b/Cargo.toml index e742dcbd..1c7b7462 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,9 +20,10 @@ makepad-code-editor = { git = "https://github.com/makepad/makepad", branch = "de ## Including this crate automatically configures all `robius-*` crates to work with Makepad. robius-use-makepad = "0.1.1" -robius-open = { git = "https://github.com/project-robius/robius" } robius-directories = { git = "https://github.com/project-robius/robius" } +robius-file-picker = { git = "https://github.com/project-robius/robius" } robius-location = { git = "https://github.com/project-robius/robius" } +robius-open = { git = "https://github.com/project-robius/robius" } anyhow = "1.0" @@ -106,11 +107,6 @@ reqwest = { version = "0.12", default-features = false, optional = true, feature "macos-system-configuration", ] } -# Desktop-only file dialog (doesn't work on iOS/Android) -[target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies] -rfd = "0.15" - - ## For OAuth/SSO login on iOS, via `ASWebAuthenticationSession`. Ugh.... [target.'cfg(target_os = "ios")'.dependencies] robius-web-auth-session = { git = "https://github.com/project-robius/robius" } diff --git a/src/home/room_image_viewer.rs b/src/home/room_image_viewer.rs index 67a4833d..aa75ee57 100644 --- a/src/home/room_image_viewer.rs +++ b/src/home/room_image_viewer.rs @@ -42,17 +42,14 @@ pub fn populate_matrix_image_modal( } } -/// Gets image name and file size in bytes from an event timeline item. +/// Gets the image's file name and size in bytes from an event timeline item. pub fn get_image_name_and_filesize(event_tl_item: &EventTimelineItem) -> (String, u64) { if let Some(message) = event_tl_item.content().as_message() { if let MessageType::Image(image_content) = message.msgtype() { - let name = message.body().to_string(); - let size = image_content - .info - .as_ref() + let name = image_content.filename().to_string(); + let size = image_content.info.as_ref() .and_then(|info| info.size) - .map(u64::from) - .unwrap_or(0); + .map_or(0, u64::from); return (name, size); } } diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index d8c29fb7..803487f4 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -32,7 +32,7 @@ use crate::{ }, room::{BasicRoomDetails, room_input_bar::{RoomInputBarState, RoomInputBarWidgetRefExt}, typing_notice::TypingNoticeWidgetExt}, shared::{ - attachment_download::{DownloadDisplayState, DownloadKind, DownloadableAttachment, PendingDownload, PendingDownloadState, media_source_mxc, start_attachment_download}, avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::ConfirmationModalContent, file_upload_modal::FileUploadAttemptId, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageStatus, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt + attachment_download::{enqueue_already_downloading_notification, DownloadDisplayState, DownloadKind, DownloadableAttachment, PendingDownload, PendingDownloadState, media_source_mxc, start_attachment_download}, avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::ConfirmationModalContent, file_upload_modal::FileUploadAttemptId, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageStatus, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt }, sliding_sync::{BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineKind, TimelineRequestSender, UserPowerLevels, get_client, submit_async_request, take_timeline_endpoints}, utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime} }; @@ -2251,6 +2251,7 @@ impl RoomScreen { let mxc = media_source_mxc(&info.media_source); // Prevent the same attachment from being downloaded more than once at a time. if tl.pending_downloads.iter().any(|p| &p.mxc == mxc) { + enqueue_already_downloading_notification(); continue; } tl.pending_downloads.push(PendingDownload { @@ -2259,7 +2260,7 @@ impl RoomScreen { }); portal_list.redraw(cx); let update_sender = tl.media_cache.timeline_update_sender().cloned(); - start_attachment_download(cx, info.clone(), update_sender); + start_attachment_download(info.clone(), update_sender); } MessageAction::CancelDownload(mxc) => { submit_async_request(MatrixRequest::CancelDownload(mxc.clone())); diff --git a/src/home/upload_progress.rs b/src/home/upload_progress.rs index cb0e7ad2..9dc61a07 100644 --- a/src/home/upload_progress.rs +++ b/src/home/upload_progress.rs @@ -89,6 +89,7 @@ script_mod! { /// The current state of the upload view. #[derive(Clone, Debug, Default)] +#[allow(clippy::large_enum_variant)] pub enum UploadViewState { /// Normal state - upload in progress or ready. #[default] diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index 02184b48..6af64aed 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -21,23 +21,7 @@ use matrix_sdk::room::reply::{EnforceThread, Reply}; use ruma::events::room::message::AddMentions; use matrix_sdk_ui::timeline::{EmbeddedEvent, EventTimelineItem, TimelineEventItemId}; use ruma::{events::room::message::{LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent}, OwnedEventId, OwnedRoomId}; -use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}, upload_progress::UploadProgressViewWidgetRefExt}, location::init_location_subscriber, settings::app_preferences::{AppPreferencesGlobal, AppPreferencesAction}, shared::{avatar::AvatarWidgetRefExt, file_upload_modal::{AttachmentUpload, FilePreviewerAction, FileUploadAttemptId}, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils}; - -/// Result of the native file picker plus background file-loading work. -#[cfg_attr(any(target_os = "ios", target_os = "android"), allow(dead_code))] -enum PendingFileSelection { - /// A file was selected and read successfully. - Selected { - upload: AttachmentUpload, - }, - /// The picker was dismissed without selecting a file. - Cancelled, - /// The file could not be selected, inspected, or read. - Error(String), -} - -/// Receives the pending file-selection result back on the UI thread. -type PendingFileSelectionReceiver = std::sync::mpsc::Receiver; +use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}, upload_progress::UploadProgressViewWidgetRefExt}, location::init_location_subscriber, settings::app_preferences::{AppPreferencesAction, AppPreferencesGlobal}, shared::{avatar::AvatarWidgetRefExt, file_upload_modal::{AttachmentUpload, FilePreviewerAction, FileUploadAttemptId, load_selected_file}, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils}; script_mod! { use mod.prelude.widgets.* @@ -210,8 +194,6 @@ pub struct RoomInputBar { /// Cached natural Fit height of the input_bar, used as the animation /// target when the editing pane is being hidden. #[rust] input_bar_natural_height: f64, - /// The pending file picker / background load operation, if any. - #[rust] pending_file_selection: Option, } impl ScriptHook for RoomInputBar { @@ -254,48 +236,9 @@ impl Widget for RoomInputBar { } if let Event::Actions(actions) = event { - // Handle changes to the `send_on_enter` preference. - for action in actions { - if let Some(AppPreferencesAction::SendOnEnterChanged(v)) = action.downcast_ref() { - self.mentionable_text_input(cx, ids!(mentionable_text_input)) - .text_input_ref() - .set_submit_on_enter(*v); - } - } - self.handle_actions(cx, actions, room_screen_props); } - // Handle signal events for pending file loads from background threads - if let Event::Signal = event { - if let Some(receiver) = &self.pending_file_selection { - let mut remove_receiver = false; - match receiver.try_recv() { - Ok(PendingFileSelection::Selected { upload }) => { - Cx::post_action(FilePreviewerAction::Show { upload }); - remove_receiver = true; - } - Ok(PendingFileSelection::Cancelled) => { - remove_receiver = true; - } - Ok(PendingFileSelection::Error(error)) => { - enqueue_popup_notification(error, PopupKind::Error, None); - remove_receiver = true; - } - Err(std::sync::mpsc::TryRecvError::Empty) => { - // Still waiting for file picker / loader. - } - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - remove_receiver = true; - } - } - if remove_receiver { - self.pending_file_selection = None; - self.redraw(cx); - } - } - } - self.view.handle_event(cx, event, scope); } @@ -342,6 +285,14 @@ impl RoomInputBar { let mentionable_text_input = self.mentionable_text_input(cx, ids!(mentionable_text_input)); let text_input = mentionable_text_input.text_input_ref(); + for action in actions { + // Handle changes to the `send_on_enter` preference. + if let Some(AppPreferencesAction::SendOnEnterChanged(v)) = action.downcast_ref() { + text_input.set_submit_on_enter(*v); + continue; + } + } + // Clear the replying-to preview pane if the "cancel reply" button was clicked // or if the `Escape` key was pressed within the message input box. if self.button(cx, ids!(cancel_reply_button)).clicked(actions) @@ -419,9 +370,7 @@ impl RoomInputBar { } // Handle the send message button being clicked, or a `Returned` action - // from the message text input. The text input only emits `Returned` - // for the key combination chosen by the user in App Settings (plus - // Cmd/Ctrl+Enter, which always submits). + // from the message text input, which already respects the user's app setting. if self.button(cx, ids!(send_message_button)).clicked(actions) || text_input.returned(actions).is_some() { @@ -664,30 +613,17 @@ impl RoomInputBar { self.view.check_box(cx, ids!(tsp_sign_checkbox)).active(cx) } - /// Opens the native file picker dialog to select a file for upload. - /// - /// The timeline target is captured at this moment to ensure the file is uploaded - /// to the correct room/thread, even if the user switches rooms while the modal is open. - #[cfg(not(any(target_os = "ios", target_os = "android")))] + /// Shows the native file picker dialog to select a file to be uploaded. fn open_file_picker( &mut self, cx: &mut Cx, timeline_kind: TimelineKind, ) { - if self.pending_file_selection.is_some() { - enqueue_popup_notification( - "A file selection is already in progress.", - PopupKind::Error, - None, - ); - return; - } - if self.view.view(cx, ids!(upload_progress_view)).visible() { enqueue_popup_notification( - "Finish the current upload before starting another one.", - PopupKind::Error, - None, + "Finish or cancel the current upload before starting another one.", + PopupKind::Warning, + Some(7.0), ); return; } @@ -698,54 +634,57 @@ impl RoomInputBar { #[cfg(feature = "tsp")] let sign_with_tsp = self.is_tsp_signing_enabled(cx); - let (sender, receiver) = std::sync::mpsc::channel(); - self.pending_file_selection = Some(receiver); - let dialog_task = rfd::AsyncFileDialog::new().pick_file(); - - // Native thread, not a tokio task: rfd's macOS dialog panics if it - // runs on a tokio worker thread. - cx.spawn_thread(move || { - let result = match futures::executor::block_on(dialog_task) { - Some(selected_file) => { - #[cfg(feature = "tsp")] - { - load_selected_file( - selected_file.path().to_path_buf(), + // `robius-file-picker` ensures that this `on_picked` callback runs on a bg thread. + let on_picked = move |result: robius_file_picker::Result>| { + match result { + Ok(Some(picked)) => match picked.into_local_file() { + Ok(local_file) => { + let loaded = load_selected_file( + local_file, timeline_kind, in_reply_to, + #[cfg(feature = "tsp")] sign_with_tsp, - ) - } - #[cfg(not(feature = "tsp"))] - { - load_selected_file( - selected_file.path().to_path_buf(), - timeline_kind, - in_reply_to, - ) + ); + match loaded { + Ok(upload) => Cx::post_action(FilePreviewerAction::Show { upload }), + Err(e) => enqueue_popup_notification(e, PopupKind::Error, None), + } } - } - None => PendingFileSelection::Cancelled, - }; - if sender.send(result).is_err() { - makepad_widgets::error!("Failed to send file picker result to UI: receiver dropped"); + Err(e) => enqueue_popup_notification( + format!("Failed to read selected file: {e}"), + PopupKind::Error, + None, + ), + }, + // User dismissed the picker, do nothing. + Ok(None) => {} + Err(err) => enqueue_popup_notification( + format!("Error selecting a file: {err}"), + PopupKind::Error, + None, + ), } - SignalToUI::set_ui_signal(); - }); - } + }; - /// Shows a "not supported" message on mobile platforms. - #[cfg(any(target_os = "ios", target_os = "android"))] - fn open_file_picker( - &mut self, - _cx: &mut Cx, - _timeline_kind: TimelineKind, - ) { - enqueue_popup_notification( - "File uploads are not yet supported on mobile.", - PopupKind::Error, - Some(5.0), - ); + match robius_file_picker::FileDialog::new().pick_file(on_picked) { + Ok(()) => {} + Err(robius_file_picker::Error::AlreadyOpen) => { + enqueue_popup_notification( + "A file picker is already open.", + PopupKind::Error, + Some(4.0), + ); + } + Err(err) => { + makepad_widgets::error!("Failed to launch file picker: {err}"); + enqueue_popup_notification( + format!("Failed to open file picker: {err}"), + PopupKind::Error, + None, + ); + } + } } } @@ -964,54 +903,3 @@ enum ShowEditingPaneBehavior { editing_pane_state: EditingPaneState, }, } - -#[cfg(not(any(target_os = "ios", target_os = "android")))] -fn load_selected_file( - selected_file_path: std::path::PathBuf, - timeline_kind: TimelineKind, - in_reply_to: Option, - #[cfg(feature = "tsp")] - sign_with_tsp: bool, -) -> PendingFileSelection { - let metadata = match std::fs::metadata(&selected_file_path) { - Ok(m) => m, - Err(e) => return PendingFileSelection::Error(format!("Unable to access file: {e}")), - }; - if !metadata.is_file() { - return PendingFileSelection::Error("Cannot upload directories or special files".to_string()); - } - let file_size = metadata.len(); - if file_size == 0 { - return PendingFileSelection::Error("Cannot upload empty file".to_string()); - } - let mime = mime_guess::from_path(&selected_file_path) - .first_or_octet_stream(); - let preview_data = if crate::image_utils::is_displayable_image(mime.essence_str()) { - match std::fs::read(&selected_file_path) { - Ok(data) => Some(std::sync::Arc::new(data)), - Err(e) => return PendingFileSelection::Error(format!("Unable to read image preview: {e}")), - } - } else { - None - }; - let name = selected_file_path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - PendingFileSelection::Selected { - upload: AttachmentUpload { - timeline_kind, - file_data: crate::shared::file_upload_modal::FileUploadMetadata { - path: selected_file_path, - caption: Some(name), - mime_type: mime.to_string(), - preview_data, - size: file_size, - }, - in_reply_to, - #[cfg(feature = "tsp")] - sign_with_tsp, - }, - } -} diff --git a/src/shared/attachment_download.rs b/src/shared/attachment_download.rs index 61fe27fa..b60c912a 100644 --- a/src/shared/attachment_download.rs +++ b/src/shared/attachment_download.rs @@ -1,12 +1,27 @@ //! Download a Matrix media attachment and save it to storage. -use std::sync::Arc; -use makepad_widgets::Cx; -#[cfg(not(any(target_os = "ios", target_os = "android")))] -use makepad_widgets::CxOsApi; +use std::{sync::Arc, time::Duration}; +use makepad_widgets::SignalToUI; use matrix_sdk::ruma::{OwnedMxcUri, events::room::MediaSource}; -use crate::home::room_screen::TimelineUpdate; -use crate::shared::popup_list::{PopupKind, enqueue_popup_notification}; +use crate::{ + home::room_screen::TimelineUpdate, + shared::popup_list::{PopupKind, enqueue_popup_notification}, + sliding_sync::{MatrixRequest, submit_async_request}, +}; + +type TimelineUpdateSenderOption = Option>; + +/// The result of a download request. +pub enum MediaDownloadResult { + Downloaded(Vec), + Failed(String), + Cancelled, +} + +pub fn enqueue_already_downloading_notification() { + const ALREADY_DOWNLOADING_MESSAGE: &str = "This media is already being downloaded."; + enqueue_popup_notification(ALREADY_DOWNLOADING_MESSAGE, PopupKind::Warning, Some(4.0)); +} /// The mxc URI inside any media source, whether plain or encrypted. pub fn media_source_mxc(source: &MediaSource) -> &OwnedMxcUri { @@ -85,98 +100,135 @@ impl DownloadKind { } } -/// Opens the rfd save dialog with sensible defaults for `info`. -#[cfg(not(any(target_os = "ios", target_os = "android")))] -fn build_save_dialog(info: &DownloadableAttachment) -> rfd::AsyncFileDialog { - let dialog = rfd::AsyncFileDialog::new().set_file_name(&info.filename); - if let Some(user_dirs) = robius_directories::UserDirs::new() { - let dir = user_dirs.download_dir() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| user_dirs.home_dir().to_path_buf()); - dialog.set_directory(dir) - } else { - dialog - } -} - -/// Opens the save dialog, then submits a request to fetch and write the file. +/// Fetches the attachment bytes via the matrix worker, then shows the native +/// save picker dialog with those bytes. /// -/// If `update_sender` is provided, it will be used to send progress updates to a timeline. +/// This *does* use the matrix SDK's media cache, so there's a good chance +/// that an attachment, especially small ones, will be instantly served from the cache. /// -/// The save dialog runs on a newly-spawned OS-native thread (not a tokio task) -/// because `rfd` requires it, at least on macOS. -#[cfg(not(any(target_os = "ios", target_os = "android")))] +/// The download indicator stays in the "in-progress" state until everything is done. +/// We transition to "success" only if the file is saved, "idle" if the user cancels, +/// and "failure" if the download fails or write to storage fails. pub fn start_attachment_download( - cx: &mut Cx, info: DownloadableAttachment, - update_sender: Option>, + update_sender: TimelineUpdateSenderOption, ) { - use crate::sliding_sync::{MatrixRequest, submit_async_request}; - - let dialog_task = build_save_dialog(&info).save_file(); - cx.spawn_thread(move || { - match futures::executor::block_on(dialog_task) { - // If Some, the user chose a valid location from the file dialog. - Some(handle) => { - submit_async_request(MatrixRequest::DownloadMediaToFile { - media_source: info.media_source, - save_path: handle.path().to_path_buf(), - filename: info.filename, - update_sender, - }); + let mxc = media_source_mxc(&info.media_source).clone(); + let filename = info.filename.clone(); + submit_async_request(MatrixRequest::DownloadMedia { + media_source: info.media_source, + filename: info.filename, + on_download_result: Box::new(move |result| match result { + MediaDownloadResult::Downloaded(bytes) => { + show_save_dialog(filename, bytes, Some(mxc), update_sender) } - // If None, the user cancelled the file dialog. - None => { - if let Some(sender) = update_sender { - let mxc = media_source_mxc(&info.media_source).clone(); - let _ = sender.send(TimelineUpdate::AttachmentDownloadReset(mxc)); - makepad_widgets::SignalToUI::set_ui_signal(); - } + MediaDownloadResult::Failed(e) => { + enqueue_popup_notification( + format!("Failed to download \"{filename}\": {e}"), + PopupKind::Error, + None, + ); + finish_download_indicator(&update_sender, Some(&mxc), DownloadOutcome::Failed); } - } + MediaDownloadResult::Cancelled => { + finish_download_indicator(&update_sender, Some(&mxc), DownloadOutcome::Cancelled); + } + }), }); } -/// Saves an attachment already in memory directly to storage. -#[cfg(not(any(target_os = "ios", target_os = "android")))] -pub fn save_loaded_attachment(cx: &mut Cx, info: DownloadableAttachment, bytes: Arc<[u8]>) { - let dialog_task = build_save_dialog(&info).save_file(); - cx.spawn_thread(move || { - let Some(handle) = futures::executor::block_on(dialog_task) else { return }; - let save_path = handle.path().to_path_buf(); - match std::fs::write(&save_path, &bytes[..]) { - Ok(()) => enqueue_popup_notification( - format!("Downloaded \"{}\".", info.filename), - PopupKind::Success, - Some(5.0), - ), - Err(e) => enqueue_popup_notification( - format!("Failed to save \"{}\": {e}", info.filename), - PopupKind::Error, - None, - ), - } - }); +/// Saves an attachment already in memory directly to storage, without showing any dialog. +pub fn save_loaded_attachment(info: DownloadableAttachment, bytes: Arc<[u8]>) { + show_save_dialog(info.filename, bytes, None, None); } -#[cfg(any(target_os = "ios", target_os = "android"))] -pub fn start_attachment_download( - _cx: &mut Cx, - _info: DownloadableAttachment, - _update_sender: Option>, +enum DownloadOutcome { + Succeeded, + Failed, + /// The user dismissed the save dialog; return the indicator to idle. + Cancelled, +} + +/// Shows the native save-file dialog and writes `data` to the user-chosen location. +fn show_save_dialog + Send + 'static>( + filename: String, + data: D, + mxc: Option, + update_sender: TimelineUpdateSenderOption, ) { - enqueue_popup_notification( - "Saving attachments is not yet supported on mobile.", - PopupKind::Error, - Some(5.0), - ); + use robius_file_picker::{PickedFile, FileDialog, StartLocation}; + let filename2 = filename.clone(); + let mxc2 = mxc.clone(); + let sender2 = update_sender.clone(); + let on_done = move |result: robius_file_picker::Result>| { + match result { + Ok(Some(_)) => { + enqueue_popup_notification( + format!("Downloaded \"{filename2}\"."), + PopupKind::Success, + Some(DOWNLOAD_RESULT_DURATION_SECS), + ); + finish_download_indicator(&update_sender, mxc.as_ref(), DownloadOutcome::Succeeded); + } + // User dismissed the save dialog. The bytes are discarded, but the + // matrix media cache makes a re-download effectively instant, so we + // just return the indicator to idle without a popup. + Ok(None) => { + finish_download_indicator(&update_sender, mxc.as_ref(), DownloadOutcome::Cancelled); + } + Err(e) => { + enqueue_popup_notification( + format!("Failed to save \"{filename2}\": {e}"), + PopupKind::Error, + None, + ); + finish_download_indicator(&update_sender, mxc.as_ref(), DownloadOutcome::Failed); + } + } + }; + + let res = FileDialog::new() + .set_file_name(&filename) + .set_start_location(StartLocation::Downloads) + .save_data(data, on_done); + if let Err(e) = res { + enqueue_popup_notification( + format!("Failed to open save dialog: {e}"), + PopupKind::Error, + None, + ); + finish_download_indicator(&sender2, mxc2.as_ref(), DownloadOutcome::Failed); + } } -#[cfg(any(target_os = "ios", target_os = "android"))] -pub fn save_loaded_attachment(_cx: &mut Cx, _info: DownloadableAttachment, _bytes: Arc<[u8]>) { - enqueue_popup_notification( - "Saving attachments is not yet supported on mobile.", - PopupKind::Error, - Some(5.0), - ); +/// Handles the completion of a download, whether success, failure, or cancelled. +fn finish_download_indicator( + update_sender: &TimelineUpdateSenderOption, + mxc: Option<&OwnedMxcUri>, + outcome: DownloadOutcome, +) { + let Some(sender) = update_sender.as_ref() else { return }; + let Some(mxc) = mxc else { return }; + match outcome { + DownloadOutcome::Cancelled => { + let _ = sender.send(TimelineUpdate::AttachmentDownloadReset(mxc.clone())); + SignalToUI::set_ui_signal(); + } + DownloadOutcome::Succeeded | DownloadOutcome::Failed => { + let result = match outcome { + DownloadOutcome::Succeeded => Ok(()), + _ => Err(String::new()), + }; + let _ = sender.send(TimelineUpdate::AttachmentDownloadFinished(mxc.clone(), result)); + SignalToUI::set_ui_signal(); + // Clear the success/failure display after a short delay. + let sender = sender.clone(); + let mxc = mxc.clone(); + crate::sliding_sync::spawn_async_task(async move { + tokio::time::sleep(Duration::from_secs_f64(DOWNLOAD_RESULT_DURATION_SECS)).await; + let _ = sender.send(TimelineUpdate::AttachmentDownloadReset(mxc)); + SignalToUI::set_ui_signal(); + }); + } + } } diff --git a/src/shared/file_upload_modal.rs b/src/shared/file_upload_modal.rs index 20945f08..ac8663b8 100644 --- a/src/shared/file_upload_modal.rs +++ b/src/shared/file_upload_modal.rs @@ -2,11 +2,12 @@ //! //! This modal shows a preview of the file (image preview or file icon) //! along with file metadata and upload/cancel buttons. +//! +//! Also includes various helper functions to uploading/previewing attachments. use makepad_widgets::*; use ruma::OwnedEventId; use std::sync::{Arc, atomic::{AtomicU64, Ordering}}; -use std::path::PathBuf; use crate::{ sliding_sync::{MatrixRequest, TimelineKind, submit_async_request}, @@ -135,6 +136,7 @@ script_mod! { width: Fill, height: Fit, is_multiline: true, + submit_on_enter: true, empty_text: "Caption" padding: 10, draw_text +: { @@ -148,7 +150,7 @@ script_mod! { padding: 0, margin: Inset{ left: 4.5} draw_text +: { - text_style: REGULAR_TEXT { font_size: 10 }, + text_style: REGULAR_TEXT { font_size: 11 }, color: (SMALL_STATE_TEXT_COLOR) } text: "" @@ -161,11 +163,24 @@ script_mod! { margin: Inset{ left: 4.5} flow: Flow.Right { wrap: true } draw_text +: { - text_style: REGULAR_TEXT { font_size: 10 }, + text_style: REGULAR_TEXT { font_size: 11 }, color: (COLOR_TEXT_WARNING_NOT_FOUND) } text: "This file is large (over 10 MB). Are you sure you want to upload it to the homeserver?" } + + empty_attachment_warning_label := Label { + visible: false, + width: Fill, + padding: 0, + margin: Inset{ left: 4.5} + flow: Flow.Right { wrap: true } + draw_text +: { + text_style: REGULAR_TEXT { font_size: 11 }, + color: (COLOR_TEXT_WARNING_NOT_FOUND) + } + text: "This file is empty (0 bytes). Are you sure you want to upload it?" + } } // Buttons @@ -194,8 +209,9 @@ script_mod! { /// Metadata describing a file to be uploaded. #[derive(Clone, Debug)] pub struct FileUploadMetadata { - /// The file path on the local filesystem. - pub path: PathBuf, + /// The local source file. For Android content selections this is a temp + /// copy, auto-deleted once this metadata and all its clones drop. + pub source: robius_file_picker::LocalFile, /// The optional user-editable caption to send with the attachment. pub caption: Option, /// The MIME type of the file. @@ -207,9 +223,14 @@ pub struct FileUploadMetadata { } impl FileUploadMetadata { + /// The local filesystem path of the file to upload. + pub fn path(&self) -> &std::path::Path { + self.source.path() + } + /// Returns the file name portion of the local path, or a fallback for invalid paths. pub fn file_name(&self) -> String { - self.path + self.path() .file_name() .and_then(|name| name.to_str()) .unwrap_or("Unknown file") @@ -233,6 +254,7 @@ pub struct AttachmentUpload { /// Actions used to show/hide the FileUploadModal. #[derive(Clone, Debug, Default)] +#[allow(clippy::large_enum_variant)] pub enum FilePreviewerAction { /// No action. #[default] @@ -264,9 +286,12 @@ impl Widget for FileUploadModal { Cx::post_action(FilePreviewerAction::Hide); } - // Handle upload button - if self.button(cx, ids!(upload_button)).clicked(actions) { - let caption = match self.text_input(cx, ids!(caption_input)).text().trim() { + // Start upload if upload button is clicked or Enter/Return is pressed in the caption text input. + let caption_input = self.text_input(cx, ids!(caption_input)); + if self.button(cx, ids!(upload_button)).clicked(actions) + || caption_input.returned(actions).is_some() + { + let caption = match caption_input.text().trim() { "" => None, caption => Some(caption.to_string()), }; @@ -321,6 +346,8 @@ impl FileUploadModal { .set_text(cx, &format_decimal_file_size(file_data.size)); self.label(cx, ids!(large_attachment_warning_label)) .set_visible(cx, file_data.size > LARGE_ATTACHMENT_WARNING_THRESHOLD_BYTES); + self.label(cx, ids!(empty_attachment_warning_label)) + .set_visible(cx, file_data.size == 0); // Show image preview if this is a displayable image let is_image = crate::image_utils::is_displayable_image(&file_data.mime_type); @@ -432,3 +459,59 @@ impl FileUploadModalRef { } } } + + +/// Builds an attachment and preview of the given local file that the user picked to upload. +/// +/// `source` is stored in the returned upload so any temp copy persists +/// until the upload completes, after which it's auto-cleans up. +/// +/// This function might block doing filesystem I/O, so it should run on a bg thread. +pub fn load_selected_file( + source: robius_file_picker::LocalFile, + timeline_kind: TimelineKind, + in_reply_to: Option, + #[cfg(feature = "tsp")] + sign_with_tsp: bool, +) -> Result { + let path = source.path(); + let metadata = std::fs::metadata(path).map_err( + |e| format!("Unable to access file: {e}") + )?; + if !metadata.is_file() { + return Err("Cannot upload directories or special files".to_string()); + } + let file_size = metadata.len(); + let mime_type = source + .mime_type() + .filter(|m| !m.is_empty() && *m != "application/octet-stream") + .map(ToOwned::to_owned) + .unwrap_or_else(|| mime_guess::from_path(path).first_or_octet_stream().to_string()); + let preview_data = if crate::image_utils::is_displayable_image(&mime_type) { + match std::fs::read(path) { + Ok(data) => Some(std::sync::Arc::new(data)), + Err(e) => return Err(format!("Unable to read image preview: {e}")), + } + } else { + None + }; + let name = source.display_name() + .filter(|n| !n.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| path.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_else(|| "unknown".to_string()); + + Ok(AttachmentUpload { + timeline_kind, + file_data: FileUploadMetadata { + source, + caption: Some(name), + mime_type, + preview_data, + size: file_size, + }, + in_reply_to, + #[cfg(feature = "tsp")] + sign_with_tsp, + }) +} diff --git a/src/shared/image_viewer.rs b/src/shared/image_viewer.rs index 0b8ab139..7916112f 100644 --- a/src/shared/image_viewer.rs +++ b/src/shared/image_viewer.rs @@ -839,9 +839,9 @@ impl MatchEvent for ImageViewer { { was_overlay_button_clicked = true; if let Some(bytes) = self.loaded_bytes.clone() { - save_loaded_attachment(cx, info, bytes); + save_loaded_attachment(info, bytes); } else { - start_attachment_download(cx, info, None); + start_attachment_download(info, None); } } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 52b9bcb1..89a32a0d 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -4,7 +4,7 @@ use mime::Mime; use clap::Parser; use eyeball::Subscriber; use eyeball_im::VectorDiff; -use futures_util::{future::join_all, pin_mut, StreamExt}; +use futures_util::{future::{Abortable, join_all}, pin_mut, StreamExt}; use imbl::Vector; use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; @@ -37,11 +37,11 @@ use hashbrown::{HashMap, HashSet}; use crate::{ app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails - }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, room_preview_cache::{enqueue_room_preview_update, RoomPreviewUpdate}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ + }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, - }, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{ - avatar::AvatarState, file_upload_modal::{AttachmentUpload, FileUploadAttemptId}, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupKind, enqueue_popup_notification} + }, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, room_preview_cache::{RoomPreviewUpdate, enqueue_room_preview_update}, shared::{ + attachment_download::{MediaDownloadResult, media_source_mxc}, avatar::AvatarState, file_upload_modal::{AttachmentUpload, FileUploadAttemptId}, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupKind, enqueue_popup_notification} }, space_service_sync::space_service_loop, utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, verification::add_verification_event_handlers_and_sync_client }; @@ -711,14 +711,16 @@ pub enum MatrixRequest { destination: Arc>, update_sender: Option>, }, - /// Request to fetch a media attachment/file and save it to the given path. + /// Request to download a media attachment/file. /// - /// Sends a [`TimelineUpdate::AttachmentDownloadFinished`] upon success or failure. - DownloadMediaToFile { + /// The given callback `on_download_result` is called from the backend + /// matrix worker tokio task. + /// If the given McxUri was already downloading, the request is rejected + /// and `on_download_result` is called with `Cancelled`. + DownloadMedia { media_source: MediaSource, - save_path: PathBuf, filename: String, - update_sender: Option>, + on_download_result: Box, }, /// Request to cancel an in-progress download. CancelDownload(OwnedMxcUri), @@ -732,6 +734,16 @@ pub fn submit_async_request(req: MatrixRequest) { } } +/// A media download in flight, tracked by MXC so duplicates are rejected and +/// the fetch can be cancelled. +struct ActiveDownload { + /// Aborts the underlying fetch when [`MatrixRequest::CancelDownload`] arrives. + abort_handle: futures_util::future::AbortHandle, + /// Delivers the result to the requester. Whichever of the completion or + /// cancellation path runs removes it from the map and calls it. + on_download_result: Box, +} + /// Spawns a one-off async task on the backend Tokio runtime. pub fn spawn_async_task(future: F) where @@ -769,8 +781,9 @@ async fn matrix_worker_task( let mut subscribers_own_user_read_receipts: HashMap> = HashMap::new(); // The async tasks that are spawned to subscribe to changes in the pinned events for each room. let mut subscribers_pinned_events: HashMap> = HashMap::new(); - // Abort handles for in-progress download tasks, keyed by MxcUri. - let download_tasks: Arc>> + // The async tasks spawned to handle media downloads, keyed by MxcUri. + // Here we intentionally use a `std` Mutex, not async, since it's cheaper under no contention. + let download_tasks: Arc>> = Arc::new(Mutex::new(HashMap::new())); while let Some(request) = request_receiver.recv().await { @@ -1871,7 +1884,7 @@ async fn matrix_worker_task( .unwrap_or(mime::APPLICATION_OCTET_STREAM); let image_dimensions = if content_type.type_() == mime::IMAGE { - image::ImageReader::open(&file_data.path) + image::ImageReader::open(file_data.path()) .ok() .and_then(|reader| reader.with_guessed_format().ok()) .and_then(|reader| reader.into_dimensions().ok()) @@ -1909,7 +1922,7 @@ async fn matrix_worker_task( }; let send_request = timeline.send_attachment( - file_data.path.clone(), + file_data.path().to_path_buf(), content_type, TimelineAttachmentConfig { info: Some(info), @@ -1962,7 +1975,7 @@ async fn matrix_worker_task( SignalToUI::set_ui_signal(); }; - match futures_util::future::Abortable::new(upload_future, abort_registration).await { + match Abortable::new(upload_future, abort_registration).await { Ok(()) => {} Err(_) => { log!("Attachment upload task {upload_id:?} for {monitor_timeline_kind} was aborted."); @@ -2185,78 +2198,66 @@ async fn matrix_worker_task( }); } - MatrixRequest::DownloadMediaToFile { media_source, save_path, filename, update_sender } => { - let Some(client) = get_client() else { continue }; - let mxc_uri = match &media_source { - MediaSource::Plain(uri) => uri.clone(), - MediaSource::Encrypted(file) => file.url.clone(), + MatrixRequest::DownloadMedia { media_source, filename, on_download_result } => { + use crate::shared::attachment_download::{enqueue_already_downloading_notification, MediaDownloadResult}; + + // Note: in this code block, we always want to call `on_download_result` with any error. + + let Some(client) = get_client() else { + on_download_result(MediaDownloadResult::Failed("Matrix client is not available".to_string())); + continue; }; + let mxc_uri = media_source_mxc(&media_source).clone(); + // Only allow a given MxcUri to be downloaded once at a time. + if download_tasks.lock().unwrap().contains_key(&mxc_uri) { + enqueue_already_downloading_notification(); + on_download_result(MediaDownloadResult::Cancelled); + continue; + } let (abort_handle, abort_registration) = futures_util::future::AbortHandle::new_pair(); - let mxc_for_task = mxc_uri.clone(); - let mxc_for_finished = mxc_uri.clone(); - let tasks_for_cleanup = download_tasks.clone(); + let download_tasks2 = download_tasks.clone(); + let mxc_uri2 = mxc_uri.clone(); let download_future = async move { let media_request = MediaRequestParameters { source: media_source, format: matrix_sdk::media::MediaFormat::File, }; - let result: Result<(), String> = match client.media().get_media_content(&media_request, true).await { - Ok(bytes) => match tokio::fs::write(&save_path, &bytes).await { - Ok(()) => { - log!("Saved downloaded attachment {filename:?} to {}", save_path.display()); - enqueue_popup_notification( - format!("Downloaded \"{filename}\"."), - PopupKind::Success, - Some(crate::shared::attachment_download::DOWNLOAD_RESULT_DURATION_SECS), - ); - Ok(()) + let res = match client.media().get_media_content(&media_request, true).await { + Ok(bytes) => { + log!("Downloaded attachment {filename:?} ({} bytes) to memory", bytes.len()); + Ok(bytes) } Err(e) => { - error!("Failed to write downloaded attachment {filename:?} to {}: {e}", save_path.display()); - enqueue_popup_notification( - format!("Failed to save \"{filename}\": {e}"), - PopupKind::Error, - None, - ); + error!("Failed to fetch media content for attachment {filename:?}: {e}"); Err(e.to_string()) } - } - Err(e) => { - error!("Failed to fetch media content for attachment {filename:?}: {e}"); - enqueue_popup_notification( - format!("Failed to download \"{filename}\": {e}"), - PopupKind::Error, - None, - ); - Err(e.to_string()) - } - }; - if let Some(sender) = update_sender { - let _ = sender.send(TimelineUpdate::AttachmentDownloadFinished(mxc_for_finished, result)); - SignalToUI::set_ui_signal(); - // Drop the success/failure indicator after a short delay. - let reset_sender = sender.clone(); - Handle::current().spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs_f64( - crate::shared::attachment_download::DOWNLOAD_RESULT_DURATION_SECS, - )).await; - let _ = reset_sender.send(TimelineUpdate::AttachmentDownloadReset(mxc_for_task)); - SignalToUI::set_ui_signal(); + }; + if let Some(active) = download_tasks2.lock().unwrap().remove(&mxc_uri2) { + (active.on_download_result)(match res { + Ok(bytes) => MediaDownloadResult::Downloaded(bytes), + Err(e) => MediaDownloadResult::Failed(e), }); } }; - - let mxc_for_cleanup = mxc_uri.clone(); - download_tasks.lock().unwrap().insert(mxc_uri, abort_handle); + + let download_tasks3 = download_tasks.clone(); + let mxc_uri3 = mxc_uri.clone(); + download_tasks.lock().unwrap().insert( + mxc_uri, + ActiveDownload { abort_handle, on_download_result }, + ); Handle::current().spawn(async move { - let _ = futures_util::future::Abortable::new(download_future, abort_registration).await; - tasks_for_cleanup.lock().unwrap().remove(&mxc_for_cleanup); + if Abortable::new(download_future, abort_registration).await.is_err() { + if let Some(active) = download_tasks3.lock().unwrap().remove(&mxc_uri3) { + (active.on_download_result)(MediaDownloadResult::Cancelled); + } + } }); } MatrixRequest::CancelDownload(mxc) => { - if let Some(abort) = download_tasks.lock().unwrap().remove(&mxc) { - abort.abort(); + if let Some(active) = download_tasks.lock().unwrap().get(&mxc) { + active.abort_handle.abort(); } } @@ -2907,7 +2908,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. loop { tokio::select! { - // If we were notified but it got canceled, check the TOKEN_EXPIRED bool. + // If we were notified but it got cancelled, check the TOKEN_EXPIRED bool. _ = TOKEN_EXPIRED_NOTIFY.notified() => { if !TOKEN_EXPIRED.load(Ordering::Acquire) { continue;