diff --git a/Cargo.lock b/Cargo.lock index a1ef5154f1..ee7a8d8a5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -839,6 +839,7 @@ dependencies = [ "subprocess_common", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-http", "tauri-plugin-log", "tauri-plugin-upload", @@ -856,6 +857,27 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" +[[package]] +name = "ashpd" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d43c03d9e36dd40cab48435be0b09646da362c278223ca535493877b2c1dee9" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.8.5", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "assert_float_eq" version = "1.1.4" @@ -871,6 +893,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.2.0", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-channel" version = "1.9.0" @@ -878,10 +912,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" dependencies = [ "concurrent-queue", - "event-listener", + "event-listener 2.5.3", "futures-core", ] +[[package]] +name = "async-channel" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f2776ead772134d55b62dd45e59a79e21612d85d0af729b8b7d3967d601a62a" +dependencies = [ + "concurrent-queue", + "event-listener 5.2.0", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" version = "0.4.5" @@ -898,6 +945,84 @@ dependencies = [ "zstd-safe 7.1.0", ] +[[package]] +name = "async-io" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" +dependencies = [ + "async-lock", + "cfg-if 1.0.0", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.0", + "parking", + "polling", + "rustix 0.38.44", + "slab", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener 5.2.0", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +dependencies = [ + "async-channel 2.3.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if 1.0.0", + "event-listener 5.2.0", + "futures-lite 2.6.0", + "rustix 0.38.44", + "tracing", +] + +[[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.87", +] + +[[package]] +name = "async-signal" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if 1.0.0", + "futures-core", + "futures-io", + "rustix 0.38.44", + "signal-hook-registry", + "slab", + "windows-sys 0.59.0", +] + [[package]] name = "async-stream" version = "0.3.5" @@ -944,6 +1069,12 @@ dependencies = [ "tokio", ] +[[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.80" @@ -1012,7 +1143,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c232177ba50b16fe7a4588495bd474a62a9e45a8e4ca6fd7d0b7ac29d164631e" dependencies = [ - "nix", + "nix 0.26.4", "rand 0.8.5", ] @@ -1409,6 +1540,19 @@ dependencies = [ "objc2 0.6.1", ] +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel 2.3.0", + "async-task", + "futures-io", + "futures-lite 2.6.0", + "piper", +] + [[package]] name = "blowfish" version = "0.9.1" @@ -1439,7 +1583,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5430e3be710b68d984d1391c854eb431a9d548640711faa54eecb1df93db91cc" dependencies = [ "borsh-derive", - "cfg_aliases", + "cfg_aliases 0.2.1", ] [[package]] @@ -1997,6 +2141,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -3037,6 +3187,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_arbitrary" version = "1.3.2" @@ -3356,6 +3517,15 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading 0.8.6", +] + [[package]] name = "dlopen2" version = "0.7.0" @@ -3415,6 +3585,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "download-job" version = "0.0.1" @@ -3786,6 +3962,12 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + [[package]] name = "enterpolation" version = "0.2.1" @@ -3810,6 +3992,27 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "enumflags2" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba2f4b465f5318854c6f8dd686ede6c0a9dc67d4b1ac241cf0eb51521a309147" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "enums" version = "0.0.1" @@ -3956,6 +4159,27 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "event-listener" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b5fb89194fa3cad959b833185b3063ba881dbfc7030680b314250779fb4cc91" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.2.0", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.73.0" @@ -4305,6 +4529,19 @@ dependencies = [ "waker-fn", ] +[[package]] +name = "futures-lite" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +dependencies = [ + "fastrand 2.3.0", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.30" @@ -5299,9 +5536,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" dependencies = [ "anyhow", - "async-channel", + "async-channel 1.9.0", "base64 0.13.1", - "futures-lite", + "futures-lite 1.13.0", "http 0.2.11", "infer 0.2.3", "pin-project-lite", @@ -7194,7 +7431,7 @@ dependencies = [ "gtk", "keyboard-types", "objc2 0.6.1", - "objc2-app-kit", + "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-foundation 0.3.1", "once_cell", @@ -7382,6 +7619,19 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.5.0", + "cfg-if 1.0.0", + "cfg_aliases 0.1.1", + "libc", + "memoffset 0.9.0", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -7623,6 +7873,22 @@ dependencies = [ "objc2-exception-helper", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.5.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + [[package]] name = "objc2-app-kit" version = "0.3.1" @@ -7634,10 +7900,10 @@ dependencies = [ "libc", "objc2 0.6.1", "objc2-cloud-kit", - "objc2-core-data", + "objc2-core-data 0.3.1", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image", + "objc2-core-image 0.3.1", "objc2-foundation 0.3.1", "objc2-quartz-core 0.3.1", ] @@ -7653,6 +7919,18 @@ dependencies = [ "objc2-foundation 0.3.1", ] +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.5.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-core-data" version = "0.3.1" @@ -7688,6 +7966,18 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + [[package]] name = "objc2-core-image" version = "0.3.1" @@ -7721,6 +8011,7 @@ checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.5.0", "block2 0.5.1", + "dispatch", "libc", "objc2 0.5.2", ] @@ -7806,7 +8097,7 @@ dependencies = [ "bitflags 2.5.0", "block2 0.6.1", "objc2 0.6.1", - "objc2-app-kit", + "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-foundation 0.3.1", ] @@ -8117,6 +8408,16 @@ dependencies = [ "hashbrown 0.14.5", ] +[[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 = "ort" version = "1.16.3" @@ -8127,7 +8428,6 @@ dependencies = [ "half", "lazy_static", "libc", - "libloading 0.7.4", "ndarray", "tar", "thiserror 1.0.63", @@ -8570,6 +8870,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand 2.3.0", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -8623,6 +8934,21 @@ dependencies = [ "miniz_oxide 0.8.4", ] +[[package]] +name = "polling" +version = "3.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" +dependencies = [ + "cfg-if 1.0.0", + "concurrent-queue", + "hermit-abi 0.4.0", + "pin-project-lite", + "rustix 0.38.44", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "portable-atomic" version = "1.10.0" @@ -9090,6 +9416,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.5" @@ -9774,6 +10109,29 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfd" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8af382a047821a08aa6bfc09ab0d80ff48d45d8726f7cd8e44891f7cb4a4278e" +dependencies = [ + "ashpd", + "block2 0.5.1", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.48.0", +] + [[package]] name = "rgb" version = "0.8.50" @@ -10017,15 +10375,15 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.34" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ "bitflags 2.5.0", "errno", "libc", "linux-raw-sys 0.4.14", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -10239,6 +10597,12 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -10922,7 +11286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" dependencies = [ "bytemuck", - "cfg_aliases", + "cfg_aliases 0.2.1", "core-graphics", "foreign-types 0.5.0", "js-sys", @@ -11049,7 +11413,7 @@ dependencies = [ "crossbeam-queue", "dotenvy", "either", - "event-listener", + "event-listener 2.5.3", "futures-channel", "futures-core", "futures-intrusive", @@ -12057,7 +12421,7 @@ dependencies = [ "ndk-context", "ndk-sys 0.6.0+11769913", "objc2 0.6.1", - "objc2-app-kit", + "objc2-app-kit 0.3.1", "objc2-foundation 0.3.1", "once_cell", "parking_lot 0.12.2", @@ -12129,7 +12493,7 @@ dependencies = [ "mime", "muda", "objc2 0.6.1", - "objc2-app-kit", + "objc2-app-kit 0.3.1", "objc2-foundation 0.3.1", "percent-encoding", "plist", @@ -12236,6 +12600,24 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcaf6e5d6062423a0f711a23c2a573ccba222b6a16a9322d8499928f27e41376" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.12", + "url", +] + [[package]] name = "tauri-plugin-fs" version = "2.2.1" @@ -12323,47 +12705,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "tauri-realtime" -version = "0.1.0" -dependencies = [ - "anyhow", - "assertor", - "base64 0.22.1", - "bytes 1.10.0", - "chrono", - "const_format", - "crockford", - "cudarc", - "directories 6.0.0", - "dirs 6.0.0", - "filesys", - "futures 0.3.29", - "half", - "image", - "libloading 0.8.6", - "log", - "memory_caching", - "ml_models", - "ml_weights_registry", - "once_cell", - "ort", - "primitives", - "rand 0.8.5", - "reqwest 0.12.15", - "serde", - "serde_json", - "serde_yaml", - "serial_test", - "tauri", - "tauri-build", - "tauri-plugin-log", - "tempdir", - "tempfile", - "tokio", - "tokio-util", -] - [[package]] name = "tauri-runtime" version = "2.5.1" @@ -12395,7 +12736,7 @@ dependencies = [ "jni", "log", "objc2 0.6.1", - "objc2-app-kit", + "objc2-app-kit 0.3.1", "objc2-foundation 0.3.1", "once_cell", "percent-encoding", @@ -12802,6 +13143,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.52.0", ] @@ -13230,7 +13572,7 @@ dependencies = [ "libappindicator", "muda", "objc2 0.6.1", - "objc2-app-kit", + "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.1", @@ -13363,6 +13705,17 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset 0.9.0", + "tempfile", + "winapi 0.3.9", +] + [[package]] name = "ug" version = "0.1.0" @@ -14019,6 +14372,66 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121" +dependencies = [ + "cc", + "downcast-rs", + "rustix 0.38.44", + "scoped-tls", + "smallvec 1.13.2", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61" +dependencies = [ + "bitflags 2.5.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a" +dependencies = [ + "bitflags 2.5.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +dependencies = [ + "proc-macro2", + "quick-xml 0.37.5", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.65" @@ -14230,7 +14643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ "objc2 0.6.1", - "objc2-app-kit", + "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-foundation 0.3.1", "raw-window-handle", @@ -14919,7 +15332,7 @@ dependencies = [ "libc", "ndk 0.9.0", "objc2 0.6.1", - "objc2-app-kit", + "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-foundation 0.3.1", "objc2-ui-kit", @@ -14989,7 +15402,17 @@ checksum = "e105d177a3871454f754b33bb0ee637ecaaac997446375fd3e5d43a2ed00c909" dependencies = [ "libc", "linux-raw-sys 0.4.14", - "rustix 0.38.34", + "rustix 0.38.44", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", ] [[package]] @@ -15016,6 +15439,65 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ff46f2a25abd690ed072054733e0bc3157e3d4c45f41bd183dce09c2ff8ab9" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "derivative", + "enumflags2", + "event-listener 5.2.0", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.28.0", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1 0.10.6", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e0e3852c93dcdb49c9462afe67a2a468f7bd464150d866e861eaf06208633e0" +dependencies = [ + "proc-macro-crate 3.1.0", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils 1.1.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.7.33" @@ -15268,3 +15750,52 @@ checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "url", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.1.0", + "proc-macro2", + "quote", + "syn 2.0.87", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_utils" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bedb16a193cc12451873fee2a1bc6550225acece0e36f333e68326c73c8172" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] diff --git a/Cargo.toml b/Cargo.toml index de0360299b..2db8ee9fc0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,6 @@ members = [ # Desktop apps "crates/desktop/artcraft", - "crates/desktop/tauri-realtime", # Shared libraries "crates/lib/actix_cors_configs", diff --git a/crates/desktop/artcraft/Cargo.toml b/crates/desktop/artcraft/Cargo.toml index e34edb03d2..a0df1c99a7 100644 --- a/crates/desktop/artcraft/Cargo.toml +++ b/crates/desktop/artcraft/Cargo.toml @@ -67,6 +67,7 @@ tauri-plugin-http = { path = "../../vendor/tauri-plugin-http" } # NB: The version of the `discord-rich-presence` crate (0.2.5) last published has non-Send/Sync error # types and can't do threading. We need to wait for a new version to unpin from Git. discord-rich-presence = { git = "https://github.com/vionya/discord-rich-presence" } +tauri-plugin-dialog = "2" [dev-dependencies] libloading = "0.8.6" diff --git a/crates/desktop/artcraft/capabilities/default.json b/crates/desktop/artcraft/capabilities/default.json index c48ed83ccd..f40dadbe37 100644 --- a/crates/desktop/artcraft/capabilities/default.json +++ b/crates/desktop/artcraft/capabilities/default.json @@ -25,6 +25,7 @@ "url": "https://private.tauri.app" } ] - } + }, + "dialog:default" ] } \ No newline at end of file diff --git a/crates/desktop/artcraft/src/commands/app_preferences/get_app_preferences_command.rs b/crates/desktop/artcraft/src/commands/app_preferences/get_app_preferences_command.rs index 6413445c35..7c5b58e611 100644 --- a/crates/desktop/artcraft/src/commands/app_preferences/get_app_preferences_command.rs +++ b/crates/desktop/artcraft/src/commands/app_preferences/get_app_preferences_command.rs @@ -14,8 +14,27 @@ pub struct GetAppPreferencesResponse { pub struct AppPreferencesPayload { /// The downloads directory to use when a user downloads a file. pub preferred_download_directory: PreferredDownloadDirectory, + /// Play sounds on events. pub play_sounds: bool, + + /// Key pointing to file; defined in the frontend code. + /// Defined for enqueue since image enqueue can be async + pub enqueue_success_sound: Option, + + /// Key pointing to file; defined in the frontend code. + /// Defined for enqueue since image enqueue can be async + pub enqueue_failure_sound: Option, + + /// Key pointing to file; defined in the frontend code. + pub generation_success_sound: Option, + + /// Key pointing to file; defined in the frontend code. + pub generation_failure_sound: Option, + + /// Key pointing to file; defined in the frontend code. + #[deprecated] + pub generation_enqueue_sound: Option, } @@ -42,5 +61,10 @@ async fn get_prefs(app_prefs: &AppPreferencesManager) -> AnyhowResult, +} + +#[derive(Deserialize, Debug)] +#[serde(untagged)] +pub enum ValueType { + Bool(bool), + String(String), + DownloadDirectory(PreferredDownloadDirectory), } #[derive(Deserialize)] @@ -19,6 +29,9 @@ pub struct UpdateAppPreferencesRequest { pub enum PreferenceName { PreferredDownloadDirectory, PlaySounds, + GenerationSuccessSound, + GenerationFailureSound, + GenerationEnqueueSound, } #[derive(Serialize)] @@ -26,7 +39,6 @@ pub struct UpdateAppPreferencesResponse { pub success: bool, } - #[tauri::command] pub async fn update_app_preferences_command( request: UpdateAppPreferencesRequest, @@ -54,12 +66,39 @@ async fn update_prefs( ) -> AnyhowResult<()> { let mut prefs = app_prefs.get_clone()?; + info!("Value is: {:?}", request.value); + match request.preference { PreferenceName::PreferredDownloadDirectory => { - prefs.preferred_download_directory = request.value.parse::()?; + match request.value { + Some(ValueType::DownloadDirectory(dir)) => + prefs.preferred_download_directory = dir, + _ => + return Err(anyhow!("Invalid value: {:?}", request.value)), + } } PreferenceName::PlaySounds => { - prefs.play_sounds = request.value.parse::()?; + match request.value { + Some(ValueType::Bool(val)) => + prefs.play_sounds = val, + _ => + return Err(anyhow!("Invalid value: {:?}", request.value)), + } + } + PreferenceName::GenerationSuccessSound => { + prefs.generation_success_sound = request.value + .map(|val| string_value(&val)) + .transpose()?; + } + PreferenceName::GenerationFailureSound => { + prefs.generation_failure_sound = request.value + .map(|val| string_value(&val)) + .transpose()?; + } + PreferenceName::GenerationEnqueueSound => { + prefs.generation_enqueue_sound = request.value + .map(|val| string_value(&val)) + .transpose()?; } } @@ -68,3 +107,10 @@ async fn update_prefs( Ok(()) } + +fn string_value(value: &ValueType) -> AnyhowResult { + match value { + ValueType::String(val) => Ok(val.to_string()), + _ => Err(anyhow!("Invalid value type: {:?}", value)), + } +} diff --git a/crates/desktop/artcraft/src/commands/command_response_wrapper.rs b/crates/desktop/artcraft/src/commands/command_response_wrapper.rs new file mode 100644 index 0000000000..aefe2a330b --- /dev/null +++ b/crates/desktop/artcraft/src/commands/command_response_wrapper.rs @@ -0,0 +1,119 @@ +use serde::Serialize; +use std::error::Error; +use std::str::FromStr; + +/// Easy to use Result type. +pub type CommandResult = Result, CommandErrorResponseWrapper>; + +/// Statuses for successful commands. +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub enum CommandSuccessStatus { + Success, // 200 +} + +/// Statuses for errorful commands. +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub enum CommandErrorStatus { + BadRequest, // 400 + Unauthorized, // 401 + ServerError, // 500 +} + +#[derive(Serialize, Debug)] +pub struct CommandSuccessResponseWrapper { + /// Both "success" and "error" types have a `status` field for the frontend. + pub status: CommandSuccessStatus, + + #[serde(skip_serializing_if = "Option::is_none")] + pub success_message: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub payload: Option, +} + +#[derive(Serialize, Debug)] +pub struct CommandErrorResponseWrapper { + /// Both "success" and "error" types have a `status` field for the frontend. + pub status: CommandErrorStatus, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error_details: Option, +} + + +impl CommandSuccessResponseWrapper<()> { + pub fn empty() -> Self { + CommandSuccessResponseWrapper { + status: CommandSuccessStatus::Success, + success_message: None, + payload: None, + } + } +} + +/// NB: This is a hack because of `()` (a.k.a. the "unit" type) and `Serialize` trait coherence and overlap. +/// We cannot currently implement a trait for `()` and generic `Serialize` because `()` itself implements +/// `Serialize`. Rust's `"specialization"`, which would let us account for this, is an unstable, nightly-only +/// feature. So instead we manually mark all types other than `()` with `SerializeMarker` as a hack to +/// distinguish the two. +trait SerializeMarker : Serialize {} + +// NB: () is Serialize! So we can't broadly implement just `T: Serialize`. +impl From<()> for CommandSuccessResponseWrapper<()> { + fn from(_: ()) -> Self { + CommandSuccessResponseWrapper { + status: CommandSuccessStatus::Success, + success_message: None, + payload: None, + } + } +} + +/// NB: Note the `"+ SerializeMarker"` bound here. This is a hack to distinguish between `()` and other types +/// for which we will manually mark as `SerializeMarker`. +impl From for CommandSuccessResponseWrapper where T: Serialize + SerializeMarker { + fn from(val: T) -> Self { + CommandSuccessResponseWrapper { + status: CommandSuccessStatus::Success, + success_message: None, + payload: Some(val), + } + } +} + +impl From<&str> for CommandSuccessResponseWrapper { + fn from(msg: &str) -> Self { + CommandSuccessResponseWrapper { + status: CommandSuccessStatus::Success, + success_message: Some(msg.to_string()), + payload: None, + } + } +} + +impl From<&str> for CommandErrorResponseWrapper { + fn from(value: &str) -> Self { + CommandErrorResponseWrapper { + status: CommandErrorStatus::BadRequest, // NB: Default to 500 error. + error_message: Some(value.to_string()), + error_details: None, + } + } +} + +#[cfg(test)] +mod tests { + + #[test] + fn test_empty_tuple_serialize() { + let response = super::CommandSuccessResponseWrapper::<()>::from(()); + let serialized = serde_json::to_string(&response).unwrap(); + // NB: This tests that the blanket "Serialize" impl doesn't produce a "null payload" field. + assert_eq!(serialized, r#"{"status":"success"}"#); + } +} diff --git a/crates/desktop/artcraft/src/commands/mod.rs b/crates/desktop/artcraft/src/commands/mod.rs index a83adf2f50..549119c5dc 100644 --- a/crates/desktop/artcraft/src/commands/mod.rs +++ b/crates/desktop/artcraft/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod app_preferences; +pub mod command_response_wrapper; pub mod flip_image; pub mod platform_info_command; pub mod sora; diff --git a/crates/desktop/artcraft/src/commands/sora/check_sora_session_command.rs b/crates/desktop/artcraft/src/commands/sora/check_sora_session_command.rs new file mode 100644 index 0000000000..8c00c91aef --- /dev/null +++ b/crates/desktop/artcraft/src/commands/sora/check_sora_session_command.rs @@ -0,0 +1,85 @@ +use crate::commands::app_preferences::get_app_preferences_command::AppPreferencesPayload; +use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::state::sora::sora_credential_manager::SoraCredentialManager; +use crate::windows::sora_login_window::open_sora_login_window::open_sora_login_window; +use errors::AnyhowResult; +use log::{error, info}; +use once_cell::sync::Lazy; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::requests::list_media::list_media::list_media; +use serde_derive::Serialize; +use tauri::{AppHandle, State}; + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub struct CheckSoraSessionCommand { + pub state: SoraSessionState, + pub maybe_account_email: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SoraSessionState{ + #[serde(rename = "not_set_up")] + NotSetUp, + #[serde(rename = "expired_or_error")] + ExpiredOrError, + #[serde(rename = "valid")] + Valid, +} + + +#[tauri::command] +pub async fn check_sora_session_command( + app: AppHandle, + sora_creds_manager: State<'_, SoraCredentialManager>, +) -> Result { + info!("check_sora_session_command called"); + + match do_check(&sora_creds_manager).await { + Ok(state) => Ok(state), + Err(err) => { + error!("Error checking session state: {:?}", err); + Err(CheckSoraSessionCommand { + state: SoraSessionState::ExpiredOrError, + maybe_account_email: None, + }) + } + } +} + +async fn do_check( + sora_creds_manager: &SoraCredentialManager, +) -> AnyhowResult { + + let mut creds = match sora_creds_manager.get_credentials()? { + Some(creds) => creds, + None => { + return Ok(CheckSoraSessionCommand { + state: SoraSessionState::NotSetUp, + maybe_account_email: None, + }); + } + }; + + let upgraded = maybe_upgrade_or_renew_session(&mut creds).await?; + + list_media(&creds).await?; + + if upgraded { + info!("Saving upgraded session to manager and disk..."); + sora_creds_manager.set_credentials(&creds)?; + sora_creds_manager.persist_all_to_disk()?; + } + + let maybe_account_email = creds + .jwt_bearer_token + .map(|token| token.jwt_claims().email.clone()); + + Ok(CheckSoraSessionCommand { + state: SoraSessionState::Valid, + maybe_account_email, + }) +} diff --git a/crates/desktop/artcraft/src/commands/sora/mod.rs b/crates/desktop/artcraft/src/commands/sora/mod.rs index 0c197b2d85..5a1e942fbe 100644 --- a/crates/desktop/artcraft/src/commands/sora/mod.rs +++ b/crates/desktop/artcraft/src/commands/sora/mod.rs @@ -1,3 +1,5 @@ +pub mod check_sora_session_command; pub mod open_sora_login_command; pub mod sora_image_generation_command; pub mod sora_image_remix_command; +pub mod sora_logout_command; diff --git a/crates/desktop/artcraft/src/commands/sora/open_sora_login_command.rs b/crates/desktop/artcraft/src/commands/sora/open_sora_login_command.rs index 6a330816cf..d60ea4192b 100644 --- a/crates/desktop/artcraft/src/commands/sora/open_sora_login_command.rs +++ b/crates/desktop/artcraft/src/commands/sora/open_sora_login_command.rs @@ -1,31 +1,21 @@ -use crate::threads::sora_session_login_thread::{LOGIN_WINDOW_NAME, SORA_LOGIN_URL}; -use crate::utils::clear_all_webview_cookies::clear_all_webview_cookies; +use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::state::sora::sora_credential_manager::SoraCredentialManager; +use crate::windows::sora_login_window::open_sora_login_window::open_sora_login_window; use errors::AnyhowResult; use log::{error, info}; use once_cell::sync::Lazy; -use std::time::Duration; -use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowBuilder}; -use tokio::time::sleep_until; - -pub const START_URL_STR: &str = "https://storyteller.ai/"; - -pub static START_URL: Lazy = Lazy::new(|| { - Url::parse(START_URL_STR).expect("URL should parse") -}); - -pub const SORA_HOMEPAGE_URL_STR: &str = "https://sora.com/"; - -pub static SORA_HOMEPAGE_URL : Lazy = Lazy::new(|| { - Url::parse(SORA_HOMEPAGE_URL_STR).expect("URL should parse") -}); +use tauri::{AppHandle, State}; #[tauri::command] pub async fn open_sora_login_command( app: AppHandle, + app_data_root: State<'_, AppDataRoot>, + sora_creds_manager: State<'_, SoraCredentialManager>, ) -> Result { info!("open_login_command called"); - do_open_login(&app) + do_open_login(&app, &app_data_root, &sora_creds_manager) .await .map_err(|err| { error!("Error opening login: {:?}", err); @@ -35,37 +25,14 @@ pub async fn open_sora_login_command( Ok("result".to_string()) } -async fn do_open_login(app: &AppHandle) -> AnyhowResult<()> { +async fn do_open_login( + app: &AppHandle, + app_data_root: &AppDataRoot, + sora_creds_manager: &SoraCredentialManager, +) -> AnyhowResult<()> { info!("Building login window..."); - let url = WebviewUrl::External(START_URL.clone()); - - let window = WebviewWindowBuilder::new(app, LOGIN_WINDOW_NAME, url) - //.user_agent(openai_sora_client::credentials::USER_AGENT) - .always_on_top(true) - .title("Login to OpenAI") - .center() - .resizable(true) - .visible(true) - .closable(true) - .focused(true) - .devtools(true) - .build()?; - - for (name, webview) in window.webviews() { - if name != LOGIN_WINDOW_NAME { - continue; - } - - clear_all_webview_cookies(&webview)?; - - // NB: We're starting to get Cloudflare protection screens. Let's try to avoid. - webview.navigate(SORA_HOMEPAGE_URL.clone())?; - tokio::time::sleep(Duration::from_millis(100)).await; - - webview.navigate(SORA_LOGIN_URL.clone())?; - break; - } + open_sora_login_window(app, app_data_root, sora_creds_manager).await?; info!("Done."); Ok(()) diff --git a/crates/desktop/artcraft/src/commands/sora/sora_image_remix_command.rs b/crates/desktop/artcraft/src/commands/sora/sora_image_remix_command.rs index 4b65ff5cbd..26b84e2667 100644 --- a/crates/desktop/artcraft/src/commands/sora/sora_image_remix_command.rs +++ b/crates/desktop/artcraft/src/commands/sora/sora_image_remix_command.rs @@ -1,3 +1,7 @@ +use crate::commands::command_response_wrapper::CommandResult; +use crate::events::sendable_event_trait::SendableEvent; +use crate::events::sora::sora_image_enqueue_failure_event::SoraImageEnqueueFailureEvent; +use crate::events::sora::sora_image_enqueue_success_event::SoraImageEnqueueSuccessEvent; use crate::state::data_dir::app_data_root::AppDataRoot; use crate::state::data_dir::trait_data_subdir::DataSubdir; use crate::state::sora::read_sora_credentials_from_disk::read_sora_credentials_from_disk; @@ -21,12 +25,12 @@ use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages}; use openai_sora_client::requests::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest}; use openai_sora_client::requests::upload::upload_media_from_bytes::sora_media_upload_from_bytes; use openai_sora_client::requests::upload::upload_media_from_file::sora_media_upload_from_file; -use serde_derive::Deserialize; +use serde_derive::{Deserialize, Serialize}; use std::fs::read_to_string; use std::io::Cursor; use storyteller_client::media_files::get_media_file::get_media_file; use storyteller_client::utils::api_host::ApiHost; -use tauri::{AppHandle, Manager, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tokens::tokens::media_files::MediaFileToken; #[derive(Deserialize)] @@ -48,26 +52,41 @@ pub struct SoraImageRemixCommand { #[tauri::command] pub async fn sora_image_remix_command( - _app: AppHandle, + app: AppHandle, request: SoraImageRemixCommand, app_data_root: State<'_, AppDataRoot>, sora_creds_manager: State<'_, SoraCredentialManager>, sora_task_queue: State<'_, SoraTaskQueue>, -) -> Result { +) -> CommandResult<(), String> { info!("image_generation_command called; scene media token: {:?}, additional images: {:?}", request.snapshot_media_token, request.maybe_additional_images); // TODO(bt,2025-04-24): Better error messages to caller - generate_image(request, &app_data_root, &sora_creds_manager, &sora_task_queue) - .await - .map_err(|err| { - error!("error: {:?}", err); - "there was an error".to_string() - })?; + let result = generate_image(request, &app_data_root, &sora_creds_manager, &sora_task_queue).await; + + if let Err(err) = result { + error!("error: {:?}", err); - Ok("success".to_string()) + let event = SoraImageEnqueueFailureEvent {}; + let result = event.send(&app); + + if let Err(err) = result { + error!("Failed to emit event: {:?}", err); + } + + return Err("there was an error".into()); + } + + let event = SoraImageEnqueueSuccessEvent {}; + let result = event.send(&app); + + if let Err(err) = result { + error!("Failed to emit event: {:?}", err); + } + + Ok(().into()) } pub async fn generate_image( diff --git a/crates/desktop/artcraft/src/commands/sora/sora_logout_command.rs b/crates/desktop/artcraft/src/commands/sora/sora_logout_command.rs new file mode 100644 index 0000000000..1c87366fd1 --- /dev/null +++ b/crates/desktop/artcraft/src/commands/sora/sora_logout_command.rs @@ -0,0 +1,35 @@ +use crate::commands::app_preferences::get_app_preferences_command::AppPreferencesPayload; +use crate::commands::command_response_wrapper::{CommandResult, CommandSuccessResponseWrapper}; +use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::state::sora::sora_credential_manager::SoraCredentialManager; +use crate::windows::sora_login_window::open_sora_login_window::open_sora_login_window; +use errors::{AnyhowError, AnyhowResult}; +use log::{error, info}; +use once_cell::sync::Lazy; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::requests::list_media::list_media::list_media; +use serde_derive::Serialize; +use std::fmt::format; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn sora_logout_command( + sora_creds_manager: State<'_, SoraCredentialManager>, +) -> CommandResult<(), String> { + info!("sora_logout_command called"); + + if let Err(err) = sora_creds_manager.purge_credentials_from_disk() { + error!("Error purging credentials from disk: {:?}", err); + return Err("error purging credentials from disk".into()); + } + + if let Err(err) = sora_creds_manager.clear_credentials() { + error!("Error clearing credentials from memory: {:?}", err); + return Err("error clearing credentials from memory".into()); + } + + Ok(().into()) +} + diff --git a/crates/desktop/artcraft/src/events/mod.rs b/crates/desktop/artcraft/src/events/mod.rs index 2db7fd03ce..ee9e0cd166 100644 --- a/crates/desktop/artcraft/src/events/mod.rs +++ b/crates/desktop/artcraft/src/events/mod.rs @@ -1 +1,4 @@ -pub mod notification_event; \ No newline at end of file +pub mod notification_event; +pub mod sendable_event_error; +pub mod sendable_event_trait; +pub mod sora; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/events/sendable_event_error.rs b/crates/desktop/artcraft/src/events/sendable_event_error.rs new file mode 100644 index 0000000000..25d4a57433 --- /dev/null +++ b/crates/desktop/artcraft/src/events/sendable_event_error.rs @@ -0,0 +1,31 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum SendableEventError { + TauriError(tauri::Error), + AnyhowError(anyhow::Error), +} + +impl Error for SendableEventError {} + +impl Display for SendableEventError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::TauriError(err) => write!(f, "SendableEventError::TauriError: {}", err), + Self::AnyhowError(err) => write!(f, "SendableEventError::AnyhowError: {}", err), + } + } +} + +impl From for SendableEventError { + fn from(err: anyhow::Error) -> Self { + SendableEventError::AnyhowError(err) + } +} + +impl From for SendableEventError { + fn from(err: tauri::Error) -> Self { + SendableEventError::TauriError(err) + } +} diff --git a/crates/desktop/artcraft/src/events/sendable_event_trait.rs b/crates/desktop/artcraft/src/events/sendable_event_trait.rs new file mode 100644 index 0000000000..faa66c3a66 --- /dev/null +++ b/crates/desktop/artcraft/src/events/sendable_event_trait.rs @@ -0,0 +1,21 @@ +use log::info; +use crate::events::sendable_event_error::SendableEventError; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +/// Implement this on a Serializable object to be able to send it as an event to the frontend. +pub trait SendableEvent : Serialize { + /// This is the name of the event that the frontend subscribes to. + const FRONTEND_EVENT_NAME: &'static str; + + /// Default implementation of send(). + /// This serializes and sends the event to the frontend. + fn send(&self, app: &AppHandle) -> Result<(), SendableEventError> { + info!("Sending event to frontend: {}", Self::FRONTEND_EVENT_NAME); + let result = app.emit(Self::FRONTEND_EVENT_NAME, self); + if let Err(err) = result { + return Err(SendableEventError::from(err)); + } + Ok(()) + } +} diff --git a/crates/desktop/artcraft/src/events/sora/mod.rs b/crates/desktop/artcraft/src/events/sora/mod.rs new file mode 100644 index 0000000000..9eb7af7626 --- /dev/null +++ b/crates/desktop/artcraft/src/events/sora/mod.rs @@ -0,0 +1,5 @@ +pub mod sora_image_enqueue_failure_event; +pub mod sora_image_enqueue_success_event; +pub mod sora_image_generation_complete_event; +pub mod sora_image_generation_failed_event; +pub mod sora_login_success_event; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/events/sora/sora_image_enqueue_failure_event.rs b/crates/desktop/artcraft/src/events/sora/sora_image_enqueue_failure_event.rs new file mode 100644 index 0000000000..c8317b088b --- /dev/null +++ b/crates/desktop/artcraft/src/events/sora/sora_image_enqueue_failure_event.rs @@ -0,0 +1,11 @@ +use crate::events::sendable_event_trait::SendableEvent; +use serde_derive::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct SoraImageEnqueueFailureEvent { + // TODO: Reason. +} + +impl SendableEvent for SoraImageEnqueueFailureEvent { + const FRONTEND_EVENT_NAME: &'static str = "sora-image-enqueue-failure"; +} diff --git a/crates/desktop/artcraft/src/events/sora/sora_image_enqueue_success_event.rs b/crates/desktop/artcraft/src/events/sora/sora_image_enqueue_success_event.rs new file mode 100644 index 0000000000..7d3a8b6a9d --- /dev/null +++ b/crates/desktop/artcraft/src/events/sora/sora_image_enqueue_success_event.rs @@ -0,0 +1,11 @@ +use crate::events::sendable_event_trait::SendableEvent; +use serde_derive::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct SoraImageEnqueueSuccessEvent { + // TODO: Token, etc. +} + +impl SendableEvent for SoraImageEnqueueSuccessEvent { + const FRONTEND_EVENT_NAME: &'static str = "sora-image-enqueue-success"; +} diff --git a/crates/desktop/artcraft/src/events/sora/sora_image_generation_complete_event.rs b/crates/desktop/artcraft/src/events/sora/sora_image_generation_complete_event.rs new file mode 100644 index 0000000000..09cddf624d --- /dev/null +++ b/crates/desktop/artcraft/src/events/sora/sora_image_generation_complete_event.rs @@ -0,0 +1,12 @@ +use serde_derive::Serialize; +use tokens::tokens::media_files::MediaFileToken; +use crate::events::sendable_event_trait::SendableEvent; + +#[derive(Debug, Clone, Serialize)] +pub struct SoraImageGenerationCompleteEvent { + pub media_file_token: MediaFileToken, +} + +impl SendableEvent for SoraImageGenerationCompleteEvent { + const FRONTEND_EVENT_NAME: &'static str = "sora-image-generation-complete"; +} diff --git a/crates/desktop/artcraft/src/events/sora/sora_image_generation_failed_event.rs b/crates/desktop/artcraft/src/events/sora/sora_image_generation_failed_event.rs new file mode 100644 index 0000000000..6a4a20f79a --- /dev/null +++ b/crates/desktop/artcraft/src/events/sora/sora_image_generation_failed_event.rs @@ -0,0 +1,12 @@ +use serde_derive::Serialize; +use crate::events::sendable_event_trait::SendableEvent; + +#[derive(Debug, Clone, Serialize)] +pub struct SoraImageGenerationFailedEvent { + pub prompt: String, +} + +impl SendableEvent for SoraImageGenerationFailedEvent { + const FRONTEND_EVENT_NAME: &'static str = "sora-image-generation-failed"; +} + diff --git a/crates/desktop/artcraft/src/events/sora/sora_login_success_event.rs b/crates/desktop/artcraft/src/events/sora/sora_login_success_event.rs new file mode 100644 index 0000000000..b64f70115c --- /dev/null +++ b/crates/desktop/artcraft/src/events/sora/sora_login_success_event.rs @@ -0,0 +1,11 @@ +use crate::events::sendable_event_trait::SendableEvent; +use serde_derive::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct SoraLoginSuccessEvent { + // TODO: Reason. +} + +impl SendableEvent for SoraLoginSuccessEvent { + const FRONTEND_EVENT_NAME: &'static str = "sora-login-success"; +} diff --git a/crates/desktop/artcraft/src/lib.rs b/crates/desktop/artcraft/src/lib.rs index 03e91f17fa..14d7339702 100644 --- a/crates/desktop/artcraft/src/lib.rs +++ b/crates/desktop/artcraft/src/lib.rs @@ -3,27 +3,32 @@ pub mod events; pub mod state; pub mod threads; pub mod utils; +pub mod windows; use tauri::Manager; +use crate::commands::app_preferences::get_app_preferences_command::get_app_preferences_command; +use crate::commands::app_preferences::update_app_preference_command::update_app_preferences_command; use crate::commands::flip_image::flip_image; use crate::commands::platform_info_command::platform_info_command; +use crate::commands::sora::check_sora_session_command::check_sora_session_command; use crate::commands::sora::open_sora_login_command::open_sora_login_command; use crate::commands::sora::sora_image_generation_command::sora_image_generation_command; use crate::commands::sora::sora_image_remix_command::sora_image_remix_command; -use crate::commands::app_preferences::get_app_preferences_command::get_app_preferences_command; +use crate::commands::sora::sora_logout_command::sora_logout_command; +use crate::state::app_preferences::app_preferences_manager::load_app_preferences_or_default; use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::state::main_window_position::MainWindowPosition; use crate::state::main_window_size::MainWindowSize; use crate::state::sora::sora_credential_manager::SoraCredentialManager; use crate::state::sora::sora_task_queue::SoraTaskQueue; use crate::state::storyteller::storyteller_credential_manager::StorytellerCredentialManager; use crate::threads::discord_presence_thread::discord_presence_thread; use crate::threads::main_window_thread::main_window_thread::main_window_thread; -use crate::threads::sora_session_login_thread::sora_session_login_thread; use crate::threads::sora_task_polling_thread::sora_task_polling_thread; use crate::utils::webview_unsafe::webview_unsafe_for_app; -use crate::state::app_preferences::app_preferences_manager::load_app_preferences_or_default; +use tauri_plugin_dialog; use tauri_plugin_http; use tauri_plugin_log::Target; use tauri_plugin_log::TargetKind; @@ -54,6 +59,7 @@ pub fn run() { println!("Initializing backend runtime..."); tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_upload::init()) .plugin(tauri_plugin_log::Builder::new() @@ -105,7 +111,20 @@ pub fn run() { } } - tauri::async_runtime::spawn(sora_session_login_thread(app_2, app_data_root_2, sora_creds_manager_2)); + match MainWindowPosition::from_filesystem_configs(&app_data_root_3) { + Ok(None) => {} + Ok(Some(pos)) => { + println!("Moving window to: {:?}", pos); + let result = pos.apply_to_main_window(&app); + if let Err(err) = result { + eprintln!("Could not set window position: {:?}", err); + } + } + Err(err) => { + eprintln!("Failed to read window position from disk: {:?}", err); + } + } + tauri::async_runtime::spawn(main_window_thread(app_3, app_data_root_3, storyteller_creds_manager_2)); tauri::async_runtime::spawn(sora_task_polling_thread(app_4, app_data_root_4, sora_creds_manager_3, storyteller_creds_manager_3, sora_task_queue_2)); tauri::async_runtime::spawn(discord_presence_thread()); @@ -117,12 +136,15 @@ pub fn run() { .manage(sora_creds_manager) .manage(sora_task_queue) .invoke_handler(tauri::generate_handler![ + check_sora_session_command, flip_image, get_app_preferences_command, open_sora_login_command, platform_info_command, sora_image_generation_command, sora_image_remix_command, + sora_logout_command, + update_app_preferences_command, ]) .run(tauri::generate_context!("tauri.conf.json")) .expect("error while running tauri application"); diff --git a/crates/desktop/artcraft/src/state/app_preferences/app_preferences.rs b/crates/desktop/artcraft/src/state/app_preferences/app_preferences.rs index 2a5b6b7118..30a2b72910 100644 --- a/crates/desktop/artcraft/src/state/app_preferences/app_preferences.rs +++ b/crates/desktop/artcraft/src/state/app_preferences/app_preferences.rs @@ -1,5 +1,5 @@ use crate::state::app_preferences::app_preferences_serializable::AppPreferencesSerializable; -use crate::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory; +use crate::state::app_preferences::preferred_download_directory::{PreferredDownloadDirectory, SystemDownloadDirectory}; use crate::state::data_dir::app_data_root::AppDataRoot; #[derive(Clone)] @@ -9,13 +9,37 @@ pub struct AppPreferences { /// Play sounds on events. pub play_sounds: bool, + + /// Key pointing to file; defined in the frontend code. + /// Defined for enqueue since image enqueue can be async + pub enqueue_success_sound: Option, + + /// Key pointing to file; defined in the frontend code. + /// Defined for enqueue since image enqueue can be async + pub enqueue_failure_sound: Option, + + /// Key pointing to file; defined in the frontend code. + pub generation_success_sound: Option, + + /// Key pointing to file; defined in the frontend code. + pub generation_failure_sound: Option, + + /// Key pointing to file; defined in the frontend code. + #[deprecated] + pub generation_enqueue_sound: Option, } impl Default for AppPreferences { fn default() -> Self { Self { - preferred_download_directory: PreferredDownloadDirectory::SystemDefault, + preferred_download_directory: PreferredDownloadDirectory::System(SystemDownloadDirectory::Downloads), play_sounds: true, + // NB: These are defined in the frontend. + enqueue_success_sound: Some("done".to_string()), + enqueue_failure_sound: Some("spike_throw".to_string()), + generation_success_sound: Some("special_flower".to_string()), + generation_failure_sound: Some("crumble".to_string()), + generation_enqueue_sound: Some("done".to_string()), } } } @@ -42,9 +66,6 @@ impl AppPreferences { } pub fn to_serializable(&self) -> AppPreferencesSerializable { - AppPreferencesSerializable { - preferred_download_directory: Some(self.preferred_download_directory.clone()), - play_sounds: Some(self.play_sounds), - } + AppPreferencesSerializable::from_preferences(self) } } diff --git a/crates/desktop/artcraft/src/state/app_preferences/app_preferences_serializable.rs b/crates/desktop/artcraft/src/state/app_preferences/app_preferences_serializable.rs index 58ebc852e7..3664a9887b 100644 --- a/crates/desktop/artcraft/src/state/app_preferences/app_preferences_serializable.rs +++ b/crates/desktop/artcraft/src/state/app_preferences/app_preferences_serializable.rs @@ -4,13 +4,28 @@ use crate::state::data_dir::app_data_root::AppDataRoot; use errors::AnyhowResult; use serde_derive::{Deserialize, Serialize}; +/// Vector clock versioning string rather than semver. +const CURRENT_VERSION: &str = "1"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppPreferencesSerializable { + /// Versioning string. + pub version: String, + /// The downloads directory to use when a user downloads a file. pub preferred_download_directory: Option, /// Play sounds on events. pub play_sounds: Option, + + /// Key pointing to file; defined in the frontend code. + pub generation_success_sound: Option, + + /// Key pointing to file; defined in the frontend code. + pub generation_failure_sound: Option, + + /// Key pointing to file; defined in the frontend code. + pub generation_enqueue_sound: Option, } impl AppPreferencesSerializable { @@ -25,11 +40,15 @@ impl AppPreferencesSerializable { Ok(Some(data)) } - pub fn save_to_file(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> { - let filename = app_data_root.settings_dir().get_app_preferences_path(); - let json = serde_json::to_string(self)?; - std::fs::write(filename, json)?; - Ok(()) + pub fn from_preferences(preferences: &AppPreferences) -> Self { + Self { + version: CURRENT_VERSION.to_string(), + preferred_download_directory: Some(preferences.preferred_download_directory.clone()), + play_sounds: Some(preferences.play_sounds), + generation_success_sound: preferences.generation_success_sound.clone(), + generation_failure_sound: preferences.generation_failure_sound.clone(), + generation_enqueue_sound: preferences.generation_enqueue_sound.clone(), + } } pub fn to_preferences(&self) -> AppPreferences { @@ -43,6 +62,10 @@ impl AppPreferencesSerializable { preferences.play_sounds = play_sounds; } + preferences.generation_success_sound = self.generation_success_sound.clone(); + preferences.generation_failure_sound = self.generation_failure_sound.clone(); + preferences.generation_enqueue_sound = self.generation_enqueue_sound.clone(); + preferences } } diff --git a/crates/desktop/artcraft/src/state/app_preferences/preferred_download_directory.rs b/crates/desktop/artcraft/src/state/app_preferences/preferred_download_directory.rs index 1b7f528ead..07bdd4c1eb 100644 --- a/crates/desktop/artcraft/src/state/app_preferences/preferred_download_directory.rs +++ b/crates/desktop/artcraft/src/state/app_preferences/preferred_download_directory.rs @@ -6,28 +6,34 @@ use std::str::FromStr; const SYSTEM_DEFAULT_SENTINEL_VALUE: &str = "system_default"; +/// NB: The reason these are not flat is so that they serialize/deserialize nicely to/from JSON. #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum PreferredDownloadDirectory { /// The system-default downloads directory, which varies by OS. /// NB: Serializes as `"system_default"` in JSON. - SystemDefault, + System(SystemDownloadDirectory), /// A user-defined path /// NB: Serializes as `{"custom": "/path/to/dir"}` in JSON. Custom(PathBuf), } -impl FromStr for PreferredDownloadDirectory { +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum SystemDownloadDirectory { + Downloads, + Documents, +} + +impl FromStr for SystemDownloadDirectory { type Err = AnyhowError; fn from_str(s: &str) -> Result { - if s == SYSTEM_DEFAULT_SENTINEL_VALUE || s == "\"system_default\"" { - Ok(PreferredDownloadDirectory::SystemDefault) - } else { - // TODO: If it gets serialized as JSON, we may need to remove the `{"custom": ...` wrapping layer. - let path = PathBuf::from(s); - Ok(PreferredDownloadDirectory::Custom(path)) + match (s) { + "downloads" => Ok(SystemDownloadDirectory::Downloads), + "documents" => Ok(SystemDownloadDirectory::Documents), + _ => Err(AnyhowError::msg(format!("Invalid system download directory: {}", s))), } } } @@ -35,17 +41,25 @@ impl FromStr for PreferredDownloadDirectory { impl Display for PreferredDownloadDirectory { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - PreferredDownloadDirectory::SystemDefault => write!(f, "{}", SYSTEM_DEFAULT_SENTINEL_VALUE), + PreferredDownloadDirectory::System(system) => system.fmt(f), PreferredDownloadDirectory::Custom(path) => write!(f, "{}", path.to_string_lossy()), } } } +impl Display for SystemDownloadDirectory { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + SystemDownloadDirectory::Downloads => write!(f, "downloads"), + SystemDownloadDirectory::Documents => write!(f, "documents"), + } + } +} #[cfg(test)] mod tests { use crate::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory; - use std::path::PathBuf; + use crate::state::app_preferences::preferred_download_directory::SystemDownloadDirectory; use std::str::FromStr; mod json { @@ -53,9 +67,9 @@ mod tests { #[test] fn to_json_system_default() { - let val = PreferredDownloadDirectory::SystemDefault; + let val = PreferredDownloadDirectory::System(SystemDownloadDirectory::Documents); let val = serde_json::to_string(&val).unwrap(); - assert_eq!(&val, "\"system_default\""); + assert_eq!(&val, "{\"system\":\"documents\"}"); } #[test] @@ -65,36 +79,4 @@ mod tests { assert_eq!(&val, "{\"custom\":\"/tmp\"}"); } } - - mod string { - use super::*; - - #[test] - fn to_string_system_default() { - let val = PreferredDownloadDirectory::SystemDefault; - let val = val.to_string(); - assert_eq!(&val, "system_default"); - } - - #[test] - fn to_string_custom() { - let val = PreferredDownloadDirectory::Custom("/tmp/foo".into()); - let val = val.to_string(); - assert_eq!(&val, "/tmp/foo"); - } - } - - #[test] - fn from_string_system_default() { - let val = "system_default"; - let val = PreferredDownloadDirectory::from_str(val).unwrap(); - assert_eq!(val, PreferredDownloadDirectory::SystemDefault); - } - - #[test] - fn from_string_custom() { - let val = "/tmp/foo"; - let val = PreferredDownloadDirectory::from_str(val).unwrap(); - assert_eq!(val, PreferredDownloadDirectory::Custom(PathBuf::from("/tmp/foo"))); - } } diff --git a/crates/desktop/artcraft/src/state/data_dir/app_data_root.rs b/crates/desktop/artcraft/src/state/data_dir/app_data_root.rs index c9e675681c..9aa50ef9ec 100644 --- a/crates/desktop/artcraft/src/state/data_dir/app_data_root.rs +++ b/crates/desktop/artcraft/src/state/data_dir/app_data_root.rs @@ -136,6 +136,10 @@ impl AppDataRoot { pub fn get_window_size_config_file(&self) -> PathBuf { self.path.join("window_size.json") } + + pub fn get_window_position_config_file(&self) -> PathBuf { + self.path.join("window_position.json") + } } // eg. /home/bob/artcraft, /Users/bob/artcraft, or C:\Users\Alice\artcraft diff --git a/crates/desktop/artcraft/src/state/data_dir/app_settings_dir.rs b/crates/desktop/artcraft/src/state/data_dir/app_settings_dir.rs index 5c304c5815..45430ab038 100644 --- a/crates/desktop/artcraft/src/state/data_dir/app_settings_dir.rs +++ b/crates/desktop/artcraft/src/state/data_dir/app_settings_dir.rs @@ -1,6 +1,7 @@ use crate::state::app_preferences::app_preferences::AppPreferences; use crate::state::data_dir::trait_data_subdir::DataSubdir; use errors::AnyhowResult; +use serde::Serialize; use std::fs::OpenOptions; use std::io::Write; use std::path::{Path, PathBuf}; @@ -33,8 +34,12 @@ impl AppSettingsDir { pub fn write_app_preferences(&self, prefs: &AppPreferences) -> AnyhowResult<()> { let serializable = prefs.to_serializable(); - let serializable = serde_json::to_string(&serializable)?; - + + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut buf = Vec::new(); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); + serializable.serialize(&mut ser)?; + let filename = self.get_app_preferences_path(); let mut file = OpenOptions::new() @@ -43,7 +48,7 @@ impl AppSettingsDir { .truncate(true) .open(filename)?; - file.write_all(serializable.as_bytes())?; + file.write_all(&buf)?; file.flush()?; Ok(()) diff --git a/crates/desktop/artcraft/src/state/main_window_position.rs b/crates/desktop/artcraft/src/state/main_window_position.rs new file mode 100644 index 0000000000..f171a45e30 --- /dev/null +++ b/crates/desktop/artcraft/src/state/main_window_position.rs @@ -0,0 +1,73 @@ +use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::windows::main_window::constants::MAIN_WINDOW_NAME; +use errors::AnyhowResult; +use serde_derive::{Deserialize, Serialize}; +use std::fs::OpenOptions; +use std::io::Write; +use tauri::{AppHandle, Manager, PhysicalPosition, Window}; + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct MainWindowPosition { + pub x: i32, + pub y: i32, +} + +impl MainWindowPosition { + pub fn new(x: i32, y: i32) -> Self { + Self { x, y } + } + + pub fn from_main_window(app: &AppHandle) -> AnyhowResult { + let windows = app.windows(); + windows.get(MAIN_WINDOW_NAME) + .map(|window| Self::from_window(window)) + .unwrap_or_else(|| Err(anyhow::anyhow!("Main window not found"))) + } + + pub fn from_window(window: &Window) -> AnyhowResult { + let position = window.outer_position()?; + Ok(Self { + x: position.x, + y: position.y, + }) + } + + pub fn from_filesystem_configs(app_data_root: &AppDataRoot) -> AnyhowResult> { + let filename = app_data_root.get_window_position_config_file(); + if !filename.exists() { + return Ok(None); + } + let contents = std::fs::read_to_string(filename)?; + let pos : MainWindowPosition = serde_json::from_str(&contents)?; + Ok(Some(pos)) + } + + pub fn apply_to_main_window(&self, app: &AppHandle) -> AnyhowResult<()> { + let windows = app.windows(); + let window = windows.get(MAIN_WINDOW_NAME) + .ok_or_else(|| anyhow::anyhow!("Main window not found"))?; + window.set_position(PhysicalPosition::new(self.x, self.y))?; + Ok(()) + } + + pub fn persist_to_filesystem(&self, app_data_root: &AppDataRoot) -> AnyhowResult<()> { + let filename = app_data_root.get_window_position_config_file(); + let json = serde_json::to_string(self)?; + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filename)?; + file.write_all(json.as_bytes())?; + file.flush()?; + Ok(()) + } + + pub fn to_physical_position(&self) -> PhysicalPosition { + PhysicalPosition::new(self.x, self.y) + } + + pub fn matches_physical_position(&self, pos: &PhysicalPosition) -> bool { + self.x == pos.x && self.y == pos.y + } +} diff --git a/crates/desktop/artcraft/src/state/main_window_size.rs b/crates/desktop/artcraft/src/state/main_window_size.rs index f1f32f6966..129aa9d964 100644 --- a/crates/desktop/artcraft/src/state/main_window_size.rs +++ b/crates/desktop/artcraft/src/state/main_window_size.rs @@ -1,12 +1,12 @@ use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::utils::best_window_size_heuristic::best_window_size_heuristic; +use crate::windows::main_window::constants::MAIN_WINDOW_NAME; use errors::AnyhowResult; use serde_derive::{Deserialize, Serialize}; use std::fs::OpenOptions; use std::io::Write; use tauri::{AppHandle, Manager, PhysicalSize, Window}; -const MAIN_WINDOW_NAME: &str = "main"; - #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct MainWindowSize { pub width: u32, @@ -26,7 +26,7 @@ impl MainWindowSize { } pub fn from_window(window: &Window) -> AnyhowResult { - let size = window.inner_size()?; + let size = best_window_size_heuristic(window)?; Ok(Self { width: size.width, height: size.height, diff --git a/crates/desktop/artcraft/src/state/mod.rs b/crates/desktop/artcraft/src/state/mod.rs index 6a4f3397a5..dca2e0399b 100644 --- a/crates/desktop/artcraft/src/state/mod.rs +++ b/crates/desktop/artcraft/src/state/mod.rs @@ -2,6 +2,7 @@ pub mod app_preferences; pub mod app_startup_time; pub mod data_dir; pub mod expanduser; +pub mod main_window_position; pub mod main_window_size; pub mod os_platform; pub mod sora; diff --git a/crates/desktop/artcraft/src/state/sora/sora_credential_manager.rs b/crates/desktop/artcraft/src/state/sora/sora_credential_manager.rs index c82b1d6de3..e69f277753 100644 --- a/crates/desktop/artcraft/src/state/sora/sora_credential_manager.rs +++ b/crates/desktop/artcraft/src/state/sora/sora_credential_manager.rs @@ -1,3 +1,4 @@ +use std::{fs, io}; use crate::state::data_dir::app_data_root::AppDataRoot; use crate::state::sora::read_sora_credentials_from_disk::read_sora_credentials_from_disk; use crate::state::sora::sora_credential_holder::SoraCredentialHolder; @@ -9,6 +10,7 @@ use openai_sora_client::creds::sora_sentinel::SoraSentinel; use openai_sora_client::requests::sentinel_refresh::generate::token::generate_token; use std::fs::OpenOptions; use std::io::Write; +use std::path::Path; use openai_sora_client::creds::sora_cookies::SoraCookies; use openai_sora_client::creds::sora_jwt_bearer_token::SoraJwtBearerToken; @@ -45,6 +47,31 @@ impl SoraCredentialManager { Ok(()) } + /// This is meant to be infallible. + pub fn purge_credentials_from_disk(&self) -> Result<(), io::Error> { + let creds_dir = self.app_data_root.credentials_dir(); + + remove_file_if_exists(creds_dir.get_sora_cookie_file_path())?; + remove_file_if_exists(creds_dir.get_sora_bearer_token_file_path())?; + remove_file_if_exists(creds_dir.get_sora_sentinel_file_path())?; + + Ok(()) + } + + /// This is meant to be infallible. + pub fn try_purge_credentials_from_disk(&self) { + let creds_dir = self.app_data_root.credentials_dir(); + if let Err(err) = fs::remove_file(creds_dir.get_sora_cookie_file_path()) { + error!("Failed to remove cookie file: {:?}", err); + } + if let Err(err) = fs::remove_file(creds_dir.get_sora_bearer_token_file_path()) { + error!("Failed to remove bearer token file: {:?}", err); + } + if let Err(err) = fs::remove_file(creds_dir.get_sora_sentinel_file_path()) { + error!("Failed to remove sentinel file: {:?}", err); + } + } + pub fn get_credentials(&self) -> AnyhowResult> { self.holder.get_credentials() } @@ -155,3 +182,11 @@ fn persist_sentinel_to_disk(sentinel: &SoraSentinel, app_data_root: &AppDataRoot Ok(()) } + +fn remove_file_if_exists>(path: P) -> Result<(), io::Error> { + let p = path.as_ref(); + if p.exists() && p.is_file() { + fs::remove_file(p)?; + } + Ok(()) +} diff --git a/crates/desktop/artcraft/src/threads/discord_presence_thread.rs b/crates/desktop/artcraft/src/threads/discord_presence_thread.rs index 23739bb288..e57e383841 100644 --- a/crates/desktop/artcraft/src/threads/discord_presence_thread.rs +++ b/crates/desktop/artcraft/src/threads/discord_presence_thread.rs @@ -1,7 +1,7 @@ use anyhow::anyhow; use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; use errors::AnyhowResult; -use log::info; +use log::{debug, info}; /// Our Discord App ID. Not sure if this is a secret. const DISCORD_APP_ID : &str = "1366596912593113138"; @@ -15,13 +15,13 @@ pub async fn discord_presence_thread() -> ! { } async fn discord_main_loop(mut client: DiscordIpcClient) -> AnyhowResult<()> { - info!("Connecting Discord IPC client"); + info!("(Re)-connecting Discord IPC client..."); client.connect() .map_err(|err| anyhow!("Error connecting to Discord IPC: {:?}", err))?; loop { - info!("Notifying discord..."); + debug!("Notifying discord presence API..."); let assets = discord_rich_presence::activity::Assets::new() .large_image("https://storyteller.ai/android-chrome-512x512.png") @@ -37,6 +37,6 @@ async fn discord_main_loop(mut client: DiscordIpcClient) -> AnyhowResult<()> { .assets(assets)) .map_err(|err| anyhow!("Error setting Discord activity: {:?}", err))?; - tokio::time::sleep(std::time::Duration::from_millis(30_000)).await; + tokio::time::sleep(std::time::Duration::from_millis(60_000)).await; } } \ No newline at end of file diff --git a/crates/desktop/artcraft/src/threads/main_window_thread/main_window_thread.rs b/crates/desktop/artcraft/src/threads/main_window_thread/main_window_thread.rs index c66b7cccaa..f9f258327b 100644 --- a/crates/desktop/artcraft/src/threads/main_window_thread/main_window_thread.rs +++ b/crates/desktop/artcraft/src/threads/main_window_thread/main_window_thread.rs @@ -1,13 +1,15 @@ +use crate::state::app_startup_time::AppStartupTime; use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::state::main_window_position::MainWindowPosition; use crate::state::main_window_size::MainWindowSize; use crate::state::storyteller::storyteller_credential_manager::StorytellerCredentialManager; use crate::threads::main_window_thread::persist_storyteller_cookies_task::persist_storyteller_cookies_task; +use crate::threads::main_window_thread::persist_window_position_task::persist_window_position_task; use crate::threads::main_window_thread::persist_window_resize_task::persist_window_resize_task; use errors::AnyhowResult; use log::{error, info}; use memory_store::clone_slot::CloneSlot; use tauri::{AppHandle, Manager, Webview, Window}; -use crate::state::app_startup_time::AppStartupTime; const MAIN_WINDOW_NAME : &str = "main"; @@ -18,6 +20,7 @@ pub async fn main_window_thread( ) -> ! { // TODO: Move these into some kind of dependency injection framework let window_size_slot: CloneSlot = CloneSlot::empty(); + let window_pos_slot: CloneSlot = CloneSlot::empty(); let app_startup_time = AppStartupTime::new(); loop { @@ -29,6 +32,7 @@ pub async fn main_window_thread( &app_data_root, &storyteller_creds_manager, &window_size_slot, + &window_pos_slot, &app_startup_time, ).await; if let Err(err) = result { @@ -46,10 +50,12 @@ pub async fn handle_main_window( app_data_root: &AppDataRoot, storyteller_creds_manager: &StorytellerCredentialManager, window_size_slot: &CloneSlot, + window_pos_slot: &CloneSlot, app_startup_time: &AppStartupTime, ) -> AnyhowResult<()> { loop { log_errors(persist_window_resize_task(window, app_data_root, window_size_slot).await); + log_errors(persist_window_position_task(window, app_data_root, window_pos_slot).await); log_errors(persist_storyteller_cookies_task(app, storyteller_creds_manager, app_startup_time).await); tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; } diff --git a/crates/desktop/artcraft/src/threads/main_window_thread/mod.rs b/crates/desktop/artcraft/src/threads/main_window_thread/mod.rs index 635ce85c2e..db7084f698 100644 --- a/crates/desktop/artcraft/src/threads/main_window_thread/mod.rs +++ b/crates/desktop/artcraft/src/threads/main_window_thread/mod.rs @@ -1,3 +1,4 @@ pub mod main_window_thread; pub mod persist_storyteller_cookies_task; +pub mod persist_window_position_task; pub mod persist_window_resize_task; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/threads/main_window_thread/persist_storyteller_cookies_task.rs b/crates/desktop/artcraft/src/threads/main_window_thread/persist_storyteller_cookies_task.rs index 066a1f294c..49c5c4bdf0 100644 --- a/crates/desktop/artcraft/src/threads/main_window_thread/persist_storyteller_cookies_task.rs +++ b/crates/desktop/artcraft/src/threads/main_window_thread/persist_storyteller_cookies_task.rs @@ -1,7 +1,6 @@ use crate::state::app_startup_time::AppStartupTime; use crate::state::data_dir::app_data_root::AppDataRoot; use crate::state::storyteller::storyteller_credential_manager::StorytellerCredentialManager; -use crate::threads::sora_session_login_thread::LOGIN_WINDOW_NAME; use anyhow::anyhow; use chrono::TimeDelta; use errors::AnyhowResult; diff --git a/crates/desktop/artcraft/src/threads/main_window_thread/persist_window_position_task.rs b/crates/desktop/artcraft/src/threads/main_window_thread/persist_window_position_task.rs new file mode 100644 index 0000000000..7abbe82869 --- /dev/null +++ b/crates/desktop/artcraft/src/threads/main_window_thread/persist_window_position_task.rs @@ -0,0 +1,34 @@ +use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::state::main_window_position::MainWindowPosition; +use crate::state::main_window_size::MainWindowSize; +use crate::utils::best_window_size_heuristic::best_window_size_heuristic; +use errors::AnyhowResult; +use log::info; +use memory_store::clone_slot::CloneSlot; +use tauri::Window; + +pub async fn persist_window_position_task( + window: &Window, + app_data_root: &AppDataRoot, + window_pos_slot: &CloneSlot, +) -> AnyhowResult<()> { + + let current_window_position = MainWindowPosition::from_window(window)?; + let current_physical_pos = window.outer_position()?; + + let mut save_size_to_disk = true; + + if let Ok(Some(old_size)) = window_pos_slot.get_clone() { + if old_size.matches_physical_position(¤t_physical_pos) { + save_size_to_disk = false; + } + } + + if save_size_to_disk { + info!("Saving window position configs to disk..."); + current_window_position.persist_to_filesystem(app_data_root)?; + window_pos_slot.set_clone(¤t_window_position)?; + } + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/threads/main_window_thread/persist_window_resize_task.rs b/crates/desktop/artcraft/src/threads/main_window_thread/persist_window_resize_task.rs index fc6c356fea..3c842478f6 100644 --- a/crates/desktop/artcraft/src/threads/main_window_thread/persist_window_resize_task.rs +++ b/crates/desktop/artcraft/src/threads/main_window_thread/persist_window_resize_task.rs @@ -1,5 +1,6 @@ use crate::state::data_dir::app_data_root::AppDataRoot; use crate::state::main_window_size::MainWindowSize; +use crate::utils::best_window_size_heuristic::best_window_size_heuristic; use errors::AnyhowResult; use log::info; use memory_store::clone_slot::CloneSlot; @@ -12,7 +13,7 @@ pub async fn persist_window_resize_task( ) -> AnyhowResult<()> { let current_window_size = MainWindowSize::from_window(window)?; - let current_physical_size = window.inner_size()?; + let current_physical_size = best_window_size_heuristic(window)?; let mut save_size_to_disk = true; diff --git a/crates/desktop/artcraft/src/threads/mod.rs b/crates/desktop/artcraft/src/threads/mod.rs index 1be2c3b811..eaae9fdf2e 100644 --- a/crates/desktop/artcraft/src/threads/mod.rs +++ b/crates/desktop/artcraft/src/threads/mod.rs @@ -1,4 +1,3 @@ pub mod discord_presence_thread; pub mod main_window_thread; -pub mod sora_session_login_thread; pub mod sora_task_polling_thread; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/threads/sora_session_login_thread.rs b/crates/desktop/artcraft/src/threads/sora_session_login_thread.rs deleted file mode 100644 index ca8562d549..0000000000 --- a/crates/desktop/artcraft/src/threads/sora_session_login_thread.rs +++ /dev/null @@ -1,178 +0,0 @@ -use crate::state::data_dir::app_data_root::AppDataRoot; -use crate::state::sora::sora_credential_holder::SoraCredentialHolder; -use crate::state::sora::sora_credential_manager::SoraCredentialManager; -use crate::utils::cookies::sora_webview_cookies::get_all_sora_cookies_as_string; -use crate::utils::sora::initialize_sora_jwt_bearer_token::initialize_sora_jwt_bearer_token; -use anyhow::anyhow; -use chrono::{DateTime, NaiveDateTime, TimeDelta}; -use errors::AnyhowResult; -use log::{error, info, warn}; -use once_cell::sync::Lazy; -use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; -use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; -use openai_sora_client::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence}; -use reqwest::Url; -use std::fs; -use std::fs::{read_to_string, OpenOptions}; -use std::io::Write; -use std::ops::Sub; -use tauri::{AppHandle, Manager, Webview}; - -pub const LOGIN_WINDOW_NAME: &str = "login_window"; - -pub const SORA_LOGIN_URL_STR: &str = "https://sora.com/auth/login?callback_path=/"; - -pub static SORA_LOGIN_URL: Lazy = Lazy::new(|| { - Url::parse(SORA_LOGIN_URL_STR).expect("URL should parse") -}); - -pub const SORA_ROOT_URL_STR: &str = "https://sora.com/"; - -pub static SORA_ROOT_URL: Lazy = Lazy::new(|| { - Url::parse(SORA_ROOT_URL_STR).expect("URL should parse") -}); - -pub async fn sora_session_login_thread( - app: AppHandle, - app_data_root: AppDataRoot, - sora_creds_manager: SoraCredentialManager -) -> ! { - loop { - for (window_name, webview) in app.webviews() { - if window_name == LOGIN_WINDOW_NAME { - let result = check_login_window( - &webview, - &app_data_root, - &sora_creds_manager, - ).await; - if let Err(err) = result { - error!("Error checking login window: {:?}", err); - } - break; - } - } - tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; - } -} - -async fn check_login_window( - webview: &Webview, - app_data_root: &AppDataRoot, - sora_credential_manager: &SoraCredentialManager, -) -> AnyhowResult<()> { - - - - - - - clear_browsing_data_on_test_domain(webview)?; - //keep_on_task(webview)?; - extract_cookies_to_file(webview, app_data_root, sora_credential_manager).await?; - //initialize_sora_jwt_bearer_token(app_data_root).await?; // TODO: This only runs once. We need better management. - Ok(()) -} - -fn keep_on_task(webview: &Webview) -> AnyhowResult<()> { - let url = webview.url()?; - let hostname= url.host() - .ok_or(anyhow!("no host in url"))? - .to_string(); - match hostname.as_str() { - "auth.openai.com" => {}, - "openai.com" => {}, - "sora.com" => {}, - // Third party SSO - "accounts.google.com" => {}, - "accounts.youtube.com" => {}, - "login.live.com" => {}, - "appleid.apple.com" => {}, - _ => { - info!("Non login hostname: {}", hostname); - webview.navigate(SORA_LOGIN_URL.clone())?; - } - } - Ok(()) -} - -/// This is just so we have a way to clear browsing data. -fn clear_browsing_data_on_test_domain(webview: &Webview) -> AnyhowResult<()> { - let url = webview.url()?; - let hostname= url.host() - .ok_or(anyhow!("no host in url"))? - .to_string(); - match hostname.as_str() { - "storyteller.ai" => { - info!("Clearing all browsing data..."); - webview.clear_all_browsing_data()?; - } - _ => {} - } - Ok(()) -} - -// TODO(bt,2025-04-07): Heuristic to detect when logged in. Only write when logged in. -async fn extract_cookies_to_file( - webview: &Webview, - app_data_root: &AppDataRoot, - sora_credential_manager: &SoraCredentialManager, -) -> AnyhowResult<()> { - let new_cookies = get_all_sora_cookies_as_string(webview)?.trim().to_string(); - - if new_cookies.is_empty() { - return Ok(()); - } - - let session_cookie_presence = has_session_cookie(&new_cookies) - .unwrap_or_else(|err| { - error!("Failed to check for session cookie: {:?}", err); - SessionCookiePresence::MaybePresent - }); - - info!("Session cookies are: {:?}", session_cookie_presence); - - let (should_write_cookies, should_upgrade_session) = match session_cookie_presence { - SessionCookiePresence::Present => (true, true), - SessionCookiePresence::MaybePresent => (true, false), - SessionCookiePresence::Absent => (false, false), - }; - - if !should_write_cookies { - return Ok(()); - } - - // TODO(bt): Race conditions ahead. - - sora_credential_manager.clear_credentials()?; - - let _r = fs::remove_file(app_data_root.get_sora_cookie_file_path()); - let _r = fs::remove_file(app_data_root.get_sora_bearer_token_file_path()); - let _r = fs::remove_file(app_data_root.get_sora_sentinel_file_path()); - - let mut new_credentials = - SoraCredentialSet::initialize_with_just_cookies_str(&new_cookies); - - if should_upgrade_session { - let _upgraded = maybe_upgrade_or_renew_session(&mut new_credentials).await?; - } - - sora_credential_manager.set_credentials(&new_credentials)?; - sora_credential_manager.persist_all_to_disk()?; - - Ok(()) -} - -pub fn read_sora_cookies_from_disk(app_data_root: &AppDataRoot) -> Option { - let cookie_file = app_data_root.get_sora_cookie_file_path(); - if !cookie_file.exists() { - return None; - } - - match read_to_string(cookie_file) { - Ok(contents) => Some(contents.trim().to_string()), - Err(err) => { - warn!("Failed to read cookie file: {:?}", err); - None - } - } -} diff --git a/crates/desktop/artcraft/src/threads/sora_task_polling_thread.rs b/crates/desktop/artcraft/src/threads/sora_task_polling_thread.rs index 220ae424b9..da8cddcbb8 100644 --- a/crates/desktop/artcraft/src/threads/sora_task_polling_thread.rs +++ b/crates/desktop/artcraft/src/threads/sora_task_polling_thread.rs @@ -1,3 +1,6 @@ +use crate::events::sendable_event_trait::SendableEvent; +use crate::events::sora::sora_image_generation_complete_event::SoraImageGenerationCompleteEvent; +use crate::events::sora::sora_image_generation_failed_event::SoraImageGenerationFailedEvent; use crate::state::data_dir::app_data_root::AppDataRoot; use crate::state::data_dir::trait_data_subdir::DataSubdir; use crate::state::sora::sora_credential_manager::SoraCredentialManager; @@ -18,16 +21,6 @@ use tauri::{AppHandle, Emitter}; use tempdir::TempDir; use tokens::tokens::media_files::MediaFileToken; -#[derive(Debug, Clone, Serialize)] -pub struct SoraImageGenerationComplete { - pub media_file_token: MediaFileToken, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SoraImageGenerationFailed { - pub prompt: String, -} - pub async fn sora_task_polling_thread( app_handle: AppHandle, app_data_root: AppDataRoot, @@ -102,9 +95,10 @@ async fn polling_loop( for task in failed_tasks.iter() { if sora_task_queue.contains_key(&task.id)? { - app_handle.emit("sora-image-generation-failed", SoraImageGenerationFailed { + let event = SoraImageGenerationFailedEvent { prompt: task.prompt.clone(), - })?; + }; + event.send(&app_handle)?; } } @@ -133,9 +127,11 @@ async fn polling_loop( info!("Uploaded to API backend: {:?}", result.media_file_token); - app_handle.emit("sora-image-generation-complete", SoraImageGenerationComplete { - media_file_token: result.media_file_token - })?; + let event = SoraImageGenerationCompleteEvent { + media_file_token: result.media_file_token, + }; + + event.send(&app_handle)?; } } @@ -146,7 +142,7 @@ async fn polling_loop( sora_task_queue.remove_list(&succeeded_task_ids)?; - tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + tokio::time::sleep(std::time::Duration::from_millis(6_000)).await; } } diff --git a/crates/desktop/artcraft/src/utils/best_window_size_heuristic.rs b/crates/desktop/artcraft/src/utils/best_window_size_heuristic.rs new file mode 100644 index 0000000000..995dbbd207 --- /dev/null +++ b/crates/desktop/artcraft/src/utils/best_window_size_heuristic.rs @@ -0,0 +1,25 @@ +use tauri::{PhysicalSize, Window}; +use errors::AnyhowResult; + +// A typical height difference when dev tools are open: +// Window inner size = PhysicalSize { width: 3704, height: 732 } +// Window outer size = PhysicalSize { width: 3704, height: 1476 } +const HEIGHT_DIFFERENCE_THRESHOLD: u32 = 300; + +/// Get the best window size +pub fn best_window_size_heuristic(window: &Window) -> AnyhowResult> { + // NB: Ordinarily we'd use inner size, as the outer size includes the window decorations. + // However, dev tools contract the inner size, and in the event they're open, we should + // prefer the outer size. + let inner = window.inner_size()?; + let outer = window.outer_size()?; + + let height_delta = outer.height.saturating_sub(inner.height); + if height_delta > HEIGHT_DIFFERENCE_THRESHOLD { + // Dev tools are open, use the outer size + Ok(outer) + } else { + // Dev tools are not open, use the inner size + Ok(inner) + } +} diff --git a/crates/desktop/artcraft/src/utils/cookies/mod.rs b/crates/desktop/artcraft/src/utils/cookies/mod.rs deleted file mode 100644 index fc9a63ecd8..0000000000 --- a/crates/desktop/artcraft/src/utils/cookies/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod sora_webview_cookies; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/utils/cookies/sora_webview_cookies.rs b/crates/desktop/artcraft/src/utils/cookies/sora_webview_cookies.rs deleted file mode 100644 index e85c887a81..0000000000 --- a/crates/desktop/artcraft/src/utils/cookies/sora_webview_cookies.rs +++ /dev/null @@ -1,26 +0,0 @@ -use once_cell::sync::Lazy; -use reqwest::Url; -use tauri::Webview; -use tauri::webview::Cookie; -use errors::AnyhowResult; - -const SORA_ROOT_COOKIE_URL_STR: &str = "https://sora.com/"; - -static SORA_ROOT_COOKIE_URL: Lazy = Lazy::new(|| { - Url::parse(SORA_ROOT_COOKIE_URL_STR).expect("URL should parse") -}); - -pub fn get_all_sora_cookies(webview: &Webview) -> AnyhowResult> { - let cookies = webview.cookies_for_url(SORA_ROOT_COOKIE_URL.clone())?; - Ok(cookies) -} - -pub fn get_all_sora_cookies_as_string(webview: &Webview) -> AnyhowResult { - let cookies = get_all_sora_cookies(webview)?; - let cookie_string = cookies - .iter() - .map(|cookie| format!("{}={}", cookie.name(), cookie.value())) - .collect::>() - .join("; "); - Ok(cookie_string) -} diff --git a/crates/desktop/artcraft/src/utils/mod.rs b/crates/desktop/artcraft/src/utils/mod.rs index 02dbf73e50..f3b0a63a1b 100644 --- a/crates/desktop/artcraft/src/utils/mod.rs +++ b/crates/desktop/artcraft/src/utils/mod.rs @@ -1,9 +1,9 @@ +pub mod best_window_size_heuristic; pub mod clear_all_webview_cookies; -pub mod cookies; pub mod get_url_file_extension; pub mod image; pub mod large_file_transfer; pub mod object_store; pub mod simple_http_download; pub mod sora; -pub mod webview_unsafe; +pub mod webview_unsafe; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/windows/main_window/constants.rs b/crates/desktop/artcraft/src/windows/main_window/constants.rs new file mode 100644 index 0000000000..948b742fd4 --- /dev/null +++ b/crates/desktop/artcraft/src/windows/main_window/constants.rs @@ -0,0 +1,4 @@ + +/// The name of the main application window. +/// This is the Artcraft application UX itself. +pub const MAIN_WINDOW_NAME : &str = "main"; diff --git a/crates/desktop/artcraft/src/windows/main_window/mod.rs b/crates/desktop/artcraft/src/windows/main_window/mod.rs new file mode 100644 index 0000000000..987b09fc8a --- /dev/null +++ b/crates/desktop/artcraft/src/windows/main_window/mod.rs @@ -0,0 +1 @@ +pub mod constants; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/windows/mod.rs b/crates/desktop/artcraft/src/windows/mod.rs new file mode 100644 index 0000000000..564f15ab84 --- /dev/null +++ b/crates/desktop/artcraft/src/windows/mod.rs @@ -0,0 +1,2 @@ +pub mod sora_login_window; +pub mod main_window; \ No newline at end of file diff --git a/crates/desktop/artcraft/src/windows/sora_login_window/clear_browsing_on_test_domain.rs b/crates/desktop/artcraft/src/windows/sora_login_window/clear_browsing_on_test_domain.rs new file mode 100644 index 0000000000..54cc5403d8 --- /dev/null +++ b/crates/desktop/artcraft/src/windows/sora_login_window/clear_browsing_on_test_domain.rs @@ -0,0 +1,20 @@ +use anyhow::anyhow; +use log::info; +use tauri::Webview; +use errors::AnyhowResult; + +// This is just so we have a way to clear browsing data while debugging / building. +pub fn clear_browsing_data_on_test_domain(webview: &Webview) -> AnyhowResult<()> { + let url = webview.url()?; + let hostname= url.host() + .ok_or(anyhow!("no host in url"))? + .to_string(); + match hostname.as_str() { + "storyteller.ai" => { + info!("Clearing all browsing data..."); + webview.clear_all_browsing_data()?; + } + _ => {} + } + Ok(()) +} diff --git a/crates/desktop/artcraft/src/windows/sora_login_window/extract_sora_webview_cookies.rs b/crates/desktop/artcraft/src/windows/sora_login_window/extract_sora_webview_cookies.rs new file mode 100644 index 0000000000..03696c522a --- /dev/null +++ b/crates/desktop/artcraft/src/windows/sora_login_window/extract_sora_webview_cookies.rs @@ -0,0 +1,28 @@ +use errors::AnyhowResult; +use once_cell::sync::Lazy; +use reqwest::Url; +use tauri::webview::Cookie; +use tauri::WebviewWindow; + +const CHAT_GPT_ROOT_COOKIE_URL_STR: &str = "https://chatgpt.com"; + +static CHAT_GPT_ROOT_COOKIE_URL: Lazy = Lazy::new(|| { + Url::parse(CHAT_GPT_ROOT_COOKIE_URL_STR).expect("URL should parse") +}); + +pub fn extract_sora_webview_cookies(webview: &WebviewWindow) -> AnyhowResult { + // NB(bt): Cookies were originally on sora.com, but now they're located on + // the sora.chatgpt.com / *.chatgpt.com domains. + let cookies = get_all_chatgpt_cookies(webview)?; + let cookie_string = cookies + .iter() + .map(|cookie| format!("{}={}", cookie.name(), cookie.value())) + .collect::>() + .join("; "); + Ok(cookie_string) +} + +fn get_all_chatgpt_cookies(webview: &WebviewWindow) -> AnyhowResult> { + let cookies = webview.cookies_for_url(CHAT_GPT_ROOT_COOKIE_URL.clone())?; + Ok(cookies) +} diff --git a/crates/desktop/artcraft/src/windows/sora_login_window/keep_on_login_domains.rs b/crates/desktop/artcraft/src/windows/sora_login_window/keep_on_login_domains.rs new file mode 100644 index 0000000000..9c485ae030 --- /dev/null +++ b/crates/desktop/artcraft/src/windows/sora_login_window/keep_on_login_domains.rs @@ -0,0 +1,28 @@ +use crate::windows::sora_login_window::open_sora_login_window::SORA_LOGIN_URL; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::info; +use tauri::Webview; + +// NB: Probably best not to use this. +pub fn keep_on_login_domains(webview: &Webview) -> AnyhowResult<()> { + let url = webview.url()?; + let hostname= url.host() + .ok_or(anyhow!("no host in url"))? + .to_string(); + match hostname.as_str() { + "auth.openai.com" => {}, + "openai.com" => {}, + "sora.com" => {}, + // Third party SSO + "accounts.google.com" => {}, + "accounts.youtube.com" => {}, + "login.live.com" => {}, + "appleid.apple.com" => {}, + _ => { + info!("Non login hostname: {}", hostname); + webview.navigate(SORA_LOGIN_URL.clone())?; + } + } + Ok(()) +} diff --git a/crates/desktop/artcraft/src/windows/sora_login_window/mod.rs b/crates/desktop/artcraft/src/windows/sora_login_window/mod.rs new file mode 100644 index 0000000000..0ff8648e3b --- /dev/null +++ b/crates/desktop/artcraft/src/windows/sora_login_window/mod.rs @@ -0,0 +1,5 @@ +pub mod clear_browsing_on_test_domain; +pub mod extract_sora_webview_cookies; +pub mod keep_on_login_domains; +pub mod open_sora_login_window; +pub mod sora_login_thread; diff --git a/crates/desktop/artcraft/src/windows/sora_login_window/open_sora_login_window.rs b/crates/desktop/artcraft/src/windows/sora_login_window/open_sora_login_window.rs new file mode 100644 index 0000000000..d017bdd2a2 --- /dev/null +++ b/crates/desktop/artcraft/src/windows/sora_login_window/open_sora_login_window.rs @@ -0,0 +1,84 @@ +use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::state::sora::sora_credential_manager::SoraCredentialManager; +use crate::utils::clear_all_webview_cookies::clear_all_webview_cookies; +use crate::windows::sora_login_window::sora_login_thread::sora_login_thread; +use anyhow::anyhow; +use errors::AnyhowResult; +use once_cell::sync::Lazy; +use reqwest::Url; +use std::time::Duration; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +/// Name of the window +pub const LOGIN_WINDOW_NAME: &str = "login_window"; + +pub const START_URL_STR: &str = "https://google.com"; + +pub static START_URL: Lazy = Lazy::new(|| { + Url::parse(START_URL_STR).expect("URL should parse") +}); + +pub const SORA_HOMEPAGE_URL_STR: &str = "https://sora.com/"; + +pub static SORA_HOMEPAGE_URL : Lazy = Lazy::new(|| { + Url::parse(SORA_HOMEPAGE_URL_STR).expect("URL should parse") +}); + +pub const SORA_LOGIN_URL_STR: &str = "https://chatgpt.com/auth/login?next=%2Fsora%2F"; + +pub static SORA_LOGIN_URL: Lazy = Lazy::new(|| { + Url::parse(SORA_LOGIN_URL_STR).expect("URL should parse") +}); + +//pub const SORA_ROOT_URL_STR: &str = "https://sora.com/"; +// +//pub static SORA_ROOT_URL: Lazy = Lazy::new(|| { +// Url::parse(SORA_ROOT_URL_STR).expect("URL should parse") +//}); + +pub async fn open_sora_login_window( + app: &AppHandle, + app_data_root: &AppDataRoot, + sora_creds_manager: &SoraCredentialManager, +) -> AnyhowResult<()> { + if app.get_window(LOGIN_WINDOW_NAME).is_some() { + return Err(anyhow!("Login window already open")); + } + + let url = WebviewUrl::External(START_URL.clone()); + + let window = WebviewWindowBuilder::new(app, LOGIN_WINDOW_NAME, url) + //.user_agent(openai_sora_client::credentials::USER_AGENT) + .always_on_top(true) + .title("Login to OpenAI's Sora") + .center() + .resizable(true) + .visible(true) + .closable(true) + .min_inner_size(200.0, 800.0) + .focused(true) + .devtools(true) + .build()?; + + let webview = window.get_webview(LOGIN_WINDOW_NAME) + .ok_or_else(|| anyhow!("no webview found"))?; + + clear_all_webview_cookies(&webview)?; + + webview.navigate(SORA_HOMEPAGE_URL.clone())?; + + // NB: We're starting to get Cloudflare protection screens. Let's try to avoid. + tokio::time::sleep(Duration::from_millis(100)).await; + + webview.navigate(SORA_LOGIN_URL.clone())?; + + let app_handle = app.clone(); + let app_data_root = app_data_root.clone(); + let sora_creds_manager = sora_creds_manager.clone(); + + let _ = tauri::async_runtime::spawn(async move { + sora_login_thread(app_handle, app_data_root, sora_creds_manager).await; + }); + + Ok(()) +} diff --git a/crates/desktop/artcraft/src/windows/sora_login_window/sora_login_thread.rs b/crates/desktop/artcraft/src/windows/sora_login_window/sora_login_thread.rs new file mode 100644 index 0000000000..06cc0145a0 --- /dev/null +++ b/crates/desktop/artcraft/src/windows/sora_login_window/sora_login_thread.rs @@ -0,0 +1,157 @@ +use crate::events::sendable_event_trait::SendableEvent; +use crate::events::sora::sora_login_success_event::SoraLoginSuccessEvent; +use crate::state::data_dir::app_data_root::AppDataRoot; +use crate::state::sora::sora_credential_holder::SoraCredentialHolder; +use crate::state::sora::sora_credential_manager::SoraCredentialManager; +use crate::utils::sora::initialize_sora_jwt_bearer_token::initialize_sora_jwt_bearer_token; +use crate::windows::sora_login_window::extract_sora_webview_cookies::extract_sora_webview_cookies; +use crate::windows::sora_login_window::open_sora_login_window::LOGIN_WINDOW_NAME; +use anyhow::anyhow; +use chrono::{DateTime, NaiveDateTime, TimeDelta}; +use errors::AnyhowResult; +use log::{error, info, warn}; +use once_cell::sync::Lazy; +use openai_sora_client::creds::sora_credential_set::SoraCredentialSet; +use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session; +use openai_sora_client::utils::has_session_cookie::{has_session_cookie, SessionCookiePresence}; +use reqwest::Url; +use std::fs; +use std::fs::{read_to_string, OpenOptions}; +use std::io::Write; +use std::ops::Sub; +use tauri::{AppHandle, Manager, Webview, WebviewWindow}; + +pub async fn sora_login_thread( + app: AppHandle, + app_data_root: AppDataRoot, + sora_creds_manager: SoraCredentialManager +) { + loop { + let login_webview_window = match app.get_webview_window(LOGIN_WINDOW_NAME) { + Some(webview) => webview, + None => { + info!("Exit sora login thread."); + return; // NB: Only exit if we don't have the webview. + } + }; + + let result = check_login_window( + &app, + &login_webview_window, + &app_data_root, + &sora_creds_manager, + ).await; + + match result { + Err(err) => { + error!("Error checking login window: {:?}", err); + } + Ok(false) => {} // Continue iteration and try again... + Ok(true) => { + info!("Successfully saved cookies from login window. Closing."); + if let Err(err) = login_webview_window.close() { + error!("Error closing login window: {:?}", err); + } + return; + } + } + + tokio::time::sleep(std::time::Duration::from_millis(2_000)).await; + } +} + +/// Returns true if we can exit. +async fn check_login_window( + app_handle: &AppHandle, + webview_window: &WebviewWindow, + app_data_root: &AppDataRoot, + sora_credential_manager: &SoraCredentialManager, +) -> AnyhowResult { + + /* Login flow looks like this: + + 1. Start: https://sora.chatgpt.com/ + 2. Login Start: https://chatgpt.com/auth/login?next=%2Fsora%2F [this is where we start] + 3. Login Continue: https://auth.openai.com/log-in + 4. SSO / Google Login (etc): https://accounts.google.com/v3/signin/challenge/pwd?[query] + 5. Done / Landing: https://sora.chatgpt.com/explore + */ + + /* + - Do not use credential manager until the end (we don't load old cookies!) + - Check if we're on the correct domain, if not exit? (or inverse, that we're not in login flow) + */ + + let hostname = get_hostname(webview_window)?; + + let mut maybe_at_destination = false; + + match hostname.as_str() { + "sora.com" | // Old destination + "sora.chatgpt.com" // New destination + => { + maybe_at_destination = true; + } + // chatgpt.com/auth is also an auth domain + "auth.openai.com" | + "accounts.google.com" | + "accounts.youtube.com" | + "login.live.com" | + "appleid.apple.com" + => { + // NB: We're in auth flow. + info!("Sora login webview is in auth flow; hostname `{}`.", hostname); + return Ok(false) + } + _ => {}, // We just don't know... + } + + let webview_cookies = extract_sora_webview_cookies(webview_window)?.trim().to_string(); + + let session_cookie_presence = has_session_cookie(&webview_cookies) + .unwrap_or_else(|err| { + error!("Failed to check for session cookie: {:?}", err); + SessionCookiePresence::MaybePresent + }); + + info!("Sora login webview is at hostname `{}`; cookie status: {:?}.", hostname, session_cookie_presence); + + match session_cookie_presence { + SessionCookiePresence::Absent => { + info!("Session cookies are absent."); + return Ok(false); + } + _ => {}, + } + + let mut new_credentials = + SoraCredentialSet::initialize_with_just_cookies_str(&webview_cookies); + + let _upgraded = maybe_upgrade_or_renew_session(&mut new_credentials).await?; + + // TODO(bt): Race conditions ahead. + + sora_credential_manager.clear_credentials()?; + sora_credential_manager.try_purge_credentials_from_disk(); + + sora_credential_manager.set_credentials(&new_credentials)?; + sora_credential_manager.persist_all_to_disk()?; + + // NB: Event sent to the frontend for the login flow. We shouldn't rely on just this + // alone as it could be brittle if the events aren't caught. + let event = SoraLoginSuccessEvent {}; + + if let Err(err) = event.send(app_handle) { + error!("Error sending Sora login success event: {:?}", err); + } + + Ok(true) +} + +fn get_hostname(webview: &WebviewWindow) -> AnyhowResult { + let url = webview.url()?; + let url_hostname= url.host() + .ok_or(anyhow!("no host in url"))? + .to_string(); + Ok(url_hostname) +} diff --git a/crates/lib/api_clients/openai_sora_client/src/recipes/maybe_upgrade_or_renew_session.rs b/crates/lib/api_clients/openai_sora_client/src/recipes/maybe_upgrade_or_renew_session.rs index 98b2f284f0..ea9fec866e 100644 --- a/crates/lib/api_clients/openai_sora_client/src/recipes/maybe_upgrade_or_renew_session.rs +++ b/crates/lib/api_clients/openai_sora_client/src/recipes/maybe_upgrade_or_renew_session.rs @@ -7,13 +7,14 @@ use chrono::{DateTime, TimeDelta, Utc}; use errors::AnyhowResult; use log::info; use std::ops::Sub; +use crate::sora_error::SoraError; const EXPIRATION_DEADLINE : TimeDelta = TimeDelta::hours(12); /// Call this at session startup to generate the tokens for the first time. /// Call this periodically to refresh the JWT as it nears its expiration date. /// This will not refresh the sentinel token once it's been generated - we can use other flows to accomplish that. -pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> AnyhowResult { +pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> Result { let mut refresh_jwt = !sora_credentials.jwt_bearer_token.is_some(); let refresh_sentinel = !sora_credentials.sora_sentinel.is_some(); diff --git a/crates/lib/api_clients/openai_sora_client/src/requests/bearer/generate.rs b/crates/lib/api_clients/openai_sora_client/src/requests/bearer/generate.rs index 81a9005f87..1db22e7bb8 100644 --- a/crates/lib/api_clients/openai_sora_client/src/requests/bearer/generate.rs +++ b/crates/lib/api_clients/openai_sora_client/src/requests/bearer/generate.rs @@ -1,69 +1,119 @@ use crate::sora_error::SoraError; use crate::utils::classify_general_http_error::classify_general_http_error; use errors::AnyhowResult; -use log::error; +use log::{debug, error, info}; use reqwest::Client; use serde_derive::Deserialize; -const SORA_BEARER_GENERATE_URL: &str = "https://sora.com/api/auth/session"; +const SORA_BEARER_GENERATE_URL: &str = "https://sora.chatgpt.com/api/auth/session"; #[derive(Debug, Deserialize)] pub struct SoraAuthResponse { - pub user: SoraUser, - pub expires: String, - #[serde(rename = "accessToken")] - pub access_token: String, - #[serde(rename = "internalApiBase")] - pub internal_api_base: Option, + pub user: SoraUser, + pub expires: String, + #[serde(rename = "accessToken")] + pub access_token: String, + #[serde(rename = "internalApiBase")] + pub internal_api_base: Option, } #[derive(Debug, Deserialize)] pub struct SoraUser { - pub id: String, - pub name: Option, - pub email: Option, - pub image: Option, - pub picture: Option, - pub provider: Option, - #[serde(rename = "lastAuthorizationCheck")] - pub last_authorization_check: Option, + pub id: String, + pub name: Option, + pub email: Option, + pub image: Option, + pub picture: Option, + pub provider: Option, + #[serde(rename = "lastAuthorizationCheck")] + pub last_authorization_check: Option, } pub async fn generate_bearer_with_cookie(cookie: &str) -> Result { - let client = Client::builder() - .gzip(true) - .build()?; + let client = Client::builder() + .gzip(true) + .build()?; - let response = client.get(SORA_BEARER_GENERATE_URL) - .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0") - .header("Accept", "*/*") - .header("Accept-Encoding", "gzip, deflate, br") - .header("Accept-Language", "en-US,en;q=0.5") - .header("Cookie", cookie) - .send() - .await?; + let response = client.get(SORA_BEARER_GENERATE_URL) + .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0") + .header("Accept", "*/*") + .header("Accept-Encoding", "gzip, deflate, br") + .header("Accept-Language", "en-US,en;q=0.5") + .header("Cookie", cookie) + .send() + .await?; - if !response.status().is_success() { - error!("Failed to generate bearer token: {}", response.status()); - let error = classify_general_http_error(response).await; - return Err(error); - } - - let response_body = &response.text().await?; - let auth_response: SoraAuthResponse = serde_json::from_str(&response_body)?; - Ok(auth_response.access_token) + if !response.status().is_success() { + error!("Failed to generate bearer token: {}", response.status()); + let error = classify_general_http_error(response).await; + return Err(error); + } + + debug!("Bearer token generation response was 200."); + + let response_body = response.text().await?; + + debug!("Auth Response: {}", response_body); + + if &response_body == "null" { + error!("Failed to generate bearer token. Response was `null`."); + return Err(SoraError::FailedToGenerateBearer) + } + + let auth_response: SoraAuthResponse = serde_json::from_str(&response_body)?; + + Ok(auth_response.access_token) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[tokio::test] - #[ignore] // Don't run in CI. Requires valid cookie - async fn test_generate_bearer_with_cookie() { - let cookie = "cf_clearance=nelyAZB.T1J.3ToyOuc_NozgPVqEmOoZXZgVqICRVLQ-1745296855-1.2.1.1-R..9Ca0ZCZjSoqDaWLD62phe1hUu.43nPpJV2zqxDibkFPCLJ2R2Am6ygUqJTB1_b86ctFGktBZ4YIPrHHow3jqPk5Mfu_VubL2A5r0R6R_1ILrdvY168CLSpAFRsJKIupMUPBFDePmQ738P7afr8W0YK6dO_m0qjYDYquZyCu5dC3u9CRUGaM8SLDZTTZkxoLzuxDzB1Skuns5rqdjTft4DoNUI_7EJvsYxKoklWylvL2FqcETULoyVq5FyElbZenz8VblXU4HLgspRTz3q0_SNh4_LAIxHygfXaCOBmmii1KY6mFHE83z4JYWgx7MM9jqxKKz1pHgVd8JR72aYgTo7WwE6ZpKmvuxhTtgobsA; oai-did=8ae351f6-bc99-45c9-9fff-bb8dd6eb128b; __Secure-authjs.session-token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwia2lkIjoiYWNBYVdTWHBfaDZnNkxtUEs1S0RvTjR3NHFxTEsxVlpSTHA4VWdiaFdWNjctMG5pbDdVVjBCN2dSUU9Bem84ZXBjMVFjb1ozNGxwa0c1OEUyU2Q4cUEifQ..n6nMP5MA40XKktVazIps6w.tsgjkdkBaHt2jpO7GjIBzgammeanUH5W-3SQMPUDIG_cMrGcgmW_uaMwQY-LJ7qfWGfSqwZyGND6LQD_e-8vYdWbhE_gl1O8AQVJaLfCxJbpauCaXQTsSD0PD_XzGYsW0GZjWIbb-J8YFc-XKi8Y2AhjPhf2El5LJrvkQ4MbtlIJAhDOBNAp7UiBR4SWvWgtD60WqqnW3BhUjqXSigqhopUIkcsXAx_NHkfQpwFyJfkI8WK7JJoW4SbD6hdOX0BCKPjXKn7bTpYG---QkfjJ5f5wsRl0QYRVIQ4lzz4iak0A7oyV5jyX_aXYzK4iroVtEjUBAt77wTDOZ8ID3idkPOWQHPWnaugDzX9ZxZ8sWOxsttqbIMWWpEIm9G9ODc3-F29XHPLzciiLuRlRp2tn2ky12vXBdpiFINL7i5-OEryQ1xs1yEsSnAkzrGcXboXDgPSh-wPpmOhWLwqsydmflSahxQH-k0fUQAnjCq2EocdXnfIL84kqwnOxD36YGsKcX0DOir_3gL2PnAcE25Dql1OTwo9SBSLEf2c8dejDVfFT63UHmCkEqvHgdtTnZRApeqJj57A_2aFvy4d8mhflSaSD5qYmYUIdRo5I-uJumWOuHeDOwXOrlnCzxpgvcl1c5ng560I_oYgNUAhIGUnL5ezMQ3MD6jKwVqk-0kKZUPr2ebE1_w7jh4Rau6esLYiMcJI-Jfm_0TfSxlAXMKC3CKIII50PcXXOrvxy0yiBU_DwU79L6fYPAq7NFJ26P6821iOQGP-0mkaHPZHxEJGaPVKKjg2Y9ZQJ9u1gnm0GWwssY4uhFet6CxURnpqzIXKf78kevXOwkEsgFa-2hhMnr_OWBzKSH4UfVIymulxqoAoOh7Em51NUF-HJ0I0rr_3m7wBlhNvVVnmaxSYkuArZoboixX9ka-JVDopTPXUvoEykm45LRRvPdGiPB5hvCrglzNrp_vep4n-YbcLp4Uzn9ljcMjhSFZfXAzqquaiO7H5ys50_-mBquTq0Hoag2pHbVr5uJL8I1V-b8F1XWOnRtRIlj5pHoLX6KyxlT7pNTTSRgMo9PwAWxmLXpRba1h4ceKQTDputfrD1-itFJfZ3FZgkAXVQGrYyXEQIj79ERqq9xw18qGoBi9-GQkqBmCg1TxeS35SBnc62L7gh-UsZ_LSchizC4l4KODSeKdyNGHRONzK0cNHAGWqAJpLu5vBh_VxiljeaQxc4-p0SRiMXPYQf06uWDaRPdglCcavnlPKOcbL1GLku8CzMMCTAeqN3TJBV8BOHXsW81jw73oaBv18peTpgaJS6-Og9jMQVDb8pwlCi7SZnf1uafeGnBb6jJN4qCBN5EjZcoQikHxv0SOYmjs2XN7xE7EyczkovGcU2K3v03cnbbe01U_1mBAXgiJrW8gb4u3x8kngtphuWjEbStqHivLCtP5dyD22LTEMUE4ixvDmKwuLoB2NukM4QcB7PwrMnjI30SfCx0l5OVToz-TmLkLa-Ous-2cckw-D5pXD4RsgLv5NsxGQ9rtaL3qWeA8KnOByvYH1thLFtBUw6rjHtwLnQUGPomJkXIV8OuEuIWWnbsD1jW7hIpbYynhizTyrLChTnxuD8Witu01NtRa0dqSEhy3H5Y0qDXqFhaziydmW_kIdDxQBCWvPBNVBMAlXldqXgazM05eK3DFP86Div8b8k0pjXNoEtmEcwGLR06taiwQ3Fjbyc3y4HSLnQ-_yYz2jKLbDzpVJdaS6tlWT9rOrgniCkfvxJ5LE6KMF_2i6L_yFprOQ1DCYniM39CFehsD-7XVpS2Ui9t-Kx1g7eebK229kdQzB6Dm_HBEHTOxyXMiR4C0n24WYxEHukfCSiAvChiXHhfHOldmzusKY0r-MB_knKgM69elcXN_VAiukuUo1EdrsaqBprHqwmbkEWYfg-TmYefXJnf-58Xtx0lC2T7Uq8JJDdQL4f4PTnuyU_JX5NOwg9CwDUZoPE4gvYTicshEKxw4TABawLB6LXzr5vxjr1PK9lP1H7iwSyutBUGHEyK_R7fM0OzFQSfMEENwKtaGmGT5GnMTkvF4iGTyEtmZS1RhWqYyIagSFkf66ZRIwt5uohQJhZ8kpU_EOfihCkPC6c8he18W2TPtvGWxe0ljNfykMl-w4564IuHIYO3sESs9RzOdHkul9YqpjZC1C831zzEyM3vgM3hYWKo2VmovC5uMYZO0roqQyuy9RNbGPbXnbzMCE5tTPNeHMd1FVYNGy76t-BLj69MfJN0C-U9RgaLRe2Nz34EZBBfK3v9NNCLYhqm04WhM7XwX3CMrG9_j8mlMKpb_MxYqvQUodtttrBoe3bS4WId7OSz8k3KiswcZD86yopGq5X_a910-C3VzVfxo9jCLlRVP4moY2iohjc9asuQrKjqkUgEXp0bLy-gBB67UxV69rXMBEtz2pLYqfNKEUFFL7cVF1d407QUuSRpANitXUFbmtWo5M-0mJQM7LA0ktMDZUl3_-hPxNHFt9Sba4xDEiSyjPdMZDd--iNpCkJAGvManh-cNYI7HIBQM3kLE4GwD3ihStlW3IT5MfITEXfaaz0vWSvYdMXXmcVjVzgVn5JchynFilRsx7QK4JT7jPb_LQ1vAENBCfIuEERty8dgrKOvS4k21Br6IXckd9hl6l_5GoZUpbQzEY4BWGA8Ly8xKGbwwF4L-Mncc4TQt-2y09qYqU0TMTTXKBGgFfjVn4r0yIvryieCRAHSWuethGyYVX_Qhk_nZB3jqFkp42vMc1mkTjBCR-jlUCJWMbMcmL0tW2_SXDOk5JrACsq-T1djWeUCPgGYXCqYIygtSEE0f025feOpQJAynV78UM2w6fbgGBvsODpVr8Q2hjiI9Ydwh6MIJW1owo1QDDnuEOvTmdwdsQT9wX1H-SyN0cusSITAzgNMEp-NCj8NDW60kqTCbi7kMLF4OtPOK8THCBBkZ5c_SasAvSIxXo36fnMEKGPOLZs9GzLKMtKupym5PZ4H6H2zl_Y6iOIBniyQ3HBs5edLBBGQ3qqeeI_j7Xpwsr06cTCuQGYFAsNhu9dSs3LQTKlVfVeTiqBjRkxAiaQJgPUvFpOCvhIe2t-5n-MNj4.0dhenwDdC-iJ2W4uNnbx9YQcfEgnYSJzo1vXNawiifA; __Host-authjs.csrf-token=61aa1209933a1c967b7af6a787481f4e249a2d6b1d535924540addd4bd4ba196%7C510cca933fb90ce231290471f3885cdee2fdaf4f8f6994d96cfef9c63227e5f7; __Secure-authjs.callback-url=https%3A%2F%2Fsora.com; _cfuvid=XjJAS2zDerYymewq8Gl3e572OMiJ3cyyMqsmFiuGMn4-1745275079858-0.0.1.1-604800000; __cf_bm=hVGZ2X.67OhOZrqzUiW9Xg4IYGdCnss73d7GDU75YHk-1745296490-1.0.1.1-ppPUYZX7zAWYWXknUVzYPLjI7HDdBfmQz9DDMT5pMUNvqKBMhbUrJCp30PzxmBjMYwQL3injMe2IammgQNE5cj7JO7o6Alljt.MN1Pp8s8k; __cflb=0H28vtyuPgbsDEUozcoHSWoAnvpSqAh9friCA2BEchB; _dd_s=rum=2&id=b52d0910-2332-43c2-9fa0-da1c33d06227&created=1745296855240&expire=1745297781201"; - let result = generate_bearer_with_cookie(cookie).await; - assert!(result.is_ok()); - println!("Bearer token: {}", result.unwrap()); - } + // NB: Logged out 2025-05-06 + const LOGGED_OUT_COOKIE :&str = "__Host-next-auth.csrf-token=ef2192abb44aad120d2bd0c67ffa156661b3a971274bebd5822bdfb45d414c38%7C05f5966f7a8c00637388b2c135eded2502ff7af04f986513354123f2fac31a24; __Secure-next-auth.callback-url=https%3A%2F%2Fsora.com; _cfuvid=oD_JCwm5zaQGmsQIy4XjzEoGM276yori_BR0EOBEEFc-1746571084534-0.0.1.1-604800000; cf_clearance=jHTsbECz4hIp7a7acOilpM5iKqbRCOcCMIiy664dbPo-1746580754-1.2.1.1-xNvHlmDlSKGCbed79EyntnpCU7U.dpI9qyVYYEW0HuXT5YKQoWEepQVbB8wMEWB0MtXemxts5SfVHYhY67uESdyN7yIrEP2LPiQ0L1tTdsjFoOvsJGURGdqRAiUF8sjqCdJ1Gfx8FpGfbmJUUYh1I.wV3c5e4zcw2M7Xs2iW6wSA4kvmuwZDXj7H4oPgInwhp_XVXa.jKPHPnNIMRoGR7dirbYhY6wcywUk53Uw4j418ml2Bb_93LWlhzVO6WVPfjnKvWc7vxw1_cutXLy.IqClatx17HXzbCISOVgOcDeGITvvVgbl5OTmXbBlGXtmaJlxtfjsFbxHJFgX93zFYq7pt9znzbM4DPNSO57A0eP0; oai-did=feeb4ee9-615d-451b-91d6-35c9b9ca9826; oai-sc=0gAAAAABoGrUiAr4TfGAcKlXSjhT1h5_5UYzixku3R9aaTwvYNKk4lKP_Yk1OzPWjJL0J1CwOzC2Jb17t3aRDM3z8kxH1WOAZhIdnTU_u2NBgkfQNUQhzMBR_Qb5xHkfJ4PFUpwO9n7YNDtSoN8QVP7U2hbi0GJfMA06k0kcaceUmTvbEK6Uceq5R13QVhTPfZC9JdEjcgXorTcq7iQW9mQPJYfVhUwKgWbtdXgwQROAM51Zq4ETutVYZklPhaXy0hHs1LZdM0GRd; _cfuvid=tYpYCaVU3akRC7xqJ9HhgKb45yUMSDkSkk6zoY7wxbA-1746571091829-0.0.1.1-604800000; cf_clearance=oAW.3k9.ail0y7zwjDtsUlJIC0OAd5NllKMUq7r6T7o-1746571092-1.2.1.1-N0zV9i8bumZPfGDsB3TBpE5dIoU51SUzUvjsycWn8GmGHo6v_M6EeuCIEg9OIfAJEeJWNvE6mf6v0wOZ4x2FrUPBEc06NfnQgVb9fkIE5vzUsukkPX7jK1pigl7vtSiqDmFKyvcg4ojUUUEHuHPZeXDmiDE8UdHzLPSrMaANI6V7k5cflmlhnho8vgnFGpkiHfOArlfABVTkHWmTQo1pJAuQxhdSM3hMyxpA_6VHeQC_fUFX8Rk.dSwQzx7dCV_TCY5PKbOvfOd6X93sPSaQWneGo9hrDOTlZxeVEGLfpd7WngdKPS_fbjf6nDmwHnMtmKPMZsd_BLS.b3TfkmqngKPG_oZxWxi730a5NJv7O6w; __Secure-next-auth.session-token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..UtJZGQCBDAEdkNRL.h7_eoTetqxFXGt6RmB8fr5JYaJPXH6vvcSMKG05JDGq5Nb3TSNXHfOIYdY5g_E-7xUgsPJnoE9RHmUZRuS1YCA0UW23cYmwHigGU89KKcdtJqg7rpcgosIQ2UTLY7aXe1WZBR8-3ACI-auo51JzMrjD7vnDL41k6UgGr7tdGoieyVxqlB58ughkcfIyneBm9k7VdSTahgD75BQRqWEtruPoAMCir5qtbu5bnDyAiCjT4S02gK-kVfgGlP5_tUTT_VoSQ-YMGE0Mw3Iae85l4X5PjuBj_3uhRlKEL6aABfs3KKr8gvsb6xGP_cypWVaTcPMpeFidLicJV4GTbj4x5ncGdJ4E9WK98j_fQRB6tQnF8op7UBuh1zOdKUFvmrKof4DgJm4ChPeff-kR_z64jRkZfiDdu7hTwEx1VIoIcXiVjBLg4MJAG_79WhETxiz61-HMrsUpNgRD3QSH6H6j4X3HpPs3hgiBZnd0L0WDOQU_mTcKBkvpm3Uubcatv5Gss797TelMs2cM7J64DDz88tMzChyMj1mBPguONIWlrj7G5pYkC9sLY5gyydZoK4cvfcxkAVgWa-UZJAtNwpf7XdYw9FutjW0Y5hucMqch3J2O51wVx6KltKjycLXGIJfFydq5qO53Ngu02WEz2AbBfHsdBqR2S-46t16LEqjCfWzJRuUOe0V_Ao4_lqVxgjc4Nd97pjaJhuHIFjWVY8GLmh5rqVUDAiDexEax7TLF8Htp6BBBdOt5-j772SbrvjCD8u06OyksGLIXElY66k4RMGsyzJLDxYoNa-c7E_38WtQJsPU2QH8kYRkwP8H_QYdYnOpukHVY-2Uv5AhAwaP2q9zqYAwPGyJ39mWQq0NrZ_rGqotRz9n_3W37Sbu1eX_5Ae0Vn33BuKG3fzxMU7akSVmA7_4DE_4dQC8PpmQ9C4UoLYhxg0aKCs4KVOWjpSTkLRoehiB-15R-SNHwNGKUMGoFg9B741jb8SHjgYsAVJnvj8VPPDhFm4r4rSqv-IIN--iebrj3ExC6YqBJmwWp-ZAvioSElJfvqZasoYkRKm0CjLoXuVawCcHVNngK0U0PjEFMKkveD9E4-MW2KO2uhud2OShZvXs4ge1Fvmk1yFwttmVM1f7MvSEoAolivtFj623iqlT-Sw0zTSg1Dw4-fDIfPk24r_eoaZvooP_r1sJ89NDDmPJDmZm_oavGdkP_2CCEEPaVNq8m72Mu2K2Le1geyvm8vepT9OR_5q3MDypRqs4KLruzrSqhfDr80Z0FMJHQGv-uyugY9yroX8GFtTBiXdIbRBiGquLgkOvjuw92JDTCT3HtMm5liFR4tGXbcsEKqp_HtK4cd1krKIphq71LKEhklkSXC-JvNzWS4qYEANdBrNfEaSa2e8-wtzW2qIuMfOZT4Cocv1H49cW7SIB1_5-DG3dy--1s0Wf6JniMn0Cprq9h01A1hraFxpSax3gAeOehYCLk3soPu1r0VlbXzdxTFg0S9AxeuXwj-IKL39C9gpQSkkeIsVeSL3VFlzYlpdupZcNbFLlp8JMoIlOnNaABBFPji12ln9dkeyEEkFq0JL4UyZLZ1SegADd0l7s_cn4dtYYfuIuAAJFC16vHDJ7UMVfVMhBeKA96U1w4ak-uKs8wk-l5EhGragNpPogCh_ccHyQf9zV3AnHbJyGsiVc1ztjNu6mduFB41FO3kQDBH2uoa2PgdgWtaO-zLSrTDns3aGffp6VWYddxuPvA3T5VQDVjVLz6eJ2_d2N0B9sPmevKkIYEaI1-OClY_StB4TG3VdNUsfcaqymweg43scS4DZBa9DPGz7ZmC-HG_z1oVfaoZGfa5-XD-u0Oyuu91xYM-QOuccvsHco2GWUTsf52qH51mLvkpc5AkaO9tUzhN0rQLabOGSUBjJZyxIFrRsLLlDWDwPSr3k7BbB1fBUvsUiPShqI_CWKJjfqsEiHjrt3MbfcbLc12XqYuvrCHwtpi0JRP67TwnUKADP7SEP0FEdXz34prhXpJSvK49hmpUkKYCbsdntJFz4J6vB7BKLcb9B6MsWNH5N14yhn35UJZc7_IToLLnjG93KcOhSWNvXtP9vTXRVmxIJtYfnZOOI6wsTxZ3WQcAypxYA1PLzJzlVrZC2TkT9Jhcph_Y2uHCKm0CFka2nr8DHF2VyrEo1n4x_bjXve_BZigWgGgFE0Vn4NEUdLislf0jTXcVhTwVCCxtYGPkKE3NTpu-Djl3HjPUGRYFC3dXa10HWJiazfOITTUO85LEpVRb9j2lhQaX9dKe6toPipSpGwzsdkOaw1liuADV9J8ssMSkhs0W_CLKd2HaJioPKUVUJ0v2B5Z39DiLyJ_NDx6vZoGzO8bRUp2Atm_z3ccrsaKuO-brZBmG_gApoXLQXdBPwovyS36TXx0fcdqcFlH5yqADtK1Yn9Zjkp5gIzbPz-k8nUtBtu4_JOayzFJpWGheTrkoj-nBIvuOfQB1yJan9BVZPF-KzALb1JdTC28k0kK60Exf7r2lulwA_3_-Ydg8inZ1HHGUYouAb6Na1r4Pk70g4waTzLItIbDPOeATPfvV9xRJ7LT8Yq-pbfIg1rHKRjrCcQ8IeonjziGxfNnFiPy4ANLtg2F4Z_dwi0BPE9MFo0yO7gKELGGpGoOB4rcd8mCK_wnm6MyBbdqC5oftova8ymTqjqNEH24L9DwCk8F_jx8DJhPvgrOnMU0Gc691Ym5uQeF_aMwtn6AaauBVr2Qf8VBE89wz1-sxk2vI4nJQasW9Q8wanqw_pSxwMoc1XzG8MY2sNRRREt1Ex1nmR4HjVdDSYJmbnqYgREPiJ6zUTcJkNJDPQjo70R1TEVjV8v0a97Ca_Fijp8CG2ZcD4VIAM8kOF90RJlUeG74pCIscB6AGi7tmgcO9a5VdHfeeqomRlFKVN9Ipi255SZHpI8JiUaUGwpP71RE-JHo4baZ-g7Z6i_UJsh6-76LKwL3HWblb2shAazc5cqC-ySHohWfY6_zeFD02zeoVN5vr-SaoQjzywVfef9V4AS5cs8BqlIJYP6Lrzh2AC9GwtSpEw22NVT6rR4VAIbER1HuGbwnNmnCDwHaOoyzFwkVGU8uohlOG8QYPUnLLfl8LzKv2O6SLlwcdZA1Fxiftv_ziMA4qo9Q3NQ-bblDycx-oKMWzSAvbx4LrjYBOxVn-XErq7B7K_i2kkK1C9Ttf63zbemz0aPcdhXWiEnKArv-q3kj5y21FKlmI4TwZuTKV5xlu-mOfXXWmTZZysOT6my6xZXsNOa1N-LiigdU_NV6gYmzV1Pvk1dM4lyFjr2SM8jB6hA_Je4gyhMfwqdDlFNKRJGG5SYGylYGgrqoF9Q8P5ZPxy-rzmliE5nIYICZDRw4GixtjlXElu1-voa1qlO-jB64-Q1pjfpY6FYNBdtVjFYUiMoSdaH2qscS0x9y_TXH-qvHmrn0Rbr6HTcaUQWuFwB05o-hrqpqIXnhsYaFRFo9mIKqerSdYcXWV7vjt66yOBOJXGGMdeUKEiOhoEBRzMONTaGzyqnyREpKAGKGnnAoBxiDJAPJiU7Un0A.N3e3RwsHSx7RIyRpkArjhg; __cf_bm=.tgjY3xPUqb8neZ82V8alW6BUjaUjhl9AKSAVrWvCok-1746580754-1.0.1.1-O_ArZyOJreXvGtcaHIt1cvKmgvmfWHl09pqbT9ThwMTzN9eioOcPFJty1P.ubiZc.mnPMaJD3dRXzkhbHNYVBPuRgzmujyF5kvUs0hE6LJQ; __cflb=0H28vBjUqcdJN5F5i8D3tzftjvH7q1fnq8Q2Zh98qhf; _dd_s=rum=2&id=a8178f5f-ae08-4826-bb1d-587e16634b15&created=1746580754683&expire=1746581708921; __cf_bm=.17Mcz2Tr68P5cveYebWZwneOqCeN0Mu3Ad_vzuw5hk-1746580754-1.0.1.1-go.48gAjI1dOIK3MfpLAfmz43EA.5_Wh.fxObP8M58fbQPeP_72H0dqO20Ei0At3Z5RqbNqS8GED9zvJ1utsNqmWHJLtgG58fLvIwcXkgTI"; + + const OLD_COOKIE : &str = "__Host-authjs.csrf-token=aae0efad45e177963b2ec92343e42fb5edd2e61600c8d5f986caa1aae5a18a45%7C88e6407c124636cc2a5de4fc557c6fbf4cb77e6876cdc798427fe739a689570b; __Secure-authjs.callback-url=https%3A%2F%2Fsora.com%2F; __cf_bm=URhSdu4GaYtMgZXeQSJUHHPrp7ncob5oLSBZVYJqUBc-1743622054-1.0.1.1-ucpMZjhlQwqfwqRcYdDCrTrfN6O7qai6OU1Fr4iBg.j6asF2FoF5Ih0f6JETLsLZEkNmugCqLVAsh7tERScqIDckcN1NT9.ARPjozqgsjw.MEe7SwynVbQCw5TCWzSIj; __cflb=0H28vtyuPgbsDEUozcoHSWoAnvpSqAh9Qx421HSZDQR; _cfuvid=3fRsqhtWJn06LJpJsT5mBtjFDIy4Hu_IkUh.xGZWqhQ-1743621009366-0.0.1.1-604800000; cf_clearance=eVjck2N7zn0.mF9XP94m.x1GyyVSYgp_F.UKgaVO20c-1743621009-1.2.1.1-ivofMJGm9YOYnPABiwCxRNOwepOeiksOUI0pBj4rGGV94RDDUE5x1b_bDSKKtkW5L7PuTxRXOK5_yjZkXfjGhMfrXDeJhiXzIJGGN3p_4OAWj2Fp0fNhvV1rtALM7gd8QgTs9iBiWwA9GmUkZIdZ9._QhWx.v7cfuVKjFv2U239z3CzjvQ7RmrTDrWS2B1RCShW_m4RD6DY4QfJZSdp0NKQqcnzrc2nzBHf19SicEo13KU7OJ0htu2MyV9XY72WFXfXy0rUJGByRgB9ebpoFUA7is7ms1.5gZmUGHJ3xfdxwhACR16r4PV4eUBmUDpLNbsw4zrc7YADDJPcujclnyM13KAo426yPsA.N1NeK5TQ; oai-did=30ce2582-2117-4941-a891-c417e3ff316d; __Secure-authjs.session-token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwia2lkIjoiYWNBYVdTWHBfaDZnNkxtUEs1S0RvTjR3NHFxTEsxVlpSTHA4VWdiaFdWNjctMG5pbDdVVjBCN2dSUU9Bem84ZXBjMVFjb1ozNGxwa0c1OEUyU2Q4cUEifQ..HCOVM3lniGpAz6nESjnKfA.nUde2F0QzNHHKj1pozvMKXvAd-YBSWrGW7k1TGGmyPYqDivgKxSZEcCYH2dvtPgBJ1M2dkc2RToDHdXHG_H1GL7QU-JPoRbf9EyfXfNf9KFTI8ymYT9sBvInIuyxzXCNp8fgWT96BjFjnWG-HK035XQhfsGp7ZvUlLdzTDibxo17xzALBYOKOwHGROGFXFZFxrA33m4_D14GAfxQ_YikvKHbnwEd6I2LeJhgrlrftn7XWTOvyzQwpoWQjSmVtvHW6el_wrvUG5ixZR_0IhMOK2lstQLQrqAv9VhC7W4v9OybqfHaSWZ_uwjjpkcP9QrOyPAeflcNKvIz_DtD_2VkoZM-ik82EZvycueEUZG0bBr6SX0T3AU71vQHQdqVrS7vL8As1rkJPAyBBLws5Os6xelij8BaG120lUmVeg0mRLyHWiD_hJk6cfKsvvpcV9wsqwaWs1PDB4HIIhYM5JMGg-LjtbMY6esFYIGibwGaQa1Ngcvp4JPW4vGIU1qzZnaYSPSdnDMPwDSYS4bZwWnYYi_7WptDbUTJ_wzG9jnUQe6fdUTZjFYBAOWjZo6ip3GeuSEZPEimlYWtCtwIsx42AkMneu7sIv2n40UnK4L5cPF-Hss9jvgm7qeuk6A9_kRS1x7owJ6H-byZiD3mIQ4UtTpOHHk97NK4lSpDyvAFIzh54xQzQiSXHcsRBC0VQ6UnLYm3CUv5d_ozv_A8o7klMUec7bG9LNIavqhEdvwxm-RuZ6VmiiDaOFQVjD4Nyid0WVjUOGjQqNJE_5nFhujw-Ob_HSGYZW0Zp1tZ6qlFV1QR3xUyo8jg7KJpng-qcNfNDTBr7AzwABZ7-3Iknw-dJIN0NaHhLYBBN36ZTNjwnySOpKsHssFWutYuqoUGiyW19deoEIBqfJ_d9SnAefTSbhKSsuxolvtdGO7wqFeq7pZdljmRnzC0dpoOLe8DPSYprnL84mDmZnCvanVObSRJW7R3F0Pf5hsWom5J8udvK8dNJhPwtrmPngnZv6Bd0-pEJhQw47qcX6j6emSTLEkAivVn2HknTMpwAoyMpairnOY4EZ3T9ETfGagvtRU7RcdMMwWH1NZ853lzkulHtMGF_ut2r83yKNi0M-ogcQVZTEU9F8QCkEWXHXO-x8aMMlG-axzkkZmpmrT9X_1YRJfQRQA18UyxdSIOPM1h3Ol6qMddA5SHF26p1u4BxE82-ZD1kDlilGNzr3El1ds4jQryQsWWBV8b6wKz7332CSTE3lQJLcoGTvLhBjFgSQv4KgT6bVEFzzBXgP8B7YNzkXTZL-wTHKi9S8SjkTDxASLNmjigCSlf15O8hEM9VlGkO-vF3D3E7uTl_ZSevyF5SJdjgYRL-Q3ll4lYBDQU9bi-tpsm9RgJ8CnH_jAMTiwNdzHvokqGoiNqQX6e5s2KSGMS45j41iXZ-A3MwahWyjWgzAdszv-uETxPGqndWXVoAWcj6NXQkPPufPolFkkDHJwL_Xu6Ol5AURoNE7p0qy3dCDtk621uruLOJMfV56kRR6cS5LZ02mc75Dnjv3zCao8qWt1rmPdHlAiRW7JiPbJV__Whkm1bBxJl2zkhJquKshu4ViUQCkkt0OEMnzp-ZRCSYhZczXlGGczD1iA3BzLeAx4OO0baPWwYxb_7_B0sN4hL4L2zOqBHu8aUyyV1C8xJJIVAefWxbppUy8sGPvL9mVZjDolJ8MZjzCJMM2Zr8si8TF9nfrL9AT3wlFUU04GhknBAbGppBYKnRC7-uu5Z5MLZSD19xxNCOrwcPwPw85_qCz8h40NJSlkicTqxQ7yjkV0x_qDjIUgqfUISFt1W5Dmk2J6zxWLpL2cgFRD8LzDgSnNPtvgCXia0rOTu28aIstp3QyPtIUplwgbyHZxAw6M_HBjoS6V16l0qMdSTVjhbIBMI_piKVXDrm_8Pcx0PGa2Z7NSGZITXPddQLukShgraxsSUQtOG8nLmULnTqBKVLUgK3_VYGOEEMkfixPnmiSEuWmEg66P4_2wi1MceFPlhzxmUWthRUygZ0u9iRzAVUvnsFJ5t892_4QwhYlMlGQTESkCyeHNNIUhEHWl1eGDTRsOAk0cL5EnZFAR_jZHxap37zvn4UV-M0XyKcWluYLWGl9A6mXBpFujt8x5VfWaX5MJugeOIlvBL-AwDaqhZ1ekIWJ6AWDKVCJoP-Y8tatndqaSRYxPs8KMmdEkkMtLgSkeF4GHe6KxGqCYk9gCDdoUD-9Qrte3p71lC8qtb28FlomS4cq0zTt7YYor7DSvA1RIUCcKo90s3rmfyIs7riAviDaBFMGtwKhrkMY0LR5AX0LFFyNncGHgWNhPDHl7KDcKvFfSF3Zs9VcSEdfPOZdoGV0Bm28-Md_gtNEtEDdI52-y2cQmpWcaIi0CNM-ebnNvRXbeq-KjZeCPgDwyAbqEjBbbj27o69sQcSzHF-GWw12s5Qc6gx9y-tZ97Xxc1JadcHNy2MXqFX8LcWMKdgQJJJZzch1FXqZFgqOBQbTPvMnFZZDJ-vIAHvAavn_SE-pj98FUghAeF_Mu8xBW6aBvkz1iJrQkP1RREF_oIq0hy_2fVIY1LSrlwre8lCnwgjSqgPIpwAKpl2FiG0YeU.JTV0M3rYnJJEs3ETueQc5lbJu6yfercLCVcsFpuhTQo; _dd_s=rum=2&id=e4297d4e-7494-44e7-b1bc-44c9c4472d8f&created=1743621072209&expire=1743623584230"; + + #[tokio::test] + #[ignore] // Don't run in CI. Requires valid cookie + async fn test_generate_bearer_with_cookie() { + let cookie = "oai-did=bb449d6b-e6d1-40c8-b5eb-972b110a0918; __cf_bm=JqXYK3aRe_0KZb8vsSijTm2YJvpDV33oyEQpfcKKKKI-1746570472-1.0.1.1-eMi1cn_XDBxJ2u_UqGql0BS5lC5FXwSxZCYjq2FR_AL38LBllAuKbWS93THhE5wjn3YwGhifW2fa0pAFEHIV3eZIZuH5oFSb.aWOqmtYEsw; _cfuvid=KbIJVncxt0WZo2fG_tci8JjgnxJBfcL3SxlLeP9OHE0-1746570472074-0.0.1.1-604800000; cf_clearance=mlWui.52Enn_gRDTISC4Lx8j.UcsRlKo2dfFd15bzKw-1746570472-1.2.1.1-MblVM7BHYxZoTmR15yY_dEyQhQJQgA4boTjDv25pG7MqKAOKh2QwH_8QHG2er0mTxrOfzOkKFwYpKD.DPV7fPp0fUIGVmoi.WwDVChrwdsH8kgnMP3g6w19TT5qmgHc9gMmpjsoop0mRjPbmPEY0lrKvobTvXAclTdPU87dBkQgaMQbFb5M4qDSi3XN2r.0aYhbTLdN0ypR3mx9Jl3Oil5HThUGNSy8rhqDis3TwtaoWu7GoeaXd16ubHX6vmEMnP1UZJUupE70WcfxSSGDMRcpkjePOjsHpbTuxDGziEdHHwODhQlrriXCp_LTZuCwiOPWNyiJSHEbZSFieW5s02S3J4smBp6qpSH_MMQgAh.0; __Secure-next-auth.session-token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..DhbnG188knzLnuC5.yyYzvDaawyv5penH9KFwivcPeEuVX1xkaIAKGhT0aeW-e2B1lqVK8uiHmOpba6RNsBuJSrJ3zGljBrCwGeeD_vCLye53SH_1wqUHg3BrsPbGFozslPHr6HBDM2RF4fMYb-wNxuHoCb3QcRfZWGQbaxwoOlL3MlUt_ZZ90kH-_Kut3tGhWM82d7x2YSJiV0Bgci9AjkNPNqKQtod9q1_MCmW7E0Hos-09CR__sGp6GUBsdMHR64V8zDbEpg6nFU0--6p7kFoJ3FidbXGyiMlqeMi_rrr3Od03S2tbUJSTUguxA0y8SnwpPZ9oliQ1Gort-EbRRnUYG5VvyxMptiJXmhnz0AJ6Ey8d4BY9WtnMo9HcOm1wUF_7tZ14oTZVdtn6oFIQvo9xSa4aoV19XlvMCifYyYnqtOS5CYq5xzmu3BUJvfzsBIafKhAaRlQg8uyI9zaYZ3JpXx0Aa_8MGuUcOuDPksoaFRHYiy_UoPPzDDJBCGd9ymiBS9CE5TsXbBPE01IkvF32nWAaC3_HDUp_qEpksP5WNA29akLUFBjYL1WXU2aeGmDHSeDqwwI_2kzzga6TPibj1HDRXxfuJPf58cYtZ0jIjLYAA77LgQ59QGInRQRr6rN2TlbEf89Onkv0NWRBqXzZMu3qr0cj17m1Smq7HaLCgcRJSGdGR2LVKZpcZl2V0EuktLQumVgmZLSvcKqpyCk-zZkGRtLBqMpnsSGa4uC8wonoejGt8y6TQSdjPoTTWDynjEH48bFF_aXLIxqF4W_DzE5M7ymQ31PdxKQ1Rkxq9A2v_igOAAb1uN5oE8TGcFGM1aWpuqX6fEKsNMS-hUitTq6aase64uSEXUWWTMFKwSNhJCGOIo1p3zsfznjAxSYsEUYy0LiVPv7SpiDCcYsIsFxZ0QksS9i3CcuFKCqWPRNkSR_F0B2juQEiAVTHBD7ugbg0j8D2y68xGNMR-HrNBHld3k1LrA3pkz1m28dcllhL-NhZ9Izm98kvY1Sj3uGP97d9_Q7QG0ce_93xXPDSKhrV_eTV_BKkUSNN4uUBHm2kYiHK3_SOfNUlFHEGa7ANiiNhOwWGzj_lAeAbN9Ab6FEPvgyPQS0BEXDRu5tey-c2KVPZJjOWReS_ZUBXEy7fNCRAH5zsO1EeD3cjlv2MIH-Gupp4YqeZWW1QJPvFIlHqTBLvturXx9lteYxweeWH1wWedr-jbJfjp1csSF5E46WuW6MjfNPWNBYCiBg_Al3876mrAvag62bRgptUFpZ2m_VWVdENA_rhyL5ruyyIoKF6war6_y0oGlpPY_AULFeK0-thWtt2zB_kGgNt8HblwnskOvta1k1xZt9oXTMG9dlnlVLn-155VEbWnMF8wtFYcT7By_Fpu_BfCVyjdg70qk8YoibUnk0Jv7L2GHfmlPejQlhfX_xZiZ9zjC80Gs-jnQyV5xG_7IAUqYZ6BrCzGXyk0ZVLKC-gGWAN_ly6N5Rs1G-X2bEGRmE-Kv8JcvSyK3ye3T02ymOzIml0qt37RGNzOLry0cPi0GVr1boeYAJmJdBpT9w5PvuoXmnoMF5yhIVfA3jIxnNjJ7vkUaLOsKT13KOKtpEA0nuUALyJ4UmIQLZtD0j8xXgMsBolDTnt5LRbS43PrvfhtKm9l0rnBJ2Ruja-gOhUmCogwitZlfmrwJGa-2hf225nMr2L0u6biBaETWFwOQ-EEseOENEhqTbygTmdDMwl7iMbFbn4bgtj3N9tpfO-mua_e0oNWInEYvrT93qIKl7UIZegoxqWhN9cMbjsHd2I5PJF-ES96tfuVDYgoo0wQ5ZY61ZbNOh04lqy7TDYjLJ-YfXy01rU5z8X3O0sS1lg636fJbpJymYhrrCMpzfNMaw3veRdTTUzcLnLfakAlB1Rv7ouLX3XJ76cUO1PezX2ukTNDaMz5ls5b8OTFh7XnOTYT08nKHZOXj7w3_udrgPvLr7YZAf17N6BosQ6FZu-AyBtIZtZwaZR24JrBypo0Ny0pc2erMjEn35q2UgB1vgyhLADfvV2qzZwNSw2ziwFyMKdw_XE5ZgapKW-x3kEwhQJw2-B1sJcJqg4uf4GZcDUE5odvDAMtbziTX6b43VM6nVUlCTRXNznwt-NQ-kWoCSEna90AtJNgBbBzZ0BXPVNyps9B7lwNF9iQukF063pVEzwReYBjlqkSMxTSU54yN1TLWdhYqgg_4BAQsXsCdZw9PwY5n8vk3j6JI4LSiFPHUI_Lok3hNHK0gV4wdOgdoLTing4iHWRyq12ZVyS3p0PeCKNKLP1ZMePG6pW90GlBo1QdL3AbukJOITy-n102UFa1udb0GHtOfjQVzDGQ3hhUmka04H0-LexyfEi1Ee0HijvQFbMaWF4-Uzpzlu4__u0PDD9Kzxhw66NGFMaDxKxAYXiXEcsmxH_ptQPHOz_i5A5mNeKCFoeSl898T0mxPg4DRwB-R7aRzHgnUiputdJ2x5TAsQsMWB4XqfAVJqEe0ORNXvzezRPKiL1gCif1tyQj6acm1Hull78zPYvzNC76p3srFW2Ohg7W3tM0ODuLLx7E5tnmzAhnIWy_fkdcJIV8gvommNkBzaGWut5c_h_f6sP4WgIAgBi6Z60YSjMaflD6bRQumHcZl1dMi8Gv7j5BDPo7-phJIqpLc1uYzxMZ-IoAcSCSvmhh8cwsqONDj9muvflfYAISA8UL2ZVlRnHI_l6E0I8RXiXWt59u_UeAv9FdRtcmoQ85wa5axtUaJ19RkpmPygiH-A3xNB0EhwfOgI6OOrJL8L7EWDVJhViSYYCGVW5ZVOGsYyT7wKHqIxbrM18TJkJ4XJuHC9rUowmWLkq2xkbfmRpUQIMrvyB8HBaWmkTA5rZnqkYJDr4Avm4SOltFX1g4Dv-ediVZRtCMPYz37olezdDfBqPchQfYWbp4Bnd5Uy_Z6Rs3ciF2-Z0HUpF2i6FxezCIR7r60QRoWah84PS_2-_7hfhQyWUoQEuqwntI9y2jp77bWULIaU81g0r66_Q8KSSdwnxmIFk0OmhQCUN4Bsh63UDyGQ0AKII29QXY7lQU8E0kAyfyL2kTthsIdo4r6ilMQU9aZVJUC2Om7wAfQjZBYzyWNHdphyboGmSb5J2uKxPhE-whc87ZSfJ2aiQ4FiWu112SpjZSQ1pmrM7NDUtsKYZeUvZ85LoSKmgwz7oy2PaW5q786s_nAKVDAHTy5JeZbtWN3pI8CGA9kJakz1axsTBis_W8aJ3kRUyxgUFAJZ4C0HukYxzr8kvL4e5gjKF0-EvzWmN7TIWV03kzbh1-vg7X_gUsijHL-SCDaZMKqOz146zVIIpS-xNjr-7lBfS-AxciI6JPlncTgLntTxfuoP2QavSBc4PZ_Fbn_Ko1UxvQQhJgWYkoBH2hUpIvObn_BaeT2PLbVcht4GhUgjwkDxpV14u1RLDqyqOyXRyVqTVO3wDtmSefrMRWze0Q2D-XW5JUAHOjvuU-AQ4MdIV9LiVlQAOjlIx5e56mVWXshjmcCNaG1vHo62yLZj5Iz0kf7SjQZ-WZEzDbpPXUeAEsCANQDB92l-sRUVTdg.DlCfPqTSppBQoodvYnNh7w; oai-sc=0gAAAAABoGoz4VyDlu57UUFNU7plsIHZekd0cT9h36pzM5uCs1hG7R9NaGclCm7hpLmaftSFPPd8EMNOHKFfjF2XMwpdhCCtYVv8N9FQUwCoC49u7I5rS6ozwDiBur0tlCduiWNhx8N2w4onMd3F1EOJpVLheW9XNY23elJ2-3ZcX-3WFvdPIrQdpgI0PRjXuEyPNcGF3IBOsELHuy5EqCww2SibjLF1yFDtua06RUtACm4J9ma1pHGTzCqVUo91FCQi4F8ktbkVQ; __Host-next-auth.csrf-token=96efb8da024d1edf44c144394fac947b9350a05dbbe1fb7c62e72a0e1198b078%7C8cdcb6b83d18d8c4b0bf75b19e9178303fd51b89bbc543a1bbc875ce7bf1cf89; __Secure-next-auth.callback-url=https%3A%2F%2Fsora.chatgpt.com%2F; __cflb=0H28vzvP5FJafnkHxj4Jor5o3JpmAUGCcWNAeV5HsmV; _dd_s=rum=0&expire=1746571374067&logs=1&id=3d0c4c67-870a-4724-9c41-3849e3df0bbc&created=1746570472537"; + let result = generate_bearer_with_cookie(cookie).await; + + assert!(result.is_ok()); + println!("Bearer token: {}", result.unwrap()); + } + + #[tokio::test] + #[ignore] // Don't run in CI. Requires valid cookie + async fn logged_out_cookie_failure() { + let result = generate_bearer_with_cookie(LOGGED_OUT_COOKIE).await; + + println!("Results: {:?}", result); + + assert!(result.is_ok()); + println!("Bearer token: {}", result.unwrap()); + } + + #[tokio::test] + #[ignore] // Don't run in CI. Requires valid cookie + async fn old_cookie_failure() { + let result = generate_bearer_with_cookie(LOGGED_OUT_COOKIE).await; + + println!("Results: {:?}", result); + + assert!(result.is_ok()); + println!("Bearer token: {}", result.unwrap()); + } + + #[tokio::test] + #[ignore] // Don't run in CI. Requires valid cookie + async fn invalid_cookie_failure() { + let result = generate_bearer_with_cookie("").await; + + println!("Results: {:?}", result); + + assert!(result.is_ok()); + println!("Bearer token: {}", result.unwrap()); + } } diff --git a/crates/lib/api_clients/openai_sora_client/src/requests/list_media/list_media.rs b/crates/lib/api_clients/openai_sora_client/src/requests/list_media/list_media.rs new file mode 100644 index 0000000000..00e490ff95 --- /dev/null +++ b/crates/lib/api_clients/openai_sora_client/src/requests/list_media/list_media.rs @@ -0,0 +1,102 @@ +use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::requests::upload::upload_media_http_request::SoraMediaUploadResponse; +use crate::sora_error::SoraError; +use crate::utils::classify_general_http_error::classify_general_http_error; +use log::{error, info}; +use once_cell::sync::Lazy; +use reqwest::{Client, Url}; +use serde_derive::Deserialize; + +const SORA_MEDIA_LIST_URL: &str = "https://sora.chatgpt.com/backend/video_gen?limit=50"; + +#[derive(Debug, Deserialize)] +pub struct ListMediaResponse { + pub task_responses: Vec, + pub last_id: Option, + pub has_more: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TaskResponse { + /// Task id (eg. task_{foo}) + pub id: String, + pub title: String, + pub user: String, + pub created_at: String, + pub status: String, + /// Text prompt that generated the image + pub prompt: String, + pub r#type: String, + pub height: u32, + pub width: u32, + pub operation: String, + + // There are many more fields [...] +} + +/// Note: this request only requires a valid bearer token, and it doesn't require a cookie payload at all! +pub async fn list_media(credentials: &SoraCredentialSet) -> Result { + let auth_header = credentials.jwt_bearer_token + .as_ref() + .map(|bearer| bearer.to_authorization_header_value()) + .ok_or_else(|| SoraError::NoBearerTokenAvailable)?; + + let cookie = credentials.cookies.to_string(); + + let client = Client::builder() + .gzip(true) + .build()?; + + let response = client.get(SORA_MEDIA_LIST_URL) + .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0") + .header("Accept", "*/*") + .header("Accept-Encoding", "gzip, deflate, br") + .header("Accept-Language", "en-US,en;q=0.5") + .header("Cookie", &cookie) + .header("Authorization", &auth_header) + .send() + .await?; + + if !response.status().is_success() { + error!("Failed to fetch media list: {}", response.status()); + let error = classify_general_http_error(response).await; + return Err(error); + } + + info!("Successfully generated bearer token."); + + // Parse response + let typed_response = response.json::().await?; + Ok(typed_response) +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; + use errors::AnyhowResult; + + #[tokio::test] + #[ignore] // Don't run in CI. Requires valid cookie + async fn test_list_media() -> AnyhowResult<()> { + //let cookie = "__Host-next-auth.csrf-token=ef2192abb44aad120d2bd0c67ffa156661b3a971274bebd5822bdfb45d414c38%7C05f5966f7a8c00637388b2c135eded2502ff7af04f986513354123f2fac31a24; __Secure-next-auth.callback-url=https%3A%2F%2Fsora.com; _cfuvid=oD_JCwm5zaQGmsQIy4XjzEoGM276yori_BR0EOBEEFc-1746571084534-0.0.1.1-604800000; cf_clearance=jHTsbECz4hIp7a7acOilpM5iKqbRCOcCMIiy664dbPo-1746580754-1.2.1.1-xNvHlmDlSKGCbed79EyntnpCU7U.dpI9qyVYYEW0HuXT5YKQoWEepQVbB8wMEWB0MtXemxts5SfVHYhY67uESdyN7yIrEP2LPiQ0L1tTdsjFoOvsJGURGdqRAiUF8sjqCdJ1Gfx8FpGfbmJUUYh1I.wV3c5e4zcw2M7Xs2iW6wSA4kvmuwZDXj7H4oPgInwhp_XVXa.jKPHPnNIMRoGR7dirbYhY6wcywUk53Uw4j418ml2Bb_93LWlhzVO6WVPfjnKvWc7vxw1_cutXLy.IqClatx17HXzbCISOVgOcDeGITvvVgbl5OTmXbBlGXtmaJlxtfjsFbxHJFgX93zFYq7pt9znzbM4DPNSO57A0eP0; oai-did=feeb4ee9-615d-451b-91d6-35c9b9ca9826; oai-sc=0gAAAAABoGrUiAr4TfGAcKlXSjhT1h5_5UYzixku3R9aaTwvYNKk4lKP_Yk1OzPWjJL0J1CwOzC2Jb17t3aRDM3z8kxH1WOAZhIdnTU_u2NBgkfQNUQhzMBR_Qb5xHkfJ4PFUpwO9n7YNDtSoN8QVP7U2hbi0GJfMA06k0kcaceUmTvbEK6Uceq5R13QVhTPfZC9JdEjcgXorTcq7iQW9mQPJYfVhUwKgWbtdXgwQROAM51Zq4ETutVYZklPhaXy0hHs1LZdM0GRd; _cfuvid=tYpYCaVU3akRC7xqJ9HhgKb45yUMSDkSkk6zoY7wxbA-1746571091829-0.0.1.1-604800000; cf_clearance=oAW.3k9.ail0y7zwjDtsUlJIC0OAd5NllKMUq7r6T7o-1746571092-1.2.1.1-N0zV9i8bumZPfGDsB3TBpE5dIoU51SUzUvjsycWn8GmGHo6v_M6EeuCIEg9OIfAJEeJWNvE6mf6v0wOZ4x2FrUPBEc06NfnQgVb9fkIE5vzUsukkPX7jK1pigl7vtSiqDmFKyvcg4ojUUUEHuHPZeXDmiDE8UdHzLPSrMaANI6V7k5cflmlhnho8vgnFGpkiHfOArlfABVTkHWmTQo1pJAuQxhdSM3hMyxpA_6VHeQC_fUFX8Rk.dSwQzx7dCV_TCY5PKbOvfOd6X93sPSaQWneGo9hrDOTlZxeVEGLfpd7WngdKPS_fbjf6nDmwHnMtmKPMZsd_BLS.b3TfkmqngKPG_oZxWxi730a5NJv7O6w; __Secure-next-auth.session-token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..UtJZGQCBDAEdkNRL.h7_eoTetqxFXGt6RmB8fr5JYaJPXH6vvcSMKG05JDGq5Nb3TSNXHfOIYdY5g_E-7xUgsPJnoE9RHmUZRuS1YCA0UW23cYmwHigGU89KKcdtJqg7rpcgosIQ2UTLY7aXe1WZBR8-3ACI-auo51JzMrjD7vnDL41k6UgGr7tdGoieyVxqlB58ughkcfIyneBm9k7VdSTahgD75BQRqWEtruPoAMCir5qtbu5bnDyAiCjT4S02gK-kVfgGlP5_tUTT_VoSQ-YMGE0Mw3Iae85l4X5PjuBj_3uhRlKEL6aABfs3KKr8gvsb6xGP_cypWVaTcPMpeFidLicJV4GTbj4x5ncGdJ4E9WK98j_fQRB6tQnF8op7UBuh1zOdKUFvmrKof4DgJm4ChPeff-kR_z64jRkZfiDdu7hTwEx1VIoIcXiVjBLg4MJAG_79WhETxiz61-HMrsUpNgRD3QSH6H6j4X3HpPs3hgiBZnd0L0WDOQU_mTcKBkvpm3Uubcatv5Gss797TelMs2cM7J64DDz88tMzChyMj1mBPguONIWlrj7G5pYkC9sLY5gyydZoK4cvfcxkAVgWa-UZJAtNwpf7XdYw9FutjW0Y5hucMqch3J2O51wVx6KltKjycLXGIJfFydq5qO53Ngu02WEz2AbBfHsdBqR2S-46t16LEqjCfWzJRuUOe0V_Ao4_lqVxgjc4Nd97pjaJhuHIFjWVY8GLmh5rqVUDAiDexEax7TLF8Htp6BBBdOt5-j772SbrvjCD8u06OyksGLIXElY66k4RMGsyzJLDxYoNa-c7E_38WtQJsPU2QH8kYRkwP8H_QYdYnOpukHVY-2Uv5AhAwaP2q9zqYAwPGyJ39mWQq0NrZ_rGqotRz9n_3W37Sbu1eX_5Ae0Vn33BuKG3fzxMU7akSVmA7_4DE_4dQC8PpmQ9C4UoLYhxg0aKCs4KVOWjpSTkLRoehiB-15R-SNHwNGKUMGoFg9B741jb8SHjgYsAVJnvj8VPPDhFm4r4rSqv-IIN--iebrj3ExC6YqBJmwWp-ZAvioSElJfvqZasoYkRKm0CjLoXuVawCcHVNngK0U0PjEFMKkveD9E4-MW2KO2uhud2OShZvXs4ge1Fvmk1yFwttmVM1f7MvSEoAolivtFj623iqlT-Sw0zTSg1Dw4-fDIfPk24r_eoaZvooP_r1sJ89NDDmPJDmZm_oavGdkP_2CCEEPaVNq8m72Mu2K2Le1geyvm8vepT9OR_5q3MDypRqs4KLruzrSqhfDr80Z0FMJHQGv-uyugY9yroX8GFtTBiXdIbRBiGquLgkOvjuw92JDTCT3HtMm5liFR4tGXbcsEKqp_HtK4cd1krKIphq71LKEhklkSXC-JvNzWS4qYEANdBrNfEaSa2e8-wtzW2qIuMfOZT4Cocv1H49cW7SIB1_5-DG3dy--1s0Wf6JniMn0Cprq9h01A1hraFxpSax3gAeOehYCLk3soPu1r0VlbXzdxTFg0S9AxeuXwj-IKL39C9gpQSkkeIsVeSL3VFlzYlpdupZcNbFLlp8JMoIlOnNaABBFPji12ln9dkeyEEkFq0JL4UyZLZ1SegADd0l7s_cn4dtYYfuIuAAJFC16vHDJ7UMVfVMhBeKA96U1w4ak-uKs8wk-l5EhGragNpPogCh_ccHyQf9zV3AnHbJyGsiVc1ztjNu6mduFB41FO3kQDBH2uoa2PgdgWtaO-zLSrTDns3aGffp6VWYddxuPvA3T5VQDVjVLz6eJ2_d2N0B9sPmevKkIYEaI1-OClY_StB4TG3VdNUsfcaqymweg43scS4DZBa9DPGz7ZmC-HG_z1oVfaoZGfa5-XD-u0Oyuu91xYM-QOuccvsHco2GWUTsf52qH51mLvkpc5AkaO9tUzhN0rQLabOGSUBjJZyxIFrRsLLlDWDwPSr3k7BbB1fBUvsUiPShqI_CWKJjfqsEiHjrt3MbfcbLc12XqYuvrCHwtpi0JRP67TwnUKADP7SEP0FEdXz34prhXpJSvK49hmpUkKYCbsdntJFz4J6vB7BKLcb9B6MsWNH5N14yhn35UJZc7_IToLLnjG93KcOhSWNvXtP9vTXRVmxIJtYfnZOOI6wsTxZ3WQcAypxYA1PLzJzlVrZC2TkT9Jhcph_Y2uHCKm0CFka2nr8DHF2VyrEo1n4x_bjXve_BZigWgGgFE0Vn4NEUdLislf0jTXcVhTwVCCxtYGPkKE3NTpu-Djl3HjPUGRYFC3dXa10HWJiazfOITTUO85LEpVRb9j2lhQaX9dKe6toPipSpGwzsdkOaw1liuADV9J8ssMSkhs0W_CLKd2HaJioPKUVUJ0v2B5Z39DiLyJ_NDx6vZoGzO8bRUp2Atm_z3ccrsaKuO-brZBmG_gApoXLQXdBPwovyS36TXx0fcdqcFlH5yqADtK1Yn9Zjkp5gIzbPz-k8nUtBtu4_JOayzFJpWGheTrkoj-nBIvuOfQB1yJan9BVZPF-KzALb1JdTC28k0kK60Exf7r2lulwA_3_-Ydg8inZ1HHGUYouAb6Na1r4Pk70g4waTzLItIbDPOeATPfvV9xRJ7LT8Yq-pbfIg1rHKRjrCcQ8IeonjziGxfNnFiPy4ANLtg2F4Z_dwi0BPE9MFo0yO7gKELGGpGoOB4rcd8mCK_wnm6MyBbdqC5oftova8ymTqjqNEH24L9DwCk8F_jx8DJhPvgrOnMU0Gc691Ym5uQeF_aMwtn6AaauBVr2Qf8VBE89wz1-sxk2vI4nJQasW9Q8wanqw_pSxwMoc1XzG8MY2sNRRREt1Ex1nmR4HjVdDSYJmbnqYgREPiJ6zUTcJkNJDPQjo70R1TEVjV8v0a97Ca_Fijp8CG2ZcD4VIAM8kOF90RJlUeG74pCIscB6AGi7tmgcO9a5VdHfeeqomRlFKVN9Ipi255SZHpI8JiUaUGwpP71RE-JHo4baZ-g7Z6i_UJsh6-76LKwL3HWblb2shAazc5cqC-ySHohWfY6_zeFD02zeoVN5vr-SaoQjzywVfef9V4AS5cs8BqlIJYP6Lrzh2AC9GwtSpEw22NVT6rR4VAIbER1HuGbwnNmnCDwHaOoyzFwkVGU8uohlOG8QYPUnLLfl8LzKv2O6SLlwcdZA1Fxiftv_ziMA4qo9Q3NQ-bblDycx-oKMWzSAvbx4LrjYBOxVn-XErq7B7K_i2kkK1C9Ttf63zbemz0aPcdhXWiEnKArv-q3kj5y21FKlmI4TwZuTKV5xlu-mOfXXWmTZZysOT6my6xZXsNOa1N-LiigdU_NV6gYmzV1Pvk1dM4lyFjr2SM8jB6hA_Je4gyhMfwqdDlFNKRJGG5SYGylYGgrqoF9Q8P5ZPxy-rzmliE5nIYICZDRw4GixtjlXElu1-voa1qlO-jB64-Q1pjfpY6FYNBdtVjFYUiMoSdaH2qscS0x9y_TXH-qvHmrn0Rbr6HTcaUQWuFwB05o-hrqpqIXnhsYaFRFo9mIKqerSdYcXWV7vjt66yOBOJXGGMdeUKEiOhoEBRzMONTaGzyqnyREpKAGKGnnAoBxiDJAPJiU7Un0A.N3e3RwsHSx7RIyRpkArjhg; __cf_bm=.tgjY3xPUqb8neZ82V8alW6BUjaUjhl9AKSAVrWvCok-1746580754-1.0.1.1-O_ArZyOJreXvGtcaHIt1cvKmgvmfWHl09pqbT9ThwMTzN9eioOcPFJty1P.ubiZc.mnPMaJD3dRXzkhbHNYVBPuRgzmujyF5kvUs0hE6LJQ; __cflb=0H28vBjUqcdJN5F5i8D3tzftjvH7q1fnq8Q2Zh98qhf; _dd_s=rum=2&id=a8178f5f-ae08-4826-bb1d-587e16634b15&created=1746580754683&expire=1746581708921; __cf_bm=.17Mcz2Tr68P5cveYebWZwneOqCeN0Mu3Ad_vzuw5hk-1746580754-1.0.1.1-go.48gAjI1dOIK3MfpLAfmz43EA.5_Wh.fxObP8M58fbQPeP_72H0dqO20Ei0At3Z5RqbNqS8GED9zvJ1utsNqmWHJLtgG58fLvIwcXkgTI"; + let cookie = ""; + + let mut creds = SoraCredentialSet::initialize_with_just_cookies_str(cookie); + + let bearer = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS92MSJdLCJjbGllbnRfaWQiOiJhcHBfWDh6WTZ2VzJwUTl0UjNkRTduSzFqTDVnSCIsImV4cCI6MTc0NzQzOTIwMiwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9hdXRoIjp7InVzZXJfaWQiOiJ1c2VyLTZOeEpmbEFIb0VCREp6Wmw5aVhocWJERyJ9LCJodHRwczovL2FwaS5vcGVuYWkuY29tL3Byb2ZpbGUiOnsiZW1haWwiOiJ2b2NvZGVzMjAyMEBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZX0sImlhdCI6MTc0NjU3NTIwMiwiaXNzIjoiaHR0cHM6Ly9hdXRoLm9wZW5haS5jb20iLCJqdGkiOiJiYzM3NjgyYS1iZjJhLTQ1MmUtODFiMi1hNDRhMzY1ZGMzZTIiLCJuYmYiOjE3NDY1NzUyMDIsInB3ZF9hdXRoX3RpbWUiOjE3NDY1NzUyMDE2NjYsInNjcCI6WyJvcGVuaWQiLCJlbWFpbCIsInByb2ZpbGUiLCJvZmZsaW5lX2FjY2VzcyIsIm1vZGVsLnJlcXVlc3QiLCJtb2RlbC5yZWFkIiwib3JnYW5pemF0aW9uLnJlYWQiLCJvcmdhbml6YXRpb24ud3JpdGUiXSwic2Vzc2lvbl9pZCI6ImF1dGhzZXNzX2M0VXcyZWZUVE0xT0ZBZkFoTml0ck43dCIsInN1YiI6Imdvb2dsZS1vYXV0aDJ8MTEzMTAxOTY3NjEyMzk2NzkzNzc3In0.JPi0OSP8T27JM_hYffctiWN2MaVVNzE-kQxbhgdHFhrirjQvNdIAfYbP5764-_3UhtM-waNNsLPx3rjTwHuDxC9319axCpCDvNJg55JZMEW-4r-juUecGFoF9VS3w57Znt3mXAk3LyB0adoGZq3Hs6Za6bqrTVyyil24iIj1kEapK0uiDKf4jW_YKR5R3ZV4igZoOAMglKSEIiQ2oR2yGLEj0Omi6XF8awe8RQ1IlAkvv-67fvUIFHU9lBHvRFgv6ktEUJSIz9FOCUADgGTUKhqXyvlT1xVeMcjifi0xpks39gmLcGhygVGsOHLf2j7DRCuSuYDd8_xqtsg_Qc58iMHwWuEIRaoPiTN_PMkT4gncmou7PnoUgRQMlpojGwMH8fyRSYsgFcg0wC0Q7QWlZvZCEMt_w8jhXk1Hn8k0wIKWH0L5v2PN6i1oDVjfXPXDq9X0Mp1vqgMwal_LOwsKB93SNHdtvzUD0ZCq9Nhr-eW-6xOF1YT9g5j3-Dk7cm97lJjmYlDEYBlDGCC34D5IBlZJy2H3Ojl68Ff3DYGsWsDiAidYAmoj2rUXio3got06l81L45ddXayrqMA9BizwmXjReJcnZZm7sCGSYTOBY5PeAo_I0i-TZe7sXoFppsO78KozLVrtfoCa6hUPlX0psJJbnbaI_IJualCuByR8gxg"; + + creds.jwt_bearer_token = Some(SoraJwtBearerToken::new(bearer.to_string())?); + + //let _updated = maybe_upgrade_or_renew_session(&mut creds).await?; + + let results = list_media(&creds).await.expect("should work"); + + println!("{:?}", results); + + assert!(results.task_responses.len() > 0); + + Ok(()) + } +} diff --git a/crates/lib/api_clients/openai_sora_client/src/requests/list_media/mod.rs b/crates/lib/api_clients/openai_sora_client/src/requests/list_media/mod.rs new file mode 100644 index 0000000000..b366dc8e8a --- /dev/null +++ b/crates/lib/api_clients/openai_sora_client/src/requests/list_media/mod.rs @@ -0,0 +1 @@ +pub mod list_media; \ No newline at end of file diff --git a/crates/lib/api_clients/openai_sora_client/src/requests/mod.rs b/crates/lib/api_clients/openai_sora_client/src/requests/mod.rs index 29da77e446..0739b457fb 100644 --- a/crates/lib/api_clients/openai_sora_client/src/requests/mod.rs +++ b/crates/lib/api_clients/openai_sora_client/src/requests/mod.rs @@ -1,5 +1,6 @@ pub mod bearer; pub mod image_gen; pub mod job; +pub mod list_media; pub mod sentinel_refresh; pub mod upload; \ No newline at end of file diff --git a/crates/lib/api_clients/openai_sora_client/src/sora_error.rs b/crates/lib/api_clients/openai_sora_client/src/sora_error.rs index 1f785d374f..824cb10d2a 100644 --- a/crates/lib/api_clients/openai_sora_client/src/sora_error.rs +++ b/crates/lib/api_clients/openai_sora_client/src/sora_error.rs @@ -26,10 +26,23 @@ pub enum SoraError { /// } UnauthorizedCookieOrBearerExpired, + /// Failed to generate a bearer token despite the `200 OK` response. + /// This has been generated by sending old/expired session cookies or empty cookies. + FailedToGenerateBearer, + + /// Something is wrong with the JWT bearer token. + /// This error originates on our end as we try to parse the JWT. + LocalJwtClaimsParseError(String), + /// Sora is having issues. /// Typically served from Cloudflare /// We preserve the message for debugging. BadGateway(String), + + /// HTTP 524 (Cloudflare-specific) + /// Cloudflare formed a TCP connection to Sora, but no payload was delivered before timeout + /// We preserve the message for debugging + CloudFlareTimeout(String), /// Another error occurred. OtherBadStatus(anyhow::Error), @@ -55,6 +68,12 @@ impl Display for SoraError { Self::NoBearerTokenAvailable => { write!(f, "Unauthorized: no bearer token set") } + Self::FailedToGenerateBearer => { + write!(f, "Failed to generate bearer. Probably invalid session.") + } + Self::LocalJwtClaimsParseError(message) => { + write!(f, "Local JWT claims parse error: {}", message) + } Self::TooManyConcurrentTasks => { write!(f, "Too many concurrent tasks. Please wait.") } @@ -64,6 +83,9 @@ impl Display for SoraError { Self::BadGateway(message) => { write!(f, "Bad Gateway; Sora is likely having issues: {:?}", message) } + Self::CloudFlareTimeout(message) => { + write!(f, "Cloudflare Timeout (524); Sora is likely having issues: {:?}", message) + } Self::OtherBadStatus(err) => { write!(f, "Other error: {}", err) } diff --git a/crates/lib/api_clients/openai_sora_client/src/utils/classify_general_http_error.rs b/crates/lib/api_clients/openai_sora_client/src/utils/classify_general_http_error.rs index f76956dfb4..3ee957b49f 100644 --- a/crates/lib/api_clients/openai_sora_client/src/utils/classify_general_http_error.rs +++ b/crates/lib/api_clients/openai_sora_client/src/utils/classify_general_http_error.rs @@ -8,7 +8,33 @@ pub async fn classify_general_http_error(response: reqwest::Response) -> SoraErr Ok(text) => text, Err(err) => return SoraError::ReqwestError(err), }; + + /* + +Old cookie - +Message: { + "error": { + "message": "Your authentication token has expired. Please try signing in again.", + "type": "invalid_request_error", + "param": null, + "code": "token_expired" + } +} + +No cookie - + +Message: { + "error": { + "message": "Your authentication token has expired. Please try signing in again.", + "type": "invalid_request_error", + "param": null, + "code": "token_expired" + } +} + */ + + // TODO: I *think* this is just the bearer token, not the cookie. let cookie_expired = message.contains("Your authentication token has expired. Please try signing in again.") || message.contains("token_expired"); @@ -17,8 +43,18 @@ pub async fn classify_general_http_error(response: reqwest::Response) -> SoraErr return SoraError::UnauthorizedCookieOrBearerExpired } - if status.as_u16() == 502 { - return SoraError::BadGateway(message); + let status_code = status.as_u16(); + + match status_code { + 502 => { + return SoraError::BadGateway(message); + } + 524 => { + if message.contains("cloudflare") || message.contains("Cloudflare") { + return SoraError::CloudFlareTimeout(message); + } + } + _ => {}, // Fall-through } SoraError::OtherBadStatus(anyhow::anyhow!("Upload failed with status {}: {}", status, message)) diff --git a/crates/lib/api_clients/openai_sora_client/src/utils/has_session_cookie.rs b/crates/lib/api_clients/openai_sora_client/src/utils/has_session_cookie.rs index f5a9c3ab2e..f3d0065a9c 100644 --- a/crates/lib/api_clients/openai_sora_client/src/utils/has_session_cookie.rs +++ b/crates/lib/api_clients/openai_sora_client/src/utils/has_session_cookie.rs @@ -4,7 +4,8 @@ use cookie::Cookie; use once_cell::sync::Lazy; use errors::AnyhowResult; -pub const SESSION_COOKIE_NAME : &str = "__Secure-authjs.session-token"; +pub const SESSION_COOKIE_NAME_OLD : &str = "__Secure-authjs.session-token"; +pub const SESSION_COOKIE_NAME_NEW : &str = "__Secure-next-auth.session-token"; /// Cookies Sora.com mints that are not the session cookie. We can ignore these. /// This list is very likely to change over time. @@ -51,7 +52,9 @@ pub fn has_session_cookie(cookies_header: &str) -> AnyhowResult AnyhowResult { +pub fn lightweight_sora_jwt_parse(token: &str) -> Result { let [_header_str, claims_str, _signature_str] = split_components(token)?; - let claims = decode_base64(claims_str)?; - let claims : serde_json::Map = serde_json::from_str(&claims)?; + let claims = decode_base64(claims_str) + .map_err(|err| SoraError::LocalJwtClaimsParseError( + format!("failure to parse claims base64: {:?}", err)))?; + + let claims : serde_json::Map = serde_json::from_str(&claims) + .map_err(|err| SoraError::LocalJwtClaimsParseError( + format!("failure to parse claims json: {:?}", err)))?; let iat = claims.get("iat") .map(|val| val.as_number()) .flatten() - .ok_or(anyhow!("no iat claim"))?; + .ok_or_else(|| jwt_error("no iat claim"))?; let iat = iat.as_i64() - .ok_or(anyhow!("iat is not a number"))?; + .ok_or_else(|| jwt_error("iat is not a number"))?; let iat = DateTime::from_timestamp(iat, 0) - .ok_or(anyhow!("iat is not a valid timestamp"))?; + .ok_or_else(|| jwt_error("iat is not a valid timestamp"))?; let exp = claims.get("exp") .map(|val| val.as_number()) .flatten() - .ok_or(anyhow!("no exp claim"))?; + .ok_or_else(|| jwt_error("no exp claim"))?; let exp = exp.as_i64() - .ok_or(anyhow!("exp is not a number"))?; + .ok_or_else(|| jwt_error("exp is not a number"))?; let exp = DateTime::from_timestamp(exp, 0) - .ok_or(anyhow!("iat is not a valid timestamp"))?; + .ok_or_else(|| jwt_error("iat is not a valid timestamp"))?; let user_id = claims.get("https://api.openai.com/auth") .map(|val| val.get("user_id")) .flatten() .map(|val| val.as_str()) .flatten() - .ok_or(anyhow!("no user_id claim"))?; + .ok_or_else(|| jwt_error("no user_id claim"))?; let profile = claims.get("https://api.openai.com/profile") - .ok_or(anyhow!("no user_id claim"))?; + .ok_or_else(|| jwt_error("no user_id claim"))?; let email = profile.get("email") .map(|val| val.as_str()) .flatten() - .ok_or(anyhow!("no email claim"))?; + .ok_or_else(|| jwt_error("no email claim"))?; let email_verified = profile.get("email_verified") .map(|val| val.as_bool()) @@ -79,14 +85,19 @@ pub fn lightweight_sora_jwt_parse(token: &str) -> AnyhowResult { }) } -fn split_components(token: &str) -> AnyhowResult<[&str; 3]> { +fn split_components(token: &str) -> Result<[&str; 3], SoraError> { let mut components = token.split(SEPARATOR); - let header = components.next().ok_or(anyhow!("no header component"))?; - let claims = components.next().ok_or(anyhow!("no claims component"))?; - let signature = components.next().ok_or(anyhow!("no signature component"))?; + let header = components.next() + .ok_or_else(|| jwt_error("no header component"))?; + + let claims = components.next() + .ok_or_else(|| jwt_error("no claims component"))?; + + let signature = components.next() + .ok_or_else(|| jwt_error("no signature component"))?; if components.next().is_some() { - return Err(anyhow!("too many components")); + return Err(jwt_error("too many components")); } Ok([header, claims, signature]) @@ -98,6 +109,10 @@ fn decode_base64(raw: &str) -> AnyhowResult { Ok(json_str) } +fn jwt_error(reason: &str) -> SoraError { + SoraError::LocalJwtClaimsParseError(reason.to_string()) +} + #[cfg(test)] mod tests { use crate::utils::lightweight_sora_jwt_parse::lightweight_sora_jwt_parse; diff --git a/frontend/apps/artcraft-website/public/videos/artcraft-3d-demo.mp4 b/frontend/apps/artcraft-website/public/videos/artcraft-3d-demo.mp4 index 1000f3e238..f0b86eb82b 100644 Binary files a/frontend/apps/artcraft-website/public/videos/artcraft-3d-demo.mp4 and b/frontend/apps/artcraft-website/public/videos/artcraft-3d-demo.mp4 differ diff --git a/frontend/apps/editor3d/app/public/resources/images/openai-logo.png b/frontend/apps/editor3d/app/public/resources/images/openai-logo.png new file mode 100644 index 0000000000..e4040b813f Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/images/openai-logo.png differ diff --git a/frontend/apps/editor3d/app/public/resources/sound/goldensun_101.wav b/frontend/apps/editor3d/app/public/resources/sound/goldensun_101.wav new file mode 100644 index 0000000000..ecdaa953cb Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/sound/goldensun_101.wav differ diff --git a/frontend/apps/editor3d/app/public/resources/sound/goldensun_102.wav b/frontend/apps/editor3d/app/public/resources/sound/goldensun_102.wav new file mode 100644 index 0000000000..d508557bd7 Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/sound/goldensun_102.wav differ diff --git a/frontend/apps/editor3d/app/public/resources/sound/goldensun_111.wav b/frontend/apps/editor3d/app/public/resources/sound/goldensun_111.wav new file mode 100644 index 0000000000..c477f5c4d0 Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/sound/goldensun_111.wav differ diff --git a/frontend/apps/editor3d/app/public/resources/sound/goldensun_113.wav b/frontend/apps/editor3d/app/public/resources/sound/goldensun_113.wav new file mode 100644 index 0000000000..5ae208c9d1 Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/sound/goldensun_113.wav differ diff --git a/frontend/apps/editor3d/app/public/resources/sound/goldensun_135.wav b/frontend/apps/editor3d/app/public/resources/sound/goldensun_135.wav new file mode 100644 index 0000000000..30b04b46fa Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/sound/goldensun_135.wav differ diff --git a/frontend/apps/editor3d/app/public/resources/sound/goldensun_173.wav b/frontend/apps/editor3d/app/public/resources/sound/goldensun_173.wav new file mode 100644 index 0000000000..70eeab321a Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/sound/goldensun_173.wav differ diff --git a/frontend/apps/editor3d/app/public/resources/sound/goldensun_174.wav b/frontend/apps/editor3d/app/public/resources/sound/goldensun_174.wav new file mode 100644 index 0000000000..2845ef0dee Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/sound/goldensun_174.wav differ diff --git a/frontend/apps/editor3d/app/public/resources/sound/goldensun_175.wav b/frontend/apps/editor3d/app/public/resources/sound/goldensun_175.wav new file mode 100644 index 0000000000..d0f5e053d7 Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/sound/goldensun_175.wav differ diff --git a/frontend/apps/editor3d/app/public/resources/sound/goldensun_214.wav b/frontend/apps/editor3d/app/public/resources/sound/goldensun_214.wav new file mode 100644 index 0000000000..fd58d5521e Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/sound/goldensun_214.wav differ diff --git a/frontend/apps/editor3d/app/public/resources/sound/smrpg_enemy_spikethrow.wav b/frontend/apps/editor3d/app/public/resources/sound/smrpg_enemy_spikethrow.wav new file mode 100644 index 0000000000..a08dc150a9 Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/sound/smrpg_enemy_spikethrow.wav differ diff --git a/frontend/apps/editor3d/app/public/resources/sound/smrpg_specialflower.wav b/frontend/apps/editor3d/app/public/resources/sound/smrpg_specialflower.wav new file mode 100644 index 0000000000..003a20ee06 Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/sound/smrpg_specialflower.wav differ diff --git a/frontend/apps/editor3d/app/public/resources/videos/artcraft-3d-demo.mp4 b/frontend/apps/editor3d/app/public/resources/videos/artcraft-3d-demo.mp4 index 1000f3e238..f0b86eb82b 100644 Binary files a/frontend/apps/editor3d/app/public/resources/videos/artcraft-3d-demo.mp4 and b/frontend/apps/editor3d/app/public/resources/videos/artcraft-3d-demo.mp4 differ diff --git a/frontend/apps/editor3d/app/public/resources/videos/artcraft-canvas-demo.mp4 b/frontend/apps/editor3d/app/public/resources/videos/artcraft-canvas-demo.mp4 new file mode 100644 index 0000000000..0e98e936a9 Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/videos/artcraft-canvas-demo.mp4 differ diff --git a/frontend/apps/editor3d/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx b/frontend/apps/editor3d/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx index f3658dc39b..c52e57fa14 100644 --- a/frontend/apps/editor3d/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx +++ b/frontend/apps/editor3d/app/src/components/reusable/UploadModal3D/UploadModal3D.tsx @@ -1,12 +1,7 @@ import { useEffect, useState } from "react"; - -import { - TransitionDialogue, - LoadingDots, - Label, - CloseButton, -} from "~/components"; - +import { LoadingDots } from "~/components"; +import { Modal } from "@storyteller/ui-modal"; +import { Label } from "@storyteller/ui-label"; import { UploadAssetError } from "../UploadModal/UploadAssetError"; import { UploadSuccess } from "../UploadModal/UploadSuccess"; import { UploadFiles3D } from "./UploadFiles3D"; @@ -19,18 +14,21 @@ import { CHARACTER_MIXAMO_FILE_TYPE, IMAGEPLANE_FILE_TYPE, } from "~/enums"; -import { assetModalVisible } from "~/pages/PageEnigma/signals"; +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; interface Props { onClose: () => void; - onSuccess: () => void; + onSuccess: (category: FilterEngineCategories) => void; isOpen: boolean; title: string; + titleIcon: IconDefinition; options?: { fileSubtypes?: { [key: string]: string }[]; hasLength?: boolean; hasThumbnailUpload?: boolean; }; + preselectedCategory?: FilterEngineCategories; + isSelectVisible: boolean; } // Map categories to their allowed file types @@ -43,16 +41,6 @@ const categoryFileTypes: Partial> = { [FilterEngineCategories.LOCATION]: Object.values(OBJECT_FILE_TYPE), [FilterEngineCategories.CREATURE]: Object.values(OBJECT_FILE_TYPE), }; - -// Map engine categories to asset modal tabs -const categoryToAssetTab: Partial> = { - [FilterEngineCategories.OBJECT]: "objects", - [FilterEngineCategories.CHARACTER]: "character", - [FilterEngineCategories.IMAGE_PLANE]: "image-planes", - [FilterEngineCategories.LOCATION]: "sets", - [FilterEngineCategories.CREATURE]: "creatures", -}; - // Excluded category options for the upload modal const categoryOptions = Object.entries(FilterEngineCategories) .filter( @@ -82,12 +70,19 @@ export function UploadModal3D({ onClose, onSuccess, title, + titleIcon, options, -}: Props) { + preselectedCategory, + isSelectVisible, +}: Props & { preselectedCategory?: FilterEngineCategories }) { const [uploaderState, setUploaderState] = useState(initialUploaderState); const [selectedCategory, setSelectedCategory] = - useState(FilterEngineCategories.OBJECT); + useState( + preselectedCategory !== undefined + ? preselectedCategory + : FilterEngineCategories.OBJECT, + ); const updateUploaderState = (newState: UploaderState) => { setUploaderState(newState); @@ -103,30 +98,51 @@ export function UploadModal3D({ } }, [isOpen]); + useEffect(() => { + if (preselectedCategory !== undefined) { + setSelectedCategory(preselectedCategory); + } + }, [preselectedCategory]); + + useEffect(() => { + console.log("Preselected category:", preselectedCategory); + console.log("Initial selected category:", selectedCategory); + }, [preselectedCategory, selectedCategory]); + + useEffect(() => { + if (uploaderState.status === UploaderStates.success) { + onSuccess(selectedCategory); + onClose(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uploaderState.status]); + const UploaderModalContent = () => { switch (uploaderState.status) { case UploaderStates.ready: return (
-
- - { + if ( + typeof value === "string" && + Object.values(FilterEngineCategories).includes( + value as FilterEngineCategories, + ) + ) { + setSelectedCategory(value as FilterEngineCategories); + } + }} + placeholder="Select a category" + /> +
+ )} Uploading...
); - case UploaderStates.success: + case UploaderStates.success: { return ( { - onSuccess(); onClose(); - // Store the selected category and tab in sessionStorage - const assetTab = categoryToAssetTab[selectedCategory]; - if (assetTab) { - sessionStorage.setItem( - "lastUploadedCategory", - selectedCategory, - ); - sessionStorage.setItem("lastUploadedTab", assetTab); - // Reopen the asset modal - assetModalVisible.value = true; - } else { - // Clear any existing stored values if we don't have a valid tab - sessionStorage.removeItem("lastUploadedCategory"); - sessionStorage.removeItem("lastUploadedTab"); - } + onSuccess(selectedCategory); }} /> ); + } case UploaderStates.assetError: return ( - - + ); } diff --git a/frontend/apps/editor3d/app/src/components/signaled/TopBar/AuthButtons.tsx b/frontend/apps/editor3d/app/src/components/signaled/TopBar/AuthButtons.tsx index b1eaff9e2c..bb39635cae 100644 --- a/frontend/apps/editor3d/app/src/components/signaled/TopBar/AuthButtons.tsx +++ b/frontend/apps/editor3d/app/src/components/signaled/TopBar/AuthButtons.tsx @@ -1,8 +1,6 @@ import { useSignals } from "@preact/signals-react/runtime"; - import { AUTH_STATUS } from "~/enums"; import { authentication } from "~/signals"; - import { ButtonLink } from "~/components"; import ProfileDropdown from "./ProfileDropdown"; diff --git a/frontend/apps/editor3d/app/src/components/signaled/TopBar/TopBar.tsx b/frontend/apps/editor3d/app/src/components/signaled/TopBar/TopBar.tsx index 2769e23932..360ab4be40 100644 --- a/frontend/apps/editor3d/app/src/components/signaled/TopBar/TopBar.tsx +++ b/frontend/apps/editor3d/app/src/components/signaled/TopBar/TopBar.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { faChevronLeft, - faChevronRight, faGear, faImages, } from "@fortawesome/pro-solid-svg-icons"; @@ -18,11 +17,14 @@ import { downloadFileFromUrl } from "@storyteller/api"; import { TabSelector, TabItem } from "@storyteller/ui-tab-selector"; import { Signal } from "@preact/signals-react"; import { useSignals } from "@preact/signals-react/runtime"; +import { setLogoutStates } from "~/signals/authentication/utilities"; + function isEditorPath(path: string) { if (path === "/") return true; if (path === "/idealenigma/") return true; return false; } + interface Props { pageName: string; appTabIdSignal: Signal; @@ -30,8 +32,8 @@ interface Props { } const appTabs: TabItem[] = [ - { id: "2D", label: "2D" }, { id: "3D", label: "3D" }, + { id: "2D", label: "2D" }, ]; export const TopBar = ({ pageName, appTabIdSignal, setAppTabId }: Props) => { @@ -70,7 +72,8 @@ export const TopBar = ({ pageName, appTabIdSignal, setAppTabId }: Props) => { tabs={appTabs} activeTab={appTabIdSignal.value} disabled={false} - onTabChange={(tabId) => setAppTabId(tabId)} + onTabChange={(tabId: string) => setAppTabId(tabId)} + className="w-fit" /> @@ -108,6 +111,7 @@ export const TopBar = ({ pageName, appTabIdSignal, setAppTabId }: Props) => { setIsSettingsModalOpen(false)} + globalAccountLogoutCallback={() => setLogoutStates()} /> { const r = SoundRegistry.getInstance(); - if (r.hasSound("fail")) { + if (r.hasSound("decline_normal")) { return; } - r.setSoundOnce("success", new SoundEffect("resources/sound/smrpg_flower.wav")); - r.setSoundOnce("bingo", new SoundEffect("resources/sound/smrpg_correct.wav")); - r.setSoundOnce("next", new SoundEffect("resources/sound/oot_dialogue_next.wav", { defaultVolume: 1.0 })); - r.setSoundOnce("done", new SoundEffect("resources/sound/oot_dialogue_done.wav", { defaultVolume: 1.0 })); - r.setSoundOnce("fail", new SoundEffect("resources/sound/smrpg_drybones_crumble.wav", { defaultVolume: 0.5 })); + + // Good for simple immediate enqueue + r.setSoundOnce("done", new SoundEffect("resources/sound/oot_dialogue_done.wav", { defaultVolume: 0.2 })); // DEFAULT + + // Good for simple immediate failure + r.setSoundOnce("error_chirp", new SoundEffect("resources/sound/goldensun_135.wav", { defaultVolume: 0.2 })); + r.setSoundOnce("spike_throw", new SoundEffect("resources/sound/smrpg_enemy_spikethrow.wav", { defaultVolume: 0.1 })); // DEFAULT + + // Good for simple async success + r.setSoundOnce("special_flower", new SoundEffect("resources/sound/smrpg_specialflower.wav", { defaultVolume: 0.2 })); // DEFAULT + + // Good for async errors + r.setSoundOnce("crumble", new SoundEffect("resources/sound/smrpg_drybones_crumble.wav", { defaultVolume: 0.1 })); // DEFAULT + r.setSoundOnce("special_alert", new SoundEffect("resources/sound/goldensun_214.wav", { defaultVolume: 0.2 })); + + // Good for menus + r.setSoundOnce("next", new SoundEffect("resources/sound/oot_dialogue_next.wav", { defaultVolume: 0.2 })); + r.setSoundOnce("select", new SoundEffect("resources/sound/goldensun_111.wav", { defaultVolume: 0.4 })); + + // Good for special reward / celebration + r.setSoundOnce("correct", new SoundEffect("resources/sound/smrpg_correct.wav", { defaultVolume: 0.1 })); + r.setSoundOnce("flower", new SoundEffect("resources/sound/smrpg_flower.wav", { defaultVolume: 0.1 })); + + // Misc / ungrouped + r.setSoundOnce("accept_chirp", new SoundEffect("resources/sound/goldensun_101.wav", { defaultVolume: 0.2 })); + r.setSoundOnce("accept_normal_level_1", new SoundEffect("resources/sound/goldensun_173.wav", { defaultVolume: 0.2 })); + r.setSoundOnce("accept_normal_level_2", new SoundEffect("resources/sound/goldensun_174.wav", { defaultVolume: 0.2 })); + r.setSoundOnce("accept_normal_level_3", new SoundEffect("resources/sound/goldensun_175.wav", { defaultVolume: 0.2 })); + r.setSoundOnce("decline_chirp", new SoundEffect("resources/sound/goldensun_102.wav", { defaultVolume: 0.2 })); + r.setSoundOnce("decline_normal", new SoundEffect("resources/sound/goldensun_113.wav", { defaultVolume: 0.2 })); + (window as any).sounds = r; } diff --git a/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx b/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx index eba1f43af9..36d55c9298 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEditor.tsx @@ -28,9 +28,7 @@ import { CameraAspectRatio } from "./enums"; import { PromptBox3D } from "@storyteller/ui-promptbox"; import { PopoverItem } from "@storyteller/ui-popover"; import { OnboardingHelper } from "./comps/OnboardingHelper"; -import { IsDesktopApp } from "@storyteller/tauri-utils"; import { FocalLengthDisplay } from "./comps/FocalLengthDisplay/FocalLengthDisplay"; -import { DemoModal } from "@storyteller/ui-demo-modal"; import { appTabId, setAppTabId } from "~/signals/appTab"; import { KonvaCanvasContainer } from "../Page2d/KonvaCanvasContainer"; import { KonvaRootComponent } from "../Page2d/KonvaRootComponent"; @@ -41,6 +39,14 @@ import { EngineContext } from "./contexts/EngineContext"; import Queue, { QueueNames } from "./Queue"; import { toEngineActions } from "./Queue/toEngineActions"; import { UnionedDataTypes } from "./Queue/Queue"; +import { LoginModal } from "@storyteller/ui-login-modal"; +import { DemoModal } from "@storyteller/ui-demo-modal"; +import { IsDesktopApp } from "@storyteller/tauri-utils"; +import { + DomLevels, +} from "./signals/hotkeys"; +import { AUTH_STATUS } from "../../enums/authentication"; +import { authentication } from "../../signals/authentication/authentication"; export const PageEditor = () => { useSignals(); @@ -244,7 +250,8 @@ export const PageEditor = () => { } const ContentFor3D = () => { - return <> + return (<> +
{ buttonOnClick={async () => { if (IsDesktopApp()) { await invoke("open_sora_login_command"); - } else { - console.error("This is not the Desktop app."); } }} /> - + {}} + videoSrc2D="/resources/videos/artcraft-canvas-demo.mp4" + videoSrc3D="/resources/videos/artcraft-3d-demo.mp4" + openAiLogo="/resources/images/openai-logo.png" + onOpenChange={(isOpen: boolean) => { + if (isOpen) { + disableHotkeyInput(DomLevels.DIALOGUE); + } else { + enableHotkeyInput(DomLevels.DIALOGUE); + } + }} + onArtCraftAuthSuccess={(userInfo: any) => { + authentication.status.value = AUTH_STATUS.LOGGED_IN; + authentication.userInfo.value = userInfo; + }} + /> + ); } const ContentFor2D = () => { diff --git a/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEnigma.tsx b/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEnigma.tsx index daf2fa1481..84ab4dfab5 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEnigma.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEnigma/PageEnigma.tsx @@ -8,7 +8,7 @@ import { ErrorDialog, LoadingDots } from "~/components"; import { GenerateModals } from "~/pages/PageEnigma/comps/GenerateModals/GenerateModals"; import { Toaster } from "@storyteller/ui-toaster"; import { EditorLoadingBar } from "./comps/EditorLoadingBar"; -import { Wizard } from "~/pages/PageEnigma/Wizard/Wizard"; +// import { Wizard } from "~/pages/PageEnigma/Wizard/Wizard"; import { useSignals } from "@preact/signals-react/runtime"; import { useEffect, useState } from "react"; import * as gpu from "detect-gpu"; @@ -23,8 +23,8 @@ import { showPrecisionSelector, } from "./signals/precisionSelectorMenu"; import { InstallSounds } from "~/pages/PageEnigma/InstallSounds"; -import { InstallImageGenerationFailure } from "~/pages/PageEnigma/tauri_events/SoraImageGenerationFailure"; -import { InstallImageGenerationSuccess } from "~/pages/PageEnigma/tauri_events/SoraImageGenerationSuccess"; +import { InstallImageGenerationFailure } from "~/pages/PageEnigma/TauriEvents/SoraImageGenerationFailure"; +import { InstallImageGenerationSuccess } from "~/pages/PageEnigma/TauriEvents/SoraImageGenerationSuccess"; export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => { useSignals(); @@ -91,6 +91,7 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => { }); useEffect(() => { + console.log("installing event listeners"); InstallSounds(); InstallImageGenerationSuccess(); InstallImageGenerationFailure(); @@ -116,7 +117,7 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => { /> - + {/* */} diff --git a/frontend/apps/editor3d/app/src/pages/PageEnigma/TauriEvents/SoraImageGenerationFailure.ts b/frontend/apps/editor3d/app/src/pages/PageEnigma/TauriEvents/SoraImageGenerationFailure.ts new file mode 100644 index 0000000000..8a3163cf13 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/PageEnigma/TauriEvents/SoraImageGenerationFailure.ts @@ -0,0 +1,24 @@ +import { SoundRegistry, SoundEffect } from "@storyteller/soundboard"; +import { listen } from '@tauri-apps/api/event'; +import { AppPreferencesPayload, CustomDirectory, GetAppPreferences, SystemDirectory } from "@storyteller/tauri-api"; +import { toast } from "@storyteller/ui-toaster"; + +type SoraImageGenerationFailed = { + prompt: string, +}; + +export const InstallImageGenerationFailure = () => { + listen('sora-image-generation-failed', async (event) => { + const prefs = await GetAppPreferences(); + const soundName = prefs.preferences?.generation_failure_sound; + if (soundName !== undefined) { + const registry = SoundRegistry.getInstance(); + registry.playSound(soundName); + } + toast.error("Image generation failed!"); + }); +} + +export const ImageGenerationFailure = () => { +} + diff --git a/frontend/apps/editor3d/app/src/pages/PageEnigma/TauriEvents/SoraImageGenerationSuccess.ts b/frontend/apps/editor3d/app/src/pages/PageEnigma/TauriEvents/SoraImageGenerationSuccess.ts new file mode 100644 index 0000000000..da4e91cb58 --- /dev/null +++ b/frontend/apps/editor3d/app/src/pages/PageEnigma/TauriEvents/SoraImageGenerationSuccess.ts @@ -0,0 +1,23 @@ +import { SoundRegistry, SoundEffect } from "@storyteller/soundboard"; +import { listen } from '@tauri-apps/api/event'; +import { AppPreferencesPayload, CustomDirectory, GetAppPreferences, SystemDirectory } from "@storyteller/tauri-api"; +import { toast } from "@storyteller/ui-toaster"; + +type ImageGenerationSuccess = { + media_file_token: string, +}; + +export const InstallImageGenerationSuccess = () => { + listen('sora-image-generation-complete', async (event) => { + const prefs = await GetAppPreferences(); + const soundName = prefs.preferences?.generation_success_sound; + if (soundName !== undefined) { + const registry = SoundRegistry.getInstance(); + registry.playSound(soundName); + } + toast.success("Image generated!"); + }); +} + +export const ImageGenerationSuccess = () => { +} diff --git a/frontend/apps/editor3d/app/src/pages/PageEnigma/comps/AssetMenu/AssetModal.tsx b/frontend/apps/editor3d/app/src/pages/PageEnigma/comps/AssetMenu/AssetModal.tsx index 5d58f2a139..e92d9df4a9 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEnigma/comps/AssetMenu/AssetModal.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEnigma/comps/AssetMenu/AssetModal.tsx @@ -1,7 +1,6 @@ import { Modal } from "@storyteller/ui-modal"; import { UploadModal3D } from "~/components/reusable/UploadModal3D"; import { - faPlus, faSearch, faChevronLeft, faLayerGroup, @@ -12,13 +11,13 @@ import { faMountainCity, faDog, faImage, + faUpFromLine, } from "@fortawesome/pro-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Tooltip } from "@storyteller/ui-tooltip"; import { Button } from "@storyteller/ui-button"; import { CloseButton } from "@storyteller/ui-close-button"; import { Input } from "@storyteller/ui-input"; -import { TabSelector } from "~/components/reusable/TabSelector"; +import { TabSelector } from "@storyteller/ui-tab-selector"; import React, { useState, useEffect, @@ -45,6 +44,7 @@ import { isAnyStatusFetching } from "../SidePanelTabs/utilities"; type AssetTab = { id: string; label: string; + labelSingle?: string; icon: typeof faLayerGroup; engineCategory?: FilterEngineCategories; items: MediaItem[]; @@ -81,6 +81,21 @@ const AllTabSection = ({
); +const categoryToTabIdMap: Record = { + [FilterEngineCategories.CHARACTER]: "characters", + [FilterEngineCategories.CREATURE]: "creatures", + [FilterEngineCategories.IMAGE_PLANE]: "image-planes", + [FilterEngineCategories.LOCATION]: "sets", + [FilterEngineCategories.OBJECT]: "objects", + [FilterEngineCategories.SKYBOX]: "skybox", + [FilterEngineCategories.ANIMATION]: "all", + [FilterEngineCategories.AUDIO]: "all", + [FilterEngineCategories.EXPRESSION]: "all", + [FilterEngineCategories.SCENE]: "all", + [FilterEngineCategories.SET_DRESSING]: "all", + [FilterEngineCategories.VIDEO_PLANE]: "all", +}; + export const AssetModal = () => { useSignals(); const [activeLibraryTab, setActiveLibraryTab] = useState("library"); @@ -88,6 +103,9 @@ export const AssetModal = () => { const [searchTerm, setSearchTerm] = useState(""); const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); const searchInputRef = useRef(null); + const [selectedCategory, setSelectedCategory] = + useState(null); + const [isSelectVisible, setIsSelectVisible] = useState(true); const handleReopenChange = (e: React.ChangeEvent) => { const newValue = e.target.checked; @@ -101,6 +119,10 @@ export const AssetModal = () => { assetModalVisible.value = false; }; + const handleOpen = () => { + assetModalVisible.value = true; + }; + useEffect(() => { if (assetModalVisible.value) { // Check for stored category and tab from upload @@ -116,7 +138,10 @@ export const AssetModal = () => { const timer = setTimeout(() => { searchInputRef.current?.focus(); }, 100); - return () => clearTimeout(timer); + return () => { + clearTimeout(timer); + // Ensure no side effects remain + }; } return undefined; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -232,7 +257,8 @@ export const AssetModal = () => { { id: "all", label: "All", icon: faLayerGroup, items: [] }, { id: "character", - label: "Character", + label: "Characters", + labelSingle: "Character", icon: faUser, engineCategory: FilterEngineCategories.CHARACTER, items: @@ -243,6 +269,7 @@ export const AssetModal = () => { { id: "objects", label: "Objects", + labelSingle: "Object", icon: faCube, engineCategory: FilterEngineCategories.OBJECT, items: @@ -253,6 +280,7 @@ export const AssetModal = () => { { id: "sets", label: "Sets", + labelSingle: "Set", icon: faMountainCity, engineCategory: FilterEngineCategories.LOCATION, items: @@ -263,6 +291,7 @@ export const AssetModal = () => { { id: "creatures", label: "Creatures", + labelSingle: "Creature", icon: faDog, engineCategory: FilterEngineCategories.CREATURE, items: @@ -273,12 +302,14 @@ export const AssetModal = () => { { id: "skybox", label: "Skybox", + labelSingle: "Skybox", icon: faSun, items: activeLibraryTab === "library" ? demoSkyboxItems.value : [], }, { id: "image-planes", label: "Image Planes", + labelSingle: "Image Plane", icon: faImage, engineCategory: FilterEngineCategories.IMAGE_PLANE, items: @@ -397,11 +428,20 @@ export const AssetModal = () => { }; const handleAddAsset = () => { - assetModalVisible.value = false; + handleClose(); setIsUploadModalOpen(true); + setSelectedCategory(null); + setIsSelectVisible(true); }; - const handleUploadSuccess = () => { + const handleAddSpecificAsset = (category: FilterEngineCategories) => { + handleClose(); + setIsUploadModalOpen(true); + setSelectedCategory(category); + setIsSelectVisible(false); + }; + + const handleUploadSuccess = (category: FilterEngineCategories) => { if (reopenAfterDragSignal.value) { // Small delay to allow the modal to close and reopen setTimeout(() => { @@ -410,10 +450,32 @@ export const AssetModal = () => { } else { handleClose(); } + + // Use the provided category to find the AssetTab ID + const lastUploadedTabId = categoryToTabIdMap[category] || "all"; + + // Close the upload modal + setTimeout(() => { + sessionStorage.setItem("lastUploadedTab", lastUploadedTabId); + console.log("AssetModal: lastUploadedTabId set to:", lastUploadedTabId); + setIsUploadModalOpen(false); + handleOpen(); + }, 300); + + fetchUserCharacters(); + fetchUserObjects(); + fetchUserSets(); + fetchUserCreatures(); + fetchUserImagePlanes(); }; const clearSearch = () => setSearchTerm(""); + const getArticle = (word: string | undefined) => { + if (!word) return "a"; + return /^[aeiou]/i.test(word) ? "an" : "a"; + }; + return ( <> {

3D Assets

- + {/* - + */}
-
+
+ {assetTabs.map((tab) => ( - {currentTab.label} +
+
+ + {currentTab.label} +
+ {activeAssetTab !== "skybox" && + activeAssetTab !== "all" && ( + + )}
)} {renderContent()} @@ -551,10 +645,15 @@ export const AssetModal = () => {
setIsUploadModalOpen(false)} - onSuccess={handleUploadSuccess} + onClose={() => { + setIsUploadModalOpen(false); + }} + onSuccess={(category) => handleUploadSuccess(category)} isOpen={isUploadModalOpen} - title="Upload 3D Asset" + title={`Upload ${selectedCategory ? (selectedCategory.toLowerCase().replace(/_/g, " ") === "location" ? "sets" : selectedCategory.toLowerCase().replace(/_/g, " ")) : "3D Asset"}`} + titleIcon={!isSelectVisible ? currentTab.icon : faUpFromLine} + preselectedCategory={selectedCategory || FilterEngineCategories.OBJECT} + isSelectVisible={isSelectVisible} /> ); diff --git a/frontend/apps/editor3d/app/src/pages/PageEnigma/comps/Controls3D/Controls3D.tsx b/frontend/apps/editor3d/app/src/pages/PageEnigma/comps/Controls3D/Controls3D.tsx index 8f1627e715..4515d10b7b 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEnigma/comps/Controls3D/Controls3D.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEnigma/comps/Controls3D/Controls3D.tsx @@ -23,7 +23,8 @@ export const Controls3D = () => { useEffect(() => { // Check if scene is empty and onboarding helper is not visible const checkSceneEmpty = () => { - const isSceneEmpty = outlinerState.items.value.length === 0; + const isSceneEmpty = + outlinerState.items.value.length === 0 && !assetModalVisible.value; setShowEmptySceneTooltip(isSceneEmpty); }; @@ -37,7 +38,8 @@ export const Controls3D = () => { return () => { unsubscribe(); }; - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assetModalVisible.value]); const handleModeChange = (value: string) => { selectedMode.value = value; @@ -119,7 +121,7 @@ export const Controls3D = () => { { return (
- { }, }, ]} - /> + /> */} +
+ {localError && ( +
+ {localError} +
+ )} +
+ +
+ ); +}; diff --git a/frontend/libs/components/login-modal/src/lib/login-modal.module.css b/frontend/libs/components/login-modal/src/lib/login-modal.module.css new file mode 100644 index 0000000000..d0246c74b9 --- /dev/null +++ b/frontend/libs/components/login-modal/src/lib/login-modal.module.css @@ -0,0 +1,7 @@ +/* + * Replace this with your own classes + * + * e.g. + * .container { + * } +*/ \ No newline at end of file diff --git a/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx b/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx new file mode 100644 index 0000000000..14abc2b9bb --- /dev/null +++ b/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx @@ -0,0 +1,12 @@ +import { render } from '@testing-library/react'; + +import LoginModal from './login-modal'; + +describe('LoginModal', () => { + + it('should render successfully', () => { + const { baseElement } = render(); + expect(baseElement).toBeTruthy(); + }); + +}); diff --git a/frontend/libs/components/login-modal/src/lib/login-modal.tsx b/frontend/libs/components/login-modal/src/lib/login-modal.tsx new file mode 100644 index 0000000000..c73e88f8cf --- /dev/null +++ b/frontend/libs/components/login-modal/src/lib/login-modal.tsx @@ -0,0 +1,414 @@ +import { Button } from "@storyteller/ui-button"; +import { Transition, TransitionChild } from "@headlessui/react"; +import { createPortal } from "react-dom"; +import { useState, useEffect, useRef } from "react"; +import { + faArrowRight, + faRightToBracket, +} from "@fortawesome/pro-solid-svg-icons"; +import { faDiscord } from "@fortawesome/free-brands-svg-icons"; +import { IsDesktopApp } from "@storyteller/tauri-utils"; +import { + CheckSoraSession, + SoraSessionState, + useSoraLoginListener, +} from "@storyteller/tauri-api"; +import { invoke } from "@tauri-apps/api/core"; +import { ArtCraftSignUp } from "./ArtCraftSignUp"; +import { UsersApi } from "@storyteller/api"; +interface LoginModalProps { + onClose: () => void; + videoSrc2D?: string; + videoSrc3D?: string; + openAiLogo?: string; + onOpenChange?: (isOpen: boolean) => void; + onArtCraftAuthSuccess?: (userInfo: any) => void; +} + +export function LoginModal({ + onClose, + videoSrc2D, + videoSrc3D, + openAiLogo, + onOpenChange, + onArtCraftAuthSuccess, +}: LoginModalProps) { + const [step, setStep] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [isLoggedInArtCraft, setIsLoggedInArtCraft] = useState(false); + const [isSignUp, setIsSignUp] = useState(true); + const [errorMessage, setErrorMessage] = useState(""); + const totalSteps = isLoggedInArtCraft ? 3 : 4; + const artCraftFormRef = useRef(null); + + const initSession = async () => { + const result = await CheckSoraSession(); + const sessionExists = result.state === SoraSessionState.Valid; + return sessionExists; + }; + + const checkArtCraftLogin = async () => { + const usersApi = new UsersApi(); + const session = await usersApi.GetSession(); + const loggedIn = session.data?.loggedIn; + return loggedIn; + }; + + useSoraLoginListener((payload:any) => { + console.log("Login success!", payload); + if (isLoggedInArtCraft) { + setStep(4); + } else { + setStep(3); + } + }); + + // Check session on component mount + useEffect(() => { + // Run both checks in parallel + Promise.all([initSession(), checkArtCraftLogin()]).then( + ([soraSessionExists, loggedIn]) => { + if (soraSessionExists && !loggedIn) { + // Sora session exists, but not logged in to ArtCraft + setIsOpen(true); + setStep(3); + } else if (!soraSessionExists) { + // No Sora session, start at step 1 + setIsOpen(true); + setStep(1); + } else if (loggedIn) { + // Already logged in, no need to show modal + setIsLoggedInArtCraft(true); + // setIsOpen(false); + } + } + ); + }, []); + + useEffect(() => { + if (onOpenChange) onOpenChange(isOpen); + }, [isOpen, onOpenChange]); + + const handleArtCraftAuth = async ( + username: string, + email: string, + password: string, + passwordConfirmation: string + ) => { + setIsLoading(true); + const usersApi = new UsersApi(); + try { + let signupResponse, loginResponse; + if (isSignUp) { + // Sign up + signupResponse = await usersApi.Signup({ + username, + email, + password, + passwordConfirmation, + }); + if (!signupResponse.success) { + // TODO: Add error handling UI + console.error(signupResponse.errorMessage || "Signup failed"); + setErrorMessage( + signupResponse.errorMessage || "Signup failed, please try again." + ); + return; + } + // Automatically login after successful signup + loginResponse = await usersApi.Login({ + usernameOrEmail: username || email, + password, + }); + if (!loginResponse.success) { + // TODO: Add error handling UI + console.error( + loginResponse.errorMessage || "Login after signup failed" + ); + setErrorMessage( + loginResponse.errorMessage || + "Login after signup failed, please try again. " + ); + return; + } + } else { + // Login + loginResponse = await usersApi.Login({ + usernameOrEmail: username || email, + password, + }); + if (!loginResponse.success) { + // TODO: Add error handling UI + console.error(loginResponse.errorMessage || "Login failed"); + setErrorMessage( + loginResponse.errorMessage || "Login failed, please try again." + ); + return; + } + } + + setStep(4); + + const session = await usersApi.GetSession(); + const userInfo = session.data?.user; + if (onArtCraftAuthSuccess && userInfo) { + onArtCraftAuthSuccess(userInfo); + } + } catch (e) { + // handle error (show error message, etc.) + // TODO: Add error handling UI + console.error(e); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + setIsOpen(false); + onClose(); + }; + + const handleNext = async () => { + if (step === 2) { + setIsLoading(true); + try { + if (IsDesktopApp()) { + await invoke("open_sora_login_command"); + await new Promise((resolve) => setTimeout(resolve, 3000)); + const result = await CheckSoraSession(); + const sessionExists = result.state === SoraSessionState.Valid; + if (sessionExists) { + setStep(step + 1); + } + } else { + alert("Please open the desktop app to login"); + await new Promise((resolve) => setTimeout(resolve, 3000)); + const sessionExists = true; + if (sessionExists) { + setStep(step + 1); + } + } + } catch (error) { + console.error("Login failed:", error); + } finally { + setIsLoading(false); + } + } else if (step === 3) { + // Trigger the form submit in ArtCraftSignUp + if (artCraftFormRef.current) { + artCraftFormRef.current.requestSubmit(); + } + } else { + setStep(step + 1); + } + }; + + const handleBack = () => { + if (step > 1) setStep(step - 1); + }; + + // Progress bar rendering + const renderProgress = () => ( +
+ {[...Array(totalSteps)].map((_, idx) => ( +
+ ))} +
+ ); + + // Step content rendering + const renderStepContent = () => { + if (step === 3 && isLoggedInArtCraft) { + setStep(4); + return null; + } + switch (step) { + case 1: + return ( +
+

+ Welcome to ArtCraft +

+

+ Here's what you can do... +

+
+
+
+ +
+

+ 2D Canvas +

+
+
+
+ +
+

+ 3D Scene Editor +

+
+
+
+ ); + case 2: + return ( +
+

+ Login with your OpenAI account +

+

+ We're the application layer for images and video. To provide you + with 100% free service, we use your existing AI accounts. + To start off, you'll need to add your OpenAI Sora account. If you + have a ChatGPT subscription, you can log in with Sora. +

+ OpenAI Logo +
+ ); + case 3: + return ( + setIsSignUp((prev) => !prev)} + formRef={artCraftFormRef} + errorMessage={errorMessage} + /> + ); + case 4: + return ( +
+

+ Thank you for signing in! +

+

+ You're all set to start creating amazing content. Join our Discord + community to connect with other creators, share your work, and get + the latest updates. +

+ +
+ ); + default: + return null; + } + }; + + return createPortal( + +
+ +
+ +
+ +
e.stopPropagation()} + > +
+ + Step {step} of {totalSteps} + + {renderProgress()} + {renderStepContent()} +
+ {step === 2 ? ( + + ) : null} + {step < totalSteps ? ( + <> + + + ) : ( + + )} +
+
+
+
+
+
+ , + document.body + ); +} + +export default LoginModal; \ No newline at end of file diff --git a/frontend/libs/components/login-modal/tsconfig.json b/frontend/libs/components/login-modal/tsconfig.json new file mode 100644 index 0000000000..1d1824cf3d --- /dev/null +++ b/frontend/libs/components/login-modal/tsconfig.json @@ -0,0 +1,28 @@ +{ + "files": [], + "include": [], + "references": [ + { + "path": "../../api" + }, + { + "path": "../../tauri-api" + }, + { + "path": "../../tauri-utils" + }, + { + "path": "../button" + }, + { + "path": "../input" + }, + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/frontend/libs/components/login-modal/tsconfig.lib.json b/frontend/libs/components/login-modal/tsconfig.lib.json new file mode 100644 index 0000000000..e0ec84e7f8 --- /dev/null +++ b/frontend/libs/components/login-modal/tsconfig.lib.json @@ -0,0 +1,62 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts", + "vite/client" + ], + "rootDir": "src", + "jsx": "react-jsx", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo" + }, + "exclude": [ + "out-tsc", + "dist", + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx", + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx" + ], + "include": [ + "src/**/*.js", + "src/**/*.jsx", + "src/**/*.ts", + "src/**/*.tsx" + ], + "references": [ + { + "path": "../../api/tsconfig.lib.json" + }, + { + "path": "../../tauri-api/tsconfig.lib.json" + }, + { + "path": "../../tauri-utils/tsconfig.lib.json" + }, + { + "path": "../button/tsconfig.lib.json" + }, + { + "path": "../input/tsconfig.lib.json" + } + ] +} diff --git a/frontend/libs/components/login-modal/tsconfig.spec.json b/frontend/libs/components/login-modal/tsconfig.spec.json new file mode 100644 index 0000000000..18b7de51b8 --- /dev/null +++ b/frontend/libs/components/login-modal/tsconfig.spec.json @@ -0,0 +1,34 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/vitest", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ], + "jsx": "react-jsx" + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/frontend/libs/components/login-modal/vite.config.ts b/frontend/libs/components/login-modal/vite.config.ts new file mode 100644 index 0000000000..98ac28b77a --- /dev/null +++ b/frontend/libs/components/login-modal/vite.config.ts @@ -0,0 +1,49 @@ +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import dts from 'vite-plugin-dts'; +import * as path from 'path'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/components/login-modal', + plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })], + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + // Configuration for building your library. + // See: https://vitejs.dev/guide/build.html#library-mode + build: { + outDir: './dist', + emptyOutDir: true, + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + lib: { + // Could also be a dictionary or array of multiple entry points. + entry: 'src/index.ts', + name: '@storyteller/ui-login-modal', + fileName: 'index', + // Change this to the formats you want to support. + // Don't forget to update your package.json as well. + formats: ['es' as const] + }, + rollupOptions: { + // External packages that should not be bundled into your library. + external: ['react','react-dom','react/jsx-runtime'] + }, + }, + test: { + watch: false, + globals: true, + environment: 'jsdom', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + } + }, +})); diff --git a/frontend/libs/components/select/src/lib/select.tsx b/frontend/libs/components/select/src/lib/select.tsx index eeca266c05..0525836efb 100644 --- a/frontend/libs/components/select/src/lib/select.tsx +++ b/frontend/libs/components/select/src/lib/select.tsx @@ -17,6 +17,7 @@ interface ListDropdownProps { placeholder?: string; value?: SelectValue; id?: string; + className?: string; } export const Select = ({ @@ -25,13 +26,14 @@ export const Select = ({ placeholder, value, id, + className, }: ListDropdownProps) => { const selectedOption = options.find( (option: SelectOption) => option.value === value ) || { label: placeholder || "", value: "" }; return ( -
+
{({ open }) => ( <> diff --git a/frontend/libs/components/settings-modal/package.json b/frontend/libs/components/settings-modal/package.json index 63f67ddfe9..9b59cee188 100644 --- a/frontend/libs/components/settings-modal/package.json +++ b/frontend/libs/components/settings-modal/package.json @@ -13,5 +13,8 @@ "import": "./dist/index.js", "default": "./dist/index.js" } + }, + "dependencies": { + "@tauri-apps/plugin-dialog": "^2.2.1" } } diff --git a/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/AccountSettingsPane.tsx b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/AccountSettingsPane.tsx new file mode 100644 index 0000000000..53f63b5bfd --- /dev/null +++ b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/AccountSettingsPane.tsx @@ -0,0 +1,127 @@ +import { useState } from "react"; +import { Button } from "@storyteller/ui-button"; +import { Input } from "@storyteller/ui-input"; +import { SoraAccountButton } from "./SoraAccountButton"; +import { SoraAccountBlock } from "./SoraAccountBlock"; +import { ArtcraftAccountBlock } from "./ArtcraftAccountBlock"; + +interface AccountInfo { + username: string; + subscription: string; + credits: number; +} + +interface AccountSettingsPaneProps { + globalAccountLogoutCallback: () => void; +} + +export const AccountSettingsPane = ({ + globalAccountLogoutCallback, +}: AccountSettingsPaneProps) => { + const [accountInfo] = useState({ + username: "-", + subscription: "-", + credits: 0, + }); + + const [palApiKey, setPalApiKey] = useState(""); + const [klingApiKey, setKlingApiKey] = useState(""); + + return ( + <> +
+ {/* +
+ ArtCraft Account: + {accountInfo.username} +
+
+ ArtCraft Subscription: + {accountInfo.subscription} +
+
+ Credits Remaining: + {accountInfo.credits} +
+ +
+

+ Note: You can optionally log into other accounts and use your + credits at those providers. Some features are only available via 3rd + party accounts, such as OpenAI / Sora GPT 4.0 Images. For other + features, ArtCraft credits are consumed unless you log into your + third party account. +

+
+ */} + + + + + + + {/* +
+ +

Coming Soon...

+ +
+ Google / Veo Account: + +
+ +
+ Midjourney Account: + +
+ +
+
+ + setKlingApiKey((e.target as any).value)} + placeholder="Enter API Key" + disabled + /> +
+
+ + setPalApiKey((e.target as any).value)} + placeholder="Enter API Key" + disabled + /> +
+
+ + setPalApiKey((e.target as any).value)} + placeholder="Enter API Key" + disabled + /> +
+
+ */} +
+ + ); +}; diff --git a/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/ArtcraftAccountBlock.tsx b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/ArtcraftAccountBlock.tsx new file mode 100644 index 0000000000..8961070381 --- /dev/null +++ b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/ArtcraftAccountBlock.tsx @@ -0,0 +1,84 @@ +import { useState, useEffect } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons"; +import { Button } from "@storyteller/ui-button"; + +import { UsersApi } from "@storyteller/api"; +import { UserInfo } from "@storyteller/api"; + +const usersApi = new UsersApi(); + +export interface ArtcraftAccountBlockProps { + globalAccountLogoutCallback: () => void, +} + +export const ArtcraftAccountBlock = ({ + globalAccountLogoutCallback, +}: ArtcraftAccountBlockProps) => { + const [artcraftSession, setArtcraftSession] = useState(undefined); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [isCheckingArtcraftSession, setIsCheckingArtcraftSession] = useState(false); + + useEffect(() => { + const fetchSession = async () => { + setIsCheckingArtcraftSession(true); + try { + const result = await usersApi.GetSession(); + console.log(">>> result", result); + setArtcraftSession(result?.data?.user); + setIsLoggedIn(result?.data?.loggedIn || false); + } catch (e) { + console.error("Error fetching Artcraft session", e); + setArtcraftSession(undefined); + setIsLoggedIn(false); + } finally { + setIsCheckingArtcraftSession(false); + } + }; + fetchSession(); + }, []); + + const handleArtcraftButton = async () => { + if (isCheckingArtcraftSession) return; + if (isLoggedIn) { + setIsCheckingArtcraftSession(true); + await usersApi.Logout(); + setArtcraftSession(undefined); + setIsLoggedIn(false); + setIsCheckingArtcraftSession(false); + globalAccountLogoutCallback(); // TODO: This resets the old global application state + } else { + window.location.href = "/login"; // TODO(bt,2025-05-08): Once we have in-page routing, get rid of this. + } + }; + + return ( +
+ ArtCraft Account: +
{artcraftSession?.display_name}
+ +
+ ) +} \ No newline at end of file diff --git a/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/SoraAccountBlock.tsx b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/SoraAccountBlock.tsx new file mode 100644 index 0000000000..b88f9c69a1 --- /dev/null +++ b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/SoraAccountBlock.tsx @@ -0,0 +1,119 @@ +import { useState, useEffect } from "react"; +import { Button } from "@storyteller/ui-button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons"; +import { + CheckSoraSession, + SoraSessionState, + LogoutSoraSession, + useSoraLoginListener, + CheckSoraSessionResult, +} from "@storyteller/tauri-api"; +import { invoke } from "@tauri-apps/api/core"; + +const DEFAULT_CACHE_TTL_MS = 1 * 60 * 1000; // 1 minute + +interface SoraAccountBlockProps { + cacheKey?: string; + cacheTtlMs?: number; +} + +export const SoraAccountBlock = ({ + cacheKey = "soraSettingsSessionCache", + cacheTtlMs = DEFAULT_CACHE_TTL_MS, +}: SoraAccountBlockProps) => { + const [soraSession, setSoraSession] = useState(undefined); + const [isCheckingSoraSession, setIsCheckingSoraSession] = useState(false); + + useSoraLoginListener(() => { + setIsCheckingSoraSession(true); + CheckSoraSession() + .then((result) => { + setSoraSession(result); + localStorage.setItem( + cacheKey, + JSON.stringify({ session: result, timestamp: Date.now() }) + ); + }) + .finally(() => setIsCheckingSoraSession(false)); + }); + + useEffect(() => { + const fetchSession = async () => { + setIsCheckingSoraSession(true); + try { + const cached = localStorage.getItem(cacheKey); + if (cached) { + const { session, timestamp } = JSON.parse(cached); + if (Date.now() - timestamp < cacheTtlMs) { + setSoraSession(session); + setIsCheckingSoraSession(false); + return; + } + } + const result = await CheckSoraSession(); + setSoraSession(result); + localStorage.setItem( + cacheKey, + JSON.stringify({ session: result, timestamp: Date.now() }) + ); + } catch (e) { + console.error("Error fetching Sora session", e); + setSoraSession(undefined); + } finally { + setIsCheckingSoraSession(false); + } + }; + fetchSession(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cacheKey, cacheTtlMs]); + + const handleSoraButton = async () => { + if (isCheckingSoraSession) return; + if (soraSession?.state === SoraSessionState.Valid) { + setIsCheckingSoraSession(true); + await LogoutSoraSession(); + setSoraSession({state: SoraSessionState.NotSetUp}); + localStorage.removeItem(cacheKey); + setIsCheckingSoraSession(false); + } else { + setIsCheckingSoraSession(true); + try { + await invoke("open_sora_login_command"); + } catch (e) { + } finally { + setIsCheckingSoraSession(false); + } + } + }; + + return( +
+ OpenAI / Sora Account: +
{soraSession?.maybe_account_email || ""}
+ +
+ ) +} \ No newline at end of file diff --git a/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/SoraAccountButton.tsx b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/SoraAccountButton.tsx new file mode 100644 index 0000000000..723e123a6c --- /dev/null +++ b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/SoraAccountButton.tsx @@ -0,0 +1,112 @@ +import { useState, useEffect } from "react"; +import { Button } from "@storyteller/ui-button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons"; +import { + CheckSoraSession, + SoraSessionState, + LogoutSoraSession, + useSoraLoginListener, +} from "@storyteller/tauri-api"; +import { invoke } from "@tauri-apps/api/core"; + +interface SoraAccountButtonProps { + cacheKey?: string; + cacheTtlMs?: number; +} + +export const SoraAccountButton = ({ + cacheKey = "soraSettingsSessionCache", + cacheTtlMs = 5 * 60 * 1000, +}: SoraAccountButtonProps) => { + const [soraSession, setSoraSession] = useState(null); + const [isCheckingSoraSession, setIsCheckingSoraSession] = useState(false); + + useSoraLoginListener(() => { + setIsCheckingSoraSession(true); + CheckSoraSession() + .then((result) => { + setSoraSession(result.state); + localStorage.setItem( + cacheKey, + JSON.stringify({ state: result.state, timestamp: Date.now() }) + ); + }) + .finally(() => setIsCheckingSoraSession(false)); + }); + + useEffect(() => { + const fetchSession = async () => { + setIsCheckingSoraSession(true); + try { + const cached = localStorage.getItem(cacheKey); + if (cached) { + const { state, timestamp } = JSON.parse(cached); + if (Date.now() - timestamp < cacheTtlMs) { + setSoraSession(state); + setIsCheckingSoraSession(false); + return; + } + } + const result = await CheckSoraSession(); + setSoraSession(result.state); + localStorage.setItem( + cacheKey, + JSON.stringify({ state: result.state, timestamp: Date.now() }) + ); + } catch (e) { + console.error("Error fetching Sora session", e); + setSoraSession(null); + } finally { + setIsCheckingSoraSession(false); + } + }; + fetchSession(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cacheKey, cacheTtlMs]); + + const handleSoraButton = async () => { + if (isCheckingSoraSession) return; + if (soraSession === SoraSessionState.Valid) { + setIsCheckingSoraSession(true); + await LogoutSoraSession(); + setSoraSession(SoraSessionState.NotSetUp); + localStorage.removeItem(cacheKey); + setIsCheckingSoraSession(false); + } else { + setIsCheckingSoraSession(true); + try { + await invoke("open_sora_login_command"); + } catch (e) { + } finally { + setIsCheckingSoraSession(false); + } + } + }; + + return ( + + ); +}; diff --git a/frontend/libs/components/settings-modal/src/lib/panes/AudioSettingsPane.tsx b/frontend/libs/components/settings-modal/src/lib/panes/AudioSettingsPane.tsx new file mode 100644 index 0000000000..b6037e6b37 --- /dev/null +++ b/frontend/libs/components/settings-modal/src/lib/panes/AudioSettingsPane.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState } from "react"; +import { SoundRegistry } from "@storyteller/soundboard"; +import { Button } from "@storyteller/ui-button"; +import { faPlay } from "@fortawesome/pro-solid-svg-icons"; +import { + AppPreferencesPayload, + GetAppPreferences, +} from "@storyteller/tauri-api"; +import { PreferenceName, UpdateAppPreferences } from "@storyteller/tauri-api"; +import { Select, SelectValue } from "@storyteller/ui-select"; +import { Switch } from "@storyteller/ui-switch"; +import { Label } from "@storyteller/ui-label"; + +// TODO: This is maintained in two places. Here and InstallSounds. +const SOUND_OPTIONS = [ + { value: "none", label: "None (Silent)" }, // NB: MUST BE FIRST + + // Other options + { value: "correct", label: "Correct" }, + { value: "crumble", label: "Crumble" }, + { value: "done", label: "Done" }, + { value: "error_chirp", label: "Error Chirp" }, + { value: "flower", label: "Flower" }, + { value: "next", label: "Next" }, + { value: "select", label: "Select" }, + { value: "special_alert", label: "Special Alert" }, + { value: "special_flower", label: "Special Flower" }, + { value: "spike_throw", label: "Spike Throw" }, +]; + +interface AudioSettingsPaneProps {} + +export const AudioSettingsPane = (args: AudioSettingsPaneProps) => { + const [preferences, setPreferences] = useState< + AppPreferencesPayload | undefined + >(undefined); + + useEffect(() => { + const fetchData = async () => { + const prefs = await GetAppPreferences(); + setPreferences(prefs.preferences); + }; + fetchData(); + }, []); + + const playSounds = preferences?.play_sounds || false; + + const enqueueSuccessSound = orNone(preferences?.enqueue_success_sound); + const enqueueFailureSound = orNone(preferences?.enqueue_failure_sound); + const generationSuccessSound = orNone(preferences?.generation_success_sound); + const generationFailureSound = orNone(preferences?.generation_failure_sound); + + const reloadPreferences = async () => { + const prefs = await GetAppPreferences(); + setPreferences(prefs.preferences); + }; + + const setPlaySounds = async (checked: boolean) => { + //const value = checked ? "true" : "false"; + await UpdateAppPreferences({ + preference: PreferenceName.PlaySounds, + value: checked, + }); + await reloadPreferences(); + }; + + const setEnqueueSuccessSound = async (val: string) => { + let sendVal = val === "none" ? undefined : val; + await UpdateAppPreferences({ + preference: PreferenceName.EnqueueSuccessSound, + value: sendVal, + }); + SoundRegistry.getInstance().playSound(val); + await reloadPreferences(); + }; + + const setEnqueueFailureSound = async (val: string) => { + let sendVal = val === "none" ? undefined : val; + await UpdateAppPreferences({ + preference: PreferenceName.EnqueueFailureSound, + value: sendVal, + }); + SoundRegistry.getInstance().playSound(val); + await reloadPreferences(); + }; + + const setSuccessSound = async (val: string) => { + let sendVal = val === "none" ? undefined : val; + await UpdateAppPreferences({ + preference: PreferenceName.GenerationSuccessSound, + value: sendVal, + }); + SoundRegistry.getInstance().playSound(val); + await reloadPreferences(); + }; + + const setFailureSound = async (val: string) => { + let sendVal = val === "none" ? undefined : val; + await UpdateAppPreferences({ + preference: PreferenceName.GenerationFailureSound, + value: sendVal, + }); + SoundRegistry.getInstance().playSound(val); + await reloadPreferences(); + }; + + const playSound = (val?: string) => { + if (val !== undefined && val !== "none") { + SoundRegistry.getInstance().playSound(val); + } + }; + + return ( + <> +
+
+ + +
+ +
+ +
+ setEnqueueFailureSound(val as string)} + options={SOUND_OPTIONS} + className="grow" + /> +
+
+ +
+ +
+ setFailureSound(val as string)} + options={SOUND_OPTIONS} + className="grow" + /> +
+
+ +
+ + ); +}; + +const orNone = (val: string | undefined | null): string => { + if (!!!val) { + return "none"; + } + return val; +}; diff --git a/frontend/libs/components/settings-modal/src/lib/panes/MiscSettingsPane.tsx b/frontend/libs/components/settings-modal/src/lib/panes/MiscSettingsPane.tsx new file mode 100644 index 0000000000..df9615f776 --- /dev/null +++ b/frontend/libs/components/settings-modal/src/lib/panes/MiscSettingsPane.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from "react"; +import { Button } from "@storyteller/ui-button"; +import { + AppPreferencesPayload, + CustomDirectory, + GetAppPreferences, + SystemDirectory, +} from "@storyteller/tauri-api"; +import { PreferenceName, UpdateAppPreferences } from "@storyteller/tauri-api"; +import { open } from "@tauri-apps/plugin-dialog"; +import { Label } from "@storyteller/ui-label"; + +interface MiscSettingsPaneProps {} + +export const MiscSettingsPane = (args: MiscSettingsPaneProps) => { + const [preferences, setPreferences] = useState< + AppPreferencesPayload | undefined + >(undefined); + + useEffect(() => { + const fetchData = async () => { + const prefs = await GetAppPreferences(); + console.log("prefs", prefs); + setPreferences(prefs.preferences); + }; + fetchData(); + }, []); + + // NB: This might be a complex type. + const outerDownloadObject = preferences?.preferred_download_directory || {}; + const downloadDirectory = + "custom" in outerDownloadObject + ? (outerDownloadObject.custom as string) + : ""; + const currentDownloadLabel = + "system" in outerDownloadObject + ? "System Download Directory" + : downloadDirectory; + + const reloadPreferences = async () => { + const prefs = await GetAppPreferences(); + console.log("prefs", prefs); + setPreferences(prefs.preferences); + }; + + const openDirectoryPicker = async () => { + let directory = await open({ + multiple: false, + directory: true, + defaultPath: downloadDirectory || undefined, + }); + if (directory === null) { + return; // User dismissed the dialog choice + } + await UpdateAppPreferences({ + preference: PreferenceName.PreferredDownloadDirectory, + value: { + custom: directory, + } as CustomDirectory, + }); + await reloadPreferences(); + }; + + const clearDirectory = async () => { + await UpdateAppPreferences({ + preference: PreferenceName.PreferredDownloadDirectory, + value: { + system: "downloads", + } as SystemDirectory, + }); + await reloadPreferences(); + }; + + return ( +
+
+ +

+ This is where downloads are placed after downloading. The current path + is: +

+
+
{currentDownloadLabel}
+
+
+
+ + +
+
+ ); +}; diff --git a/frontend/libs/components/settings-modal/src/lib/panes/VideoSettingsPane.tsx b/frontend/libs/components/settings-modal/src/lib/panes/VideoSettingsPane.tsx new file mode 100644 index 0000000000..7148143036 --- /dev/null +++ b/frontend/libs/components/settings-modal/src/lib/panes/VideoSettingsPane.tsx @@ -0,0 +1,40 @@ +import { Select, SelectValue } from "@storyteller/ui-select"; +import { useState } from "react"; + +export const VideoSettingsPane = () => { + const [defaultVideoModel, setDefaultVideoModel] = useState("veo"); + const [humanVideoProvider, setHumanVideoProvider] = useState("artcraft"); + + return ( +
+
+ + setHumanVideoProvider(val as string)} + options={[ + { value: "artcraft", label: "ArtCraft" }, + { value: "pal", label: "Pal" }, + ]} + /> +
+
+ ); +}; diff --git a/frontend/libs/components/settings-modal/src/lib/settings-modal.tsx b/frontend/libs/components/settings-modal/src/lib/settings-modal.tsx index 7acf2e91c5..aaf15eb515 100644 --- a/frontend/libs/components/settings-modal/src/lib/settings-modal.tsx +++ b/frontend/libs/components/settings-modal/src/lib/settings-modal.tsx @@ -1,172 +1,53 @@ import { Modal } from "@storyteller/ui-modal"; -import { Button } from "@storyteller/ui-button"; import { useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faUser, - faVideo, - faImage, faCog, + faVolumeHigh, } from "@fortawesome/pro-solid-svg-icons"; import { twMerge } from "tailwind-merge"; -import { Input } from "@storyteller/ui-input"; -import { Select, SelectValue } from "@storyteller/ui-select"; +import { MiscSettingsPane } from "./panes/MiscSettingsPane"; +import { AudioSettingsPane } from "./panes/AudioSettingsPane"; +import { AccountSettingsPane } from "./panes/AccountSettings/AccountSettingsPane"; interface SettingsModalProps { isOpen: boolean; onClose: () => void; + globalAccountLogoutCallback: () => void; } -interface AccountInfo { - username: string; - subscription: string; - credits: number; -} - -type SettingsSection = "accounts" | "video" | "image" | "misc"; - -export const SettingsModal = ({ isOpen, onClose }: SettingsModalProps) => { - const [accountInfo] = useState({ - username: "Username [logout]", - subscription: "Pro-tier [change/upgrade]", - credits: 10233, - }); +//type SettingsSection = "misc" | "audio" | "accounts" | "video" | "image"; +type SettingsSection = "general" | "accounts" | "alerts"; +export const SettingsModal = ({ isOpen, onClose, globalAccountLogoutCallback }: SettingsModalProps) => { const [selectedSection, setSelectedSection] = - useState("accounts"); - const [palApiKey, setPalApiKey] = useState(""); - const [klingApiKey, setKlingApiKey] = useState(""); - const [defaultVideoModel, setDefaultVideoModel] = useState("veo"); - const [humanVideoProvider, setHumanVideoProvider] = useState("artcraft"); + useState("general"); const sections = [ + { id: "general" as const, label: "General", icon: faCog }, { id: "accounts" as const, label: "Accounts", icon: faUser }, - { id: "video" as const, label: "Video", icon: faVideo }, - { id: "image" as const, label: "Image", icon: faImage }, - { id: "misc" as const, label: "Misc", icon: faCog }, + { id: "alerts" as const, label: "Alerts", icon: faVolumeHigh }, + //{ id: "video" as const, label: "Video", icon: faVideo }, + //{ id: "image" as const, label: "Image", icon: faImage }, ]; const renderContent = () => { switch (selectedSection) { + case "alerts": + return ; + case "general": + return ; case "accounts": - return ( -
-
- ArtCraft Account: - {accountInfo.username} -
-
- ArtCraft Subscription: - {accountInfo.subscription} -
-
- Credits Remaining: - {accountInfo.credits} -
- -
-

- Note: You can optionally log into other accounts and use your - credits at those providers. Some features are only available via - 3rd party accounts, such as OpenAI / Sora GPT 4.0 Images. For - other features, ArtCraft credits are consumed unless you log - into your third party account. -

-
- -
- OpenAI / Sora Account: - -
-
- Google / Veo Account: - -
- -
-
- - setPalApiKey((e.target as any).value)} - placeholder="Enter API Key" - /> -
-
- - setKlingApiKey((e.target as any).value)} - placeholder="Enter API Key" - /> -
-
-
- ); - - case "video": - return ( -
-
- - - setHumanVideoProvider(val as string) - } - options={[ - { value: "artcraft", label: "ArtCraft" }, - { value: "pal", label: "Pal" }, - ]} - /> -
-
- ); - - case "image": - return ( -
- -
- ); - - case "misc": - return ( -
- -
- ); + return ; + //case "video": + // return ; + //case "image": + // return ( + //
+ // + //
+ // ); } }; @@ -202,7 +83,7 @@ export const SettingsModal = ({ isOpen, onClose }: SettingsModalProps) => { ))}
-
+

