Merge branch 'main' into feature/merge-2d-3d-diverge

This commit is contained in:
Michael Chung committed 2025-05-08 23:46:08 -04:00
commit 74b8f9ace5
168 files changed
+4977 -831

No files matched your search

Generated
+594 -63
View File
@@ -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",
]
-1
View File
@@ -25,7 +25,6 @@ members = [
# Desktop apps
"crates/desktop/artcraft",
"crates/desktop/tauri-realtime",
# Shared libraries
"crates/lib/actix_cors_configs",
+1
View File
@@ -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"
@@ -25,6 +25,7 @@
"url": "https://private.tauri.app"
}
]
}
},
"dialog:default"
]
}
@@ -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<String>,
/// Key pointing to file; defined in the frontend code.
/// Defined for enqueue since image enqueue can be async
pub enqueue_failure_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
pub generation_success_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
pub generation_failure_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
#[deprecated]
pub generation_enqueue_sound: Option<String>,
}
@@ -42,5 +61,10 @@ async fn get_prefs(app_prefs: &AppPreferencesManager) -> AnyhowResult<AppPrefere
Ok(AppPreferencesPayload {
preferred_download_directory: prefs.preferred_download_directory,
play_sounds: prefs.play_sounds,
enqueue_success_sound: prefs.enqueue_success_sound,
enqueue_failure_sound: prefs.enqueue_failure_sound,
generation_success_sound: prefs.generation_success_sound,
generation_failure_sound: prefs.generation_failure_sound,
generation_enqueue_sound: prefs.generation_enqueue_sound,
})
}
@@ -1,8 +1,10 @@
use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager;
use crate::state::app_preferences::preferred_download_directory::PreferredDownloadDirectory;
use crate::state::data_dir::app_data_root::AppDataRoot;
use anyhow::anyhow;
use errors::AnyhowResult;
use log::{error, info};
use openai_sora_client::sora_error::SoraError::AnyhowError;
use serde_derive::{Deserialize, Serialize};
use tauri::{AppHandle, State};
@@ -11,7 +13,15 @@ use tauri::{AppHandle, State};
pub struct UpdateAppPreferencesRequest {
pub preference: PreferenceName,
/// We'll decode this with respect to the preference value.
pub value: String,
pub value: Option<ValueType>,
}
#[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::<PreferredDownloadDirectory>()?;
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::<bool>()?;
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<String> {
match value {
ValueType::String(val) => Ok(val.to_string()),
_ => Err(anyhow!("Invalid value type: {:?}", value)),
}
}
@@ -0,0 +1,119 @@
use serde::Serialize;
use std::error::Error;
use std::str::FromStr;
/// Easy to use Result<T, E> type.
pub type CommandResult<T, E> = Result<CommandSuccessResponseWrapper<T>, CommandErrorResponseWrapper<E>>;
/// 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<T: Serialize> {
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload: Option<T>,
}
#[derive(Serialize, Debug)]
pub struct CommandErrorResponseWrapper<T: Serialize> {
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_details: Option<T>,
}
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 <T> From<T> for CommandSuccessResponseWrapper<T> where T: Serialize + SerializeMarker {
fn from(val: T) -> Self {
CommandSuccessResponseWrapper {
status: CommandSuccessStatus::Success,
success_message: None,
payload: Some(val),
}
}
}
impl From<&str> for CommandSuccessResponseWrapper<String> {
fn from(msg: &str) -> Self {
CommandSuccessResponseWrapper {
status: CommandSuccessStatus::Success,
success_message: Some(msg.to_string()),
payload: None,
}
}
}
impl From<&str> for CommandErrorResponseWrapper<String> {
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"}"#);
}
}
@@ -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;
@@ -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<String>,
}
#[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<CheckSoraSessionCommand, CheckSoraSessionCommand> {
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<CheckSoraSessionCommand> {
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,
})
}
@@ -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;
@@ -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<Url> = 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<Url> = 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<String, String> {
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(())
@@ -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<String, String> {
) -> 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(
@@ -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())
}
+4 -1
View File
@@ -1 +1,4 @@
pub mod notification_event;
pub mod notification_event;
pub mod sendable_event_error;
pub mod sendable_event_trait;
pub mod sora;
@@ -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<anyhow::Error> for SendableEventError {
fn from(err: anyhow::Error) -> Self {
SendableEventError::AnyhowError(err)
}
}
impl From<tauri::Error> for SendableEventError {
fn from(err: tauri::Error) -> Self {
SendableEventError::TauriError(err)
}
}
@@ -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(())
}
}
@@ -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;
@@ -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";
}
@@ -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";
}
@@ -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";
}
@@ -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";
}
@@ -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";
}
+26 -4
View File
@@ -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");
@@ -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<String>,
/// Key pointing to file; defined in the frontend code.
/// Defined for enqueue since image enqueue can be async
pub enqueue_failure_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
pub generation_success_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
pub generation_failure_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
#[deprecated]
pub generation_enqueue_sound: Option<String>,
}
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)
}
}
@@ -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<PreferredDownloadDirectory>,
/// Play sounds on events.
pub play_sounds: Option<bool>,
/// Key pointing to file; defined in the frontend code.
pub generation_success_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
pub generation_failure_sound: Option<String>,
/// Key pointing to file; defined in the frontend code.
pub generation_enqueue_sound: Option<String>,
}
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
}
}
@@ -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<Self, Self::Err> {
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")));
}
}
@@ -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
@@ -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(())
@@ -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<Self> {
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<Self> {
let position = window.outer_position()?;
Ok(Self {
x: position.x,
y: position.y,
})
}
pub fn from_filesystem_configs(app_data_root: &AppDataRoot) -> AnyhowResult<Option<Self>> {
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<i32> {
PhysicalPosition::new(self.x, self.y)
}
pub fn matches_physical_position(&self, pos: &PhysicalPosition<i32>) -> bool {
self.x == pos.x && self.y == pos.y
}
}
@@ -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<Self> {
let size = window.inner_size()?;
let size = best_window_size_heuristic(window)?;
Ok(Self {
width: size.width,
height: size.height,
+1
View File
@@ -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;
@@ -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<Option<SoraCredentialSet>> {
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<P: AsRef<Path>>(path: P) -> Result<(), io::Error> {
let p = path.as_ref();
if p.exists() && p.is_file() {
fs::remove_file(p)?;
}
Ok(())
}
@@ -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;
}
}
@@ -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<MainWindowSize> = CloneSlot::empty();
let window_pos_slot: CloneSlot<MainWindowPosition> = 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<MainWindowSize>,
window_pos_slot: &CloneSlot<MainWindowPosition>,
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;
}
@@ -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;
@@ -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;
@@ -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<MainWindowPosition>,
) -> 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(&current_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(&current_window_position)?;
}
Ok(())
}
@@ -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;
@@ -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;
@@ -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<Url> = 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<Url> = 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<String> {
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
}
}
}
@@ -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;
}
}
@@ -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<PhysicalSize<u32>> {
// 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)
}
}
@@ -1 +0,0 @@
pub mod sora_webview_cookies;
@@ -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<Url> = Lazy::new(|| {
Url::parse(SORA_ROOT_COOKIE_URL_STR).expect("URL should parse")
});
pub fn get_all_sora_cookies(webview: &Webview) -> AnyhowResult<Vec<Cookie>> {
let cookies = webview.cookies_for_url(SORA_ROOT_COOKIE_URL.clone())?;
Ok(cookies)
}
pub fn get_all_sora_cookies_as_string(webview: &Webview) -> AnyhowResult<String> {
let cookies = get_all_sora_cookies(webview)?;
let cookie_string = cookies
.iter()
.map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
.collect::<Vec<String>>()
.join("; ");
Ok(cookie_string)
}
+2 -2
View File
@@ -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;
@@ -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";
@@ -0,0 +1 @@
pub mod constants;
@@ -0,0 +1,2 @@
pub mod sora_login_window;
pub mod main_window;
@@ -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(())
}
@@ -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<Url> = Lazy::new(|| {
Url::parse(CHAT_GPT_ROOT_COOKIE_URL_STR).expect("URL should parse")
});
pub fn extract_sora_webview_cookies(webview: &WebviewWindow) -> AnyhowResult<String> {
// 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::<Vec<String>>()
.join("; ");
Ok(cookie_string)
}
fn get_all_chatgpt_cookies(webview: &WebviewWindow) -> AnyhowResult<Vec<Cookie>> {
let cookies = webview.cookies_for_url(CHAT_GPT_ROOT_COOKIE_URL.clone())?;
Ok(cookies)
}
@@ -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(())
}
@@ -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;
@@ -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<Url> = 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<Url> = 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<Url> = 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<Url> = 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(())
}
@@ -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<bool> {
/* 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<String> {
let url = webview.url()?;
let url_hostname= url.host()
.ok_or(anyhow!("no host in url"))?
.to_string();
Ok(url_hostname)
}
@@ -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<bool> {
pub async fn maybe_upgrade_or_renew_session(sora_credentials: &mut SoraCredentialSet) -> Result<bool, SoraError> {
let mut refresh_jwt = !sora_credentials.jwt_bearer_token.is_some();
let refresh_sentinel = !sora_credentials.sora_sentinel.is_some();
@@ -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<String>,
pub user: SoraUser,
pub expires: String,
#[serde(rename = "accessToken")]
pub access_token: String,
#[serde(rename = "internalApiBase")]
pub internal_api_base: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SoraUser {
pub id: String,
pub name: Option<String>,
pub email: Option<String>,
pub image: Option<String>,
pub picture: Option<String>,
pub provider: Option<String>,
#[serde(rename = "lastAuthorizationCheck")]
pub last_authorization_check: Option<i64>,
pub id: String,
pub name: Option<String>,
pub email: Option<String>,
pub image: Option<String>,
pub picture: Option<String>,
pub provider: Option<String>,
#[serde(rename = "lastAuthorizationCheck")]
pub last_authorization_check: Option<i64>,
}
pub async fn generate_bearer_with_cookie(cookie: &str) -> Result<String, SoraError> {
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-mOfXXWmTZZysOT6my6x Line truncated
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=0H28vzvP5FJafnkHxj4Jor5o3J Line truncated
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());
}
}
@@ -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<TaskResponse>,
pub last_id: Option<String>,
pub has_more: Option<bool>,
}
#[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<ListMediaResponse, SoraError> {
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::<ListMediaResponse>().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 Line truncated
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(())
}
}
@@ -0,0 +1 @@
pub mod list_media;
@@ -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;
@@ -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)
}
@@ -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))
@@ -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<SessionCookiePre
for cookie in cookies {
let cookie= cookie?;
let cookie_name = cookie.name();
if cookie_name == SESSION_COOKIE_NAME {
if cookie_name == SESSION_COOKIE_NAME_NEW {
return Ok(SessionCookiePresence::Present);
} else if cookie_name == SESSION_COOKIE_NAME_NEW {
return Ok(SessionCookiePresence::Present);
} else if !IRRELEVANT_COOKIE_NAMES.contains(cookie_name) {
unknown_cookie_count += 1;
@@ -1,12 +1,13 @@
//! Adapted from 'jwt' crate
use crate::sora_error::SoraError;
use anyhow::anyhow;
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use base64::Engine;
use chrono::{DateTime, Utc};
use errors::AnyhowResult;
use log::warn;
use serde_json::Value;
use errors::AnyhowResult;
const SEPARATOR: &str = ".";
@@ -19,48 +20,53 @@ pub struct SoraJwtClaims {
pub email_verified: bool,
}
pub fn lightweight_sora_jwt_parse(token: &str) -> AnyhowResult<SoraJwtClaims> {
pub fn lightweight_sora_jwt_parse(token: &str) -> Result<SoraJwtClaims, SoraError> {
let [_header_str, claims_str, _signature_str] = split_components(token)?;
let claims = decode_base64(claims_str)?;
let claims : serde_json::Map<String, Value> = 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<String, Value> = 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<SoraJwtClaims> {
})
}
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<String> {
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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -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<Record<FilterEngineCategories, string[]>> = {
[FilterEngineCategories.LOCATION]: Object.values(OBJECT_FILE_TYPE),
[FilterEngineCategories.CREATURE]: Object.values(OBJECT_FILE_TYPE),
};
// Map engine categories to asset modal tabs
const categoryToAssetTab: Partial<Record<FilterEngineCategories, string>> = {
[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<UploaderState>(initialUploaderState);
const [selectedCategory, setSelectedCategory] =
useState<FilterEngineCategories>(FilterEngineCategories.OBJECT);
useState<FilterEngineCategories>(
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 (
<div className="space-y-4">
<div className="flex w-full flex-col">
<Label>Category</Label>
<Select
id="category-select"
options={categoryOptions}
value={selectedCategory}
onChange={(value) => {
if (
typeof value === "string" &&
Object.values(FilterEngineCategories).includes(
value as FilterEngineCategories,
)
) {
setSelectedCategory(value as FilterEngineCategories);
}
}}
placeholder="Select a category"
/>
</div>
{isSelectVisible && (
<div className="flex w-full flex-col">
<Label className="mb-1">Category</Label>
<Select
id="category-select"
options={categoryOptions}
value={selectedCategory}
onChange={(value) => {
if (
typeof value === "string" &&
Object.values(FilterEngineCategories).includes(
value as FilterEngineCategories,
)
) {
setSelectedCategory(value as FilterEngineCategories);
}
}}
placeholder="Select a category"
/>
</div>
)}
<UploadFiles3D
title={title}
engineCategory={selectedCategory}
@@ -146,31 +162,17 @@ export function UploadModal3D({
<div className="w-100 text-center opacity-50">Uploading...</div>
</>
);
case UploaderStates.success:
case UploaderStates.success: {
return (
<UploadSuccess
title="3D model"
onOk={() => {
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 (
<UploadAssetError
@@ -199,15 +201,15 @@ export function UploadModal3D({
};
return (
<TransitionDialogue
<Modal
isOpen={isOpen}
onClose={onClose}
titleIcon={titleIcon}
title={title}
className="max-w-xl"
showClose={false}
showClose={true}
>
<CloseButton className="absolute right-4 top-4" onClick={onClose} />
<UploaderModalContent />
</TransitionDialogue>
</Modal>
);
}
@@ -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";
@@ -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<string>;
@@ -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"
/>
</div>
@@ -108,6 +111,7 @@ export const TopBar = ({ pageName, appTabIdSignal, setAppTabId }: Props) => {
<SettingsModal
isOpen={isSettingsModalOpen}
onClose={() => setIsSettingsModalOpen(false)}
globalAccountLogoutCallback={() => setLogoutStates()}
/>
<GalleryModal
+3 -3
View File
@@ -66,8 +66,8 @@ export class Configs {
break;
}
console.debug(`Build environment type: ${environment}`);
console.debug(`Deploy context: ${this.deployContext}`);
console.debug(`Upload API: ${this.uploadApiVideo}`);
//console.debug(`Build environment type: ${environment}`);
//console.debug(`Deploy context: ${this.deployContext}`);
//console.debug(`Upload API: ${this.uploadApiVideo}`);
}
}
@@ -1,14 +1,41 @@
import { SoundRegistry, SoundEffect } from "@storyteller/soundboard";
// TODO: This is maintained in two places. Here and MiscSettingsPane.
export const InstallSounds = () => {
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;
}
@@ -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 <> <OnboardingHelper />
return (<>
<OnboardingHelper />
<div
className="relative flex w-screen"
style={{ height: "calc(100vh - 68px)" }}
@@ -346,12 +353,27 @@ export const PageEditor = () => {
buttonOnClick={async () => {
if (IsDesktopApp()) {
await invoke("open_sora_login_command");
} else {
console.error("This is not the Desktop app.");
}
}}
/>
</>
<LoginModal
onClose={() => {}}
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 = () => {
@@ -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 }) => {
/>
<GenerateModals />
<ErrorDialog />
<Wizard />
{/* <Wizard /> */}
</EngineProvider>
<EditorLoadingBar />
<Toaster offsetTop={70} offsetRight={12} />
@@ -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<SoraImageGenerationFailed>('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 = () => {
}
@@ -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<ImageGenerationSuccess>('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 = () => {
}
@@ -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 = ({
</div>
);
const categoryToTabIdMap: Record<FilterEngineCategories, string> = {
[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<HTMLInputElement>(null);
const [selectedCategory, setSelectedCategory] =
useState<FilterEngineCategories | null>(null);
const [isSelectVisible, setIsSelectVisible] = useState(true);
const handleReopenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<>
<Modal
@@ -429,17 +491,31 @@ export const AssetModal = () => {
<h2 className="text-[18px] font-semibold opacity-80">
3D Assets
</h2>
<Tooltip content="Upload model" position="top" delay={200}>
{/* <Tooltip content="Upload model" position="top" delay={200}>
<Button
className="h-6 w-6 rounded-full border-none bg-transparent text-white/70 transition-colors hover:bg-transparent hover:text-white/100"
className="flex h-7 w-7 items-center justify-center rounded-md border-none bg-primary/50 text-white/70 transition-colors hover:bg-primary/70 hover:text-white/100"
onClick={handleAddAsset}
>
<FontAwesomeIcon icon={faPlus} className="text-xl" />
<FontAwesomeIcon icon={faPlus} className="text-lg" />
</Button>
</Tooltip>
</Tooltip> */}
</div>
<hr className="my-2 w-full border-white/10" />
<div className="space-y-2">
<div className="flex h-full flex-col space-y-2">
<Button
variant="primary"
className={twMerge(
"w-full justify-start rounded-xl border border-white/[2%] bg-white/[4%] px-3.5 py-2.5 text-left hover:bg-white/15",
"border-primary bg-primary/70 hover:bg-primary",
)}
onClick={handleAddAsset}
>
<FontAwesomeIcon
icon={faUpFromLine}
className="mr-2 text-lg opacity-70"
/>
Upload an asset
</Button>
{assetTabs.map((tab) => (
<Button
key={tab.id}
@@ -447,7 +523,7 @@ export const AssetModal = () => {
className={twMerge(
"w-full justify-start rounded-xl border border-white/[2%] bg-white/[4%] px-3.5 py-2.5 text-left hover:bg-white/15",
activeAssetTab === tab.id &&
"border-brand-primary bg-brand-primary/10 hover:bg-brand-primary/10",
"border-primary bg-primary/10 hover:bg-primary/10",
)}
onClick={() => setActiveAssetTab(tab.id)}
>
@@ -479,7 +555,7 @@ export const AssetModal = () => {
id="reopen-after-add"
checked={reopenAfterDragSignal.value}
onChange={handleReopenChange}
className="h-4 w-4 cursor-pointer rounded-lg border-gray-300 bg-gray-700 text-brand-primary focus:ring-brand-primary"
className="h-4 w-4 cursor-pointer rounded-lg border-gray-300 bg-gray-700 text-primary focus:ring-primary"
/>
<label
htmlFor="reopen-after-add"
@@ -529,18 +605,36 @@ export const AssetModal = () => {
)}
>
{activeAssetTab !== "all" && !searchTerm && (
<div className="mb-2 flex items-center font-semibold">
<Button
variant="secondary"
className="flex items-center gap-2 border-none bg-transparent px-3 py-1.5 text-sm text-white/70 hover:bg-transparent hover:text-white/100"
onClick={() => setActiveAssetTab("all")}
>
<FontAwesomeIcon
icon={faChevronLeft}
className="text-sm font-semibold opacity-70"
/>
</Button>
{currentTab.label}
<div className="mb-2 flex items-center justify-between font-semibold">
<div className="flex items-center">
<Button
variant="secondary"
className="flex items-center gap-2 border-none bg-transparent px-3 py-1.5 text-sm text-white/70 hover:bg-transparent hover:text-white/100"
onClick={() => setActiveAssetTab("all")}
>
<FontAwesomeIcon
icon={faChevronLeft}
className="text-sm font-semibold opacity-70"
/>
</Button>
{currentTab.label}
</div>
{activeAssetTab !== "skybox" &&
activeAssetTab !== "all" && (
<Button
icon={faUpFromLine}
onClick={() =>
handleAddSpecificAsset(
currentTab.engineCategory ||
FilterEngineCategories.OBJECT,
)
}
className="border-primary bg-primary/70 px-2 py-1 text-xs transition-colors hover:bg-primary"
>
Upload {getArticle(currentTab.labelSingle)}{" "}
{currentTab.labelSingle}
</Button>
)}
</div>
)}
{renderContent()}
@@ -551,10 +645,15 @@ export const AssetModal = () => {
</div>
</Modal>
<UploadModal3D
onClose={() => 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}
/>
</>
);
@@ -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 = () => {
<Tooltip
content="Add 3D asset to scene (B)"
position="bottom"
delay={100}
delay={300}
closeOnClick
className={twMerge(
showEmptySceneTooltip ? "hidden" : "block",
@@ -187,7 +187,7 @@ export const ControlsTopButtons = () => {
return (
<div className="flex flex-col gap-2 pl-2 pt-2">
<div className="flex gap-1.5">
<ButtonDropdown
{/* <ButtonDropdown
label="File"
icon={faFile}
className="shadow-xl"
@@ -285,7 +285,7 @@ export const ControlsTopButtons = () => {
},
},
]}
/>
/> */}
<Button
icon={outlinerIsShowing.value ? faCheckSquare : faSquare}
@@ -46,6 +46,8 @@ import Queue from "~/pages/PageEnigma/Queue/Queue";
import { toEngineActions } from "~/pages/PageEnigma/Queue/toEngineActions";
import { PromptsApi } from "@storyteller/api";
import { SoundRegistry } from "@storyteller/soundboard";
import { GetAppPreferences, SoraImageRemix } from "@storyteller/tauri-api";
import { CommandSuccessStatus } from "@storyteller/tauri-api";
import {
cameras,
@@ -509,6 +511,15 @@ export const PromptBox = () => {
setisEnqueueing(true);
setTimeout(() => {
// TODO(bt,2025-05-08): This is a hack so we don't accidentally wind up with a permanently disabled prompt box if
// the backend hangs on a given request. (Sometimes Sora doesn't respond.) Ideally what we would do instead is only
// force the prompt box to be enabled again as long as another request isn't running, ie. if we just fired off two
// requests in succession, we wouldn't want to turn it off as the first one gets submitted.
console.debug('Turn off blocking of prompt box...');
setisEnqueueing(false);
}, 10000);
if (editorEngine) {
editorEngine.positive_prompt = prompt;
@@ -522,19 +533,54 @@ export const PromptBox = () => {
//sceneMediaToken: "",
});
const generateResponse = await invoke("sora_image_remix_command", {
request: {
snapshot_media_token: snapshotResult.data!,
disable_system_prompt: !useSystemPrompt,
prompt: prompt,
maybe_additional_images: referenceImages.map(
(image) => image.mediaToken,
),
maybe_number_of_samples: 1,
},
console.log("snapshotResult", snapshotResult)
const generateResponse = await SoraImageRemix({
snapshot_media_token: snapshotResult.data!,
disable_system_prompt: !useSystemPrompt,
prompt: prompt,
maybe_additional_images: referenceImages.map(
(image) => image.mediaToken,
),
maybe_number_of_samples: 1,
});
console.log("response", generateResponse);
console.log("generateResponse", generateResponse)
if (generateResponse.status === CommandSuccessStatus.Success) {
const prefs = await GetAppPreferences();
const soundName = prefs.preferences?.enqueue_success_sound;
if (soundName !== undefined) {
const registry = SoundRegistry.getInstance();
registry.playSound(soundName);
}
toast.success("Image added to queue");
setisEnqueueing(false);
return;
} else {
const prefs = await GetAppPreferences();
const soundName = prefs.preferences?.enqueue_failure_sound;
if (soundName !== undefined) {
const registry = SoundRegistry.getInstance();
registry.playSound(soundName);
}
toast.error("Failed to enqueue image");
setisEnqueueing(false);
return;
}
// const generateResponse = await invoke("sora_image_remix_command", {
// request: {
// snapshot_media_token: snapshotResult.data!,
// disable_system_prompt: !useSystemPrompt,
// prompt: prompt,
// maybe_additional_images: referenceImages.map(
// (image) => image.mediaToken,
// ),
// maybe_number_of_samples: 1,
// },
// });
//if (generateResponse.errorMessage) {
// handleError(response.errorMessage);
@@ -3,7 +3,7 @@ import { useSignals } from "@preact/signals-react/runtime";
import DndAsset from "~/pages/PageEnigma/DragAndDrop/DndAsset";
import { Badge } from "~/components";
import { AssetType } from "~/enums";
import { useState } from "react";
// import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUpDownLeftRight } from "@fortawesome/pro-solid-svg-icons";
@@ -37,7 +37,7 @@ const patchExpressionObjectType = (mediaType: string) => {
export const ItemElement = ({ item }: Props) => {
useSignals();
const [imageError, setImageError] = useState(false);
// const [imageError, setImageError] = useState(false);
return (
<div className="group relative w-full select-none overflow-hidden transition-all duration-200">
@@ -59,16 +59,13 @@ export const ItemElement = ({ item }: Props) => {
onPointerDown={(event) => DndAsset.onPointerDown(event, item)}
style={{ cursor: "grab", pointerEvents: "auto" }}
>
{item.thumbnail && !imageError && (
<img
crossOrigin="anonymous"
referrerPolicy="no-referrer"
src={item.thumbnail}
alt={item.name}
className="h-full w-full object-cover object-center"
onError={() => setImageError(true)}
/>
)}
<img
crossOrigin="anonymous"
referrerPolicy="no-referrer"
src={item.thumbnail}
alt={item.name}
className="h-full w-full object-cover object-center"
/>
<div className="text-shadow-md absolute inset-0 flex items-center justify-center bg-brand-primary-950/50 text-[13px] font-medium text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<FontAwesomeIcon icon={faUpDownLeftRight} className="mr-1.5" />
@@ -1,17 +0,0 @@
import { SoundRegistry, SoundEffect } from "@storyteller/soundboard";
import { listen } from '@tauri-apps/api/event';
type SoraImageGenerationFailed = {
prompt: string,
};
export const InstallImageGenerationFailure = () => {
listen<SoraImageGenerationFailed>('sora-image-generation-failed', (event) => {
const registry = SoundRegistry.getInstance();
registry.playSound("fail");
});
}
export const ImageGenerationFailure = () => {
}
@@ -1,16 +0,0 @@
import { SoundRegistry, SoundEffect } from "@storyteller/soundboard";
import { listen } from '@tauri-apps/api/event';
type ImageGenerationSuccess = {
media_file_token: string,
};
export const InstallImageGenerationSuccess = () => {
listen<ImageGenerationSuccess>('sora-image-generation-complete', (event) => {
const registry = SoundRegistry.getInstance();
registry.playSound("success");
});
}
export const ImageGenerationSuccess = () => {
}
+13 -1
View File
@@ -43,7 +43,7 @@
"path": "../../libs/components/close-button"
},
{
"path": "../../libs/components/modal"
"path": "../../libs/tauri-api"
},
{
"path": "../../libs/components/toaster"
@@ -51,6 +51,9 @@
{
"path": "../../libs/components/demo-modal"
},
{
"path": "../../libs/components/login-modal"
},
{
"path": "../../libs/components/popover"
},
@@ -63,6 +66,9 @@
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/common"
},
{
"path": "../../libs/components/tab-selector"
},
@@ -78,6 +84,12 @@
{
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/components/label"
},
{
"path": "../../libs/components/modal"
},
{
"path": "../../libs/tauri-utils"
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./lib/enums/index.js";
export * from "./lib/models/Users.js";
export * from "./lib/AnalyticsApi.js";
export * from "./lib/BillingApi.js";
export * from "./lib/EngineApi.js";
@@ -14,7 +14,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
iconClassName?: string;
iconFlip?: boolean;
htmlFor?: string;
variant?: "primary" | "secondary" | "action";
variant?: "primary" | "secondary" | "action" | "destructive";
loading?: boolean;
as?: "button" | "link";
href?: string;
@@ -44,6 +44,9 @@ export const Button = ({
case "action": {
return "bg-action hover:bg-action-900 text-white focus-visible:outline-action";
}
case "destructive": {
return "bg-red/70 hover:bg-red/50 text-white focus-visible:outline-red";
}
case "primary":
default: {
return "bg-primary hover:bg-primary-400 text-white focus-visible:outline-primary-600";
@@ -54,7 +54,6 @@ export function DemoModal({
<video autoPlay muted loop controls={false}>
<source src={videoSrc} type="video/mp4" />
</video>
W
</div>
<Button
className="font-semibold"
@@ -116,8 +116,10 @@ export const GalleryModal = React.memo(
setUsername(session.data.user.username);
}
};
getUsername();
}, [usersApi]);
if (isOpen) {
getUsername();
}
}, [usersApi, isOpen]);
useEffect(() => {
const loadItems = async () => {
@@ -46,7 +46,8 @@ export const Input = React.forwardRef(
id={id ? id : label ? label : undefined}
className={twMerge(
"h-10 w-full rounded-lg bg-[#242424] px-3 py-2.5 text-white placeholder-white/50 outline-none",
"border border-[#363636] transition-all duration-150 ease-in-out hover:border-brand-primary/60 focus:border-brand-primary focus:!outline-none",
"border border-[#363636] transition-all duration-150 ease-in-out hover:border-primary/60 focus:border-primary focus:!outline-none",
"disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:border-[#363636]",
icon && "pl-10",
isError && "outline-red focus:outline-red",
inputClassName
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more