diff --git a/.gitignore b/.gitignore index a547bf3..4e90c93 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +src-tauri/binaries/ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 76d2a03..b62bbf3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -41,6 +41,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -71,6 +121,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -453,6 +515,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -460,11 +528,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -475,6 +551,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -484,6 +578,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -700,6 +803,16 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", ] [[package]] @@ -708,7 +821,19 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -719,7 +844,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -882,6 +1007,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1225,8 +1373,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1236,9 +1386,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1447,6 +1599,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1516,6 +1677,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1745,6 +1922,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -1774,6 +1957,30 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "jni" version = "0.21.1" @@ -1954,6 +2161,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -1986,6 +2199,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2296,6 +2519,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.5" @@ -2519,6 +2748,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2534,6 +2778,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2621,6 +2874,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2642,6 +2950,65 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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" +dependencies = [ + "getrandom 0.2.17", +] + +[[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 = "raw-window-handle" version = "0.6.2" @@ -2657,6 +3024,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -2717,6 +3095,53 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.3" @@ -2747,10 +3172,72 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rquickjs" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5227859c4dfc83f428e58f9569bf439e628c8d139020e7faff437e6f5abaa0" +dependencies = [ + "rquickjs-core", + "rquickjs-macro", +] + +[[package]] +name = "rquickjs-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e82e0ca83028ad5b533b53b96c395bbaab905a5774de4aaf1004eeacafa3d85d" +dependencies = [ + "async-lock", + "chrono", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-macro" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4d2eccd988a924a470a76fbd81a191b22d1f5f4f4619cf5662a8c1ab4ca1db7" +dependencies = [ + "convert_case", + "fnv", + "ident_case", + "indexmap 2.14.0", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "rquickjs-core", + "syn 2.0.117", +] + +[[package]] +name = "rquickjs-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fed0097b0b4fbb2a87f6dd3b995a7c64ca56de30007eb7e867dfdfc78324ba5" +dependencies = [ + "cc", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2779,12 +3266,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2975,6 +3503,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.20.0" @@ -3038,6 +3578,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3183,6 +3734,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3314,7 +3871,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3335,7 +3892,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.3", "serde", "serde_json", "serde_repr", @@ -3360,12 +3917,32 @@ dependencies = [ name = "tauri-app" version = "0.1.0" dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "dirs 5.0.1", + "env_logger", + "futures-util", + "hmac", + "log", + "mime_guess", + "once_cell", + "rand 0.8.6", + "regex", + "reqwest 0.12.28", + "rquickjs", "serde", "serde_json", + "sha1", "tauri", "tauri-build", "tauri-plugin-opener", "tauri-plugin-single-instance", + "tempfile", + "thiserror 2.0.18", + "tokio", + "urlencoding", + "uuid", ] [[package]] @@ -3376,7 +3953,7 @@ checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3711,11 +4288,35 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3870,12 +4471,17 @@ version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ + "async-compression", "bitflags 2.11.1", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -3932,7 +4538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -4017,6 +4623,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4035,6 +4647,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4048,6 +4666,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -4072,6 +4696,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" @@ -4236,6 +4866,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -4271,6 +4914,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.4" @@ -4327,6 +4980,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4557,6 +5219,24 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4599,6 +5279,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4656,6 +5351,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4674,6 +5375,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4692,6 +5399,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4722,6 +5435,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4740,6 +5459,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4758,6 +5483,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4776,6 +5507,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4932,7 +5669,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -5071,6 +5808,26 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5092,6 +5849,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e62d32c..bb6d198 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -5,12 +5,7 @@ description = "A Tauri App" authors = ["you"] edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [lib] -# The `_lib` suffix may seem redundant but it is necessary -# to make the lib name unique and wouldn't conflict with the bin name. -# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 name = "tauri_app_lib" crate-type = ["staticlib", "cdylib", "rlib"] @@ -23,6 +18,57 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# Async runtime +tokio = { version = "1", features = ["full"] } +futures-util = "0.3" +async-trait = "0.1" + +# HTTP client +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", + "stream", + "gzip", + "brotli", +] } + +# Embedded JS engine +rquickjs = { version = "0.9", features = [ + "macro", + "futures", + "loader", + "array-buffer", + "chrono", + "parallel", +] } + +# Text / parsing +regex = "1.10" +once_cell = "1.19" + +# Crypto for Aliyun OSS HMAC-SHA1 signing +hmac = "0.12" +sha1 = "0.10" +base64 = "0.22" +urlencoding = "2.1" + +# Time +chrono = { version = "0.4", default-features = false, features = [ + "clock", + "std", +] } + +# Logging + errors +log = "0.4" +env_logger = "0.11" +thiserror = "2" + +# Misc +mime_guess = "2" +dirs = "5" +rand = "0.8" +tempfile = "3" +uuid = { version = "1", features = ["v4"] } + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = "2" - diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a846287..cf0cd1e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,9 @@ "core:default", "core:window:default", "core:window:allow-start-dragging", + "core:event:default", + "core:event:allow-listen", + "core:event:allow-unlisten", "opener:default" ] } diff --git a/src-tauri/js/douyin_pipeline.js b/src-tauri/js/douyin_pipeline.js new file mode 100644 index 0000000..6413281 --- /dev/null +++ b/src-tauri/js/douyin_pipeline.js @@ -0,0 +1,381 @@ +// ============================================================================ +// douyin_pipeline.js +// +// Pure-JS port of the original `VideoRewriteIntegration.processDouyinShareComplete` +// pipeline. All host capabilities (network, fs, ffmpeg, OSS, ASR, AI chat, +// log, progress) come from the `__native` global injected by the Rust side +// (see `js_runtime/native.rs`). Nothing in this file should ever depend on +// Node.js, Electron or browser globals. +// +// Public entry point exposed to Rust: +// processDouyinShare(params) -> Promise +// +// `params` shape mirrors the original Electron IPC payload: +// { +// shareText: string, +// modelConfig: { providerId, modelId, apiUrl, apiKey, +// asrMode: "online" | "local", +// aliyunApiKey, ossConfig, asrServerInfo }, +// rewriteConfig: object, +// customPrompt: string | null +// } +// ============================================================================ + +const log = (level, msg, fields) => __native.log(level, msg, fields || null); +const progress = (s) => { try { __native.emitProgress(s); } catch (_) { /* ignore */ } }; + +// --------------------------------------------------------------------------- +// DouyinContentParser (pure-JS, no host calls) +// --------------------------------------------------------------------------- +const DouyinContentParser = { + // Extract a sharable URL from a raw "share text". Handles: + // - direct https://... links + // - "复制此链接,打开Dou音搜索,直接观看视频!https://v.douyin.com/xxxx/" + // - bare v.douyin.com short links + parseContent(shareText) { + if (!shareText || typeof shareText !== "string") { + return { success: false, error: "分享文本为空" }; + } + const urlRegex = /https?:\/\/[^\s\u4e00-\u9fa5]+/g; + const matches = shareText.match(urlRegex); + if (!matches || matches.length === 0) { + return { success: false, error: "未在文本中识别到任何 URL" }; + } + // Prefer douyin/iesdouyin domains, fallback to first match. + const douyinUrl = + matches.find((u) => /douyin\.com|iesdouyin\.com/i.test(u)) || matches[0]; + return { success: true, content: { videoUrl: douyinUrl, raw: shareText } }; + }, + + // Whether `url` already points at an actual media file we can fetch directly. + isDirectVideoUrl(url) { + if (!url) return false; + return /\.(mp4|mov|m4v|webm|mkv|flv|avi)(\?|$)/i.test(url); + }, + + // Derive a short title (<= 20 chars) from a long description. + generateTitleFromDescription(desc) { + if (!desc) return "改写视频"; + const firstLine = String(desc).split(/[\r\n]/)[0].trim(); + const cleaned = firstLine.replace(/[#@].*$/g, "").trim(); + return (cleaned || "改写视频").slice(0, 20); + }, +}; + +// --------------------------------------------------------------------------- +// DouyinVideoDownloader +// Resolves the final media URL (following 302 redirects on the share +// domain) and saves the binary to a host-managed temp file. +// --------------------------------------------------------------------------- +class DouyinVideoDownloader { + async downloadDirectVideo(url) { + log("info", "downloader.directVideo", { url }); + const dest = __native.tempPath("video", "mp4"); + const res = await __native.httpRequest({ + method: "GET", + url, + headers: { "User-Agent": this._ua() }, + response_type: "file", + dest_path: dest, + timeout_ms: 120000, + }); + if (!res || res.status >= 400) { + return { success: false, error: `直链下载失败 status=${res && res.status}` }; + } + return { success: true, videoPath: dest }; + } + + async downloadDouyinVideo(shareUrl) { + log("info", "downloader.douyin", { shareUrl }); + // Step 1: follow redirect on v.douyin.com -> iesdouyin.com to get itemId + let landing; + try { + landing = await __native.httpRequest({ + method: "GET", + url: shareUrl, + headers: { "User-Agent": this._ua() }, + response_type: "text", + follow_redirects: true, + timeout_ms: 30000, + }); + } catch (e) { + return { success: false, error: `解析跳转失败: ${e.message || e}` }; + } + const finalUrl = (landing && landing.final_url) || shareUrl; + const itemId = + this._extract(/\/video\/(\d+)/, finalUrl) || + this._extract(/item_ids?=(\d+)/, finalUrl) || + this._extract(/modal_id=(\d+)/, finalUrl); + if (!itemId) { + return { + success: false, + error: "未能从分享链接中提取视频 itemId,可能是抖音改版", + }; + } + // Step 2: ask iesdouyin for the playable URL. + const apiUrl = + "https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids=" + itemId; + let info; + try { + info = await __native.httpRequest({ + method: "GET", + url: apiUrl, + headers: { "User-Agent": this._ua() }, + response_type: "json", + timeout_ms: 30000, + }); + } catch (e) { + return { success: false, error: `获取视频信息失败: ${e.message || e}` }; + } + const item = + info && info.body && info.body.item_list && info.body.item_list[0]; + const playAddr = + item && item.video && item.video.play_addr && item.video.play_addr.url_list; + if (!playAddr || !playAddr.length) { + return { success: false, error: "iesdouyin 接口未返回播放地址" }; + } + // Force HTTPS + replace `playwm` (watermarked) with `play` if present. + const mediaUrl = playAddr[0] + .replace(/^http:\/\//, "https://") + .replace("/playwm/", "/play/"); + return this.downloadDirectVideo(mediaUrl); + } + + _ua() { + return ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/124.0.0.0 Safari/537.36" + ); + } + _extract(re, s) { + const m = String(s || "").match(re); + return m ? m[1] : null; + } +} + +// --------------------------------------------------------------------------- +// AI rewrite (OpenAI-compatible chat completions) +// --------------------------------------------------------------------------- +const VideoRewriteOptimizer = { + buildRewritePrompt(content, rewriteConfig, customPrompt) { + if (customPrompt && String(customPrompt).trim().length > 0) { + return String(customPrompt).replace(/\{\{\s*content\s*\}\}/g, content); + } + const cfg = rewriteConfig || {}; + const style = cfg.style || "短视频口播风格"; + const tone = cfg.tone || "自然、口语化"; + const lengthHint = cfg.length ? `控制在 ${cfg.length} 字以内` : "保持与原文相近的长度"; + return [ + "你是一名专业的短视频文案改写助手。", + `请基于下面这段视频转写文案,用【${style}】重新仿写一版。`, + `要求:语气 ${tone},${lengthHint},保留原意但避免与原文雷同;`, + "不要解释、不要加任何额外标题,直接输出仿写后的正文。", + "", + "【原文案】", + content, + ].join("\n"); + }, +}; + +async function rewriteWithAI(content, modelConfig, rewriteConfig, customPrompt) { + if (!modelConfig || !modelConfig.apiUrl || !modelConfig.apiKey) { + log("error", "rewrite.missingConfig"); + return null; + } + const prompt = VideoRewriteOptimizer.buildRewritePrompt( + content, + rewriteConfig || {}, + customPrompt + ); + const result = await __native.openaiChat({ + apiUrl: modelConfig.apiUrl, + apiKey: modelConfig.apiKey, + model: modelConfig.modelId, + messages: [ + { role: "system", content: "你是专业的短视频文案改写助手。" }, + { role: "user", content: prompt }, + ], + temperature: typeof modelConfig.temperature === "number" ? modelConfig.temperature : 0.8, + max_tokens: modelConfig.maxTokens || 1024, + }); + if (!result || !result.success) { + log("error", "rewrite.failed", { error: result && result.error }); + return null; + } + return (result.content || "").trim(); +} + +// --------------------------------------------------------------------------- +// VideoRewriteIntegration.processDouyinShareComplete +// The 1:1 port of the Electron version's pipeline. +// --------------------------------------------------------------------------- +async function processDouyinShareComplete(shareText, modelConfig, rewriteConfig, customPrompt) { + let videoPath = null; + let audioPath = null; + try { + log("info", "pipeline.start", { + inputLength: shareText && shareText.length, + modelConfig: { + providerId: modelConfig && modelConfig.providerId, + modelId: modelConfig && modelConfig.modelId, + asrMode: modelConfig && modelConfig.asrMode, + }, + }); + + // 1) parse share text -> video URL + progress("正在解析视频URL..."); + const parseResult = DouyinContentParser.parseContent(shareText); + if (!parseResult.success || !parseResult.content) { + return { success: false, error: parseResult.error || "内容解析失败" }; + } + const { videoUrl } = parseResult.content; + log("info", "pipeline.urlExtracted", { videoUrl }); + + // 2) download video + progress("正在下载视频..."); + const downloader = new DouyinVideoDownloader(); + const downloadResult = DouyinContentParser.isDirectVideoUrl(videoUrl) + ? await downloader.downloadDirectVideo(videoUrl) + : await downloader.downloadDouyinVideo(videoUrl); + if (!downloadResult.success || !downloadResult.videoPath) { + return { success: false, error: downloadResult.error || "视频下载失败" }; + } + videoPath = downloadResult.videoPath; + log("info", "pipeline.videoDownloaded", { videoPath }); + + // 3) extract audio (ffmpeg sidecar) + progress("视频下载完成,正在提取音频..."); + try { + audioPath = await __native.ffmpegExtractAudio(videoPath); + } catch (e) { + return { + success: false, + error: `音频提取失败: ${(e && e.message) || e}`, + }; + } + log("info", "pipeline.audioExtracted", { audioPath }); + + // 4) ASR + const asrMode = (modelConfig && modelConfig.asrMode) || "online"; + let recognizedText = ""; + if (asrMode === "online") { + progress("音频提取完成,正在上传到OSS..."); + const ossConfig = modelConfig.ossConfig; + if (!ossConfig || !ossConfig.bucket) { + return { success: false, error: "未配置阿里云 OSS(bucket / region / key)" }; + } + const upload = await __native.aliyunOssUpload(audioPath, ossConfig); + if (!upload || !upload.success || !upload.url) { + return { + success: false, + error: `音频上传失败: ${(upload && upload.error) || "unknown"}`, + }; + } + log("info", "pipeline.ossOk", { audioUrl: upload.url.slice(0, 100) + "..." }); + + progress("音频上传完成,正在识别文案(在线模式)..."); + const asr = await __native.aliyunAsrFiletrans(upload.url, { + apiKey: modelConfig.aliyunApiKey, + model: "qwen3-asr-flash-filetrans", + }); + if (!asr || !asr.success) { + return { + success: false, + error: `语音识别失败(在线模式): ${(asr && asr.error) || "unknown"}`, + }; + } + recognizedText = asr.text || ""; + log("info", "pipeline.asrOk", { + method: "aliyun-online", + len: recognizedText.length, + }); + } else { + progress("音频提取完成,正在识别文案(本地模式)..."); + const info = modelConfig.asrServerInfo; + if (!info) { + return { + success: false, + error: "未配置本地语音识别服务器,请在「设置」中配置", + }; + } + const asr = await __native.localAsr(audioPath, info); + if (!asr || !asr.success) { + return { + success: false, + error: `语音识别失败(本地模式): ${(asr && asr.error) || "unknown"}`, + }; + } + recognizedText = asr.text || ""; + log("info", "pipeline.asrOk", { + method: "local", + server: info.name, + len: recognizedText.length, + }); + } + + // 5) rewrite + if (!modelConfig || !modelConfig.apiUrl || !modelConfig.apiKey) { + return { success: false, error: "未配置AI模型,请先在设置中配置" }; + } + progress("文案识别完成,正在改写..."); + const rewrittenDescription = await rewriteWithAI( + recognizedText, + modelConfig, + rewriteConfig, + customPrompt + ); + if (!rewrittenDescription) { + return { success: false, error: "AI仿写失败,请检查模型配置和网络连接" }; + } + const rewrittenTitle = DouyinContentParser.generateTitleFromDescription(rewrittenDescription); + + log("info", "pipeline.success", { + originalLength: recognizedText.length, + rewrittenLength: rewrittenDescription.length, + }); + progress("完成!"); + + return { + success: true, + original: { + videoUrl, + description: recognizedText, + hashtags: [], + title: "视频音频识别结果", + }, + rewritten: { + description: rewrittenDescription, + title: rewrittenTitle, + fullContent: rewrittenDescription, + }, + }; + } catch (e) { + log("error", "pipeline.error", { message: (e && e.message) || String(e) }); + return { success: false, error: (e && e.message) || String(e) }; + } finally { + // 6) cleanup local temp files + if (videoPath) { + try { __native.deleteFile(videoPath); } catch (_) {} + } + if (audioPath) { + try { __native.deleteFile(audioPath); } catch (_) {} + } + } +} + +// --------------------------------------------------------------------------- +// Bootstrap: register into the global script table. +// The Rust runtime looks the entry up by `scriptName` at invocation time +// (see commands/quickjs.rs + js_runtime/mod.rs). +// --------------------------------------------------------------------------- +globalThis.__scripts = globalThis.__scripts || {}; +globalThis.__scripts["process_douyin_share"] = function (params) { + const p = params || {}; + return processDouyinShareComplete( + p.shareText, + p.modelConfig, + p.rewriteConfig, + p.customPrompt || null + ); +}; diff --git a/src-tauri/src/asr.rs b/src-tauri/src/asr.rs new file mode 100644 index 0000000..8326e97 --- /dev/null +++ b/src-tauri/src/asr.rs @@ -0,0 +1,165 @@ +//! Aliyun DashScope file-trans ASR client (qwen3-asr-flash-filetrans). +//! +//! The dashscope async transcription flow has two steps: +//! 1. POST https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription +//! header `X-DashScope-Async: enable` +//! body { model, input: { file_urls: [audio_url] }, parameters: {...} } +//! => returns task_id +//! 2. GET https://dashscope.aliyuncs.com/api/v1/tasks/{task_id} +//! poll until task_status in {SUCCEEDED, FAILED} +//! +//! On success the response carries one or more `transcription_url`s pointing +//! at JSON files containing the recognized sentences. We download the first +//! one and concatenate the sentence texts. + +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use thiserror::Error; +use tokio::time::sleep; + +const SUBMIT_URL: &str = + "https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription"; +const TASK_URL_PREFIX: &str = "https://dashscope.aliyuncs.com/api/v1/tasks/"; + +#[derive(Debug, Error)] +pub enum AsrError { + #[error("missing api key")] + MissingApiKey, + #[error("http: {0}")] + Http(#[from] reqwest::Error), + #[error("dashscope: {0}")] + Api(String), + #[error("timeout waiting for asr task")] + Timeout, + #[error("json: {0}")] + Json(#[from] serde_json::Error), +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct AsrSentence { + #[serde(default)] + pub text: String, + #[serde(default)] + pub begin_time: Option, + #[serde(default)] + pub end_time: Option, +} + +#[derive(Debug, Serialize)] +pub struct AsrResult { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sentences: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub async fn transcribe( + audio_url: &str, + api_key: &str, + model: &str, +) -> Result { + if api_key.trim().is_empty() { + return Err(AsrError::MissingApiKey); + } + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build()?; + + // 1) submit + let submit_body = json!({ + "model": model, + "input": { "file_urls": [audio_url] }, + "parameters": {} + }); + let submit: Value = client + .post(SUBMIT_URL) + .bearer_auth(api_key) + .header("X-DashScope-Async", "enable") + .json(&submit_body) + .send() + .await? + .json() + .await?; + let task_id = submit + .pointer("/output/task_id") + .and_then(Value::as_str) + .ok_or_else(|| AsrError::Api(format!("submit: no task_id, raw={submit}")))? + .to_string(); + + // 2) poll + let max_polls = 60; // ~5 min @ 5s + let mut poll_resp: Value = Value::Null; + let mut succeeded = false; + for _ in 0..max_polls { + sleep(Duration::from_secs(5)).await; + poll_resp = client + .get(format!("{TASK_URL_PREFIX}{task_id}")) + .bearer_auth(api_key) + .send() + .await? + .json() + .await?; + let status = poll_resp + .pointer("/output/task_status") + .and_then(Value::as_str) + .unwrap_or(""); + match status { + "SUCCEEDED" => { succeeded = true; break; } + "FAILED" | "UNKNOWN" => { + let msg = poll_resp + .pointer("/output/message") + .and_then(Value::as_str) + .unwrap_or("FAILED"); + return Err(AsrError::Api(msg.to_string())); + } + _ => continue, + } + } + if !succeeded { + return Err(AsrError::Timeout); + } + + // 3) fetch transcription file + let trans_url = poll_resp + .pointer("/output/results/0/transcription_url") + .and_then(Value::as_str) + .ok_or_else(|| AsrError::Api(format!("no transcription_url, raw={poll_resp}")))? + .to_string(); + let trans_json: Value = client.get(&trans_url).send().await?.json().await?; + + // dashscope transcription file shape: + // { "transcripts": [{ "text": "...", "sentences": [{text, begin_time, end_time}, ...] }] } + let mut full_text = String::new(); + let mut sentences = Vec::::new(); + if let Some(transcripts) = trans_json.get("transcripts").and_then(Value::as_array) { + for t in transcripts { + if let Some(text) = t.get("text").and_then(Value::as_str) { + if !full_text.is_empty() { full_text.push('\n'); } + full_text.push_str(text); + } + if let Some(arr) = t.get("sentences").and_then(Value::as_array) { + for s in arr { + sentences.push(AsrSentence { + text: s.get("text").and_then(Value::as_str).unwrap_or("").to_string(), + begin_time: s.get("begin_time").and_then(Value::as_i64), + end_time: s.get("end_time").and_then(Value::as_i64), + }); + } + } + } + } else if let Some(text) = trans_json.get("text").and_then(Value::as_str) { + full_text.push_str(text); + } + + Ok(AsrResult { + success: true, + text: Some(full_text), + sentences: if sentences.is_empty() { None } else { Some(sentences) }, + error: None, + }) +} diff --git a/src-tauri/src/chat.rs b/src-tauri/src/chat.rs new file mode 100644 index 0000000..c4371fb --- /dev/null +++ b/src-tauri/src/chat.rs @@ -0,0 +1,95 @@ +//! Minimal OpenAI-compatible chat completions client. +//! Used for the rewrite step. + +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ChatError { + #[error("missing api url or key")] + MissingConfig, + #[error("http: {0}")] + Http(#[from] reqwest::Error), + #[error("api: status={status}, body={body}")] + Api { status: u16, body: String }, + #[error("api response had no choices")] + NoChoices, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChatRequest { + #[serde(rename = "apiUrl")] + pub api_url: String, + #[serde(rename = "apiKey")] + pub api_key: String, + pub model: String, + pub messages: Vec, + #[serde(default)] + pub temperature: Option, + #[serde(default, rename = "max_tokens")] + pub max_tokens: Option, +} + +#[derive(Debug, Serialize)] +pub struct ChatResponse { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub async fn complete(req: &ChatRequest) -> Result { + if req.api_url.trim().is_empty() || req.api_key.trim().is_empty() { + return Err(ChatError::MissingConfig); + } + // Accept both `https://host` (will append /v1/chat/completions) and the + // full URL. + let url = if req.api_url.contains("/chat/completions") { + req.api_url.trim_end_matches('/').to_string() + } else { + format!("{}/v1/chat/completions", req.api_url.trim_end_matches('/')) + }; + + let mut body = json!({ + "model": req.model, + "messages": req.messages.iter().map(|m| json!({ + "role": m.role, + "content": m.content, + })).collect::>(), + "stream": false, + }); + if let Some(t) = req.temperature { body["temperature"] = json!(t); } + if let Some(mt) = req.max_tokens { body["max_tokens"] = json!(mt); } + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build()?; + let resp = client + .post(&url) + .bearer_auth(&req.api_key) + .json(&body) + .send() + .await?; + let status = resp.status().as_u16(); + if !(200..300).contains(&status) { + let txt = resp.text().await.unwrap_or_default(); + return Err(ChatError::Api { status, body: txt }); + } + let v: Value = resp.json().await?; + let content = v + .pointer("/choices/0/message/content") + .and_then(Value::as_str) + .ok_or(ChatError::NoChoices)? + .to_string(); + Ok(ChatResponse { success: true, content: Some(content), error: None }) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..932bf50 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod quickjs; diff --git a/src-tauri/src/commands/quickjs.rs b/src-tauri/src/commands/quickjs.rs new file mode 100644 index 0000000..f625ee7 --- /dev/null +++ b/src-tauri/src/commands/quickjs.rs @@ -0,0 +1,99 @@ +//! Generic Tauri commands for invoking any JS script registered in the +//! embedded QuickJS runtime. Replaces the per-pipeline command so adding a +//! new flow only requires: +//! +//! 1. Drop a new `.js` file into `src-tauri/js/`. +//! 2. Add it to `js_runtime::SCRIPT_FILES`. +//! 3. Inside the JS file, register one or more entries on +//! `globalThis.__scripts[]`. +//! +//! Frontend usage: +//! +//! ```ts +//! import { invoke } from "@tauri-apps/api/core"; +//! import { listen } from "@tauri-apps/api/event"; +//! +//! const off = await listen("quickjs:event", (e) => console.log(e.payload)); +//! const result = await invoke("run_quickjs_script", { +//! scriptName: "process_douyin_share", +//! params: { shareText: "...", modelConfig: {...}, ... }, +//! }); +//! off(); +//! ``` + +use serde::Serialize; +use serde_json::Value; +use tauri::{AppHandle, Emitter, Window}; +use tokio::sync::mpsc; + +use crate::js_runtime::{self, PipelineEvent}; + +#[derive(Debug, Serialize, Clone)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum FrontendEvent { + Progress { + #[serde(rename = "scriptName")] + script_name: String, + status: String, + }, + Log { + #[serde(rename = "scriptName")] + script_name: String, + level: String, + msg: String, + #[serde(skip_serializing_if = "Option::is_none")] + fields: Option, + }, +} + +const EVENT_NAME: &str = "quickjs:event"; + +/// Run an arbitrary registered QuickJS script by name. +/// +/// `script_name` must already be registered on `globalThis.__scripts` by +/// one of the bundled `js/*.js` files. `params` is forwarded as-is to the +/// script entry function. +#[tauri::command] +pub async fn run_quickjs_script( + app: AppHandle, + window: Window, + script_name: String, + params: Value, +) -> Result { + let (tx, mut rx) = mpsc::unbounded_channel::(); + + // Forward script events to the frontend window. + let app_for_emit = app.clone(); + let win_label = window.label().to_string(); + let script_for_emit = script_name.clone(); + let pump = tokio::spawn(async move { + while let Some(ev) = rx.recv().await { + let payload: FrontendEvent = match ev { + PipelineEvent::Progress(s) => FrontendEvent::Progress { + script_name: script_for_emit.clone(), + status: s, + }, + PipelineEvent::Log { level, msg, fields } => FrontendEvent::Log { + script_name: script_for_emit.clone(), + level, + msg, + fields, + }, + }; + let _ = app_for_emit.emit_to(&win_label, EVENT_NAME, payload); + } + }); + + let result = js_runtime::run_script(script_name, params, tx) + .await + .map_err(|e| e.to_string())?; + + pump.await.ok(); + Ok(result) +} + +/// Diagnostic helper — returns the list of registered script names. +#[tauri::command] +pub async fn list_quickjs_scripts() -> Result, String> { + js_runtime::list_scripts().await.map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/ffmpeg.rs b/src-tauri/src/ffmpeg.rs new file mode 100644 index 0000000..b76f018 --- /dev/null +++ b/src-tauri/src/ffmpeg.rs @@ -0,0 +1,83 @@ +//! Minimal ffmpeg sidecar wrapper used by the Douyin pipeline. +//! +//! Resolution order for the ffmpeg binary: +//! 1. `AICLIENT_FFMPEG_PATH` env override +//! 2. `binaries/ffmpeg(.exe)` next to the running executable (production) +//! 3. `src-tauri/binaries/ffmpeg(.exe)` relative to CWD (development) +//! 4. plain `ffmpeg` from the user's PATH + +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use thiserror::Error; +use tokio::process::Command; + +#[derive(Debug, Error)] +pub enum FfmpegError { + #[error("ffmpeg binary not found")] + NotFound, + #[error("ffmpeg failed (status={status:?}): {stderr}")] + Failed { status: Option, stderr: String }, + #[error("io: {0}")] + Io(#[from] std::io::Error), +} + +fn exe_name() -> &'static str { + if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" } +} + +pub fn locate_ffmpeg() -> Option { + if let Ok(p) = std::env::var("AICLIENT_FFMPEG_PATH") { + let pb = PathBuf::from(p); + if pb.exists() { + return Some(pb); + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join("binaries").join(exe_name()); + if candidate.exists() { + return Some(candidate); + } + let flat = dir.join(exe_name()); + if flat.exists() { + return Some(flat); + } + } + } + let dev = PathBuf::from("src-tauri/binaries").join(exe_name()); + if dev.exists() { + return Some(dev); + } + let dev2 = PathBuf::from("binaries").join(exe_name()); + if dev2.exists() { + return Some(dev2); + } + Some(PathBuf::from(exe_name())) +} + +/// Extract the audio track from `video_path` and re-encode to 16 kHz mono WAV +/// (the format dashscope/funasr accept). Returns the produced wav path. +pub async fn extract_audio(video_path: &Path, dest_wav: &Path) -> Result<(), FfmpegError> { + let ffmpeg = locate_ffmpeg().ok_or(FfmpegError::NotFound)?; + let output = Command::new(&ffmpeg) + .arg("-y") + .arg("-i").arg(video_path) + .arg("-vn") + .arg("-ac").arg("1") + .arg("-ar").arg("16000") + .arg("-acodec").arg("pcm_s16le") + .arg(dest_wav) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn()? + .wait_with_output() + .await?; + if !output.status.success() { + return Err(FfmpegError::Failed { + status: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + Ok(()) +} diff --git a/src-tauri/src/http.rs b/src-tauri/src/http.rs new file mode 100644 index 0000000..1090a46 --- /dev/null +++ b/src-tauri/src/http.rs @@ -0,0 +1,137 @@ +//! Generic HTTP helper exposed to the embedded QuickJS runtime. +//! +//! Designed to keep the JS side dumb — the JS layer only describes a request +//! and decides whether it wants the body as text/json/binary/file. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use tokio::io::AsyncWriteExt; + +#[derive(Debug, Error)] +pub enum HttpError { + #[error("invalid request: {0}")] + Invalid(String), + #[error("http: {0}")] + Http(#[from] reqwest::Error), + #[error("io: {0}")] + Io(#[from] std::io::Error), +} + +#[derive(Debug, Deserialize)] +pub struct HttpRequestSpec { + #[serde(default = "default_method")] + pub method: String, + pub url: String, + #[serde(default)] + pub headers: Option>, + #[serde(default)] + pub body: Option, + /// "text" | "json" | "binary" | "file" + #[serde(default = "default_response_type")] + pub response_type: String, + /// Required when `response_type == "file"`. + #[serde(default)] + pub dest_path: Option, + #[serde(default)] + pub timeout_ms: Option, + #[serde(default = "default_true")] + pub follow_redirects: bool, +} + +fn default_method() -> String { "GET".into() } +fn default_response_type() -> String { "text".into() } +fn default_true() -> bool { true } + +#[derive(Debug, Serialize)] +pub struct HttpResponse { + pub status: u16, + pub final_url: String, + pub headers: HashMap, + /// Present when response_type == "text" + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + /// Present when response_type == "file" — the local path written to. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_path: Option, + /// Present when response_type == "binary" (base64-encoded). + #[serde(skip_serializing_if = "Option::is_none")] + pub body_base64: Option, +} + +pub async fn execute(spec: HttpRequestSpec) -> Result { + let mut builder = reqwest::Client::builder(); + if !spec.follow_redirects { + builder = builder.redirect(reqwest::redirect::Policy::none()); + } + if let Some(ms) = spec.timeout_ms { + builder = builder.timeout(Duration::from_millis(ms)); + } + let client = builder.build()?; + + let method = reqwest::Method::from_bytes(spec.method.to_uppercase().as_bytes()) + .map_err(|e| HttpError::Invalid(format!("bad method: {e}")))?; + + let mut req = client.request(method, &spec.url); + if let Some(h) = &spec.headers { + for (k, v) in h { req = req.header(k, v); } + } + if let Some(b) = &spec.body { + req = match b { + Value::String(s) => req.body(s.clone()), + other => req.json(other), + }; + } + + let resp = req.send().await?; + let status = resp.status().as_u16(); + let final_url = resp.url().to_string(); + let headers = resp + .headers() + .iter() + .filter_map(|(k, v)| v.to_str().ok().map(|s| (k.to_string(), s.to_string()))) + .collect(); + + let mut out = HttpResponse { status, final_url, headers, body: None, file_path: None, body_base64: None }; + + match spec.response_type.as_str() { + "json" => { + let v: Value = resp.json().await?; + out.body = Some(v); + } + "binary" => { + use base64::Engine as _; + let bytes = resp.bytes().await?; + out.body_base64 = Some(base64::engine::general_purpose::STANDARD.encode(&bytes)); + } + "file" => { + let dest = spec.dest_path.as_ref() + .ok_or_else(|| HttpError::Invalid("dest_path is required for response_type=file".into()))?; + let path = PathBuf::from(dest); + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + let _ = tokio::fs::create_dir_all(parent).await; + } + } + let mut file = tokio::fs::File::create(&path).await?; + let mut stream = resp.bytes_stream(); + use futures_util::StreamExt; + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + file.write_all(&chunk).await?; + } + file.flush().await?; + out.file_path = Some(path.to_string_lossy().into_owned()); + } + _ => { + let txt = resp.text().await?; + out.body = Some(Value::String(txt)); + } + } + + Ok(out) +} diff --git a/src-tauri/src/js_runtime/mod.rs b/src-tauri/src/js_runtime/mod.rs new file mode 100644 index 0000000..238e636 --- /dev/null +++ b/src-tauri/src/js_runtime/mod.rs @@ -0,0 +1,147 @@ +//! Embedded QuickJS runtime that runs *named* JS scripts. +//! +//! Each script file under `src-tauri/js/*.js` is compiled into the binary +//! via `include_str!`. At startup the runtime evaluates all of them in +//! order; each file is expected to register one or more handlers into the +//! global `__scripts` table: +//! +//! globalThis.__scripts = globalThis.__scripts || {}; +//! globalThis.__scripts["my_script"] = function (params) { ... }; +//! +//! The Rust side then resolves `__scripts[name]` at invocation time. + +pub mod native; + +use std::sync::Arc; + +use rquickjs::{async_with, promise::Promise, AsyncContext, AsyncRuntime, CatchResultExt}; +use serde_json::Value; +use thiserror::Error; +use tokio::sync::mpsc::UnboundedSender; + +use native::NativeBridge; + +// --------------------------------------------------------------------------- +// Bundled script registry +// +// Add a new line here whenever you drop a new file into `src-tauri/js/`. +// The order matters only if scripts depend on each other; ours don't. +// --------------------------------------------------------------------------- +const BOOTSTRAP_JS: &str = "globalThis.__scripts = globalThis.__scripts || {};"; + +const SCRIPT_FILES: &[(&str, &str)] = &[ + ("douyin_pipeline.js", include_str!("../../js/douyin_pipeline.js")), + // ("my_next_pipeline.js", include_str!("../../js/my_next_pipeline.js")), +]; + +#[derive(Debug, Error)] +pub enum JsError { + #[error("rquickjs: {0}")] + QuickJs(String), + #[error("serde: {0}")] + Serde(#[from] serde_json::Error), + #[error("unknown script: {0}")] + UnknownScript(String), +} + +impl From for JsError { + fn from(e: rquickjs::Error) -> Self { JsError::QuickJs(e.to_string()) } +} + +/// Progress / log message produced by a running script. +#[derive(Debug, Clone)] +pub enum PipelineEvent { + Progress(String), + Log { + level: String, + msg: String, + fields: Option, + }, +} + +/// Execute the script registered under `script_name` and return its result. +pub async fn run_script( + script_name: String, + params: Value, + events: UnboundedSender, +) -> Result { + let bridge = Arc::new(NativeBridge::new(events)); + + let runtime = AsyncRuntime::new()?; + let context = AsyncContext::full(&runtime).await?; + + let result_json: Value = async_with!(context => |ctx| { + // 1) install the __native bridge + let native_obj = native::build_native_object(ctx.clone(), bridge.clone()) + .map_err(|e| JsError::QuickJs(format!("install native: {e}")))?; + ctx.globals().set("__native", native_obj) + .map_err(|e| JsError::QuickJs(format!("set __native: {e}")))?; + + // 2) bootstrap + evaluate every registered script file + let _: () = ctx.eval(BOOTSTRAP_JS).catch(&ctx) + .map_err(|e| JsError::QuickJs(format!("eval bootstrap: {e}")))?; + for (file_name, source) in SCRIPT_FILES { + let _: () = ctx.eval(*source).catch(&ctx) + .map_err(|e| JsError::QuickJs(format!("eval {file_name}: {e}")))?; + } + + // 3) look up __scripts[script_name] + let scripts: rquickjs::Object = ctx.globals().get("__scripts") + .map_err(|e| JsError::QuickJs(format!("get __scripts: {e}")))?; + let entry: rquickjs::Function = scripts + .get(script_name.as_str()) + .map_err(|_| JsError::UnknownScript(script_name.clone()))?; + + // 4) call entry(params) + let params_str = serde_json::to_string(¶ms) + .map_err(|e| JsError::QuickJs(format!("serialize params: {e}")))?; + let params_value: rquickjs::Value = ctx.json_parse(params_str) + .map_err(|e| JsError::QuickJs(format!("parse params: {e}")))?; + + // The entry may be sync (returns Value) or async (returns Promise). + // Cast the return value to Value first, then promote to Promise if it + // *is* a Promise; otherwise use it as-is. + let raw: rquickjs::Value = entry.call((params_value,)) + .map_err(|e| JsError::QuickJs(format!("call entry: {e}")))?; + let value: rquickjs::Value = match Promise::from_value(raw.clone()) { + Ok(p) => p.into_future().await + .map_err(|e| JsError::QuickJs(format!("await {script_name}: {e}")))?, + Err(_) => raw, + }; + + // 5) JS value -> serde_json::Value via JSON.stringify + let json_global: rquickjs::Object = ctx.globals().get("JSON") + .map_err(|e| JsError::QuickJs(format!("get JSON: {e}")))?; + let stringify: rquickjs::Function = json_global.get("stringify") + .map_err(|e| JsError::QuickJs(format!("get JSON.stringify: {e}")))?; + let s: String = stringify.call((value,)) + .map_err(|e| JsError::QuickJs(format!("stringify result: {e}")))?; + let parsed: Value = serde_json::from_str(&s)?; + Ok::(parsed) + }) + .await?; + + Ok(result_json) +} + +/// Best-effort list of names currently registered in `__scripts`. +/// Useful for diagnostics / a `list_quickjs_scripts` command. +pub async fn list_scripts() -> Result, JsError> { + let runtime = AsyncRuntime::new()?; + let context = AsyncContext::full(&runtime).await?; + let names: Vec = async_with!(context => |ctx| { + let _: () = ctx.eval(BOOTSTRAP_JS).catch(&ctx) + .map_err(|e| JsError::QuickJs(format!("bootstrap: {e}")))?; + for (file_name, src) in SCRIPT_FILES { + let _: () = ctx.eval(*src).catch(&ctx) + .map_err(|e| JsError::QuickJs(format!("eval {file_name}: {e}")))?; + } + let keys: String = ctx.eval(r#"Object.keys(globalThis.__scripts || {}).join(",")"#) + .map_err(|e| JsError::QuickJs(format!("collect keys: {e}")))?; + Ok::, JsError>( + keys.split(',').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect() + ) + }) + .await?; + Ok(names) +} diff --git a/src-tauri/src/js_runtime/native.rs b/src-tauri/src/js_runtime/native.rs new file mode 100644 index 0000000..960c2da --- /dev/null +++ b/src-tauri/src/js_runtime/native.rs @@ -0,0 +1,295 @@ +//! `__native` bridge: the set of host functions exposed to the embedded +//! QuickJS pipeline. +//! +//! Conventions: +//! - Sync helpers (log, tempPath, deleteFile, ...) take/return primitives +//! and may panic-free `Func::from` directly. +//! - Async helpers always return a `String` (JSON-encoded result). +//! Failures are encoded as `{ "__error": "..." }`; a JS shim +//! installed at the end of `build_native_object` detects that shape +//! and `throw`s, so JS callers can use natural `try/catch`. +//! - On success, the JSON string is parsed by the same JS shim and +//! returned to the caller as a plain object. + +use std::path::PathBuf; +use std::sync::Arc; + +use rquickjs::{ + function::{Async, Func}, + Ctx, Object, Result as JsResult, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::sync::mpsc::UnboundedSender; +use uuid::Uuid; + +use super::PipelineEvent; +use crate::{asr, chat, ffmpeg, http, oss}; + +/// Shared host state passed by reference into every native function. +pub struct NativeBridge { + pub events: UnboundedSender, + pub temp_dir: PathBuf, +} + +impl NativeBridge { + pub fn new(events: UnboundedSender) -> Self { + let temp_dir = std::env::temp_dir().join("aiclient-pipeline"); + let _ = std::fs::create_dir_all(&temp_dir); + Self { events, temp_dir } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn err_json(msg: impl ToString) -> String { + json!({ "__error": msg.to_string() }).to_string() +} + +fn value_to_json<'js>(v: &rquickjs::Value<'js>) -> Result { + if v.is_null() || v.is_undefined() { + return Ok(Value::Null); + } + let ctx = v.ctx().clone(); + let json: rquickjs::Object = ctx.globals().get("JSON").map_err(|e| e.to_string())?; + let stringify: rquickjs::Function = json.get("stringify").map_err(|e| e.to_string())?; + let s: String = stringify + .call((v.clone(),)) + .map_err(|e| e.to_string())?; + serde_json::from_str(&s).map_err(|e| e.to_string()) +} + +// --------------------------------------------------------------------------- +// Build the __native object +// --------------------------------------------------------------------------- +pub fn build_native_object<'js>( + ctx: Ctx<'js>, + bridge: Arc, +) -> JsResult> { + let obj = Object::new(ctx.clone())?; + + // ---- sync helpers ---------------------------------------------------- + { + let b = bridge.clone(); + obj.set( + "log", + Func::from(move |level: String, msg: String, fields: Option>| { + let fields_json = fields.and_then(|v| value_to_json(&v).ok()); + let _ = b.events.send(PipelineEvent::Log { + level: level.clone(), + msg: msg.clone(), + fields: fields_json.clone(), + }); + log::info!(target: "pipeline", "[{}] {} {:?}", level, msg, fields_json); + }), + )?; + } + { + let b = bridge.clone(); + obj.set( + "emitProgress", + Func::from(move |status: String| { + let _ = b.events.send(PipelineEvent::Progress(status)); + }), + )?; + } + { + let b = bridge.clone(); + obj.set( + "tempPath", + Func::from(move |prefix: String, ext: Option| -> String { + let ext = ext.unwrap_or_else(|| "tmp".into()); + let name = format!("{}-{}.{}", prefix, Uuid::new_v4(), ext); + b.temp_dir.join(name).to_string_lossy().into_owned() + }), + )?; + } + obj.set( + "deleteFile", + Func::from(|path: String| { + let _ = std::fs::remove_file(&path); + }), + )?; + obj.set( + "fileExists", + Func::from(|path: String| std::path::Path::new(&path).exists()), + )?; + obj.set( + "now", + Func::from(|| chrono::Utc::now().timestamp_millis() as f64), + )?; + obj.set("uuid", Func::from(|| Uuid::new_v4().to_string()))?; + + // ---- async: HTTP ----------------------------------------------------- + obj.set( + "httpRequest", + Func::from(Async(|spec: rquickjs::Value<'js>| { + let spec_json = value_to_json(&spec); + async move { + let spec_json = match spec_json { + Ok(v) => v, + Err(e) => return Ok::(err_json(format!("invalid http spec: {e}"))), + }; + let parsed: http::HttpRequestSpec = match serde_json::from_value(spec_json) { + Ok(p) => p, + Err(e) => return Ok(err_json(format!("http spec deserialize: {e}"))), + }; + match http::execute(parsed).await { + Ok(resp) => Ok(serde_json::to_string(&resp).unwrap_or_else(|e| err_json(e))), + Err(e) => Ok(err_json(format!("http: {e}"))), + } + } + })), + )?; + + // ---- async: ffmpeg extract audio ------------------------------------ + { + let b = bridge.clone(); + obj.set( + "ffmpegExtractAudio", + Func::from(Async(move |video_path: String| { + let temp_dir = b.temp_dir.clone(); + async move { + let dest = temp_dir.join(format!("audio-{}.wav", Uuid::new_v4())); + match ffmpeg::extract_audio(std::path::Path::new(&video_path), &dest).await { + Ok(()) => Ok::( + json!(dest.to_string_lossy()).to_string(), + ), + Err(e) => Ok(err_json(format!("ffmpeg: {e}"))), + } + } + })), + )?; + } + + // ---- async: aliyun OSS upload --------------------------------------- + obj.set( + "aliyunOssUpload", + Func::from(Async(|local: String, cfg: rquickjs::Value<'js>| { + let cfg_json = value_to_json(&cfg); + async move { + let cfg_json = match cfg_json { + Ok(v) => v, + Err(e) => return Ok::( + json!({"success": false, "error": format!("oss cfg: {e}")}).to_string(), + ), + }; + let cfg: oss::OssConfig = match serde_json::from_value(cfg_json) { + Ok(c) => c, + Err(e) => return Ok( + json!({"success": false, "error": format!("oss cfg parse: {e}")}).to_string(), + ), + }; + let key = format!("audio/{}.wav", Uuid::new_v4()); + match oss::put_object(&cfg, std::path::Path::new(&local), &key, "audio/wav").await { + Ok(url) => Ok(json!({"success": true, "url": url}).to_string()), + Err(e) => Ok(json!({"success": false, "error": e.to_string()}).to_string()), + } + } + })), + )?; + + // ---- async: aliyun dashscope ASR ------------------------------------ + obj.set( + "aliyunAsrFiletrans", + Func::from(Async(|audio_url: String, opts: rquickjs::Value<'js>| { + let opts_json = value_to_json(&opts); + async move { + let opts_json = match opts_json { + Ok(v) => v, + Err(e) => return Ok::( + json!({"success": false, "error": format!("asr opts: {e}")}).to_string(), + ), + }; + #[derive(Deserialize)] + struct AsrOpts { + #[serde(rename = "apiKey")] + api_key: String, + #[serde(default = "default_asr_model")] + model: String, + } + fn default_asr_model() -> String { "qwen3-asr-flash-filetrans".into() } + let parsed: AsrOpts = match serde_json::from_value(opts_json) { + Ok(p) => p, + Err(e) => return Ok( + json!({"success": false, "error": format!("asr opts parse: {e}")}).to_string(), + ), + }; + let result = match asr::transcribe(&audio_url, &parsed.api_key, &parsed.model).await { + Ok(r) => serde_json::to_value(r).unwrap_or(Value::Null), + Err(e) => json!({"success": false, "error": e.to_string()}), + }; + Ok(result.to_string()) + } + })), + )?; + + // ---- async: local ASR (placeholder; user can plug in own server) ---- + obj.set( + "localAsr", + Func::from(Async(|_audio_path: String, _info: rquickjs::Value<'js>| async move { + Ok::( + json!({ + "success": false, + "error": "local ASR not implemented in Rust port — please call your local server explicitly" + }) + .to_string(), + ) + })), + )?; + + // ---- async: OpenAI-compatible chat ---------------------------------- + obj.set( + "openaiChat", + Func::from(Async(|req: rquickjs::Value<'js>| { + let req_json = value_to_json(&req); + async move { + let req_json = match req_json { + Ok(v) => v, + Err(e) => return Ok::( + json!({"success": false, "error": format!("chat req: {e}")}).to_string(), + ), + }; + let parsed: chat::ChatRequest = match serde_json::from_value(req_json) { + Ok(p) => p, + Err(e) => return Ok( + json!({"success": false, "error": format!("chat req parse: {e}")}).to_string(), + ), + }; + let result = match chat::complete(&parsed).await { + Ok(r) => serde_json::to_value(r).unwrap_or(Value::Null), + Err(e) => json!({"success": false, "error": e.to_string()}), + }; + Ok(result.to_string()) + } + })), + )?; + + // The async functions above all return JSON strings. Wrap each one in + // a JS shim that JSON.parses the result and throws when the payload + // carries the `__error` discriminator. + let shim = r#" + (function () { + const wrap = (name) => { + const orig = __native[name]; + __native[name] = async function (...args) { + const s = await orig.apply(__native, args); + if (typeof s !== "string") return s; + let v; + try { v = JSON.parse(s); } catch (_) { return s; } + if (v && typeof v === "object" && typeof v.__error === "string") { + throw new Error(v.__error); + } + return v; + }; + }; + ["httpRequest", "ffmpegExtractAudio", "aliyunOssUpload", + "aliyunAsrFiletrans", "localAsr", "openaiChat"].forEach(wrap); + })(); + "#; + let _: () = ctx.eval(shim)?; + + Ok(obj) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8908a15..dde76c0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,17 @@ +pub mod asr; +pub mod chat; +pub mod commands; +pub mod ffmpeg; +pub mod http; +pub mod js_runtime; +pub mod oss; + use tauri::Manager; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let _ = env_logger::try_init(); + tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Some(window) = app.get_webview_window("main") { @@ -11,6 +21,10 @@ pub fn run() { } })) .plugin(tauri_plugin_opener::init()) + .invoke_handler(tauri::generate_handler![ + commands::quickjs::run_quickjs_script, + commands::quickjs::list_quickjs_scripts, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/oss.rs b/src-tauri/src/oss.rs new file mode 100644 index 0000000..7a2c1d1 --- /dev/null +++ b/src-tauri/src/oss.rs @@ -0,0 +1,126 @@ +//! Minimal Aliyun OSS PUT-Object client. +//! +//! Only the subset required by the Douyin pipeline is implemented: +//! - PUT a single local file under `audio/.` +//! - Sign with HMAC-SHA1 using AccessKey ID + Secret +//! - Return the public https URL +//! +//! No external `aliyun-oss` crate is used so that the dependency footprint +//! stays small. + +use std::path::Path; + +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha1::Sha1; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum OssError { + #[error("missing field: {0}")] + MissingField(&'static str), + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("http: {0}")] + Http(#[from] reqwest::Error), + #[error("oss returned status={status}, body={body}")] + BadStatus { status: u16, body: String }, + #[error("hmac error")] + Hmac, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OssConfig { + #[serde(rename = "accessKeyId")] + pub access_key_id: String, + #[serde(rename = "accessKeySecret")] + pub access_key_secret: String, + pub bucket: String, + pub region: String, + #[serde(default)] + pub endpoint: Option, +} + +#[derive(Debug, Serialize)] +pub struct OssUploadResult { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +type HmacSha1 = Hmac; + +fn rfc1123_now() -> String { + Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string() +} + +fn sign(secret: &str, str_to_sign: &str) -> Result { + let mut mac = HmacSha1::new_from_slice(secret.as_bytes()).map_err(|_| OssError::Hmac)?; + mac.update(str_to_sign.as_bytes()); + Ok(B64.encode(mac.finalize().into_bytes())) +} + +fn require<'a>(v: &'a str, name: &'static str) -> Result<&'a str, OssError> { + if v.trim().is_empty() { + Err(OssError::MissingField(name)) + } else { + Ok(v) + } +} + +pub async fn put_object( + cfg: &OssConfig, + local_path: &Path, + object_key: &str, + content_type: &str, +) -> Result { + let ak = require(&cfg.access_key_id, "accessKeyId")?; + let sk = require(&cfg.access_key_secret, "accessKeySecret")?; + let bucket = require(&cfg.bucket, "bucket")?; + let region = require(&cfg.region, "region")?; + + let host = format!("{bucket}.{region}.aliyuncs.com"); + let endpoint = cfg + .endpoint + .clone() + .unwrap_or_else(|| format!("https://{host}")); + let url = format!("{}/{}", endpoint.trim_end_matches('/'), object_key); + + let body = tokio::fs::read(local_path).await?; + let date = rfc1123_now(); + + // String-to-sign for OSS V1: + // VERB\nContent-MD5\nContent-Type\nDate\nCanonicalizedOSSHeaders+CanonicalizedResource + let canonicalized_resource = format!("/{bucket}/{object_key}"); + let str_to_sign = format!( + "PUT\n\n{ct}\n{date}\n{res}", + ct = content_type, + date = date, + res = canonicalized_resource + ); + let signature = sign(sk, &str_to_sign)?; + let authorization = format!("OSS {ak}:{signature}"); + + let client = reqwest::Client::builder() + .build() + .map_err(OssError::Http)?; + let resp = client + .put(&url) + .header("Date", &date) + .header("Content-Type", content_type) + .header("Authorization", authorization) + .body(body) + .send() + .await?; + + let status = resp.status().as_u16(); + if !(200..300).contains(&status) { + let body = resp.text().await.unwrap_or_default(); + return Err(OssError::BadStatus { status, body }); + } + Ok(url) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fa5cc63..38e12d7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -26,6 +26,9 @@ } }, "bundle": { + "resources": [ + "binaries/*" + ], "active": true, "targets": "all", "icon": [ diff --git a/src/stores/workflow.js b/src/stores/workflow.js index fcc99a4..cf67ad7 100644 --- a/src/stores/workflow.js +++ b/src/stores/workflow.js @@ -1,4 +1,6 @@ import { defineStore, acceptHMRUpdate } from "pinia"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; const defaultPublishPlatforms = () => [ { label: "*音", checked: false, account: null }, @@ -93,7 +95,7 @@ export const useWorkflowStore = defineStore("workflow", { closeIpBrainDialog() { this.ipBrainModalVisible = false; }, - + async startIpBrainCollect() { const url = this.ipBrainProfileUrl?.trim(); if (!url) { @@ -153,7 +155,31 @@ export const useWorkflowStore = defineStore("workflow", { }, }, }); - +export async function processDouyinShare(shareText){ + const result = await invoke("run_quickjs_script", { + scriptName:"douyin_pipeline.js", + params: { + shareText: "复制此链接,打开Dou音搜索,直接观看视频!https://v.douyin.com/xxxxx/", + modelConfig: { + providerId: "openai", + modelId: "gpt-4o-mini", + apiUrl: "https://api.openai.com", // 或 dashscope/openrouter 等任意兼容端点 + apiKey: "sk-...", + asrMode: "online", + aliyunApiKey: "sk-dashscope-...", + ossConfig: { + accessKeyId: "...", + accessKeySecret: "...", + bucket: "my-bucket", + region: "oss-cn-hangzhou", + }, + }, + rewriteConfig: { style: "幽默口播", tone: "活泼" }, + customPrompt: null, + }, + }); + return result; +} export async function executeVideoRewrite(store) { const text = store.videoShareText?.trim(); if (!text) {