{sections.find((s) => s.id === selectedSection)?.label} diff --git a/frontend/libs/components/settings-modal/tsconfig.json b/frontend/libs/components/settings-modal/tsconfig.json index 4e61576c7a..e9a5e9dc5a 100644 --- a/frontend/libs/components/settings-modal/tsconfig.json +++ b/frontend/libs/components/settings-modal/tsconfig.json @@ -2,18 +2,33 @@ "files": [], "include": [], "references": [ + { + "path": "../modal" + }, + { + "path": "../label" + }, + { + "path": "../switch" + }, { "path": "../select" }, + { + "path": "../../soundboard" + }, + { + "path": "../../tauri-api" + }, + { + "path": "../../api" + }, { "path": "../input" }, { "path": "../button" }, - { - "path": "../modal" - }, { "path": "./tsconfig.lib.json" }, diff --git a/frontend/libs/components/settings-modal/tsconfig.lib.json b/frontend/libs/components/settings-modal/tsconfig.lib.json index 23d2410384..4bd87f0f93 100644 --- a/frontend/libs/components/settings-modal/tsconfig.lib.json +++ b/frontend/libs/components/settings-modal/tsconfig.lib.json @@ -43,17 +43,32 @@ "src/**/*.tsx" ], "references": [ + { + "path": "../modal/tsconfig.lib.json" + }, + { + "path": "../label/tsconfig.lib.json" + }, + { + "path": "../switch/tsconfig.lib.json" + }, { "path": "../select/tsconfig.lib.json" }, + { + "path": "../../soundboard/tsconfig.lib.json" + }, + { + "path": "../../tauri-api/tsconfig.lib.json" + }, + { + "path": "../../api/tsconfig.lib.json" + }, { "path": "../input/tsconfig.lib.json" }, { "path": "../button/tsconfig.lib.json" - }, - { - "path": "../modal/tsconfig.lib.json" } ] } diff --git a/frontend/libs/components/switch/.babelrc b/frontend/libs/components/switch/.babelrc new file mode 100644 index 0000000000..1ea870ead4 --- /dev/null +++ b/frontend/libs/components/switch/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/frontend/libs/components/switch/README.md b/frontend/libs/components/switch/README.md new file mode 100644 index 0000000000..8260eb0135 --- /dev/null +++ b/frontend/libs/components/switch/README.md @@ -0,0 +1,7 @@ +# @storyteller/ui-switch + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test @storyteller/ui-switch` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/frontend/libs/components/switch/package.json b/frontend/libs/components/switch/package.json new file mode 100644 index 0000000000..70e60b78ac --- /dev/null +++ b/frontend/libs/components/switch/package.json @@ -0,0 +1,17 @@ +{ + "name": "@storyteller/ui-switch", + "version": "0.0.1", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + } +} diff --git a/frontend/libs/components/switch/src/index.ts b/frontend/libs/components/switch/src/index.ts new file mode 100644 index 0000000000..3f2441cfda --- /dev/null +++ b/frontend/libs/components/switch/src/index.ts @@ -0,0 +1,2 @@ + +export * from './lib/switch'; diff --git a/frontend/libs/components/switch/src/lib/switch.module.css b/frontend/libs/components/switch/src/lib/switch.module.css new file mode 100644 index 0000000000..d0246c74b9 --- /dev/null +++ b/frontend/libs/components/switch/src/lib/switch.module.css @@ -0,0 +1,7 @@ +/* + * Replace this with your own classes + * + * e.g. + * .container { + * } +*/ \ No newline at end of file diff --git a/frontend/libs/components/switch/src/lib/switch.spec.tsx b/frontend/libs/components/switch/src/lib/switch.spec.tsx new file mode 100644 index 0000000000..364763baec --- /dev/null +++ b/frontend/libs/components/switch/src/lib/switch.spec.tsx @@ -0,0 +1,12 @@ +import { render } from '@testing-library/react'; + +import Switch from './switch'; + +describe('Switch', () => { + + it('should render successfully', () => { + const { baseElement } = render(); + expect(baseElement).toBeTruthy(); + }); + +}); diff --git a/frontend/libs/components/switch/src/lib/switch.tsx b/frontend/libs/components/switch/src/lib/switch.tsx new file mode 100644 index 0000000000..5561b4eeda --- /dev/null +++ b/frontend/libs/components/switch/src/lib/switch.tsx @@ -0,0 +1,36 @@ +import { Switch as HeadlessSwitch } from "@headlessui/react"; +import clsx from "clsx"; +import { Fragment } from "react"; + +interface SwitchProps { + enabled: boolean; + setEnabled: (enabled: boolean) => void; + className?: string; +} + +export function Switch({ enabled, setEnabled, className }: SwitchProps) { + return ( + + {({ checked, disabled }) => ( + + )} + + ); +} + +export default Switch; diff --git a/frontend/libs/components/switch/tsconfig.json b/frontend/libs/components/switch/tsconfig.json new file mode 100644 index 0000000000..b3d19d8f5e --- /dev/null +++ b/frontend/libs/components/switch/tsconfig.json @@ -0,0 +1,13 @@ +{ + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/frontend/libs/components/switch/tsconfig.lib.json b/frontend/libs/components/switch/tsconfig.lib.json new file mode 100644 index 0000000000..f0782e3dd8 --- /dev/null +++ b/frontend/libs/components/switch/tsconfig.lib.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts", + "vite/client" + ], + "rootDir": "src", + "jsx": "react-jsx", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo" + }, + "exclude": [ + "out-tsc", + "dist", + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx", + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx" + ], + "include": [ + "src/**/*.js", + "src/**/*.jsx", + "src/**/*.ts", + "src/**/*.tsx" + ] +} diff --git a/frontend/libs/components/switch/tsconfig.spec.json b/frontend/libs/components/switch/tsconfig.spec.json new file mode 100644 index 0000000000..18b7de51b8 --- /dev/null +++ b/frontend/libs/components/switch/tsconfig.spec.json @@ -0,0 +1,34 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/vitest", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ], + "jsx": "react-jsx" + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/frontend/libs/components/switch/vite.config.ts b/frontend/libs/components/switch/vite.config.ts new file mode 100644 index 0000000000..63d9fe0a36 --- /dev/null +++ b/frontend/libs/components/switch/vite.config.ts @@ -0,0 +1,49 @@ +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import dts from 'vite-plugin-dts'; +import * as path from 'path'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/components/switch', + plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })], + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + // Configuration for building your library. + // See: https://vitejs.dev/guide/build.html#library-mode + build: { + outDir: './dist', + emptyOutDir: true, + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + lib: { + // Could also be a dictionary or array of multiple entry points. + entry: 'src/index.ts', + name: '@storyteller/ui-switch', + fileName: 'index', + // Change this to the formats you want to support. + // Don't forget to update your package.json as well. + formats: ['es' as const] + }, + rollupOptions: { + // External packages that should not be bundled into your library. + external: ['react','react-dom','react/jsx-runtime'] + }, + }, + test: { + watch: false, + globals: true, + environment: 'jsdom', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + } + }, +})); diff --git a/frontend/libs/components/tab-selector/src/lib/tab-selector.tsx b/frontend/libs/components/tab-selector/src/lib/tab-selector.tsx index ee88c1feda..d0c46b9f1e 100644 --- a/frontend/libs/components/tab-selector/src/lib/tab-selector.tsx +++ b/frontend/libs/components/tab-selector/src/lib/tab-selector.tsx @@ -82,6 +82,7 @@ export const TabSelector: React.FC = ({ className={({ selected }) => twMerge( "relative z-20 mx-0.5 min-w-max rounded-md border-2 border-transparent px-4 py-0.5 text-center text-sm font-semibold transition-all duration-200 ease-in-out", + "focus-visible:outline-none focus-visible:ring-0", selected ? "text-white" : "text-gray-300 hover:text-white", disabled ? "cursor-not-allowed opacity-60" : "" ) diff --git a/frontend/libs/tauri-api/.babelrc b/frontend/libs/tauri-api/.babelrc new file mode 100644 index 0000000000..1ea870ead4 --- /dev/null +++ b/frontend/libs/tauri-api/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/frontend/libs/tauri-api/.eslintrc.json b/frontend/libs/tauri-api/.eslintrc.json new file mode 100644 index 0000000000..def19dc990 --- /dev/null +++ b/frontend/libs/tauri-api/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": [ + "plugin:@nx/react", + "../../.eslintrc.json" + ], + "ignorePatterns": [ + "!**/*", + "**/vite.config.*.timestamp*", + "**/vitest.config.*.timestamp*" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": {} + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "rules": {} + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "rules": {} + } + ] +} diff --git a/frontend/libs/tauri-api/README.md b/frontend/libs/tauri-api/README.md new file mode 100644 index 0000000000..eedd302338 --- /dev/null +++ b/frontend/libs/tauri-api/README.md @@ -0,0 +1,7 @@ +# @frontend/tauri-api + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test @frontend/tauri-api` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/frontend/libs/tauri-api/package.json b/frontend/libs/tauri-api/package.json new file mode 100644 index 0000000000..03ab315e9e --- /dev/null +++ b/frontend/libs/tauri-api/package.json @@ -0,0 +1,20 @@ +{ + "name": "@storyteller/tauri-api", + "version": "0.0.1", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "dependencies": { + "@tauri-apps/api": "^2.4.0" + } +} diff --git a/frontend/libs/tauri-api/src/index.ts b/frontend/libs/tauri-api/src/index.ts new file mode 100644 index 0000000000..56c11deb4a --- /dev/null +++ b/frontend/libs/tauri-api/src/index.ts @@ -0,0 +1,7 @@ +export * from "./lib/common/CommandStatus.js"; +export * from "./lib/settings/GetAppPreferences.js"; +export * from "./lib/settings/UpdateAppPreference.js"; +export * from "./lib/sora/CheckSoraSession.js"; +export * from "./lib/sora/LogoutSoraSession.js"; +export * from "./lib/sora/SoraImageRemix.js"; +export * from "./lib/sora/useSoraLoginListener.js"; diff --git a/frontend/libs/tauri-api/src/lib/common/CommandStatus.ts b/frontend/libs/tauri-api/src/lib/common/CommandStatus.ts new file mode 100644 index 0000000000..b47a520ea4 --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/common/CommandStatus.ts @@ -0,0 +1,17 @@ + + +export enum CommandSuccessStatus { + Success = "success", +} + +export enum CommandErrorStatus { + BadRequest = "bad_request", + Unauthorized = "unauthorized", + ServerError = "server_error", +} + +export type CommandStatus = CommandSuccessStatus | CommandErrorStatus; + +export interface CommandResult { + status: CommandStatus; +} diff --git a/frontend/libs/tauri-api/src/lib/settings/GetAppPreferences.ts b/frontend/libs/tauri-api/src/lib/settings/GetAppPreferences.ts new file mode 100644 index 0000000000..03777ee460 --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/settings/GetAppPreferences.ts @@ -0,0 +1,38 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface GetAppPreferencesResult { + preferences: AppPreferencesPayload, +} + +export interface AppPreferencesPayload { + // Preferred download directory + preferred_download_directory: PreferredDownloadDirectory, + + // Play sounds on events. + play_sounds: boolean, + + // Sound names to play. + // These are keys, not filenames, and are defined by the frontend. + enqueue_success_sound?: string, + enqueue_failure_sound?: string, + generation_success_sound?: string, + generation_failure_sound?: string, + generation_enqueue_sound?: string, +} + +export type PreferredDownloadDirectory = SystemDirectory | CustomDirectory; + +export interface SystemDirectory { + // If the directory is a system directory. + system: string, +} + +export interface CustomDirectory { + // If the directory is a custom user directory. + custom: string, +} + +export const GetAppPreferences = async () : Promise => { + let result = await invoke("get_app_preferences_command"); + return (result as GetAppPreferencesResult); +} diff --git a/frontend/libs/tauri-api/src/lib/settings/UpdateAppPreference.ts b/frontend/libs/tauri-api/src/lib/settings/UpdateAppPreference.ts new file mode 100644 index 0000000000..4cd129a670 --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/settings/UpdateAppPreference.ts @@ -0,0 +1,32 @@ + +import { invoke } from "@tauri-apps/api/core"; +import { PreferredDownloadDirectory } from "./GetAppPreferences"; + +export interface UpdateAppPreferencesRequest { + preference: PreferenceName, + value: undefined | string | boolean | PreferredDownloadDirectory, +} + +export enum PreferenceName { + PreferredDownloadDirectory = "preferred_download_directory", + PlaySounds = "play_sounds", + EnqueueSuccessSound = "enqueue_success_sound", + EnqueueFailureSound = "enqueue_failure_sound", + GenerationSuccessSound = "generation_success_sound", + GenerationFailureSound = "generation_failure_sound", + GenerationEnqueueSound = "generation_enqueue_sound", +} + +export interface UpdateAppPreferencesResult { + success: boolean +} + +export const UpdateAppPreferences = async (request: UpdateAppPreferencesRequest) : Promise => { + let result = await invoke("update_app_preferences_command", { + request: { + preference: request.preference, + value: request.value, + } + }); + return (result as UpdateAppPreferencesResult); +} diff --git a/frontend/libs/tauri-api/src/lib/sora/CheckSoraSession.ts b/frontend/libs/tauri-api/src/lib/sora/CheckSoraSession.ts new file mode 100644 index 0000000000..69cc587692 --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/sora/CheckSoraSession.ts @@ -0,0 +1,18 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface CheckSoraSessionResult { + state: SoraSessionState; + maybe_account_email?: string; +} + +export enum SoraSessionState { + NotSetUp = "not_set_up", + ExpiredOrError = "expired_or_error", + Valid = "valid", +} + +export const CheckSoraSession = async (): Promise => { + const result = await invoke("check_sora_session_command"); + console.log(">>> sora result", result); + return result as CheckSoraSessionResult; +}; diff --git a/frontend/libs/tauri-api/src/lib/sora/LogoutSoraSession.ts b/frontend/libs/tauri-api/src/lib/sora/LogoutSoraSession.ts new file mode 100644 index 0000000000..eb2be0d3c2 --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/sora/LogoutSoraSession.ts @@ -0,0 +1,5 @@ +import { invoke } from "@tauri-apps/api/core"; + +export const LogoutSoraSession = async (): Promise => { + return await invoke("sora_logout_command"); +}; diff --git a/frontend/libs/tauri-api/src/lib/sora/SoraImageRemix.ts b/frontend/libs/tauri-api/src/lib/sora/SoraImageRemix.ts new file mode 100644 index 0000000000..4535b4f177 --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/sora/SoraImageRemix.ts @@ -0,0 +1,48 @@ + +import { invoke } from "@tauri-apps/api/core"; +import { CommandResult } from "../common/CommandStatus"; + +export interface SoraImageRemixRequest { + // The media token of the visible screen snapshot + snapshot_media_token: string, + + // Whether to disable (our) system prompt. + disable_system_prompt: boolean, + + // The text prompt + prompt: string, + + // Additional images (media tokens) to use as reference. + maybe_additional_images: string[], + + // Typically "1" + maybe_number_of_samples: number, +} + +export interface SoraImageRemixResult extends CommandResult { + // TODO: Status fields (they're not on the backend yet) +} + +// Returns the Success and Error variants directly. +// Throws on Network/Tauri errors. +export const SoraImageRemix = async (request: SoraImageRemixRequest) : Promise => { + try { + return await invoke("sora_image_remix_command", { + request: { + snapshot_media_token: request.snapshot_media_token, + disable_system_prompt: request.disable_system_prompt, + prompt: request.prompt, + maybe_additional_images: request.maybe_additional_images, + maybe_number_of_samples: request.maybe_number_of_samples, + }, + }) as SoraImageRemixResult; + } catch (error) { + let maybeTypedError = error as CommandResult; + if ("status" in maybeTypedError) { + return maybeTypedError + } else { + // Something else with Tauri, network, etc. + throw error; + } + } +} diff --git a/frontend/libs/tauri-api/src/lib/sora/useSoraLoginListener.ts b/frontend/libs/tauri-api/src/lib/sora/useSoraLoginListener.ts new file mode 100644 index 0000000000..40cb23a8d0 --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/sora/useSoraLoginListener.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; + +type SoraLoginSuccessPayload = { + // TODO +}; + +export function useSoraLoginListener( + onSuccess: (payload: SoraLoginSuccessPayload) => void +) { + useEffect(() => { + let unlisten: () => void; + + const setupListener = async () => { + const stop = await listen( + "sora-login-success", + (event) => { + onSuccess(event.payload); + } + ); + unlisten = stop; + }; + + setupListener(); + + return () => { + if (unlisten) unlisten(); + }; + }, [onSuccess]); +} diff --git a/frontend/libs/tauri-api/src/server.ts b/frontend/libs/tauri-api/src/server.ts new file mode 100644 index 0000000000..82a91ac5e2 --- /dev/null +++ b/frontend/libs/tauri-api/src/server.ts @@ -0,0 +1 @@ +// This file should be used to export ONLY server-code from the library. \ No newline at end of file diff --git a/frontend/libs/tauri-api/src/test-setup.ts b/frontend/libs/tauri-api/src/test-setup.ts new file mode 100644 index 0000000000..59f7836d9c --- /dev/null +++ b/frontend/libs/tauri-api/src/test-setup.ts @@ -0,0 +1,3 @@ +import { installGlobals } from '@remix-run/node'; +import "@testing-library/jest-dom/matchers"; +installGlobals(); \ No newline at end of file diff --git a/frontend/libs/tauri-api/tsconfig.json b/frontend/libs/tauri-api/tsconfig.json new file mode 100644 index 0000000000..8c5fc025ff --- /dev/null +++ b/frontend/libs/tauri-api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/frontend/libs/tauri-api/tsconfig.lib.json b/frontend/libs/tauri-api/tsconfig.lib.json new file mode 100644 index 0000000000..c43e2edbaa --- /dev/null +++ b/frontend/libs/tauri-api/tsconfig.lib.json @@ -0,0 +1,48 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts", + "vite/client" + ], + "rootDir": "src", + "jsx": "react-jsx", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo" + }, + "exclude": [ + "out-tsc", + "dist", + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx", + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "eslint.config.js", + "eslint.config.cjs", + "eslint.config.mjs" + ], + "include": [ + "src/**/*.js", + "src/**/*.jsx", + "src/**/*.ts", + "src/**/*.tsx" + ] +} diff --git a/frontend/libs/tauri-api/tsconfig.spec.json b/frontend/libs/tauri-api/tsconfig.spec.json new file mode 100644 index 0000000000..c1faced82c --- /dev/null +++ b/frontend/libs/tauri-api/tsconfig.spec.json @@ -0,0 +1,34 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/vitest", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ], + "jsx": "react-jsx" + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/frontend/libs/tauri-api/vite.config.ts b/frontend/libs/tauri-api/vite.config.ts new file mode 100644 index 0000000000..b5478fa6ee --- /dev/null +++ b/frontend/libs/tauri-api/vite.config.ts @@ -0,0 +1,49 @@ +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import dts from 'vite-plugin-dts'; +import * as path from 'path'; + +export default defineConfig(() => ({ +root: __dirname, +cacheDir: '../../node_modules/.vite/libs/tauri-api', +plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })], +// Uncomment this if you are using workers. +// worker: { +// plugins: [ nxViteTsPaths() ], +// }, +// Configuration for building your library. +// See: https://vitejs.dev/guide/build.html#library-mode +build: { +outDir: './dist', +emptyOutDir: true, +reportCompressedSize: true, +commonjsOptions: { +transformMixedEsModules: true, +}, +lib: { +// Could also be a dictionary or array of multiple entry points. +entry: 'src/index.ts', +name: '@frontend/tauri-api', +fileName: 'index', +// Change this to the formats you want to support. +// Don't forget to update your package.json as well. +formats: ['es' as const] +}, +rollupOptions: { +// External packages that should not be bundled into your library. +external: ['react','react-dom','react/jsx-runtime'] +}, +}, +test: {setupFiles: ['./src/test-setup.ts'], +watch: false, +globals: true, +environment: 'jsdom', +include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], +reporters: ['default'], +coverage: { +reportsDirectory: './test-output/vitest/coverage', +provider: 'v8' as const, +} +}, +})); \ No newline at end of file diff --git a/frontend/libs/tauri-utils/package.json b/frontend/libs/tauri-utils/package.json index 5f240e6b96..de57dc8464 100644 --- a/frontend/libs/tauri-utils/package.json +++ b/frontend/libs/tauri-utils/package.json @@ -13,5 +13,8 @@ "import": "./dist/index.js", "default": "./dist/index.js" } + }, + "dependencies": { + "@tauri-apps/plugin-http": "^2.4.3" } } diff --git a/frontend/libs/tauri-utils/src/lib/api/GetAppPreferences.ts b/frontend/libs/tauri-utils/src/lib/api/GetAppPreferences.ts new file mode 100644 index 0000000000..a8f60aab24 --- /dev/null +++ b/frontend/libs/tauri-utils/src/lib/api/GetAppPreferences.ts @@ -0,0 +1,2 @@ + +export const TEST = 1; \ No newline at end of file diff --git a/frontend/libs/tauri-utils/src/lib/api/index.ts b/frontend/libs/tauri-utils/src/lib/api/index.ts new file mode 100644 index 0000000000..cd0c6fe954 --- /dev/null +++ b/frontend/libs/tauri-utils/src/lib/api/index.ts @@ -0,0 +1,2 @@ + +export * from './GetAppPreferences'; \ No newline at end of file diff --git a/frontend/libs/tauri-utils/src/lib/tauri-utils.tsx b/frontend/libs/tauri-utils/src/lib/tauri-utils.tsx index 62526e40f7..8354df73dd 100644 --- a/frontend/libs/tauri-utils/src/lib/tauri-utils.tsx +++ b/frontend/libs/tauri-utils/src/lib/tauri-utils.tsx @@ -1,2 +1,3 @@ +export * from './api'; +export { FetchProxy } from './FetchProxy'; export { IsDesktopApp } from './IsDesktopApp'; -export { FetchProxy } from './FetchProxy'; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7ad04dd996..cf292008a3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2241,6 +2241,10 @@ "name": "@storyteller/ui-loading-spinner", "version": "0.0.1" }, + "libs/components/login-modal": { + "name": "@storyteller/ui-login-modal", + "version": "0.0.1" + }, "libs/components/modal": { "name": "@storyteller/ui-modal", "version": "0.0.1" @@ -2259,12 +2263,19 @@ }, "libs/components/settings-modal": { "name": "@storyteller/ui-settings-modal", - "version": "0.0.1" + "version": "0.0.1", + "dependencies": { + "@tauri-apps/plugin-dialog": "^2.2.1" + } }, "libs/components/slider-v2": { "name": "@storyteller/ui-sliderv2", "version": "0.0.1" }, + "libs/components/switch": { + "name": "@storyteller/ui-switch", + "version": "0.0.1" + }, "libs/components/tab-selector": { "name": "@storyteller/ui-tab-selector", "version": "0.0.1" @@ -2295,9 +2306,19 @@ "@types/howler": "^2.2.12" } }, + "libs/tauri-api": { + "name": "@storyteller/tauri-api", + "version": "0.0.1", + "dependencies": { + "@tauri-apps/api": "^2.4.0" + } + }, "libs/tauri-utils": { "name": "@storyteller/tauri-utils", - "version": "0.0.1" + "version": "0.0.1", + "dependencies": { + "@tauri-apps/plugin-http": "^2.4.3" + } }, "node_modules/@adobe/css-tools": { "version": "4.4.2", @@ -10254,6 +10275,10 @@ "resolved": "libs/soundboard", "link": true }, + "node_modules/@storyteller/tauri-api": { + "resolved": "libs/tauri-api", + "link": true + }, "node_modules/@storyteller/tauri-utils": { "resolved": "libs/tauri-utils", "link": true @@ -10294,6 +10319,10 @@ "resolved": "libs/components/loading-spinner", "link": true }, + "node_modules/@storyteller/ui-login-modal": { + "resolved": "libs/components/login-modal", + "link": true + }, "node_modules/@storyteller/ui-modal": { "resolved": "libs/components/modal", "link": true @@ -10318,6 +10347,10 @@ "resolved": "libs/components/slider-v2", "link": true }, + "node_modules/@storyteller/ui-switch": { + "resolved": "libs/components/switch", + "link": true + }, "node_modules/@storyteller/ui-tab-selector": { "resolved": "libs/components/tab-selector", "link": true @@ -11157,6 +11190,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.2.1.tgz", + "integrity": "sha512-wZmCouo4PgTosh/UoejPw9DPs6RllS5Pp3fuOV2JobCu36mR5AXU2MzU9NZiVaFi/5Zfc8RN0IhcZHnksJ1o8A==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, "node_modules/@tauri-apps/plugin-http": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.4.3.tgz", diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index ab8aee409b..3c6736b982 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -80,6 +80,15 @@ }, { "path": "./libs/common" + }, + { + "path": "./libs/tauri-api" + }, + { + "path": "./libs/components/login-modal" + }, + { + "path": "./libs/components/switch" } ] } diff --git a/package-lock.json b/package-lock.json index 96caf5298b..b1edbbf68d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/plugin-upload": "^2.2.1" }, "devDependencies": { @@ -238,6 +239,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.2.1.tgz", + "integrity": "sha512-wZmCouo4PgTosh/UoejPw9DPs6RllS5Pp3fuOV2JobCu36mR5AXU2MzU9NZiVaFi/5Zfc8RN0IhcZHnksJ1o8A==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, "node_modules/@tauri-apps/plugin-upload": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-upload/-/plugin-upload-2.2.1.tgz", diff --git a/package.json b/package.json index e0945f99fe..bc8c6d2055 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "@tauri-apps/cli": "^2.5.0" }, "dependencies": { + "@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/plugin-upload": "^2.2.1" } } diff --git a/script/artcraft/unix_build.sh b/script/artcraft/unix_build.sh new file mode 100755 index 0000000000..9678967b6d --- /dev/null +++ b/script/artcraft/unix_build.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# This works on Linux and MacOS to build production Artcraft + +set -euxo pipefail + +echo "Building production Artcraft..." +echo "" + +root_dir=$(pwd) +frontend_path="${root_dir}/frontend" +rust_crate_path="${root_dir}/crates/desktop/artcraft" + +# The tauri dev server integration is super annoying: It eats ctrl-c interrupts, +# it decoheres the and corrupts terminal output, and it's slow. This configuration +# allows us to start without it. Simply launch `nx` and the dev server as a separate +# process and leave tauri out of the loop +config_path="${rust_crate_path}/tauri.artcraft_3d.no_dev.conf.toml" + +pushd "${frontend_path}" || exit + +npm install + +popd || exit + +export TAURI_FRONTEND_PATH="${frontend_path}" +export TAURI_APP_PATH="${rust_crate_path}" +export VITE_ENVIRONMENT_TYPE="production" + +cargo tauri build --config "${config_path}" + +echo "Done!" diff --git a/script/artcraft/unix_frontend_dev.sh b/script/artcraft/unix_frontend_dev.sh new file mode 100755 index 0000000000..d374feff44 --- /dev/null +++ b/script/artcraft/unix_frontend_dev.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# This works on Linux and MacOS to launch the frontend dev server + +root_dir=$(pwd) +frontend_path="${root_dir}/frontend" + +echo "Running Artcraft Frontend in Dev Mode..." +echo "" + +echo "You'll need to launch the Rust dev server as a second script!" +echo "" + +# Kill any process running on port 5741, which will block startup +if lsof -i tcp:5173 &>/dev/null; then + lsof -i tcp:5173 -t | xargs kill -9 + echo "Killed process running on port 5173" +else + echo "No process running on port 5173" +fi + +pushd "${frontend_path}" || exit + +npm install + +export VITE_ENVIRONMENT_TYPE="production" + +nx dev editor3d + +popd || exit diff --git a/script/artcraft/unix_rust_dev.sh b/script/artcraft/unix_rust_dev.sh new file mode 100755 index 0000000000..ce7ce7b7f5 --- /dev/null +++ b/script/artcraft/unix_rust_dev.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# This works on Linux and MacOS to launch the Rust dev server + +set -euxo pipefail + +echo "Running Artcraft Rust in Dev Mode..." +echo "" + +echo "You'll need to launch the frontend dev server as a second script!" +echo "" + +root_dir=$(pwd) +frontend_path="${root_dir}/frontend" +rust_crate_path="${root_dir}/crates/desktop/artcraft" + +# The tauri dev server integration is super annoying: It eats ctrl-c interrupts, +# it decoheres the and corrupts terminal output, and it's slow. This configuration +# allows us to start without it. Simply launch `nx` and the dev server as a separate +# process and leave tauri out of the loop +config_path="${rust_crate_path}/tauri.artcraft_3d.no_dev.conf.toml" + +export TAURI_FRONTEND_PATH="${frontend_path}" +export TAURI_APP_PATH="${rust_crate_path}" + +RUSTFLAGS="-Awarnings" cargo tauri dev \ + --no-dev-server \ + --no-dev-server-wait \ + --no-watch \ + --config "${config_path}" diff --git a/script/artcraft/windows_build.ps1 b/script/artcraft/windows_build.ps1 new file mode 100644 index 0000000000..25a865738a --- /dev/null +++ b/script/artcraft/windows_build.ps1 @@ -0,0 +1,36 @@ +# Build Production Artcraft + +Write-Host "Building production Artcraft..." +Write-Host "" + +try +{ + Push-Location -Path ".\frontend" + + Write-Host "Installing latest dependencies..." + Write-Host "" + Write-Host "You may need to run ./clean_modules.sh or manually clean up occasionally!" -ForegroundColor red -BackgroundColor white + Write-Host "" + + npm install +} +finally +{ + Pop-Location +} + +$env:VITE_ENVIRONMENT_TYPE="production" + +# This tells Tauri *which* frontend and *which* Rust app to use since we're in a monorepo with several apps. +$env:TAURI_FRONTEND_PATH=".\frontend" +$env:TAURI_APP_PATH=".\crates\desktop\artcraft" + +# The config file tells Tauri more instructions for the frontend build. +cargo tauri build --config ".\crates\desktop\artcraft\tauri.artcraft_3d.no_dev.conf.json" + +Write-Host "" +Write-Host "Production Build Done!" -ForegroundColor green -BackgroundColor white +Write-Host "" +Write-Host "You can find the installer in: target\release\bundle\nsis\ArtCraft_(version_info)-setup.exe" + +Start-Process "explorer.exe" -ArgumentList ".\target\release\bundle\nsis" diff --git a/script/artcraft/windows_frontend_dev.ps1 b/script/artcraft/windows_frontend_dev.ps1 new file mode 100644 index 0000000000..0e7c70d0ab --- /dev/null +++ b/script/artcraft/windows_frontend_dev.ps1 @@ -0,0 +1,31 @@ +# This runs Artcraft Frontend in dev mode on Windows + +Write-Host "Running Artcraft Frontend in Dev Mode..." +Write-Host "" +Write-Host "You'll need to launch the Rust dev server as a second script!" -ForegroundColor red -BackgroundColor white +Write-Host "" + +Push-Location -Path ".\frontend" + +$env:VITE_ENVIRONMENT_TYPE="production" + +try +{ + Write-Host "Installing latest dependencies..." + Write-Host "" + Write-Host "You may need to run ./clean_modules.sh or manually clean up occasionally!" -ForegroundColor red -BackgroundColor white + Write-Host "" + + npm install + + Write-Host "Running dev server..." + Write-Host "" + Write-Host "If this isn't running on port 5173, you may need to kill a zombie process!" -ForegroundColor red -BackgroundColor white + Write-Host "" + + nx dev editor3d +} +finally +{ + Pop-Location +} diff --git a/script/artcraft/windows_rust_dev.ps1 b/script/artcraft/windows_rust_dev.ps1 new file mode 100644 index 0000000000..d66cfd39f0 --- /dev/null +++ b/script/artcraft/windows_rust_dev.ps1 @@ -0,0 +1,13 @@ +# This runs Artcraft Rust in dev mode on Windows + +Write-Host "Running Artcraft Rust in Dev Mode..." +Write-Host "" +Write-Host "You'll need to launch the frontend dev server as a second script!" -ForegroundColor red -BackgroundColor white +Write-Host "" + +# This tells Tauri *which* frontend and *which* Rust app to use since we're in a monorepo with several apps. +$env:TAURI_FRONTEND_PATH=".\frontend" +$env:TAURI_APP_PATH=".\crates\desktop\artcraft" + +# The config file tells Tauri more instructions for the frontend build. +cargo tauri dev --config ".\crates\desktop\artcraft\tauri.artcraft_3d.no_dev.conf.json"