commit ad64c14a04009c7b7cf74b40fe860b9dd056093a Author: viraladmin <00purple@gmail.com> Date: Wed Jul 1 10:01:30 2026 -0600 Initial Contractless GUI wallet commit diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..78c5fb3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,6682 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blockchain" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "cid", + "colored", + "encrypted_images", + "falcon-rs", + "flexi_logger", + "fn-dsa", + "hex", + "ipnetwork", + "lazy_static", + "log", + "nix 0.27.1", + "once_cell", + "rand 0.8.6", + "rayon", + "ripemd", + "rpassword", + "rust-ini", + "rustyline", + "rustyline-derive", + "serde", + "serde_json", + "shellexpand", + "skein", + "sled", + "tokio", + "tokio-postgres", + "windows-service", + "windows-sys 0.61.2", + "winreg 0.52.0", +] + +[[package]] +name = "brotli" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cid" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a304f95f84d169a6f31c4d0a30d784643aaa0bbc9c1e449a2c23e963ec4971" +dependencies = [ + "multibase", + "multihash", + "unsigned-varint", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "contractless-wallet" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "blockchain", + "clipboard-win", + "native-dialog", + "reqwest 0.12.28", + "serde", + "serde_json", + "tauri", + "tauri-build", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "cty" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn 2.0.117", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deflate" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73770f8e1fe7d64df17ca66ad28994a0a623ea497fa69486e14984e715c5d174" +dependencies = [ + "adler32", + "byteorder", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" +dependencies = [ + "encoding-index-japanese", + "encoding-index-korean", + "encoding-index-simpchinese", + "encoding-index-singlebyte", + "encoding-index-tradchinese", +] + +[[package]] +name = "encoding-index-japanese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-korean" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-simpchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-singlebyte" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-tradchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding_index_tests" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "encrypted_images" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761fe0fc7059c1e9cb4217f49263b146d269322354aeefb8fc519a60b2e8cef1" +dependencies = [ + "base64 0.21.7", + "encoding", + "hex-literal", + "hmac 0.12.1", + "image", + "native-dialog", + "openssl", + "rand 0.8.6", + "sha2 0.10.9", + "subtle", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "falcon-rs" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5277cc4194542e71f3fe79ff8c981d5ebcb7f4627fd1fa5526ec27518ed600df" +dependencies = [ + "getrandom 0.2.17", + "libm", + "zeroize", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "flexi_logger" +version = "0.31.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e90140a77c0ffbe2e4839e062983ec4ec60d4473e41a4fcce0884809d1b76d6" +dependencies = [ + "chrono", + "log", + "nu-ansi-term", + "regex", + "thiserror 2.0.18", +] + +[[package]] +name = "fn-dsa" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afb39d94be3f4538ccfdf23dc69a89904c9b802331b1975538fcc251973593f9" +dependencies = [ + "fn-dsa-comm", + "fn-dsa-kgen", + "fn-dsa-sign", + "fn-dsa-vrfy", +] + +[[package]] +name = "fn-dsa-comm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28104bba227c79bff8ffe34dff7c5a5631fd790dfd4d4f0a84b01952ef7c6f46" +dependencies = [ + "cpufeatures 0.2.17", + "rand_core 0.6.4", +] + +[[package]] +name = "fn-dsa-kgen" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a04fe82799cc1550d32778b415648d1962a7b4b4860ff5f2e6ca32494fc9aa" +dependencies = [ + "fn-dsa-comm", + "zeroize", +] + +[[package]] +name = "fn-dsa-sign" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ffb7d9c94e56de7e64019849b3b6110b1495690da2e9e1decef8f1744b3bab4" +dependencies = [ + "fn-dsa-comm", + "zeroize", +] + +[[package]] +name = "fn-dsa-vrfy" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc96b904b213160c8804cc2642ccd15cb968318a64648fc5ca3edab9153cda2b" +dependencies = [ + "fn-dsa-comm", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3edd93c6756b4dfaf2709eafcc345ba2636565295c198a9cfbf75fa5e3e00b06" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.23.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ffcb7e7244a9bf19d35bf2883b9c080c4ced3c07a9895572178cdb8f13f6a1" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "gif", + "jpeg-decoder", + "num-iter", + "num-rational", + "num-traits", + "png 0.16.8", + "scoped_threadpool", + "tiff", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jpeg-decoder" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229d53d58899083193af11e15917b5640cd40b29ff475a1fe4ef725deb02d0f2" +dependencies = [ + "rayon", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791daaae1ed6889560f8c4359194f56648355540573244a5448a83ba1ecc7435" +dependencies = [ + "adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" +dependencies = [ + "adler", + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "unsigned-varint", +] + +[[package]] +name = "native-dialog" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bbf55edb2747e4e4b3a9cd3989194b88aae32274b4422635dcf98aa6e84197b" +dependencies = [ + "ascii", + "block", + "cocoa", + "dirs-next", + "objc", + "objc-foundation", + "objc_id", + "once_cell", + "raw-window-handle 0.4.3", + "thiserror 1.0.69", + "wfd", + "which", + "winapi", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle 0.6.2", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12ac428b1cb17fce6f731001d307d351ec70a6d202fc2e60f7d4c5e42d8f4f07" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ed8acf08e98e744e5384c8bc63ceb0364e68a6854187221c18df61c4797690e" +dependencies = [ + "dlv-list", + "hashbrown 0.13.2", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.16.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3287920cb847dee3de33d301c463fba14dda99db24214ddf93f83d3021f4c6" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "deflate", + "miniz_oxide 0.3.7", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator", + "hmac 0.13.0", + "md-5", + "memchr", + "rand 0.10.1", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "raw-window-handle" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b800beb9b6e7d2df1fe337c9e3d04e3af22a124460fb4c30fcc22c9117cefb41" +dependencies = [ + "cty", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "rust-ini" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e2a3bcec1f113553ef1c88aae6c020a369d03d55b58de9869a0908930385091" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.28.0", + "radix_trie", + "rustyline-derive", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustyline-derive" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5af959c8bf6af1aff6d2b463a57f71aae53d1332da58419e30ad8dc7011d951" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scoped_threadpool" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skein" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a90a220ab98dbbfeabeae7c558a79f37839c10b9ef55c77082a741a463cab7" +dependencies = [ + "digest 0.10.7", + "threefish", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sled" +version = "0.34.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935" +dependencies = [ + "crc32fast", + "crossbeam-epoch", + "crossbeam-utils", + "fs2", + "fxhash", + "libc", + "log", + "parking_lot 0.11.2", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle 0.6.2", + "redox_syscall 0.5.18", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot 0.12.5", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot 0.12.5", + "percent-encoding", + "raw-window-handle 0.6.2", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle 0.6.2", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle 0.6.2", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle 0.6.2", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "threefish" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a693d0c8cf16973fac5a93fbe47b8c6452e7097d4fcac49f3d7a18e39c76e62e" + +[[package]] +name = "tiff" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a53f4706d65497df0c4349241deddf35f84cee19c87ed86ea8ca590f4464437" +dependencies = [ + "jpeg-decoder", + "miniz_oxide 0.4.4", + "weezl", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot 0.12.5", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot 0.12.5", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.1", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wfd" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c17bbfb155305bcb79144f568c3b796275ba4db5d5856597bc85acefe29b819" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle 0.6.2", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-service" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2" +dependencies = [ + "bitflags 2.13.0", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle 0.6.2", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6f9f5eb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "contractless-wallet" +version = "0.1.0" +edition = "2021" +build = "build.rs" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +blockchain = { path = ".." } +base64 = "0.22" +native-dialog = "0.6" +reqwest = { version = "0.12", features = ["rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tauri = { version = "2", features = [] } + +[target.'cfg(windows)'.dependencies] +clipboard-win = "5.4" diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f6c906 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Contractless Wallet GUI + +This folder contains the early Contractless desktop wallet prototype. + +## Required Location + +This wallet crate must be placed directly inside the Contractless blockchain repository root. + +Expected layout: + +```text +Contractless/ + Cargo.toml + src/ + gui_wallet/ + Cargo.toml + tauri.conf.json + frontend/ + src/ +``` + +The wallet depends on the blockchain crate with: + +```toml +blockchain = { path = ".." } +``` + +Because of that, `gui wallet/` must be in the same folder that contains the Contractless `src/` folder and root `Cargo.toml`. If the wallet folder is moved somewhere else, the parent `..` path will not point at the blockchain crate and the wallet will not build. + +## Layout + +- `frontend/` contains the static HTML, CSS, and JavaScript wallet UI. +- `src/` contains the Rust/Tauri desktop app code. +- `icons/`, `capabilities/`, `tauri.conf.json`, `build.rs`, `Cargo.toml`, and `Cargo.lock` are the Tauri project files. + +## Build Check + +From `gui wallet/`: + +```bash +cargo check +cargo build +``` + +## Linux Prerequisites + +Tauri depends on native Linux desktop libraries. On Ubuntu/Debian, install: + +```bash +sudo apt update +sudo apt install \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libdbus-1-dev \ + pkg-config +``` + +Some older distributions may use `libwebkit2gtk-4.0-dev` instead of `libwebkit2gtk-4.1-dev`. + +Fedora-style systems generally need the equivalent WebKitGTK, GTK, AppIndicator/Ayatana, librsvg, DBus, and pkg-config development packages. + +## Icons + +Tauri expects platform icon assets in `icons/`. + +- Windows uses `icon.ico`. +- Linux expects `icon.png`. + +The current icons are placeholders and should be replaced before release packaging. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..261851f --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/capabilities/default.json b/capabilities/default.json new file mode 100644 index 0000000..ca2da9a --- /dev/null +++ b/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default wallet window permissions", + "windows": ["main"], + "permissions": ["core:default"] +} diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..afcc78d --- /dev/null +++ b/frontend/app.js @@ -0,0 +1 @@ +// Contractless Wallet frontend is split by feature in frontend/js/*.js. diff --git a/frontend/images/icon.png b/frontend/images/icon.png new file mode 100644 index 0000000..7cf42b0 Binary files /dev/null and b/frontend/images/icon.png differ diff --git a/frontend/images/iconw.png b/frontend/images/iconw.png new file mode 100644 index 0000000..b50c764 Binary files /dev/null and b/frontend/images/iconw.png differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3c05da3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,1568 @@ + + + + + + Contractless Wallet + + + +
+
+
+
+
+
Contractless
+
Wallet
+
+
+
+

Open Wallet

+

Create a new address, load an existing wallet file, or restore from a private key source.

+
+
+ + +
+
+ + + + +
+ + + + +
+
+ +
+ + +
+
+ +
+ Wallet + +
+
+ Broadcast Node + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+

Balance overview

+
+
+
+
+
+
+ CLTC Balance +
Load wallet
+
Balances refresh while dashboard is open.
+
+
+
+
Token Balances
+
No wallet loaded.
+
+
+
+ +
+
+
+

Wallet Registration

+
+ Registered +
+
+ Address + +
+
+ Vanity Address + +
+
+ Registration + +
+
+ +
+
+
+

Quick Actions

+
+
+
+ + + + +
+
+ +
+
+
+

Recent Transactions

+
+ +
+
+ + + + + + + + + + + + +
TypeAssetCounterpartyStatusAmount
+
+ Load a wallet to view recent transactions. +
+
+
+ +
+
+
+

Network Information

+
+
+
+
Latest Block--
+
NetworkTestnet / CLTC
+
Difficulty--
+
Mempool Tx--
+
Chain Tx--
+
+
+ +
+ Address must be registered before this wallet can receive coins or tokens. +
+ +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/js/address_book.js b/frontend/js/address_book.js new file mode 100644 index 0000000..7a9b79f --- /dev/null +++ b/frontend/js/address_book.js @@ -0,0 +1,655 @@ +let addressBookCache = { entries: {} }; +let addressBookLoaded = false; +let addressBookLoadingPromise = null; +let addressBookWriteTimer = null; +let addressBookWritePromise = Promise.resolve(); +let addressBookVanityRefreshRunning = false; +let addressBookVanityRefreshDoneForWallet = ""; +let addressBookVanityLookupRunning = false; +const addressBookVanityLookupQueue = new Set(); + +const ADDRESS_BOOK_UNKNOWN = ""; +const ADDRESS_BOOK_WRITE_DELAY_MS = 500; +const ADDRESS_BOOK_VANITY_DELAY_MS = 650; + +function normalizeAddressBook(source) { + const entries = source?.entries && typeof source.entries === "object" && !Array.isArray(source.entries) + ? source.entries + : {}; + const normalized = {}; + + Object.entries(entries).forEach(([address, entry]) => { + if (isRawVanityAddressHex(address)) { + return; + } + + const canonical = canonicalAddressBookAddress(address); + if (!canonical || !entry || typeof entry !== "object") { + return; + } + + normalized[canonical] = { + label: stringOrDefault(entry.label, ADDRESS_BOOK_UNKNOWN), + label_source: stringOrDefault(entry.label_source, "sync"), + vanity: stringOrDefault(entry.vanity, ADDRESS_BOOK_UNKNOWN), + latest_txid: stringOrDefault(entry.latest_txid, ""), + created_at: Number(entry.created_at || 0) || currentUnixSeconds(), + updated_at: Number(entry.updated_at || 0) || currentUnixSeconds() + }; + }); + + return { entries: normalized }; +} + +function stringOrDefault(value, fallback) { + const text = String(value ?? "").trim(); + return text || fallback; +} + +function currentUnixSeconds() { + return Math.floor(Date.now() / 1000); +} + +function shortAddressFromRawAddressHex(address) { + const text = String(address || "").trim().toLowerCase(); + if (!/^[a-f0-9]{44}$/.test(text) || text.slice(40, 42) !== "2e") { + return ""; + } + + const network = { + "01": "clc", + "02": "cltc" + }[text.slice(42, 44)]; + + return network ? `${text.slice(0, 40)}.${network}` : ""; +} + +function vanityAddressFromRawAddressHex(address) { + const text = String(address || "").trim().toLowerCase(); + if (!/^[a-f0-9]{44}$/.test(text) || text.slice(40, 42) !== "2e") { + return ""; + } + + const network = { + "01": "clc", + "02": "cltc" + }[text.slice(42, 44)]; + if (!network) { + return ""; + } + + const payloadBytes = text.slice(0, 40).match(/../g) || []; + const payload = payloadBytes + .map((part) => String.fromCharCode(Number.parseInt(part, 16))) + .join("") + .trimStart(); + if (!payload || !/^[a-z]+$/i.test(payload)) { + return ""; + } + + return `${payload.toLowerCase()}.${network}`; +} + +function isRawVanityAddressHex(address) { + return Boolean(vanityAddressFromRawAddressHex(address)); +} + +function canonicalAddressBookAddress(address) { + const text = String(address || "").trim().toLowerCase(); + if (/^[a-f0-9]{40}\.[a-z0-9]+$/.test(text)) { + return text; + } + if (isRawVanityAddressHex(text)) { + return ""; + } + const fromRawBytes = shortAddressFromRawAddressHex(text); + if (fromRawBytes) { + return fromRawBytes; + } + return ""; +} + +function shouldSkipAddressBookAddress(address) { + const canonical = canonicalAddressBookAddress(address); + return ( + !canonical || + canonical === canonicalAddressBookAddress(activeWalletAddressShort) || + canonical === "network" || + canonical === "self" + ); +} + +function addressBookSleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function addressBookEscape(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function addressBookInvoke() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + throw new Error("Tauri bridge is not ready."); + } + return invoke; +} + +async function lookupAddressBookVanity(address) { + const broadcastNode = typeof activeBroadcastNode === "function" ? activeBroadcastNode() : ""; + if (!walletLoaded || !broadcastNode) { + return ""; + } + + const invoke = addressBookInvoke(); + const vanity = await invoke("lookup_address_vanity", { + broadcastNode, + address + }); + return String(vanity || "").trim(); +} + +function enqueueAddressBookVanityLookup(address) { + const canonical = canonicalAddressBookAddress(address); + if (!canonical || shouldSkipAddressBookAddress(canonical)) { + return; + } + + addressBookVanityLookupQueue.add(canonical); + runAddressBookVanityLookupQueue(); +} + +async function runAddressBookVanityLookupQueue() { + if (addressBookVanityLookupRunning) { + return; + } + + addressBookVanityLookupRunning = true; + try { + while (walletLoaded && addressBookVanityLookupQueue.size) { + const [address] = addressBookVanityLookupQueue; + addressBookVanityLookupQueue.delete(address); + + await loadAddressBookCache(); + const entry = addressBookCache.entries[address]; + if (!entry || (entry.vanity && entry.vanity !== ADDRESS_BOOK_UNKNOWN)) { + continue; + } + + try { + const vanity = await lookupAddressBookVanity(address); + if (vanity && addressBookCache.entries[address]?.vanity !== vanity) { + addressBookCache.entries[address].vanity = vanity; + addressBookCache.entries[address].updated_at = currentUnixSeconds(); + scheduleAddressBookWrite(); + renderAddressBookPage(); + } + } catch (_) { + // Vanity lookup is best-effort; failed lookups should not block address discovery. + } + + if (addressBookVanityLookupQueue.size) { + await addressBookSleep(ADDRESS_BOOK_VANITY_DELAY_MS); + } + } + } finally { + addressBookVanityLookupRunning = false; + } +} + +async function loadAddressBookCache(force = false) { + if (!walletLoaded) { + addressBookCache = { entries: {} }; + addressBookLoaded = false; + return addressBookCache; + } + + if (addressBookLoaded && !force) { + return addressBookCache; + } + + if (addressBookLoadingPromise && !force) { + return addressBookLoadingPromise; + } + + addressBookLoadingPromise = (async () => { + try { + const invoke = addressBookInvoke(); + const raw = await invoke("read_address_book"); + addressBookCache = normalizeAddressBook(JSON.parse(raw || "{}")); + } catch (_) { + addressBookCache = { entries: {} }; + } finally { + addressBookLoaded = true; + addressBookLoadingPromise = null; + } + renderAddressBookPage(); + return addressBookCache; + })(); + + return addressBookLoadingPromise; +} + +function scheduleAddressBookWrite() { + if (!walletLoaded || !addressBookLoaded) { + return; + } + clearTimeout(addressBookWriteTimer); + addressBookWriteTimer = setTimeout(() => { + addressBookWritePromise = addressBookWritePromise + .catch(() => {}) + .then(() => { + const invoke = addressBookInvoke(); + return invoke("write_address_book", { + addressBookJson: JSON.stringify(normalizeAddressBook(addressBookCache)) + }); + }) + .catch((error) => console.error("Address book write failed:", error)); + }, ADDRESS_BOOK_WRITE_DELAY_MS); +} + +async function flushAddressBookWrite() { + clearTimeout(addressBookWriteTimer); + if (!walletLoaded || !addressBookLoaded) { + return; + } + addressBookWritePromise = addressBookWritePromise + .catch(() => {}) + .then(() => { + const invoke = addressBookInvoke(); + return invoke("write_address_book", { + addressBookJson: JSON.stringify(normalizeAddressBook(addressBookCache)) + }); + }); + await addressBookWritePromise; +} + +async function upsertAddressBookEntry(address, options = {}) { + const canonical = canonicalAddressBookAddress(address); + if (shouldSkipAddressBookAddress(canonical)) { + return false; + } + + await loadAddressBookCache(); + + const now = currentUnixSeconds(); + const existing = addressBookCache.entries[canonical]; + const label = String(options.label || "").trim(); + const labelSource = options.labelSource || "sync"; + const vanity = String(options.vanity || "").trim(); + let changed = false; + let shouldLookupVanity = false; + + if (!existing) { + addressBookCache.entries[canonical] = { + label: label || ADDRESS_BOOK_UNKNOWN, + label_source: label ? labelSource : "sync", + vanity: vanity || ADDRESS_BOOK_UNKNOWN, + latest_txid: String(options.latestTxid || ""), + created_at: now, + updated_at: now + }; + changed = true; + shouldLookupVanity = !vanity; + } else { + const canReplaceLabel = + label && + existing.label_source !== "user" && + (!existing.label || existing.label === ADDRESS_BOOK_UNKNOWN || existing.label_source === "sync"); + + if (canReplaceLabel && existing.label !== label) { + existing.label = label; + existing.label_source = labelSource; + changed = true; + } + + if (options.latestTxid && existing.latest_txid !== options.latestTxid) { + existing.latest_txid = String(options.latestTxid); + changed = true; + } + + if (vanity && existing.vanity !== vanity) { + existing.vanity = vanity; + changed = true; + } + + if (changed) { + existing.updated_at = now; + } + } + + if (changed) { + scheduleAddressBookWrite(); + renderAddressBookPage(); + } + if (shouldLookupVanity) { + enqueueAddressBookVanityLookup(canonical); + } + return changed; +} + +async function upsertVanityRegistrationAddressBookEntry(txid, transaction) { + const vanity = transaction?.unsigned_vanity_address; + if (!vanity) { + return false; + } + + const owner = canonicalAddressBookAddress(vanity.address); + const displayVanity = vanityAddressFromRawAddressHex(vanity.vanity_address); + if (!owner || !displayVanity) { + return false; + } + + return upsertAddressBookEntry(owner, { + vanity: displayVanity, + latestTxid: txid, + labelSource: "sync" + }); +} + +function collectAddressBookAddressesFromValue(value, found = new Set()) { + if (!value || typeof value !== "object") { + if (typeof value === "string") { + const canonical = canonicalAddressBookAddress(value); + if (!shouldSkipAddressBookAddress(canonical)) { + found.add(canonical); + } + } + return found; + } + + if (Array.isArray(value)) { + value.forEach((item) => collectAddressBookAddressesFromValue(item, found)); + return found; + } + + Object.entries(value).forEach(([key, item]) => { + const keyText = String(key || "").toLowerCase(); + if (["sender", "receiver", "creator", "advertiser", "sender1", "sender2", "lender", "borrower", "address"].includes(keyText)) { + const canonical = canonicalAddressBookAddress(item); + if (!shouldSkipAddressBookAddress(canonical)) { + found.add(canonical); + } + return; + } + collectAddressBookAddressesFromValue(item, found); + }); + + return found; +} + +async function syncAddressBookFromTransactionRecord(txid, record) { + const vanityChanged = await upsertVanityRegistrationAddressBookEntry(txid, record?.transaction); + const found = collectAddressBookAddressesFromValue(record?.transaction); + if (!found.size) { + if (vanityChanged) { + await flushAddressBookWrite(); + } + return; + } + + for (const address of found) { + await upsertAddressBookEntry(address, { latestTxid: txid, labelSource: "sync" }); + } +} + +async function addPaymentUriAddressBookEntry(address, label) { + const changed = await upsertAddressBookEntry(address, { + label, + labelSource: "payment_uri" + }); + if (changed) { + await flushAddressBookWrite(); + } +} + +function resetAddressBookPage() { + addressBookCache = { entries: {} }; + addressBookLoaded = false; + addressBookLoadingPromise = null; + clearTimeout(addressBookWriteTimer); + addressBookVanityRefreshRunning = false; + addressBookVanityRefreshDoneForWallet = ""; + addressBookVanityLookupRunning = false; + addressBookVanityLookupQueue.clear(); + if (addressBookSearch) { + addressBookSearch.value = ""; + } + renderAddressBookPage(); +} + +function sortedAddressBookEntries() { + const query = String(addressBookSearch?.value || "").trim().toLowerCase(); + const sort = addressBookSort?.value || "latest"; + let entries = Object.entries(addressBookCache.entries || {}).map(([address, entry]) => ({ + address, + ...entry + })); + + if (query) { + entries = entries.filter((entry) => { + return [entry.address, entry.label, entry.vanity, entry.latest_txid] + .some((value) => String(value || "").toLowerCase().includes(query)); + }); + } + + entries.sort((left, right) => { + if (sort === "label") { + return String(left.label || "").localeCompare(String(right.label || "")); + } + if (sort === "vanity") { + return String(left.vanity || "").localeCompare(String(right.vanity || "")); + } + return Number(right.updated_at || 0) - Number(left.updated_at || 0); + }); + + return entries; +} + +function renderAddressBookPage() { + if (!addressBookRows) { + return; + } + + const entries = sortedAddressBookEntries(); + const entryParts = entries.map((entry) => { + const txid = entry.latest_txid || ""; + const label = entry.label && entry.label !== ADDRESS_BOOK_UNKNOWN ? entry.label : ""; + const vanity = entry.vanity && entry.vanity !== ADDRESS_BOOK_UNKNOWN ? entry.vanity : ""; + const escapedAddress = addressBookEscape(entry.address); + const escapedLabel = addressBookEscape(label); + const escapedVanity = addressBookEscape(vanity); + const escapedTxid = addressBookEscape(shortenAddress(txid)); + return { + txid, + vanity, + escapedAddress, + escapedLabel, + escapedVanity, + escapedTxid + }; + }); + + addressBookRows.innerHTML = entries.map((entry) => { + const txid = entry.latest_txid || ""; + const label = entry.label && entry.label !== ADDRESS_BOOK_UNKNOWN ? entry.label : ""; + const vanity = entry.vanity && entry.vanity !== ADDRESS_BOOK_UNKNOWN ? entry.vanity : ""; + return ` + + + + + ${vanity ? `` : ADDRESS_BOOK_UNKNOWN} + + ${txid ? `${addressBookEscape(shortenAddress(txid))}` : "--"} + + + + + `; + }).join(""); + + if (addressBookCards) { + addressBookCards.innerHTML = entryParts.map((entry) => ` +
+
+ + +
+
+ Vanity + ${entry.vanity ? `` : ADDRESS_BOOK_UNKNOWN} +
+
+ Address + +
+
+ Last Transaction + ${entry.txid ? `${entry.escapedTxid}` : "--"} +
+
+ `).join(""); + } + + if (addressBookState) { + addressBookState.textContent = walletLoaded + ? `${entries.length} contact${entries.length === 1 ? "" : "s"}` + : "Load a wallet to view the address book."; + } +} + +async function prepareAddressBookPage() { + await loadAddressBookCache(); + renderAddressBookPage(); +} + +async function refreshAddressBookVanitiesOnce(force = false) { + if (!walletLoaded || addressBookVanityRefreshRunning) { + return; + } + + const walletKey = activeWalletAddressShort || activeWalletPath || ""; + if (!force && addressBookVanityRefreshDoneForWallet === walletKey) { + return; + } + + addressBookVanityRefreshRunning = true; + addressBookVanityRefreshDoneForWallet = walletKey; + + try { + await loadAddressBookCache(); + const addresses = Object.keys(addressBookCache.entries || {}); + for (const address of addresses) { + if (!walletLoaded || (activeWalletAddressShort || activeWalletPath || "") !== walletKey) { + return; + } + try { + const invoke = addressBookInvoke(); + const vanity = await invoke("lookup_address_vanity", { + broadcastNode: activeBroadcastNode(), + address + }); + const entry = addressBookCache.entries[address]; + const nextVanity = vanity || ADDRESS_BOOK_UNKNOWN; + if (entry && entry.vanity !== nextVanity) { + entry.vanity = nextVanity; + entry.updated_at = currentUnixSeconds(); + scheduleAddressBookWrite(); + renderAddressBookPage(); + } + } catch (_) { + // Vanity refresh is best-effort; connection errors should not block the wallet. + } + await addressBookSleep(ADDRESS_BOOK_VANITY_DELAY_MS); + } + } finally { + addressBookVanityRefreshRunning = false; + } +} + +addressBookSearch?.addEventListener("input", renderAddressBookPage); +addressBookSort?.addEventListener("change", renderAddressBookPage); +addressBookRefreshVanity?.addEventListener("click", () => refreshAddressBookVanitiesOnce(true)); + +async function handleAddressBookLabelChange(event) { + const input = event.target.closest("[data-address-book-label]"); + if (!input) { + return; + } + + const address = canonicalAddressBookAddress(input.dataset.addressBookLabel); + if (!address) { + return; + } + + await loadAddressBookCache(); + const entry = addressBookCache.entries[address]; + if (!entry) { + return; + } + + entry.label = input.value.trim() || ADDRESS_BOOK_UNKNOWN; + entry.label_source = "user"; + entry.updated_at = currentUnixSeconds(); + scheduleAddressBookWrite(); + renderAddressBookPage(); +} + +async function handleAddressBookClick(event) { + const copyButton = event.target.closest("[data-address-book-copy]"); + if (copyButton) { + const value = copyButton.dataset.addressBookCopy || ""; + const copied = typeof writeClipboardText === "function" + ? await writeClipboardText(value) + : false; + if (copied) { + const previous = copyButton.textContent; + copyButton.textContent = "Copied"; + setTimeout(() => { + if (copyButton.isConnected) { + copyButton.textContent = previous; + } + }, 1000); + } + return; + } + + const button = event.target.closest("[data-address-book-delete]"); + if (!button) { + return; + } + + const address = canonicalAddressBookAddress(button.dataset.addressBookDelete); + if (!address) { + return; + } + + await loadAddressBookCache(); + if (!addressBookCache.entries[address]) { + return; + } + + delete addressBookCache.entries[address]; + scheduleAddressBookWrite(); + renderAddressBookPage(); +} + +function handleAddressBookKeydown(event) { + const input = event.target.closest("[data-address-book-label]"); + if (input && event.key === "Enter") { + event.preventDefault(); + input.blur(); + } +} + +[addressBookRows, addressBookCards].forEach((container) => { + container?.addEventListener("change", handleAddressBookLabelChange); + container?.addEventListener("click", handleAddressBookClick); + container?.addEventListener("keydown", handleAddressBookKeydown); +}); diff --git a/frontend/js/app_state.js b/frontend/js/app_state.js new file mode 100644 index 0000000..284c0ea --- /dev/null +++ b/frontend/js/app_state.js @@ -0,0 +1,899 @@ +const titles = { + dashboard: ["Dashboard", "Overview for the active address."], + send: ["Send", "Create and preview transaction forms."], + receive: ["Receive", "Show wallet address and payment request details."], + history: ["History", "Full local transaction history for the active address."], + transactions: ["Transactions", "Review local and confirmed transaction activity."], + assets: ["Assets", "View CLC, tokens, and balances."], + nfts: ["NFTs", "View and manage non-fungible token holdings."], + loans: ["Loans", "Create loans, payments, and collateral claims."], + marketing: ["Marketing", "Review campaign activity from agency wallets."], + addressbook: ["Address Book", "Manage known addresses and labels."], + settings: ["Settings", "Manage wallets, nodes, security, and app preferences."] +}; + +let currentTransactionType = ""; + +function setScopedTransactionMessage(formType, element, message, type = "") { + if (!element) { + return; + } + if (message && (currentView !== "send" || currentTransactionType !== formType)) { + return; + } + element.textContent = message; + element.classList.toggle("hidden", !message); + element.classList.toggle("error", type === "error"); + element.classList.toggle("success", type === "success"); +} + +const navButtons = document.querySelectorAll("[data-view]"); +const dashboardView = document.querySelector("#dashboard-view"); +const transactionView = document.querySelector("#transaction-view"); +const receiveView = document.querySelector("#receive-view"); +const historyView = document.querySelector("#history-view"); +const assetsView = document.querySelector("#assets-view"); +const nftsView = document.querySelector("#nfts-view"); +const loansView = document.querySelector("#loans-view"); +const marketingView = document.querySelector("#marketing-view"); +const addressbookView = document.querySelector("#addressbook-view"); +const settingsView = document.querySelector("#settings-view"); +const viewTitle = document.querySelector("#view-title"); +const viewSubtitle = document.querySelector("#view-subtitle"); +const transactionTitle = document.querySelector("#transaction-panel-title"); +const transactionCopy = document.querySelector("#transaction-panel-copy"); +const transactionList = document.querySelector("[data-transaction-list]"); +const formPanel = document.querySelector("[data-form-panel]"); +const reviewPanel = document.querySelector("[data-review-panel]"); +const unbroadcastListMessage = document.querySelector("[data-unbroadcast-list-message]"); +const unbroadcastReviewGrid = document.querySelector("[data-unbroadcast-review-grid]"); +const unbroadcastJsonPreview = document.querySelector("[data-unbroadcast-json]"); +const unbroadcastReviewMessage = document.querySelector("[data-unbroadcast-review-message]"); +const unbroadcastBackButton = document.querySelector("[data-unbroadcast-back]"); +const unbroadcastDeleteButton = document.querySelector("[data-unbroadcast-delete]"); +const unbroadcastSignButton = document.querySelector("[data-unbroadcast-sign]"); +const unbroadcastBroadcastButton = document.querySelector("[data-unbroadcast-broadcast]"); +const transferAssetSelect = document.querySelector("[data-transfer-asset]"); +const transferReceiverInput = document.querySelector("[data-transfer-receiver]"); +const transferAmountInput = document.querySelector("[data-transfer-amount]"); +const transferFeeInput = document.querySelector("[data-transfer-fee]"); +const transferCoin = document.querySelector("[data-transfer-coin]"); +const transferNftSeries = document.querySelector("[data-transfer-nft-series]"); +const transferBalance = document.querySelector("[data-transfer-balance]"); +const transferSender = document.querySelector("[data-transfer-sender]"); +const transferMessage = document.querySelector("[data-transfer-message]"); +const transferActions = document.querySelector("[data-transfer-actions]"); +const marketingCampaignForm = document.querySelector("[data-marketing-campaign-form]"); +const marketingCampaignAdvertiserInput = document.querySelector("[data-marketing-campaign-advertiser]"); +const marketingCampaignIdInput = document.querySelector("[data-marketing-campaign-id]"); +const marketingCampaignLoadButton = document.querySelector("[data-marketing-campaign-load]"); +const marketingCampaignRefreshButton = document.querySelector("[data-marketing-campaign-refresh]"); +const marketingCampaignRows = document.querySelector("[data-marketing-campaign-rows]"); +const marketingCampaignCards = document.querySelector("[data-marketing-campaign-cards]"); +const marketingCampaignState = document.querySelector("[data-marketing-campaign-state]"); +const marketingCampaignCount = document.querySelector("[data-marketing-campaign-count]"); +const marketingCampaignPagination = document.querySelector("[data-marketing-campaign-pagination]"); +const marketingCampaignPageInput = document.querySelector("[data-marketing-campaign-page-input]"); +const marketingCampaignPageTotal = document.querySelector("[data-marketing-campaign-page-total]"); +const marketingTotalRecords = document.querySelector("[data-marketing-total-records]"); +const marketingTotalImpressions = document.querySelector("[data-marketing-total-impressions]"); +const marketingTotalClicks = document.querySelector("[data-marketing-total-clicks]"); +const marketingAvgImpressionValue = document.querySelector("[data-marketing-avg-impression-value]"); +const marketingAvgClickValue = document.querySelector("[data-marketing-avg-click-value]"); +const marketingTotalValue = document.querySelector("[data-marketing-total-value]"); +const createTokenForm = document.querySelector("[data-create-token-form]"); +const createTokenActions = document.querySelector("[data-create-token-actions]"); +const createTokenTickerInput = document.querySelector("[data-create-token-ticker]"); +const createTokenNumberInput = document.querySelector("[data-create-token-number]"); +const createTokenFeeInput = document.querySelector("[data-create-token-fee]"); +const createTokenAllowMoreInput = document.querySelector("[data-create-token-allow-more]"); +const createTokenCap = document.querySelector("[data-create-token-cap]"); +const createTokenCreator = document.querySelector("[data-create-token-creator]"); +const createTokenFeeLabel = document.querySelector("[data-create-token-fee-label]"); +const createTokenMessage = document.querySelector("[data-create-token-message]"); +const issueTokenForm = document.querySelector("[data-issue-token-form]"); +const issueTokenActions = document.querySelector("[data-issue-token-actions]"); +const issueTokenTickerSelect = document.querySelector("[data-issue-token-ticker]"); +const issueTokenNumberInput = document.querySelector("[data-issue-token-number]"); +const issueTokenFeeInput = document.querySelector("[data-issue-token-fee]"); +const issueTokenCreator = document.querySelector("[data-issue-token-creator]"); +const issueTokenPolicy = document.querySelector("[data-issue-token-policy]"); +const issueTokenFeeLabel = document.querySelector("[data-issue-token-fee-label]"); +const issueTokenMessage = document.querySelector("[data-issue-token-message]"); +const createNftForm = document.querySelector("[data-create-nft-form]"); +const createNftActions = document.querySelector("[data-create-nft-actions]"); +const createNftSeriesSelect = document.querySelector("[data-create-nft-series]"); +const createNftNameInput = document.querySelector("[data-create-nft-name]"); +const createNftIpfsInput = document.querySelector("[data-create-nft-ipfs]"); +const createNftCountInput = document.querySelector("[data-create-nft-count]"); +const createNftDescriptionInput = document.querySelector("[data-create-nft-description]"); +const createNftFeeInput = document.querySelector("[data-create-nft-fee]"); +const createNftCreator = document.querySelector("[data-create-nft-creator]"); +const createNftTypeLabel = document.querySelector("[data-create-nft-type-label]"); +const createNftCountLabel = document.querySelector("[data-create-nft-count-label]"); +const createNftFeeLabel = document.querySelector("[data-create-nft-fee-label]"); +const createNftMessage = document.querySelector("[data-create-nft-message]"); +const swapOfferForm = document.querySelector("[data-swap-offer-form]"); +const swapOfferActions = document.querySelector("[data-swap-offer-actions]"); +const swapOfferAssetSelect = document.querySelector("[data-swap-offer-asset]"); +const swapOfferAmountInput = document.querySelector("[data-swap-offer-amount]"); +const swapOfferTipInput = document.querySelector("[data-swap-offer-tip]"); +const swapRequestAssetSelect = document.querySelector("[data-swap-request-asset]"); +const swapRequestAmountInput = document.querySelector("[data-swap-request-amount]"); +const swapRequestTipInput = document.querySelector("[data-swap-request-tip]"); +const swapCounterpartyInput = document.querySelector("[data-swap-counterparty]"); +const swapExpirationInput = document.querySelector("[data-swap-expiration]"); +const swapSender = document.querySelector("[data-swap-sender]"); +const swapAvailable = document.querySelector("[data-swap-available]"); +const swapFees = document.querySelector("[data-swap-fees]"); +const swapSavedPathPanel = document.querySelector("[data-swap-saved-path-panel]"); +const swapSavedPathInput = document.querySelector("[data-swap-saved-path]"); +const swapCopyPathButton = document.querySelector("[data-swap-copy-path]"); +const swapOfferMessage = document.querySelector("[data-swap-offer-message]"); +const loanOfferForm = document.querySelector("[data-loan-offer-form]"); +const loanOfferActions = document.querySelector("[data-loan-offer-actions]"); +const loanAssetSelect = document.querySelector("[data-loan-asset]"); +const loanAmountInput = document.querySelector("[data-loan-amount]"); +const loanBorrowerInput = document.querySelector("[data-loan-borrower]"); +const loanStartInput = document.querySelector("[data-loan-start]"); +const loanCollateralSelect = document.querySelector("[data-loan-collateral]"); +const loanCollateralAmountInput = document.querySelector("[data-loan-collateral-amount]"); +const loanPaymentPeriodSelect = document.querySelector("[data-loan-payment-period]"); +const loanPaymentNumberInput = document.querySelector("[data-loan-payment-number]"); +const loanPaymentAmountInput = document.querySelector("[data-loan-payment-amount]"); +const loanGracePeriodInput = document.querySelector("[data-loan-grace-period]"); +const loanMaxLateValueInput = document.querySelector("[data-loan-max-late-value]"); +const loanFeeInput = document.querySelector("[data-loan-fee]"); +const loanLender = document.querySelector("[data-loan-lender]"); +const loanAvailable = document.querySelector("[data-loan-available]"); +const loanTotalRepayment = document.querySelector("[data-loan-total-repayment]"); +const loanSavedPathPanel = document.querySelector("[data-loan-saved-path-panel]"); +const loanSavedPathInput = document.querySelector("[data-loan-saved-path]"); +const loanCopyPathButton = document.querySelector("[data-loan-copy-path]"); +const loanOfferMessage = document.querySelector("[data-loan-offer-message]"); +const loanPaymentForm = document.querySelector("[data-loan-payment-form]"); +const loanPaymentActions = document.querySelector("[data-loan-payment-actions]"); +const loanPaymentContractSelect = document.querySelector("[data-loan-payment-contract]"); +const loanPaymentValueInput = document.querySelector("[data-loan-payment-value]"); +const loanPaymentTipInput = document.querySelector("[data-loan-payment-tip]"); +const loanPaymentFeeInput = document.querySelector("[data-loan-payment-fee]"); +const loanPaymentLender = document.querySelector("[data-loan-payment-lender]"); +const loanPaymentAsset = document.querySelector("[data-loan-payment-asset]"); +const loanPaymentScheduled = document.querySelector("[data-loan-payment-scheduled]"); +const loanPaymentConfirmed = document.querySelector("[data-loan-payment-confirmed]"); +const loanPaymentPending = document.querySelector("[data-loan-payment-pending]"); +const loanPaymentRemaining = document.querySelector("[data-loan-payment-remaining]"); +const loanPaymentWarning = document.querySelector("[data-loan-payment-warning]"); +const loanPaymentMessage = document.querySelector("[data-loan-payment-message]"); +const collateralClaimForm = document.querySelector("[data-collateral-claim-form]"); +const collateralClaimActions = document.querySelector("[data-collateral-claim-actions]"); +const collateralClaimContractSelect = document.querySelector("[data-collateral-claim-contract]"); +const collateralClaimFeeInput = document.querySelector("[data-collateral-claim-fee]"); +const collateralClaimRole = document.querySelector("[data-collateral-claim-role]"); +const collateralClaimReason = document.querySelector("[data-collateral-claim-reason]"); +const collateralClaimLender = document.querySelector("[data-collateral-claim-lender]"); +const collateralClaimBorrower = document.querySelector("[data-collateral-claim-borrower]"); +const collateralClaimLoan = document.querySelector("[data-collateral-claim-loan]"); +const collateralClaimCollateral = document.querySelector("[data-collateral-claim-collateral]"); +const collateralClaimConfirmed = document.querySelector("[data-collateral-claim-confirmed]"); +const collateralClaimRemaining = document.querySelector("[data-collateral-claim-remaining]"); +const collateralClaimMessage = document.querySelector("[data-collateral-claim-message]"); +const burnForm = document.querySelector("[data-burn-form]"); +const burnActions = document.querySelector("[data-burn-actions]"); +const burnAssetSelect = document.querySelector("[data-burn-asset]"); +const burnAmountInput = document.querySelector("[data-burn-amount]"); +const burnFeeInput = document.querySelector("[data-burn-fee]"); +const burnAddress = document.querySelector("[data-burn-address]"); +const burnAvailable = document.querySelector("[data-burn-available]"); +const burnType = document.querySelector("[data-burn-type]"); +const burnFeeLabel = document.querySelector("[data-burn-fee-label]"); +const burnMessage = document.querySelector("[data-burn-message]"); +const marketingForm = document.querySelector("[data-marketing-form]"); +const marketingActions = document.querySelector("[data-marketing-actions]"); +const marketingCampaignInput = document.querySelector("[data-marketing-campaign]"); +const marketingAdTypeSelect = document.querySelector("[data-marketing-ad-type]"); +const marketingKeywordInput = document.querySelector("[data-marketing-keyword]"); +const marketingDisplayedInput = document.querySelector("[data-marketing-displayed]"); +const marketingImpressionInput = document.querySelector("[data-marketing-impression]"); +const marketingClickInput = document.querySelector("[data-marketing-click]"); +const marketingImpressionValueInput = document.querySelector("[data-marketing-impression-value]"); +const marketingClickValueInput = document.querySelector("[data-marketing-click-value]"); +const marketingFeeInput = document.querySelector("[data-marketing-fee]"); +const marketingAdvertiser = document.querySelector("[data-marketing-advertiser]"); +const marketingFeeLabel = document.querySelector("[data-marketing-fee-label]"); +const marketingMessage = document.querySelector("[data-marketing-message]"); +const vanityForm = document.querySelector("[data-vanity-form]"); +const vanityActions = document.querySelector("[data-vanity-actions]"); +const vanityNameInput = document.querySelector("[data-vanity-name]"); +const vanityFeeInput = document.querySelector("[data-vanity-fee]"); +const vanityOwner = document.querySelector("[data-vanity-owner]"); +const vanityPreview = document.querySelector("[data-vanity-preview]"); +const vanityFeeLabel = document.querySelector("[data-vanity-fee-label]"); +const vanityMessage = document.querySelector("[data-vanity-message]"); +const bridgeStatus = document.querySelector("[data-bridge-status]"); +const createWalletForm = document.querySelector("[data-create-wallet-form]"); +const loadWalletForm = document.querySelector("[data-load-wallet-form]"); +const importKeyForm = document.querySelector("[data-import-key-form]"); +const importImageForm = document.querySelector("[data-import-image-form]"); +const walletNameInput = document.querySelector("[data-wallet-name]"); +const walletKeyInput = document.querySelector("[data-wallet-key]"); +const walletKeyConfirmInput = document.querySelector("[data-wallet-key-confirm]"); +const createWalletSubmit = document.querySelector("[data-create-wallet-submit]"); +const startupMessage = document.querySelector("[data-startup-message]"); +const walletListSelect = document.querySelector("[data-wallet-list]"); +const loadWalletKeyInput = document.querySelector("[data-load-wallet-key]"); +const loadWalletSubmit = document.querySelector("[data-load-wallet-submit]"); +const loadMessage = document.querySelector("[data-load-message]"); +const importWalletNameInput = document.querySelector("[data-import-wallet-name]"); +const importPrivateKeyInput = document.querySelector("[data-import-private-key]"); +const importWalletKeyInput = document.querySelector("[data-import-wallet-key]"); +const importWalletKeyConfirmInput = document.querySelector("[data-import-wallet-key-confirm]"); +const importKeySubmit = document.querySelector("[data-import-key-submit]"); +const importMessage = document.querySelector("[data-import-message]"); +const importImageWalletNameInput = document.querySelector("[data-import-image-wallet-name]"); +const importImagePathInput = document.querySelector("[data-import-image-path]"); +const importImageWalletKeyInput = document.querySelector("[data-import-image-wallet-key]"); +const importImageWalletKeyConfirmInput = document.querySelector("[data-import-image-wallet-key-confirm]"); +const importImageSubmit = document.querySelector("[data-import-image-submit]"); +const importImageMessage = document.querySelector("[data-import-image-message]"); +const lockWalletButton = document.querySelector("[data-lock-wallet]"); +const browseWalletButton = document.querySelector("[data-browse-wallet]"); +const browsePrivateKeyImageButton = document.querySelector("[data-browse-private-key-image]"); +const networkButtons = document.querySelectorAll("[data-network-toggle]"); +const networkLabels = document.querySelectorAll("[data-network-label]"); +const clcBalance = document.querySelector("[data-clc-balance]"); +const baseBalanceLabel = document.querySelector("[data-base-balance-label]"); +const balanceStatus = document.querySelector("[data-balance-status]"); +const tokenBalances = document.querySelector("[data-token-balances]"); +const broadcastNodeButton = document.querySelector("[data-broadcast-node]"); +const registrationBadge = document.querySelector(".registration-badge"); +const registrationAction = document.querySelector("[data-registration-action]"); +const registrationNotice = document.querySelector("[data-registration-notice]"); +const recentTransactionsBody = document.querySelector("[data-recent-transactions]"); +const recentTransactionsState = document.querySelector("[data-recent-transactions-state]"); +const historyTransactionsBody = document.querySelector("[data-history-transactions]"); +const historyCards = document.querySelector("[data-history-cards]"); +const historyState = document.querySelector("[data-history-state]"); +const historyCount = document.querySelector("[data-history-count]"); +const historyPagination = document.querySelector("[data-history-pagination]"); +const historyPageInput = document.querySelector("[data-history-page-input]"); +const historyPageTotal = document.querySelector("[data-history-page-total]"); +const historySearch = document.querySelector("[data-history-search]"); +const historyExportOpen = document.querySelector("[data-history-export-open]"); +const historyExportModal = document.querySelector("[data-history-export-modal]"); +const historyExportClose = document.querySelector("[data-history-export-close]"); +const historyExportMessage = document.querySelector("[data-history-export-message]"); +const historyExportFormatButtons = document.querySelectorAll("[data-history-export-format]"); +const assetsSearch = document.querySelector("[data-assets-search]"); +const assetsSort = document.querySelector("[data-assets-sort]"); +const assetsFilterButtons = document.querySelectorAll("[data-assets-filter]"); +const assetsCards = document.querySelector("[data-assets-cards]"); +const assetsState = document.querySelector("[data-assets-state]"); +const loansSearch = document.querySelector("[data-loans-search]"); +const loansRoleFilter = document.querySelector("[data-loans-role-filter]"); +const loansStatusButtons = document.querySelectorAll("[data-loans-status-filter]"); +const loansRefreshButton = document.querySelector("[data-loans-refresh]"); +const loansCreateButton = document.querySelector("[data-loans-create]"); +const loansList = document.querySelector("[data-loans-list]"); +const loansState = document.querySelector("[data-loans-state]"); +const loanDetailGrid = document.querySelector("[data-loan-detail-grid]"); +const loanPaymentHistory = document.querySelector("[data-loan-payment-history]"); +const loanCopyContractButton = document.querySelector("[data-loan-copy-contract]"); +const loanMakePaymentButton = document.querySelector("[data-loan-make-payment]"); +const loanClaimCollateralButton = document.querySelector("[data-loan-claim-collateral]"); +const networkInfoHeight = document.querySelector("[data-network-info-height]"); +const networkInfoNetwork = document.querySelector("[data-network-info-network]"); +const networkInfoDifficulty = document.querySelector("[data-network-info-difficulty]"); +const networkInfoMempool = document.querySelector("[data-network-info-mempool]"); +const networkInfoChainTx = document.querySelector("[data-network-info-chain-tx]"); +const nodeInfoNode = document.querySelector("[data-node-info-node]"); +const nodeInfoHeight = document.querySelector("[data-node-info-height]"); +const nodeInfoTime = document.querySelector("[data-node-info-time]"); +const nodeInfoLastChecked = document.querySelector("[data-node-info-last-checked]"); +const nodeInfoLargestFee = document.querySelector("[data-node-info-largest-fee]"); +const paymentAssetSelect = document.querySelector("[data-payment-asset]"); +const paymentAmountInput = document.querySelector("[data-payment-amount]"); +const paymentLabelInput = document.querySelector("[data-payment-label]"); +const paymentUriInput = document.querySelector("[data-payment-uri]"); +const topbarPaymentUriForm = document.querySelector("[data-payment-uri-entry]"); +const topbarPaymentUriInput = document.querySelector("[data-topbar-payment-uri]"); +const paymentQrCanvas = document.querySelector("[data-payment-qr]"); +const paymentQrLabel = document.querySelector("[data-payment-qr-label]"); +const copyPaymentQrButton = document.querySelector("[data-copy-payment-qr]"); +const copyPaymentUriButton = document.querySelector("[data-copy-payment-uri]"); +const paymentMessage = document.querySelector("[data-payment-message]"); +const receiveRegistration = document.querySelector("[data-receive-registration]"); +const addressBookRows = document.querySelector("[data-address-book-rows]"); +const addressBookCards = document.querySelector("[data-address-book-cards]"); +const addressBookSearch = document.querySelector("[data-address-book-search]"); +const addressBookSort = document.querySelector("[data-address-book-sort]"); +const addressBookState = document.querySelector("[data-address-book-state]"); +const addressBookRefreshVanity = document.querySelector("[data-address-book-refresh-vanity]"); +const settingsSaveButton = document.querySelector("[data-settings-save]"); +const settingsMessage = document.querySelector("[data-settings-message]"); +const settingsWalletPath = document.querySelector("[data-settings-wallet-path]"); +const settingsDefaultNode = document.querySelector("[data-settings-default-node]"); +const settingsSavedNodes = document.querySelector("[data-settings-saved-nodes]"); +const settingsUnbroadcastPath = document.querySelector("[data-settings-unbroadcast-path]"); +const settingsAddressBookPath = document.querySelector("[data-settings-address-book-path]"); +const settingsIpfsGateway = document.querySelector("[data-settings-ipfs-gateway]"); +const settingsAutoLockTimeout = document.querySelector("[data-settings-auto-lock-timeout]"); +const settingsClipboardTimeout = document.querySelector("[data-settings-clipboard-timeout]"); +const settingsRequireKey = document.querySelector("[data-settings-require-key]"); +const settingsBrowseButtons = document.querySelectorAll("[data-settings-browse]"); +const settingsTestNodeButton = document.querySelector("[data-settings-test-node]"); +const settingsNodeTestStatus = document.querySelector("[data-settings-node-test-status]"); +const settingsCacheOpenButton = document.querySelector("[data-settings-cache-open]"); +const cacheClearModal = document.querySelector("[data-cache-clear-modal]"); +const cacheClearCloseButton = document.querySelector("[data-cache-clear-close]"); +const cacheClearMessage = document.querySelector("[data-cache-clear-message]"); +const nftsList = document.querySelector("[data-nfts-list]"); +const nftsState = document.querySelector("[data-nfts-state]"); +const nftsRefreshButton = document.querySelector("[data-nfts-refresh]"); +const nftsPagination = document.querySelector("[data-nfts-pagination]"); +const nftsPageInput = document.querySelector("[data-nfts-page-input]"); +const nftsPageTotal = document.querySelector("[data-nfts-page-total]"); +const nftDetailTitle = document.querySelector("[data-nft-detail-title]"); +const nftDetailSubtitle = document.querySelector("[data-nft-detail-subtitle]"); +const nftDetailName = document.querySelector("[data-nft-detail-name]"); +const nftDetailAsset = document.querySelector("[data-nft-detail-asset]"); +const nftDetailCreator = document.querySelector("[data-nft-detail-creator]"); +const nftDetailGenesis = document.querySelector("[data-nft-detail-genesis]"); +const nftDetailMetadata = document.querySelector("[data-nft-detail-metadata]"); +const nftMetadataSummary = document.querySelector("[data-nft-metadata-summary]"); +const nftHistory = document.querySelector("[data-nft-history]"); +let activeNetwork = "testnet"; +let currentView = "dashboard"; +let walletLoaded = false; +let registrationRegistered = false; +let registrationCheckInFlight = false; +let registrationRequestId = 0; +let dashboardRegisteredLoadId = 0; +let balanceRefreshTimer = null; +let balanceRefreshInFlight = false; +let balanceRefreshRequestId = 0; +let dashboardLastBalanceStatus = ""; +let activeWalletBalances = []; +let activeUnbroadcastTransaction = null; +let unbroadcastRefreshInFlight = false; +let unbroadcastReviewInFlight = false; +let networkInfoRefreshTimer = null; +let nodeInfoRefreshTimer = null; +let networkInfoRefreshInFlight = false; +let nodeInfoRefreshInFlight = false; +let recentTransactionsCacheReadId = 0; +let latestTransactionsSyncId = 0; +let latestTransactionsSyncInFlight = false; +let latestDashboardTransactionsTimer = null; +let latestDashboardTxids = []; +let latestDashboardTransactionRecords = new Map(); +let backgroundTransactionSyncId = 0; +let historyRefreshTimer = null; +let historyRefreshReadId = 0; +let historyRefreshInFlight = false; +let historyRefreshPending = false; +let historyCurrentPage = 1; +let historyLastRenderSignature = ""; +let historySearchDebounceTimer = null; +let transactionCacheMutationChain = Promise.resolve(); +let guiRuntimeSettings = { + auto_lock_timeout: "15 minutes", + clipboard_clear_timeout: "60 seconds", + require_key_before_signing: true +}; +let autoLockTimer = null; +let clipboardClearTimer = null; +let lastClipboardText = ""; +let activeWalletAddressHex = ""; +let activeWalletAddressShort = ""; +let latestKnownBlockHeight = 0; +const BALANCE_REFRESH_MS = 120000; +const NETWORK_INFO_REFRESH_MS = 60000; +const NODE_INFO_REFRESH_MS = 15000; +const DASHBOARD_RECENT_LIMIT = 5; +const BACKGROUND_HISTORY_PAGE_SIZE = 100; +const BACKGROUND_SYNC_IDLE_MS = 15000; +const DASHBOARD_LATEST_REFRESH_MS = 5000; +const DASHBOARD_INITIAL_TX_FETCH_DELAY_MS = 500; +const DASHBOARD_REFRESH_TX_FETCH_DELAY_MS = 2000; +const BACKGROUND_TX_FETCH_DELAY_MS = 2000; +const HISTORY_REFRESH_MS = 5000; +const HISTORY_PAGE_SIZE = 100; +const WALLET_RPC_RETRY_LIMIT = 3; +const WALLET_RPC_BACKOFF_BASE_MS = 1500; +const WALLET_RPC_BACKOFF_MAX_MS = 15000; +const CONFIRMED_AFTER_BLOCKS = 20; +const CHAIN_STATUS_PENDING = "pending"; +const CHAIN_STATUS_FINALIZED = "finalized"; +const startupTitle = document.querySelector(".startup-copy h1"); +const startupDescription = document.querySelector(".startup-copy p"); +const startModeButtons = document.querySelectorAll("[data-start-mode]"); +const topWalletListSelect = document.querySelector("[data-top-wallet-list]"); +const walletSwitchModal = document.querySelector("[data-wallet-switch-modal]"); +const walletSwitchForm = document.querySelector("[data-wallet-switch-form]"); +const walletSwitchName = document.querySelector("[data-wallet-switch-name]"); +const walletSwitchKeyInput = document.querySelector("[data-wallet-switch-key]"); +const walletSwitchSubmit = document.querySelector("[data-wallet-switch-submit]"); +const walletSwitchMessage = document.querySelector("[data-wallet-switch-message]"); +const walletSwitchCloseButton = document.querySelector("[data-wallet-switch-close]"); +const walletSwitchCancelButton = document.querySelector("[data-wallet-switch-cancel]"); +const backupKeyModal = document.querySelector("[data-backup-key-modal]"); +const backupKeyForm = document.querySelector("[data-backup-key-form]"); +const backupKeyTitle = document.querySelector("[data-backup-key-title]"); +const backupKeyCopy = document.querySelector("[data-backup-key-copy]"); +const backupKeyInput = document.querySelector("[data-backup-key-input]"); +const backupKeyMessage = document.querySelector("[data-backup-key-message]"); +const backupKeyCloseButton = document.querySelector("[data-backup-key-close]"); +const backupKeyCancelButton = document.querySelector("[data-backup-key-cancel]"); +const backupKeySubmitButton = document.querySelector("[data-backup-key-submit]"); +let activeWalletPath = ""; +let pendingWalletSwitch = null; +let pendingBackupKeyRequest = null; + +const startupCopy = { + load: ["Open Wallet", "Select a wallet file already on this device."], + create: ["Create New Wallet", "Generate a new encrypted wallet file."], + "import-key": ["Import Private Key", "Restore from a private key value."], + "import-image": ["Import Private Key Image", "Restore from a base64 or image-based key backup."] +}; + +function setActiveStartMode(mode) { + startModeButtons.forEach((button) => { + button.classList.toggle("primary-start", button.dataset.startMode === mode); + }); + + const [title, description] = startupCopy[mode] || startupCopy.load; + + if (startupTitle) { + startupTitle.textContent = title; + } + + if (startupDescription) { + startupDescription.textContent = description; + } +} + +function activeBaseCoin() { + return activeNetwork === "mainnet" ? "CLC" : "CLTC"; +} + +function shortenAddress(address) { + if (!address || address.length <= 17) { + return address || "No wallet"; + } + + const suffix = address.includes(".") ? address.slice(address.lastIndexOf(".") - 4) : address.slice(-8); + return `${address.slice(0, 4)}...${suffix}`; +} + +function privateKeyImageSrc(privateKeyImage) { + const image = (privateKeyImage || "").trim(); + if (!image) { + return ""; + } + + return image.startsWith("data:") ? image : `data:image/png;base64,${image}`; +} + +function setWalletImageMarks(privateKeyImage) { + const imageSrc = privateKeyImageSrc(privateKeyImage); + + document.querySelectorAll("[data-wallet-image-mark]").forEach((item) => { + if (!imageSrc) { + item.classList.remove("wallet-image"); + item.style.backgroundImage = ""; + item.textContent = "C"; + return; + } + + item.classList.add("wallet-image"); + item.style.backgroundImage = `url("${imageSrc}")`; + item.textContent = ""; + }); +} + +function formatAtomicBalance(value) { + const atomic = BigInt(value || "0"); + const whole = atomic / 100000000n; + const fractionalRaw = atomic % 100000000n; + const fractional = fractionalRaw.toString().padStart(8, "0").replace(/0+$/, ""); + return fractional ? `${whole}.${fractional}` : `${whole}`; +} + +function formatNumber(value) { + return Number(value || 0).toLocaleString(); +} + +function formatNetworkTime(timestamp) { + const numeric = Number(timestamp || 0); + if (!numeric) { + return "--"; + } + return new Date(numeric * 1000).toLocaleString(); +} + +function formatLargestFee(value) { + const formatted = formatAtomicBalance(value); + return `${formatted} ${activeBaseCoin()}`; +} + +function networkDisplayName(info) { + const network = String(info?.network || activeNetwork || "").trim(); + const prefix = String(info?.wallet_prefix || activeBaseCoin()).trim(); + const label = network ? network.charAt(0).toUpperCase() + network.slice(1) : "Network"; + return prefix ? `${label} / ${prefix}` : label; +} + +async function populateTopWalletList() { + const invoke = window.__TAURI__?.core?.invoke; + + if (!invoke || !topWalletListSelect) { + return; + } + + try { + const wallets = await invoke("list_wallets", { network: activeNetwork }); + + topWalletListSelect.innerHTML = ""; + + if (!wallets.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No wallets found"; + topWalletListSelect.appendChild(option); + return; + } + + wallets.forEach((wallet) => { + const option = document.createElement("option"); + option.value = wallet.wallet_path; + option.textContent = wallet.wallet_name; + + if (wallet.wallet_path === activeWalletPath) { + option.selected = true; + } + + topWalletListSelect.appendChild(option); + }); + } catch (error) { + console.error(error); + } +} + +function setBroadcastNodeOptions(nodes) { + if (!broadcastNodeButton) { + return; + } + + const previous = activeBroadcastNode(); + const normalized = Array.isArray(nodes) ? nodes.filter((node) => node?.address) : []; + broadcastNodeButton.innerHTML = ""; + + if (!normalized.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No broadcast nodes"; + broadcastNodeButton.appendChild(option); + return; + } + + normalized.forEach((node) => { + const option = document.createElement("option"); + option.value = node.address; + option.textContent = node.address; + option.title = node.label ? `${node.label}: ${node.address}` : node.address; + broadcastNodeButton.appendChild(option); + }); + + const selected = normalized.some((node) => node.address === previous) + ? previous + : normalized[0].address; + broadcastNodeButton.value = selected; +} + +async function populateBroadcastNodeList() { + const invoke = window.__TAURI__?.core?.invoke; + + if (!invoke || !broadcastNodeButton) { + return; + } + + try { + const nodes = await invoke("list_broadcast_nodes"); + setBroadcastNodeOptions(nodes); + } catch (error) { + console.error(error); + setBroadcastNodeOptions([]); + } +} + +function assetInitial(asset) { + return (asset || "?").trim().slice(0, 1).toUpperCase() || "?"; +} + +function balanceAssetLabel(balance) { + if (balance.nft_series && balance.nft_series > 0) { + return `${balance.asset} #${balance.nft_series}`; + } + + return balance.asset; +} + +let knownNftBalanceKeys = new Set(); +let knownNftBalanceNode = ""; +let knownNftBalanceLoadedAt = 0; +let knownNftBalanceLoad = null; +const DASHBOARD_NFT_BALANCE_CACHE_MS = 300_000; + +function balanceRegistryKey(balance) { + const asset = String(balance?.asset || "").trim().toLowerCase(); + const series = Number(balance?.nft_series || 0); + return `${asset}|${series}`; +} + +function isDashboardNftBalance(balance) { + if (Number(balance?.nft_series || 0) > 0) { + return true; + } + + return knownNftBalanceKeys.has(balanceRegistryKey(balance)); +} + +function dashboardNftBalanceKeysReady() { + const broadcastNode = typeof activeBroadcastNode === "function" ? activeBroadcastNode() : ""; + return ( + Boolean(broadcastNode) && + knownNftBalanceNode === broadcastNode + ); +} + +function dashboardNftBalanceKeysFresh() { + const broadcastNode = typeof activeBroadcastNode === "function" ? activeBroadcastNode() : ""; + return ( + dashboardNftBalanceKeysReady() && + Date.now() - knownNftBalanceLoadedAt < DASHBOARD_NFT_BALANCE_CACHE_MS + ); +} + +function invalidateDashboardNftBalanceKeys() { + knownNftBalanceNode = ""; + knownNftBalanceLoadedAt = 0; +} + +async function refreshDashboardNftBalanceKeys() { + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = typeof activeBroadcastNode === "function" ? activeBroadcastNode() : ""; + + if (!invoke || !broadcastNode) { + return false; + } + + if (dashboardNftBalanceKeysFresh()) { + return false; + } + + if (knownNftBalanceLoad) { + return knownNftBalanceLoad; + } + + knownNftBalanceLoad = invoke("nft_list", { broadcastNode }) + .then((rows) => { + const nextKeys = new Set(); + (Array.isArray(rows) ? rows : []).forEach((nft) => { + const name = String(nft?.nft_name || "").trim().toLowerCase(); + if (!name) { + return; + } + nextKeys.add(`${name}|${Number(nft?.series || 0)}`); + }); + + const previous = Array.from(knownNftBalanceKeys).sort().join(","); + const next = Array.from(nextKeys).sort().join(","); + knownNftBalanceKeys = nextKeys; + knownNftBalanceNode = broadcastNode; + knownNftBalanceLoadedAt = Date.now(); + return previous !== next; + }) + .catch(() => false) + .finally(() => { + knownNftBalanceLoad = null; + }); + + return knownNftBalanceLoad; +} + +function setBalanceStatus(message, type = "") { + if (!balanceStatus) { + return; + } + + balanceStatus.textContent = message; + balanceStatus.classList.toggle("error", type === "error"); + balanceStatus.classList.toggle("success", type === "success"); +} + +function setBridgeStatus(message, type = "") { + if (!bridgeStatus) { + return; + } + + bridgeStatus.textContent = message; + bridgeStatus.classList.toggle("error", type === "error"); + bridgeStatus.classList.toggle("success", type === "success"); +} + +function renderBalances(balances) { + const normalized = Array.isArray(balances) ? balances : []; + activeWalletBalances = normalized; + if (typeof refreshTransferAssetOptions === "function") { + refreshTransferAssetOptions(); + } + if (typeof refreshActiveTransactionFormAfterWalletData === "function") { + refreshActiveTransactionFormAfterWalletData(); + } + + const baseCoin = activeBaseCoin(); + const baseBalance = normalized.find((balance) => { + return (balance.asset || "").trim().toUpperCase() === baseCoin && !balance.nft_series; + }); + const nftKeysReady = dashboardNftBalanceKeysReady(); + const tokenRows = normalized + .filter((balance) => { + const isBaseCoin = (balance.asset || "").trim().toUpperCase() === baseCoin && !balance.nft_series; + return !isBaseCoin && !isDashboardNftBalance(balance); + }) + .sort((left, right) => { + const leftBalance = BigInt(left.balance || "0"); + const rightBalance = BigInt(right.balance || "0"); + if (rightBalance !== leftBalance) { + return rightBalance > leftBalance ? 1 : -1; + } + return balanceAssetLabel(left).localeCompare(balanceAssetLabel(right)); + }); + + if (baseBalanceLabel) { + baseBalanceLabel.textContent = `${baseCoin} Balance`; + } + + if (clcBalance) { + clcBalance.textContent = baseBalance ? `${formatAtomicBalance(baseBalance.balance)} ${baseCoin}` : `0 ${baseCoin}`; + } + + if (!tokenBalances) { + return; + } + + const rerenderAfterNftKeyRefresh = () => { + refreshDashboardNftBalanceKeys().then((changed) => { + if (changed && Array.isArray(activeWalletBalances)) { + renderBalances(activeWalletBalances); + } + }); + }; + + tokenBalances.innerHTML = ""; + const title = document.createElement("div"); + title.className = "token-title"; + title.textContent = nftKeysReady ? `Token Balances (${tokenRows.length})` : "Token Balances"; + tokenBalances.appendChild(title); + + if (!nftKeysReady) { + const pending = document.createElement("div"); + pending.className = "empty-balance"; + pending.textContent = "Loading token balances..."; + tokenBalances.appendChild(pending); + rerenderAfterNftKeyRefresh(); + return; + } + + if (!tokenRows.length) { + const empty = document.createElement("div"); + empty.className = "empty-balance"; + empty.textContent = "No token balances found."; + tokenBalances.appendChild(empty); + rerenderAfterNftKeyRefresh(); + return; + } + + tokenRows.slice(0, 4).forEach((balance) => { + const row = document.createElement("div"); + row.className = "token-row"; + + const mark = document.createElement("span"); + mark.className = "asset-mark mini"; + mark.textContent = assetInitial(balance.asset); + + const asset = document.createElement("span"); + asset.textContent = balanceAssetLabel(balance); + + const amount = document.createElement("b"); + amount.textContent = formatAtomicBalance(balance.balance); + + row.append(mark, asset, amount); + tokenBalances.appendChild(row); + }); + + rerenderAfterNftKeyRefresh(); + + if (currentView === "assets" && typeof renderAssetsPage === "function") { + renderAssetsPage(); + } +} + +function resetTransactionForms() { + [ + "resetTransferSession", + "resetCreateTokenSession", + "resetIssueTokenSession", + "resetCreateNftSession", + "resetSwapOfferSession", + "resetLoanOfferSession", + "resetLoanPaymentSession", + "resetCollateralClaimSession", + "resetBurnSession", + "resetMarketingSession", + "resetVanityAddressSession" + ].forEach((name) => { + if (typeof window[name] === "function") { + window[name](); + } + }); + + [ + "clearTransferForm", + "clearCreateTokenForm", + "clearIssueTokenForm", + "clearCreateNftForm", + "clearSwapOfferForm", + "clearLoanOfferForm", + "clearLoanPaymentForm", + "clearCollateralClaimForm", + "clearBurnForm", + "clearMarketingForm", + "clearVanityForm" + ].forEach((name) => { + if (typeof window[name] === "function") { + window[name](); + } + }); + + [ + transferMessage, + createTokenMessage, + issueTokenMessage, + createNftMessage, + swapOfferMessage, + loanOfferMessage, + loanPaymentMessage, + collateralClaimMessage, + burnMessage, + marketingMessage, + vanityMessage + ].forEach((element) => { + if (!element) { + return; + } + element.textContent = ""; + element.classList.add("hidden"); + element.classList.remove("error", "success"); + }); + + loanSavedPathPanel?.classList.add("hidden"); + currentTransactionType = ""; +} + +function setRecentTransactionsState(message, mode = "") { + if (!recentTransactionsState) { + return; + } + + recentTransactionsState.textContent = message; + recentTransactionsState.classList.toggle("hidden", !message); + recentTransactionsState.classList.toggle("loading", mode === "loading"); +} + +function clearRecentTransactionsRows() { + if (recentTransactionsBody) { + recentTransactionsBody.innerHTML = ""; + } +} diff --git a/frontend/js/assets_page.js b/frontend/js/assets_page.js new file mode 100644 index 0000000..554135d --- /dev/null +++ b/frontend/js/assets_page.js @@ -0,0 +1,256 @@ +let assetsCatalog = []; +let assetsLoadedForNode = ""; +let assetsLoadInFlight = false; +let assetsRefreshId = 0; +let activeAssetFilter = "all"; +let assetsEmptyStateMessage = ""; + +function invalidateAssetsCatalog() { + assetsLoadedForNode = ""; +} + +function assetCatalogBalance(asset) { + const ticker = String(asset || "").trim().toLowerCase(); + const match = activeWalletBalances.find((balance) => { + return ( + String(balance.asset || "").trim().toLowerCase() === ticker && + Number(balance.nft_series || 0) === 0 + ); + }); + return match ? String(match.balance || "0") : "0"; +} + +function formatAssetSupply(value) { + return formatAtomicBalance(value || 0); +} + +function assetCreatorLabel(asset, isCreator) { + const creator = String(asset.creator || "").trim(); + if (isCreator) { + return "You"; + } + return creator ? shortenAddress(creator) : "Unknown"; +} + +function formatAssetDate(timestamp) { + const numeric = Number(timestamp || 0); + if (!numeric) { + return "--"; + } + return new Date(numeric * 1000).toLocaleDateString(); +} + +function escapeAssetText(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function compareAssetBalance(left, right) { + const leftBalance = BigInt(assetCatalogBalance(left.ticker)); + const rightBalance = BigInt(assetCatalogBalance(right.ticker)); + if (rightBalance !== leftBalance) { + return rightBalance > leftBalance ? 1 : -1; + } + return String(left.ticker || "").localeCompare(String(right.ticker || "")); +} + +function sortedAssetCards() { + const query = String(assetsSearch?.value || "").trim().toLowerCase(); + const sortMode = assetsSort?.value || "default"; + const activeShortAddress = String(activeWalletAddressShort || "").toLowerCase(); + const sourceRows = assetsCatalog; + + let rows = sourceRows.filter((asset) => { + const ticker = String(asset.ticker || "").trim(); + if (!ticker) { + return false; + } + const balance = BigInt(assetCatalogBalance(ticker)); + const creator = String(asset.creator || "").toLowerCase(); + const searchText = `${ticker} ${asset.origin_txid || ""} ${asset.creator || ""}`.toLowerCase(); + + if (activeAssetFilter === "holding" && balance <= 0n) { + return false; + } + if (activeAssetFilter === "created" && creator !== activeShortAddress) { + return false; + } + return !query || searchText.includes(query); + }); + + if (sortMode === "balance" || sortMode === "default") { + rows.sort(compareAssetBalance); + if (sortMode === "default") { + rows.sort((left, right) => { + const leftHolding = BigInt(assetCatalogBalance(left.ticker)) > 0n ? 0 : 1; + const rightHolding = BigInt(assetCatalogBalance(right.ticker)) > 0n ? 0 : 1; + return leftHolding - rightHolding || compareAssetBalance(left, right); + }); + } + } else if (sortMode === "created") { + rows.sort((left, right) => Number(right.created_timestamp || 0) - Number(left.created_timestamp || 0)); + } else { + rows.sort((left, right) => String(left.ticker || "").localeCompare(String(right.ticker || ""))); + } + + return rows; +} + +function renderAssetCard(asset) { + const ticker = String(asset.ticker || "").trim(); + const balance = assetCatalogBalance(ticker); + const isHolding = BigInt(balance || "0") > 0n; + const isCreator = String(asset.creator || "").toLowerCase() === String(activeWalletAddressShort || "").toLowerCase(); + const policy = asset.hard_limit ? "Fixed Supply" : "Issuable"; + const escapedTicker = escapeAssetText(ticker); + const escapedPolicy = escapeAssetText(policy); + const creatorLabel = assetCreatorLabel(asset, isCreator); + + return ` +
+
+
${escapeAssetText(assetInitial(ticker))}
+
+ ${escapedTicker} + ${escapedPolicy} +
+
+ ${escapeAssetText(formatAtomicBalance(balance))} ${escapedTicker} +
+ ${isHolding ? 'Holding' : ""} + ${isCreator ? 'Created by me' : ""} + ${escapedPolicy} +
+
+
Current Supply${escapeAssetText(formatAssetSupply(asset.current_supply))}
+
Total Burned${escapeAssetText(formatAssetSupply(asset.total_burned))}
+
Holders${escapeAssetText(formatNumber(asset.holder_count))}
+
Created${escapeAssetText(formatAssetDate(asset.created_timestamp))}
+
Initial Supply${escapeAssetText(formatAssetSupply(asset.initial_supply))}
+
Creator${escapeAssetText(creatorLabel)}
+
+ ${escapeAssetText(asset.origin_txid || "")} +
+ `; +} + +function setAssetsState(message) { + if (assetsState) { + assetsState.textContent = message || ""; + } +} + +function renderAssetsPage() { + if (!assetsCards) { + return; + } + + const rows = sortedAssetCards(); + assetsCards.innerHTML = rows.map(renderAssetCard).join(""); + + if (!walletLoaded) { + setAssetsState("Load a wallet to view assets."); + } else if (assetsLoadInFlight && !assetsCatalog.length) { + setAssetsState("Loading assets..."); + } else if (!assetsCatalog.length) { + setAssetsState(assetsEmptyStateMessage || "Token catalog returned no rows."); + } else if (!rows.length) { + setAssetsState("No assets match the current filter."); + } else { + setAssetsState(`${rows.length} token${rows.length === 1 ? "" : "s"} shown`); + } +} + +async function loadAssetsCatalog({ force = false } = {}) { + const invoke = window.__TAURI__?.core?.invoke; + const initialBroadcastNode = activeBroadcastNode(); + if (!walletLoaded) { + assetsCatalog = []; + assetsEmptyStateMessage = "Asset catalog skipped: no wallet is loaded."; + renderAssetsPage(); + return; + } + if (!invoke) { + assetsCatalog = []; + assetsEmptyStateMessage = "Asset catalog skipped: Tauri invoke is unavailable."; + renderAssetsPage(); + return; + } + if (!initialBroadcastNode) { + assetsCatalog = []; + assetsEmptyStateMessage = "Asset catalog skipped: no broadcast node is selected."; + renderAssetsPage(); + return; + } + + const broadcastNode = initialBroadcastNode; + if (!force && assetsCatalog.length && assetsLoadedForNode === broadcastNode) { + renderAssetsPage(); + return; + } + if (assetsLoadInFlight) { + return; + } + + const refreshId = ++assetsRefreshId; + assetsLoadInFlight = true; + assetsEmptyStateMessage = "Calling token catalog RPC command 48..."; + renderAssetsPage(); + + try { + const catalog = await invokeWalletRpcWithBackoff( + "asset catalog", + () => invoke("token_catalog", { broadcastNode }), + () => refreshId === assetsRefreshId && activeBroadcastNode() === broadcastNode + ); + if (refreshId !== assetsRefreshId || activeBroadcastNode() !== broadcastNode) { + return; + } + assetsCatalog = Array.isArray(catalog) ? catalog : []; + assetsEmptyStateMessage = assetsCatalog.length + ? "" + : "Token catalog command 48 returned 0 rows from the selected node."; + assetsLoadedForNode = broadcastNode; + } catch (error) { + if (refreshId === assetsRefreshId) { + assetsCatalog = []; + assetsEmptyStateMessage = `Token catalog command 48 failed: ${String(error)}`; + setAssetsState(String(error)); + } + } finally { + if (refreshId === assetsRefreshId) { + assetsLoadInFlight = false; + renderAssetsPage(); + } + } +} + +function prepareAssetsPage() { + renderAssetsPage(); + loadAssetsCatalog({ force: true }); +} + +function resetAssetsPage() { + assetsRefreshId += 1; + assetsCatalog = []; + assetsEmptyStateMessage = ""; + assetsLoadedForNode = ""; + assetsLoadInFlight = false; + renderAssetsPage(); +} + +assetsSearch?.addEventListener("input", renderAssetsPage); +assetsSort?.addEventListener("change", renderAssetsPage); +assetsFilterButtons.forEach((button) => { + button.addEventListener("click", () => { + activeAssetFilter = button.dataset.assetsFilter || "all"; + assetsFilterButtons.forEach((item) => { + item.classList.toggle("active", item === button); + }); + renderAssetsPage(); + }); +}); diff --git a/frontend/js/burn_form.js b/frontend/js/burn_form.js new file mode 100644 index 0000000..b5bfefb --- /dev/null +++ b/frontend/js/burn_form.js @@ -0,0 +1,273 @@ +const BURN_FEE_ATOMIC = 10000n; +let burnBalancesRefreshPromise = null; + +function setBurnMessage(message, type = "") { + setScopedTransactionMessage("Burn", burnMessage, message, type); +} + +function burnableBalances() { + return (Array.isArray(activeWalletBalances) ? activeWalletBalances : []) + .filter((balance) => { + const asset = String(balance.asset || "").trim(); + return asset + && !isBaseTransferAsset(asset) + && balanceAtomicValue(balance) > 0n; + }); +} + +function burnAssetOptionLabel(balance) { + const amount = formatAtomicBalance(balance.balance || "0"); + const series = Number(balance.nft_series || 0); + if (series > 0) { + return `${balance.asset} #${series} (${amount})`; + } + return `${balance.asset} (${amount})`; +} + +function selectedBurnAsset() { + const option = burnAssetSelect?.selectedOptions?.[0]; + if (!option?.value) { + return null; + } + return { + asset: option.dataset.asset || "", + nftSeries: Number(option.dataset.nftSeries || 0), + balance: BigInt(option.dataset.balance || "0") + }; +} + +function populateBurnAssets() { + if (!burnAssetSelect) { + return; + } + const previous = burnAssetSelect.value; + burnAssetSelect.innerHTML = ""; + const balances = burnableBalances(); + if (balances.length) { + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Select an asset"; + burnAssetSelect.appendChild(placeholder); + } + balances.forEach((balance) => { + const option = document.createElement("option"); + option.value = transferAssetKey(balance); + option.dataset.asset = balance.asset || ""; + option.dataset.nftSeries = String(balance.nft_series || 0); + option.dataset.balance = String(balance.balance || "0"); + option.textContent = burnAssetOptionLabel(balance); + burnAssetSelect.appendChild(option); + }); + if (!balances.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No burnable assets"; + burnAssetSelect.appendChild(option); + } else if ([...burnAssetSelect.options].some((option) => option.value === previous)) { + burnAssetSelect.value = previous; + } + updateBurnDerivedFields(); +} + +async function ensureBurnBalances() { + if (activeWalletBalances.length || !walletLoaded || !registrationRegistered) { + return; + } + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode) { + return; + } + burnBalancesRefreshPromise ||= invoke("get_active_wallet_balances", { broadcastNode }) + .then((balances) => { + activeWalletBalances = Array.isArray(balances) ? balances : []; + }) + .finally(() => { + burnBalancesRefreshPromise = null; + }); + await burnBalancesRefreshPromise; +} + +function updateBurnDerivedFields() { + const selected = selectedBurnAsset(); + if (burnFeeInput) { + burnFeeInput.value = atomicToDecimal(BURN_FEE_ATOMIC); + } + if (burnFeeLabel) { + burnFeeLabel.textContent = `${atomicToDecimal(BURN_FEE_ATOMIC)} ${activeBaseCoin()}`; + } + if (burnAddress) { + burnAddress.textContent = activeWalletAddressShort || activeWalletAddressHex || "--"; + } + if (!selected) { + if (burnAvailable) { + burnAvailable.textContent = "--"; + } + if (burnType) { + burnType.textContent = "--"; + } + if (burnAmountInput) { + burnAmountInput.disabled = false; + burnAmountInput.value = ""; + } + return; + } + + const isNft = selected.nftSeries > 0; + if (burnAvailable) { + burnAvailable.textContent = `${atomicToDecimal(selected.balance)} ${selected.asset}`; + } + if (burnType) { + burnType.textContent = isNft ? `NFT #${selected.nftSeries}` : "Token"; + } + if (burnAmountInput) { + burnAmountInput.disabled = isNft; + if (isNft) { + burnAmountInput.value = "1.00000000"; + } + } +} + +function validateBurnForm() { + if (!walletLoaded) { + return "Load a wallet before burning an asset."; + } + if (!registrationRegistered) { + return "Address registration is required before burning an asset."; + } + const selected = selectedBurnAsset(); + if (!selected) { + return "Select an asset to burn."; + } + const amount = decimalToAtomic(burnAmountInput?.value); + if (amount === null || amount <= 0n) { + return "Enter a valid burn amount."; + } + if (selected.nftSeries > 0 && amount !== ATOMIC_UNITS_PER_COIN) { + return "NFT burns must destroy exactly 1.00000000 units."; + } + if (amount > selected.balance) { + return "Burn amount cannot exceed the selected asset balance."; + } + if (baseCoinBalanceAtomic() < BURN_FEE_ATOMIC) { + return `Burning requires ${atomicToDecimal(BURN_FEE_ATOMIC)} ${activeBaseCoin()} for the transaction fee.`; + } + return ""; +} + +function burnPayload() { + const selected = selectedBurnAsset(); + return { + coin: selected.asset, + nftSeries: selected.nftSeries, + value: String(decimalToAtomic(burnAmountInput.value)), + txfee: String(BURN_FEE_ATOMIC) + }; +} + +function clearBurnForm() { + if (burnAssetSelect) { + burnAssetSelect.value = ""; + } + if (burnAmountInput) { + burnAmountInput.value = ""; + burnAmountInput.disabled = false; + } + updateBurnDerivedFields(); +} + +function setBurnButtonsDisabled(disabled) { + document.querySelector("[data-burn-save]")?.toggleAttribute("disabled", disabled); + document.querySelector("[data-burn-broadcast]")?.toggleAttribute("disabled", disabled); +} + +async function handleBurnAction(action) { + try { + await ensureBurnBalances(); + populateBurnAssets(); + } catch (error) { + setBurnMessage(String(error), "error"); + return; + } + + const validationError = validateBurnForm(); + if (validationError) { + setBurnMessage(validationError, "error"); + return; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setBurnMessage("Wallet bridge is not available.", "error"); + return; + } + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setBurnMessage("Signing cancelled."); + return; + } + + setBurnButtonsDisabled(true); + try { + if (action === "save") { + setBurnMessage("Saving burn transaction..."); + const review = await invoke("save_burn_transaction", { + ...burnPayload(), + walletKey + }); + clearBurnForm(); + setBurnMessage(`Burn transaction saved: ${review.file_name}`, "success"); + if (typeof refreshUnbroadcastTransactions === "function") { + refreshUnbroadcastTransactions(); + } + } else { + const broadcastNode = activeBroadcastNode(); + if (!broadcastNode) { + throw new Error("Select a broadcast node before broadcasting."); + } + setBurnMessage("Broadcasting burn transaction..."); + const result = await invoke("broadcast_burn_transaction", { + ...burnPayload(), + broadcastNode, + walletKey + }); + if (typeof invalidateAssetsCatalog === "function") { + invalidateAssetsCatalog(); + } + activeWalletBalances = []; + clearBurnForm(); + setBurnMessage(result.response || "Burn transaction broadcast.", "success"); + } + } catch (error) { + setBurnMessage(String(error), "error"); + } finally { + setBurnButtonsDisabled(false); + } +} + +async function prepareBurnForm() { + setBurnMessage(""); + try { + await ensureBurnBalances(); + } catch (error) { + setBurnMessage(String(error), "error"); + } + populateBurnAssets(); +} + +function resetBurnSession() { + burnBalancesRefreshPromise = null; + clearBurnForm(); + setBurnMessage(""); +} + +burnAssetSelect?.addEventListener("change", () => { + setBurnMessage(""); + updateBurnDerivedFields(); +}); +burnAmountInput?.addEventListener("input", () => setBurnMessage("")); +document + .querySelector("[data-burn-save]") + ?.addEventListener("click", () => handleBurnAction("save")); +document + .querySelector("[data-burn-broadcast]") + ?.addEventListener("click", () => handleBurnAction("broadcast")); diff --git a/frontend/js/collateral_claim_form.js b/frontend/js/collateral_claim_form.js new file mode 100644 index 0000000..5445198 --- /dev/null +++ b/frontend/js/collateral_claim_form.js @@ -0,0 +1,220 @@ +const COLLATERAL_CLAIM_FEE_ATOMIC = 300000000n; +let collateralClaimContracts = []; +let collateralClaimLoadPromise = null; +let collateralClaimContractsKey = ""; + +function setCollateralClaimMessage(message, type = "") { + setScopedTransactionMessage("Collateral Claim", collateralClaimMessage, message, type); +} + +function selectedCollateralClaimContract() { + const hash = String(collateralClaimContractSelect?.value || ""); + return collateralClaimContracts.find((contract) => contract.contract === hash) || null; +} + +function claimAssetLabel(asset, amount) { + return `${atomicToDecimal(BigInt(amount || "0"))} ${String(asset || "").trim()}`.trim(); +} + +function collateralClaimOptionLabel(contract) { + const role = String(contract.role || "").toLowerCase() === "borrower" + ? "reclaim" + : "claim"; + return `${role} ${claimAssetLabel(contract.collateral, contract.collateral_amount)} from ${truncateMiddle(contract.contract, 8, 8)}`; +} + +function populateCollateralClaimContracts() { + if (!collateralClaimContractSelect) { + return; + } + const previous = collateralClaimContractSelect.value; + collateralClaimContractSelect.innerHTML = ""; + if (collateralClaimContracts.length) { + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Select claimable collateral"; + collateralClaimContractSelect.appendChild(placeholder); + } + collateralClaimContracts.forEach((contract) => { + const option = document.createElement("option"); + option.value = contract.contract; + option.textContent = collateralClaimOptionLabel(contract); + collateralClaimContractSelect.appendChild(option); + }); + if (!collateralClaimContracts.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No collateral is currently claimable"; + collateralClaimContractSelect.appendChild(option); + } else if ([...collateralClaimContractSelect.options].some((option) => option.value === previous)) { + collateralClaimContractSelect.value = previous; + } + updateCollateralClaimDetails(); +} + +async function loadCollateralClaimContracts(force = false) { + if (collateralClaimLoadPromise) { + return collateralClaimLoadPromise; + } + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) { + return; + } + const key = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`; + if (!force && collateralClaimContractsKey === key) { + populateCollateralClaimContracts(); + return; + } + + collateralClaimLoadPromise = (async () => { + setCollateralClaimMessage("Loading claimable collateral..."); + const contracts = await invoke("claimable_collateral_contracts", { broadcastNode }); + collateralClaimContracts = Array.isArray(contracts) ? contracts : []; + collateralClaimContractsKey = key; + populateCollateralClaimContracts(); + setCollateralClaimMessage( + collateralClaimContracts.length ? "" : "No collateral is currently claimable." + ); + })().catch((error) => { + collateralClaimContracts = []; + populateCollateralClaimContracts(); + setCollateralClaimMessage(String(error), "error"); + throw error; + }).finally(() => { + collateralClaimLoadPromise = null; + }); + return collateralClaimLoadPromise; +} + +function updateCollateralClaimDetails() { + const contract = selectedCollateralClaimContract(); + if (!contract) { + collateralClaimRole.textContent = "--"; + collateralClaimReason.textContent = "--"; + collateralClaimLender.textContent = "--"; + collateralClaimBorrower.textContent = "--"; + collateralClaimLoan.textContent = "--"; + collateralClaimCollateral.textContent = "--"; + collateralClaimConfirmed.textContent = "--"; + collateralClaimRemaining.textContent = "--"; + collateralClaimFeeInput.value = atomicToDecimal(COLLATERAL_CLAIM_FEE_ATOMIC); + return; + } + + collateralClaimRole.textContent = contract.role === "borrower" ? "Borrower reclaim" : "Lender claim"; + collateralClaimReason.textContent = contract.reason || "Collateral claim is currently allowed."; + collateralClaimLender.textContent = contract.lender; + collateralClaimBorrower.textContent = contract.borrower; + collateralClaimLoan.textContent = claimAssetLabel(contract.loan_asset, contract.loan_amount); + collateralClaimCollateral.textContent = claimAssetLabel(contract.collateral, contract.collateral_amount); + collateralClaimConfirmed.textContent = claimAssetLabel(contract.loan_asset, contract.confirmed_paid); + collateralClaimRemaining.textContent = claimAssetLabel(contract.loan_asset, contract.remaining_balance); + collateralClaimFeeInput.value = atomicToDecimal(BigInt(contract.fee || COLLATERAL_CLAIM_FEE_ATOMIC)); +} + +function validateCollateralClaimForm() { + if (!walletLoaded) { + return "Load a wallet before claiming collateral."; + } + if (!registrationRegistered) { + return "Address registration is required before claiming collateral."; + } + if (!selectedCollateralClaimContract()) { + return "Select claimable collateral."; + } + if (baseCoinBalanceAtomic() < COLLATERAL_CLAIM_FEE_ATOMIC) { + return `Claiming collateral requires ${atomicToDecimal(COLLATERAL_CLAIM_FEE_ATOMIC)} ${activeBaseCoin()} for the transaction fee.`; + } + return ""; +} + +function collateralClaimPayload() { + return { + broadcastNode: activeBroadcastNode(), + contractHash: selectedCollateralClaimContract().contract + }; +} + +function setCollateralClaimButtonsDisabled(disabled) { + document.querySelector("[data-collateral-claim-save]")?.toggleAttribute("disabled", disabled); + document.querySelector("[data-collateral-claim-broadcast]")?.toggleAttribute("disabled", disabled); +} + +function clearCollateralClaimForm() { + collateralClaimContractSelect.value = ""; + updateCollateralClaimDetails(); +} + +async function handleCollateralClaimAction(action) { + const validationError = validateCollateralClaimForm(); + if (validationError) { + setCollateralClaimMessage(validationError, "error"); + return; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setCollateralClaimMessage("Wallet bridge is not available.", "error"); + return; + } + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setCollateralClaimMessage("Signing cancelled."); + return; + } + + setCollateralClaimButtonsDisabled(true); + try { + if (action === "save") { + setCollateralClaimMessage("Saving collateral claim transaction..."); + const review = await invoke("save_collateral_claim_transaction", { + ...collateralClaimPayload(), + walletKey + }); + clearCollateralClaimForm(); + setCollateralClaimMessage(`Collateral claim saved: ${review.file_name}`, "success"); + if (typeof refreshUnbroadcastTransactions === "function") { + refreshUnbroadcastTransactions(); + } + } else { + setCollateralClaimMessage("Broadcasting collateral claim..."); + const result = await invoke("broadcast_collateral_claim_transaction", { + ...collateralClaimPayload(), + walletKey + }); + await loadCollateralClaimContracts(true); + clearCollateralClaimForm(); + setCollateralClaimMessage(result.response || "Collateral claim broadcast.", "success"); + } + } catch (error) { + setCollateralClaimMessage(String(error), "error"); + await loadCollateralClaimContracts(true).catch(() => {}); + } finally { + setCollateralClaimButtonsDisabled(false); + } +} + +function prepareCollateralClaimForm() { + setCollateralClaimMessage(""); + loadCollateralClaimContracts(false).catch(() => {}); +} + +function resetCollateralClaimSession() { + collateralClaimContracts = []; + collateralClaimLoadPromise = null; + collateralClaimContractsKey = ""; + collateralClaimContractSelect.innerHTML = ""; + updateCollateralClaimDetails(); + setCollateralClaimMessage(""); +} + +collateralClaimContractSelect?.addEventListener("change", () => { + setCollateralClaimMessage(""); + updateCollateralClaimDetails(); +}); +document + .querySelector("[data-collateral-claim-save]") + ?.addEventListener("click", () => handleCollateralClaimAction("save")); +document + .querySelector("[data-collateral-claim-broadcast]") + ?.addEventListener("click", () => handleCollateralClaimAction("broadcast")); diff --git a/frontend/js/create_nft_form.js b/frontend/js/create_nft_form.js new file mode 100644 index 0000000..79f271e --- /dev/null +++ b/frontend/js/create_nft_form.js @@ -0,0 +1,248 @@ +const CREATE_NFT_FEE_ATOMIC = 50000000n; +let createNftBalancesRefreshPromise = null; + +function setCreateNftMessage(message, type = "") { + setScopedTransactionMessage("Create NFT", createNftMessage, message, type); +} + +function createNftSeriesValue() { + return Number(createNftSeriesSelect?.value || 0) === 1 ? 1 : 0; +} + +function normalizeCreateNftCountInput() { + const isCollection = createNftSeriesValue() === 1; + if (createNftCountInput) { + createNftCountInput.disabled = !isCollection; + createNftCountInput.min = isCollection ? "2" : "1"; + if (!isCollection) { + createNftCountInput.value = "1"; + } else if (Number(createNftCountInput.value || 0) < 2) { + createNftCountInput.value = "2"; + } + } +} + +function updateCreateNftDerivedFields({ clampCount = false } = {}) { + const isCollection = createNftSeriesValue() === 1; + if (clampCount) { + normalizeCreateNftCountInput(); + } else if (createNftCountInput) { + createNftCountInput.disabled = !isCollection; + createNftCountInput.min = isCollection ? "2" : "1"; + } + + const rawCount = String(createNftCountInput?.value || "").trim(); + const parsedCount = Number.parseInt(rawCount, 10); + const countLabel = Number.isInteger(parsedCount) && parsedCount > 0 ? String(parsedCount) : "--"; + if (createNftFeeInput) { + createNftFeeInput.value = tokenAtomicToDecimal(CREATE_NFT_FEE_ATOMIC); + } + if (createNftCreator) { + createNftCreator.textContent = activeWalletAddressShort || activeWalletAddressHex || "--"; + } + if (createNftTypeLabel) { + createNftTypeLabel.textContent = isCollection ? "Collection" : "Standalone NFT"; + } + if (createNftCountLabel) { + createNftCountLabel.textContent = countLabel; + } + if (createNftFeeLabel) { + createNftFeeLabel.textContent = + `${tokenAtomicToDecimal(CREATE_NFT_FEE_ATOMIC)} ${activeBaseCoin()}`; + } +} + +function validateCreateNftForm() { + if (!walletLoaded) { + return "Load a wallet before creating an NFT."; + } + if (!registrationRegistered) { + return "Address registration is required before creating an NFT."; + } + + const name = String(createNftNameInput?.value || "").trim(); + if (!name) { + return "NFT name is required."; + } + if (!/^[A-Za-z0-9\s]+$/.test(name)) { + return "NFT name can only contain letters, numbers, and spaces."; + } + const canonicalName = name.replace(/\s+/g, "").toLowerCase(); + if (canonicalName.length < 3 || canonicalName.length > 15) { + return "NFT name must normalize to 3 to 15 letters or numbers."; + } + if (canonicalName.toUpperCase() === activeBaseCoin()) { + return "NFT name is reserved for the base coin."; + } + + const cid = String(createNftIpfsInput?.value || "").trim(); + if (!cid) { + return "IPFS CID is required."; + } + if (!/^[\x20-\x7E]+$/.test(cid) || cid.length > 100) { + return "IPFS CID must be an ASCII value no longer than 100 characters."; + } + + const description = String(createNftDescriptionInput?.value || "").trim(); + if (description && (!/^[\x20-\x7E]+$/.test(description) || description.length > 100)) { + return "NFT description must be ASCII and no longer than 100 characters."; + } + + const count = Number.parseInt(createNftCountInput?.value || "0", 10); + if (!Number.isInteger(count) || count < 1 || count > 4294967295) { + return "Enter a valid NFT item count."; + } + if (createNftSeriesValue() === 0 && count !== 1) { + return "A standalone NFT must create exactly 1 item."; + } + if (createNftSeriesValue() === 1 && count < 2) { + return "An NFT collection must contain at least 2 items."; + } + + if (baseCoinBalanceAtomic() < CREATE_NFT_FEE_ATOMIC) { + return `This transaction requires ${tokenAtomicToDecimal(CREATE_NFT_FEE_ATOMIC)} ${activeBaseCoin()}.`; + } + return ""; +} + +function createNftPayload() { + return { + series: createNftSeriesValue(), + nftName: String(createNftNameInput?.value || "").trim(), + itemIpfs: String(createNftIpfsInput?.value || "").trim(), + count: Number.parseInt(createNftCountInput?.value || "1", 10), + description: String(createNftDescriptionInput?.value || "").trim(), + txfee: String(CREATE_NFT_FEE_ATOMIC) + }; +} + +function clearCreateNftForm() { + if (createNftSeriesSelect) { + createNftSeriesSelect.value = "0"; + } + if (createNftNameInput) { + createNftNameInput.value = ""; + } + if (createNftIpfsInput) { + createNftIpfsInput.value = ""; + } + if (createNftCountInput) { + createNftCountInput.value = "1"; + } + if (createNftDescriptionInput) { + createNftDescriptionInput.value = ""; + } + updateCreateNftDerivedFields({ clampCount: true }); +} + +function setCreateNftButtonsDisabled(disabled) { + document.querySelector("[data-create-nft-save]")?.toggleAttribute("disabled", disabled); + document.querySelector("[data-create-nft-broadcast]")?.toggleAttribute("disabled", disabled); +} + +async function handleCreateNftAction(action) { + if (!activeWalletBalances.length && walletLoaded && registrationRegistered) { + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (invoke && broadcastNode) { + createNftBalancesRefreshPromise ||= invoke("get_active_wallet_balances", { broadcastNode }) + .then((balances) => { + activeWalletBalances = Array.isArray(balances) ? balances : []; + }) + .finally(() => { + createNftBalancesRefreshPromise = null; + }); + try { + await createNftBalancesRefreshPromise; + } catch (error) { + setCreateNftMessage(String(error), "error"); + return; + } + } + } + + const validationError = validateCreateNftForm(); + if (validationError) { + setCreateNftMessage(validationError, "error"); + return; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setCreateNftMessage("Wallet bridge is not available.", "error"); + return; + } + + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setCreateNftMessage("Signing cancelled."); + return; + } + + setCreateNftButtonsDisabled(true); + try { + if (action === "save") { + setCreateNftMessage("Saving NFT transaction..."); + const review = await invoke("save_create_nft_transaction", { + ...createNftPayload(), + walletKey + }); + clearCreateNftForm(); + setCreateNftMessage(`NFT transaction saved: ${review.file_name}`, "success"); + if (typeof refreshUnbroadcastTransactions === "function") { + refreshUnbroadcastTransactions(); + } + } else { + const broadcastNode = activeBroadcastNode(); + if (!broadcastNode) { + throw new Error("Select a broadcast node before broadcasting."); + } + setCreateNftMessage("Broadcasting NFT transaction..."); + const result = await invoke("broadcast_create_nft_transaction", { + ...createNftPayload(), + broadcastNode, + walletKey + }); + if (typeof invalidateDashboardNftBalanceKeys === "function") { + invalidateDashboardNftBalanceKeys(); + } + clearCreateNftForm(); + setCreateNftMessage(result.response || "NFT transaction broadcast.", "success"); + } + } catch (error) { + setCreateNftMessage(String(error), "error"); + } finally { + setCreateNftButtonsDisabled(false); + } +} + +function prepareCreateNftForm() { + updateCreateNftDerivedFields({ clampCount: true }); + setCreateNftMessage(""); +} + +function resetCreateNftSession() { + createNftBalancesRefreshPromise = null; + clearCreateNftForm(); + setCreateNftMessage(""); +} + +createNftSeriesSelect?.addEventListener("change", () => { + updateCreateNftDerivedFields({ clampCount: true }); + setCreateNftMessage(""); +}); +createNftCountInput?.addEventListener("input", () => { + updateCreateNftDerivedFields(); + setCreateNftMessage(""); +}); +createNftCountInput?.addEventListener("blur", () => { + updateCreateNftDerivedFields({ clampCount: true }); +}); +createNftNameInput?.addEventListener("input", () => setCreateNftMessage("")); +createNftIpfsInput?.addEventListener("input", () => setCreateNftMessage("")); +createNftDescriptionInput?.addEventListener("input", () => setCreateNftMessage("")); +document + .querySelector("[data-create-nft-save]") + ?.addEventListener("click", () => handleCreateNftAction("save")); +document + .querySelector("[data-create-nft-broadcast]") + ?.addEventListener("click", () => handleCreateNftAction("broadcast")); diff --git a/frontend/js/create_token_form.js b/frontend/js/create_token_form.js new file mode 100644 index 0000000..82d907b --- /dev/null +++ b/frontend/js/create_token_form.js @@ -0,0 +1,171 @@ +const CREATE_TOKEN_FEE_ATOMIC = 50000000000n; +const TOKEN_ATOMIC_UNITS = 100000000n; + +function setCreateTokenMessage(message, type = "") { + setScopedTransactionMessage("Create Token", createTokenMessage, message, type); +} + +function tokenDecimalToAtomic(value) { + const text = String(value || "").trim(); + if (!/^\d+(\.\d{0,8})?$/.test(text)) { + return null; + } + + const [whole, fraction = ""] = text.split("."); + const paddedFraction = fraction.padEnd(8, "0"); + return BigInt(whole || "0") * TOKEN_ATOMIC_UNITS + BigInt(paddedFraction || "0"); +} + +function tokenAtomicToDecimal(value) { + const atomic = BigInt(value || 0); + const whole = atomic / TOKEN_ATOMIC_UNITS; + const fraction = String(atomic % TOKEN_ATOMIC_UNITS).padStart(8, "0"); + return `${whole}.${fraction}`; +} + +function updateCreateTokenDerivedFields() { + if (createTokenFeeInput) { + createTokenFeeInput.value = tokenAtomicToDecimal(CREATE_TOKEN_FEE_ATOMIC); + } + if (createTokenFeeLabel) { + createTokenFeeLabel.textContent = `${tokenAtomicToDecimal(CREATE_TOKEN_FEE_ATOMIC)} ${activeBaseCoin()}`; + } + if (createTokenCap) { + createTokenCap.textContent = createTokenAllowMoreInput?.checked ? "Future issuance allowed" : "Hard capped"; + } + if (createTokenCreator) { + createTokenCreator.textContent = activeWalletAddressShort || activeWalletAddressHex || "--"; + } +} + +function validateCreateTokenForm() { + if (!walletLoaded) { + return "Load a wallet before creating a token."; + } + if (!registrationRegistered) { + return "Address registration is required before creating a token."; + } + + const ticker = String(createTokenTickerInput?.value || "").trim(); + if (!ticker) { + return "Token ticker is required."; + } + if (!/^[A-Za-z0-9\s]+$/.test(ticker)) { + return "Token ticker can only contain letters, numbers, and spaces."; + } + const canonicalTicker = ticker.replace(/\s+/g, "").toLowerCase(); + if (canonicalTicker.length < 3 || canonicalTicker.length > 15) { + return "Token ticker must normalize to 3 to 15 letters or numbers."; + } + if (canonicalTicker.toUpperCase() === activeBaseCoin()) { + return "Token ticker is reserved for the base coin."; + } + + const number = tokenDecimalToAtomic(createTokenNumberInput?.value); + if (number === null || number <= 0n) { + return "Enter a valid initial supply with up to 8 decimal places."; + } + + return ""; +} + +function createTokenPayload() { + return { + ticker: String(createTokenTickerInput?.value || "").trim(), + number: String(tokenDecimalToAtomic(createTokenNumberInput?.value) || 0n), + allowMore: Boolean(createTokenAllowMoreInput?.checked), + txfee: String(CREATE_TOKEN_FEE_ATOMIC) + }; +} + +function clearCreateTokenForm() { + if (createTokenTickerInput) { + createTokenTickerInput.value = ""; + } + if (createTokenNumberInput) { + createTokenNumberInput.value = ""; + } + if (createTokenAllowMoreInput) { + createTokenAllowMoreInput.checked = false; + } + updateCreateTokenDerivedFields(); +} + +function setCreateTokenButtonsDisabled(disabled) { + document.querySelector("[data-create-token-save]")?.toggleAttribute("disabled", disabled); + document.querySelector("[data-create-token-broadcast]")?.toggleAttribute("disabled", disabled); +} + +async function handleCreateTokenAction(action) { + const error = validateCreateTokenForm(); + if (error) { + setCreateTokenMessage(error, "error"); + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setCreateTokenMessage("Wallet bridge is not available.", "error"); + return; + } + + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setCreateTokenMessage("Signing cancelled."); + return; + } + + setCreateTokenButtonsDisabled(true); + try { + if (action === "save") { + setCreateTokenMessage("Saving token transaction..."); + const review = await invoke("save_create_token_transaction", { + ...createTokenPayload(), + walletKey + }); + clearCreateTokenForm(); + setCreateTokenMessage(`Token transaction saved: ${review.file_name}`, "success"); + if (typeof refreshUnbroadcastTransactions === "function") { + refreshUnbroadcastTransactions(); + } + } else { + const broadcastNode = activeBroadcastNode(); + if (!broadcastNode) { + throw new Error("Select a broadcast node before broadcasting."); + } + setCreateTokenMessage("Broadcasting token transaction..."); + const result = await invoke("broadcast_create_token_transaction", { + ...createTokenPayload(), + broadcastNode, + walletKey + }); + if (typeof invalidateAssetsCatalog === "function") { + invalidateAssetsCatalog(); + } + clearCreateTokenForm(); + setCreateTokenMessage(result.response || "Token transaction broadcast.", "success"); + } + } catch (error) { + setCreateTokenMessage(String(error), "error"); + } finally { + setCreateTokenButtonsDisabled(false); + } +} + +function prepareCreateTokenForm() { + updateCreateTokenDerivedFields(); + setCreateTokenMessage(""); +} + +createTokenTickerInput?.addEventListener("input", () => { + updateCreateTokenDerivedFields(); + setCreateTokenMessage(""); +}); +createTokenNumberInput?.addEventListener("input", () => setCreateTokenMessage("")); +createTokenAllowMoreInput?.addEventListener("change", () => { + updateCreateTokenDerivedFields(); + setCreateTokenMessage(""); +}); +document.querySelector("[data-create-token-save]")?.addEventListener("click", () => handleCreateTokenAction("save")); +document.querySelector("[data-create-token-broadcast]")?.addEventListener("click", () => handleCreateTokenAction("broadcast")); + diff --git a/frontend/js/dashboard_balance_registration.js b/frontend/js/dashboard_balance_registration.js new file mode 100644 index 0000000..8383855 --- /dev/null +++ b/frontend/js/dashboard_balance_registration.js @@ -0,0 +1,369 @@ +function setRecentTransactionsForWalletState(state) { + recentTransactionsCacheReadId += 1; + latestTransactionsSyncId += 1; + clearRecentTransactionsRows(); + stopLatestDashboardTransactionsPolling(); + + if (state === "registered") { + setRecentTransactionsState(""); + resetLatestDashboardTransactionsMemory(); + updateLatestDashboardTransactionsPolling(); + startBackgroundTransactionSync(); + return; + } + + stopBackgroundTransactionSync(); + resetLatestDashboardTransactionsMemory(); + if (state === "checking") { + setRecentTransactionsState("Checking address registration before loading transactions."); + return; + } + + if (state === "unregistered") { + setRecentTransactionsState("Address registration required before transactions can appear."); + return; + } + + setRecentTransactionsState("Load a wallet to view recent transactions."); +} + +function resetBalances(message = "Load wallet", statusMessage = "Balances refresh while dashboard is open.") { + activeWalletBalances = []; + if (typeof refreshTransferAssetOptions === "function") { + refreshTransferAssetOptions(); + } + if (typeof refreshActiveTransactionFormAfterWalletData === "function") { + refreshActiveTransactionFormAfterWalletData(); + } + + if (baseBalanceLabel) { + baseBalanceLabel.textContent = `${activeBaseCoin()} Balance`; + } + + if (clcBalance) { + clcBalance.textContent = message; + } + + if (tokenBalances) { + tokenBalances.innerHTML = ` +
Token Balances
+
No wallet loaded.
+ `; + } + + dashboardLastBalanceStatus = ""; + setBalanceStatus(statusMessage); +} + +async function refreshDashboardBalances({ force = false, quiet = false } = {}) { + if (!walletLoaded || !registrationRegistered || currentView !== "dashboard" || balanceRefreshInFlight) { + return false; + } + + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode) { + return false; + } + + balanceRefreshInFlight = true; + const requestId = ++balanceRefreshRequestId; + if (force && !quiet) { + setBalanceStatus("Refreshing balances..."); + } + + try { + const balances = await invoke("get_active_wallet_balances", { + broadcastNode + }); + if (requestId !== balanceRefreshRequestId || !walletLoaded) { + return false; + } + renderBalances(balances); + dashboardLastBalanceStatus = `Updated ${new Date().toLocaleTimeString()}`; + setBalanceStatus(dashboardLastBalanceStatus, "success"); + return true; + } catch (error) { + if (requestId !== balanceRefreshRequestId || !walletLoaded) { + return false; + } + setBalanceStatus(String(error), "error"); + return false; + } finally { + if (requestId === balanceRefreshRequestId) { + balanceRefreshInFlight = false; + } + } +} + +function startBalanceRefresh({ immediate = true } = {}) { + if (balanceRefreshTimer || !walletLoaded || !registrationRegistered || currentView !== "dashboard") { + return; + } + + if (immediate) { + refreshDashboardBalances({ force: true }); + } + balanceRefreshTimer = setInterval(() => { + refreshDashboardBalances(); + }, BALANCE_REFRESH_MS); +} + +function stopBalanceRefresh() { + if (balanceRefreshTimer) { + clearInterval(balanceRefreshTimer); + balanceRefreshTimer = null; + } +} + +function updateBalanceRefreshState() { + if (walletLoaded && registrationRegistered && currentView === "dashboard") { + startBalanceRefresh(); + return; + } + + stopBalanceRefresh(); +} + +function restoreLatestDashboardTransactionsFromMemory() { + if (!latestDashboardTransactionRecords.size || !latestDashboardTxids.length) { + return false; + } + + const latestCache = { transactions: Object.fromEntries(latestDashboardTransactionRecords) }; + updateTransactionCacheChainStatus(latestCache); + latestDashboardTransactionRecords = new Map(Object.entries(latestCache.transactions)); + renderRecentTransactions(normalizeRecentTransactionRecords( + transactionRecordsForTxids(latestCache, latestDashboardTxids), + { preserveOrder: true } + )); + setRecentTransactionsState(""); + return true; +} + +async function startRegisteredDashboardLoadSequence({ resetTransactions = false, quiet = false } = {}) { + const loadId = ++dashboardRegisteredLoadId; + stopLatestDashboardTransactionsPolling(); + stopBalanceRefresh(); + + if (resetTransactions) { + resetLatestDashboardTransactionsMemory(); + } + + const restoredTransactions = restoreLatestDashboardTransactionsFromMemory(); + if (!restoredTransactions && !quiet) { + setRecentTransactionsForWalletState("checking"); + } + + if (!walletLoaded || !registrationRegistered || currentView !== "dashboard") { + return; + } + + if (dashboardLastBalanceStatus) { + setBalanceStatus(dashboardLastBalanceStatus, "success"); + } + + await refreshDashboardBalances({ force: true, quiet: quiet || Boolean(dashboardLastBalanceStatus) }); + if ( + loadId !== dashboardRegisteredLoadId || + !walletLoaded || + !registrationRegistered || + currentView !== "dashboard" + ) { + return; + } + + startBalanceRefresh({ immediate: false }); + if (!restoredTransactions) { + setRecentTransactionsForWalletState("registered"); + return; + } + + updateLatestDashboardTransactionsPolling(); + startBackgroundTransactionSync(); +} + +function startNetworkInfoRefresh() { + if (networkInfoRefreshTimer || !walletLoaded || currentView !== "dashboard") { + return; + } + + refreshNetworkInfo({ target: "dashboard" }); + networkInfoRefreshTimer = setInterval(() => { + refreshNetworkInfo({ target: "dashboard" }); + }, NETWORK_INFO_REFRESH_MS); +} + +function stopNetworkInfoRefresh() { + if (networkInfoRefreshTimer) { + clearInterval(networkInfoRefreshTimer); + networkInfoRefreshTimer = null; + } +} + +function updateNetworkInfoRefreshState() { + if (walletLoaded && currentView === "dashboard") { + startNetworkInfoRefresh(); + return; + } + + stopNetworkInfoRefresh(); +} + +function startNodeInfoRefresh() { + if (nodeInfoRefreshTimer || !walletLoaded) { + renderNodeInfo(null); + return; + } + + refreshNetworkInfo({ target: "node", force: true }); + nodeInfoRefreshTimer = setInterval(() => { + refreshNetworkInfo({ target: "node", force: true }); + }, NODE_INFO_REFRESH_MS); +} + +function stopNodeInfoRefresh() { + if (nodeInfoRefreshTimer) { + clearInterval(nodeInfoRefreshTimer); + nodeInfoRefreshTimer = null; + } +} + +function activeBroadcastNode() { + return broadcastNodeButton?.value || broadcastNodeButton?.dataset.broadcastNode || broadcastNodeButton?.textContent?.trim() || ""; +} + +function setRegistrationState(state) { + const states = ["registered", "unregistered", "checking"]; + registrationBadge?.classList.remove(...states); + registrationAction?.classList.remove(...states); + registrationNotice?.classList.remove(...states); + + if (state === "registered") { + registrationRegistered = true; + registrationBadge && (registrationBadge.textContent = "Registered"); + registrationBadge?.classList.add("registered"); + if (registrationNotice) { + registrationNotice.textContent = "Address registered. This wallet can receive coins and tokens."; + registrationNotice.classList.add("registered"); + } + if (registrationAction) { + registrationAction.textContent = "Address Registered"; + registrationAction.disabled = true; + registrationAction.classList.add("registered"); + } + startRegisteredDashboardLoadSequence(); + updateHistoryRefreshState(); + updateNetworkInfoRefreshState(); + refreshReceivePage(); + if (typeof refreshActiveTransactionFormAfterWalletData === "function") { + refreshActiveTransactionFormAfterWalletData(); + } + return; + } + + if (state === "checking") { + registrationBadge && (registrationBadge.textContent = "Checking"); + registrationBadge?.classList.add("checking"); + if (registrationNotice) { + registrationNotice.textContent = "Checking whether this address can receive coins and tokens."; + registrationNotice.classList.add("checking"); + } + if (registrationAction) { + registrationAction.textContent = "Checking..."; + registrationAction.disabled = true; + registrationAction.classList.add("checking"); + } + dashboardRegisteredLoadId += 1; + stopBalanceRefresh(); + setRecentTransactionsForWalletState("checking"); + updateHistoryRefreshState(); + refreshReceivePage(); + return; + } + + registrationRegistered = false; + registrationBadge && (registrationBadge.textContent = "Unregistered"); + registrationBadge?.classList.add("unregistered"); + if (registrationNotice) { + registrationNotice.textContent = "Address must be registered before this wallet can receive coins or tokens."; + registrationNotice.classList.add("unregistered"); + } + if (registrationAction) { + registrationAction.textContent = "Register Address"; + registrationAction.disabled = false; + registrationAction.classList.add("unregistered"); + } + dashboardRegisteredLoadId += 1; + stopBalanceRefresh(); + setRecentTransactionsForWalletState("unregistered"); + updateHistoryRefreshState(); + updateNetworkInfoRefreshState(); + refreshReceivePage(); +} + +async function checkWalletRegistrationOnce() { + if (!walletLoaded || registrationRegistered || registrationCheckInFlight) { + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode) { + setRegistrationState("unregistered"); + return; + } + + registrationCheckInFlight = true; + const requestId = ++registrationRequestId; + setRegistrationState("checking"); + try { + const status = await invoke("check_wallet_registration", { broadcastNode }); + if (requestId !== registrationRequestId || !walletLoaded) { + return; + } + setRegistrationState(status?.registered ? "registered" : "unregistered"); + } catch (_) { + if (requestId !== registrationRequestId || !walletLoaded) { + return; + } + setRegistrationState("unregistered"); + } finally { + if (requestId === registrationRequestId) { + registrationCheckInFlight = false; + } + } +} + +async function registerActiveWallet() { + if (!walletLoaded || registrationRegistered || registrationCheckInFlight) { + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode) { + setRegistrationState("unregistered"); + return; + } + + registrationCheckInFlight = true; + const requestId = ++registrationRequestId; + setRegistrationState("checking"); + try { + const status = await invoke("register_active_wallet", { broadcastNode }); + if (requestId !== registrationRequestId || !walletLoaded) { + return; + } + setRegistrationState(status?.registered ? "registered" : "unregistered"); + } catch (_) { + if (requestId !== registrationRequestId || !walletLoaded) { + return; + } + setRegistrationState("unregistered"); + } finally { + if (requestId === registrationRequestId) { + registrationCheckInFlight = false; + } + } +} diff --git a/frontend/js/dashboard_network.js b/frontend/js/dashboard_network.js new file mode 100644 index 0000000..27bf65c --- /dev/null +++ b/frontend/js/dashboard_network.js @@ -0,0 +1,284 @@ +function renderDashboardNetworkInfo(info) { + if (!info || typeof info !== "object") { + return; + } + + latestKnownBlockHeight = Number(info.height || latestKnownBlockHeight || 0); + setBridgeStatus(`${networkDisplayName(info)} connected`, "success"); + networkInfoHeight && (networkInfoHeight.textContent = formatNumber(info.height)); + networkInfoNetwork && (networkInfoNetwork.textContent = networkDisplayName(info)); + networkInfoDifficulty && (networkInfoDifficulty.textContent = formatNumber(info.next_block_difficulty)); + networkInfoMempool && (networkInfoMempool.textContent = formatNumber(info.total_mempool_transactions)); + networkInfoChainTx && (networkInfoChainTx.textContent = formatNumber(info.total_block_transactions)); +} + +function renderNodeInfo(info, checkedAt = new Date()) { + const broadcastNode = activeBroadcastNode(); + nodeInfoNode && (nodeInfoNode.textContent = broadcastNode || "--"); + + if (!info || typeof info !== "object") { + nodeInfoHeight && (nodeInfoHeight.textContent = "--"); + nodeInfoTime && (nodeInfoTime.textContent = "--"); + nodeInfoLastChecked && (nodeInfoLastChecked.textContent = "--"); + nodeInfoLargestFee && (nodeInfoLargestFee.textContent = "--"); + return; + } + + nodeInfoHeight && (nodeInfoHeight.textContent = formatNumber(info.height)); + nodeInfoTime && (nodeInfoTime.textContent = formatNetworkTime(info.time)); + nodeInfoLastChecked && (nodeInfoLastChecked.textContent = checkedAt.toLocaleTimeString()); + nodeInfoLargestFee && (nodeInfoLargestFee.textContent = formatLargestFee(info.largest_tx_fee)); +} + +function resetNetworkInfoDisplays() { + const networkName = activeNetwork === "mainnet" ? "Mainnet" : "Testnet"; + networkInfoHeight && (networkInfoHeight.textContent = "--"); + networkInfoNetwork && (networkInfoNetwork.textContent = `${networkName} / ${activeBaseCoin()}`); + networkInfoDifficulty && (networkInfoDifficulty.textContent = "--"); + networkInfoMempool && (networkInfoMempool.textContent = "--"); + networkInfoChainTx && (networkInfoChainTx.textContent = "--"); + setBridgeStatus(`${networkName} disconnected`, "error"); + renderNodeInfo(null); +} + +async function refreshNetworkInfo({ target = "dashboard", force = false } = {}) { + if (!walletLoaded) { + renderNodeInfo(null); + setBridgeStatus(`${activeNetwork === "mainnet" ? "Mainnet" : "Testnet"} disconnected`, "error"); + return null; + } + + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode) { + setBridgeStatus(`${activeNetwork === "mainnet" ? "Mainnet" : "Testnet"} unavailable`, "error"); + return null; + } + + if (target === "node" && nodeInfoRefreshInFlight) { + return null; + } + if (target !== "node" && networkInfoRefreshInFlight) { + return null; + } + + if (target === "node") { + nodeInfoRefreshInFlight = true; + } else { + networkInfoRefreshInFlight = true; + } + + try { + const info = await invokeWalletRpcWithBackoff( + "Network info lookup", + () => invoke("network_info", { broadcastNode }), + () => walletLoaded && activeBroadcastNode() === broadcastNode + ); + const checkedAt = new Date(); + renderDashboardNetworkInfo(info); + if (target === "node" || force) { + renderNodeInfo(info, checkedAt); + } + return info; + } catch (error) { + setBridgeStatus(`${activeNetwork === "mainnet" ? "Mainnet" : "Testnet"} disconnected`, "error"); + if (target === "node") { + nodeInfoLastChecked && (nodeInfoLastChecked.textContent = String(error)); + } + return null; + } finally { + if (target === "node") { + nodeInfoRefreshInFlight = false; + } else { + networkInfoRefreshInFlight = false; + } + } +} + +function sleepMs(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function walletRpcBackoffDelay(attempt) { + return Math.min(WALLET_RPC_BACKOFF_BASE_MS * 2 ** Math.max(0, attempt - 1), WALLET_RPC_BACKOFF_MAX_MS); +} + +async function invokeWalletRpcWithBackoff(label, operation, shouldContinue = () => true) { + let lastError = null; + + for (let attempt = 1; attempt <= WALLET_RPC_RETRY_LIMIT; attempt += 1) { + if (!shouldContinue()) { + throw new Error(`${label} cancelled`); + } + + try { + return await operation(); + } catch (error) { + lastError = error; + if (attempt >= WALLET_RPC_RETRY_LIMIT || !shouldContinue()) { + break; + } + await sleepMs(walletRpcBackoffDelay(attempt)); + } + } + + throw lastError || new Error(`${label} failed`); +} + +function bytesToHex(bytes) { + return Array.from(bytes || []) + .map((byte) => Number(byte).toString(16).padStart(2, "0")) + .join(""); +} + +function bytesToText(bytes) { + return String.fromCharCode(...Array.from(bytes || [])).trim(); +} + +function readU32LE(bytes, offset) { + return ( + Number(bytes[offset] || 0) | + (Number(bytes[offset + 1] || 0) << 8) | + (Number(bytes[offset + 2] || 0) << 16) | + (Number(bytes[offset + 3] || 0) << 24) + ) >>> 0; +} + +function readU64LE(bytes, offset) { + let value = 0n; + for (let index = 7; index >= 0; index -= 1) { + value = (value << 8n) + BigInt(Number(bytes[offset + index] || 0)); + } + return value; +} + +function formatAtomicValue(value) { + const atomic = typeof value === "bigint" ? value : BigInt(value || 0); + const whole = atomic / 100000000n; + const fraction = atomic % 100000000n; + const fractionText = fraction.toString().padStart(8, "0").replace(/0+$/, ""); + return fractionText ? `${whole}.${fractionText}` : whole.toString(); +} + +function bytesToAddressHex(bytes) { + return bytesToHex(bytes).toLowerCase(); +} + +function addressLabel(addressHex) { + return addressHex ? `${addressHex.slice(0, 8)}...${addressHex.slice(-8)}` : "Unknown"; +} + +function isActiveAddress(addressHex) { + return Boolean(addressHex && activeWalletAddressHex && addressHex.toLowerCase() === activeWalletAddressHex.toLowerCase()); +} + +function isActiveShortAddress(address) { + return Boolean(address && activeWalletAddressShort && String(address).trim().toLowerCase() === activeWalletAddressShort.toLowerCase()); +} + +function isActiveWalletAddress(address) { + const clean = String(address || "").trim().toLowerCase(); + if (!clean) { + return false; + } + return clean.includes(".") ? isActiveShortAddress(clean) : isActiveAddress(clean); +} + +function assetWithSeries(asset, series) { + const cleanAsset = (asset || "").trim() || activeBaseCoin(); + return Number(series || 0) > 0 ? `${cleanAsset} #${series}` : cleanAsset; +} + +function signedAmount(sign, value) { + const formatted = formatAtomicValue(value); + if (!formatted || formatted === "0") { + return formatted; + } + return `${sign}${formatted}`; +} + +function signedBaseFee(sign, value) { + const amount = signedAmount(sign, value); + return amount && amount !== "0" ? `${amount} ${activeBaseCoin()}` : "--"; +} + +function statusForBlock(blockHeight, currentHeight = latestKnownBlockHeight) { + if (!currentHeight || !blockHeight || currentHeight < blockHeight) { + return "Pending"; + } + return currentHeight - blockHeight >= CONFIRMED_AFTER_BLOCKS ? "Confirmed" : "Pending"; +} + +function walletStatusForBlock(blockHeight, currentHeight = latestKnownBlockHeight) { + const height = Number(blockHeight || 0); + if (!height) { + return "Unconfirmed"; + } + return statusForBlock(height, currentHeight); +} + +function blockValueFromRecord(record) { + const block = record?.block ?? record?.block_height ?? record?.blockHeight ?? null; + const numeric = Number(block || 0); + return numeric > 0 ? numeric : null; +} + +function dashboardRow(base, type, asset, counterparty, amount, fee = "--") { + return { + ...base, + type, + asset, + counterparty, + amount, + fee + }; +} + +function txTypeName(type) { + return { + 0: "Genesis", + 1: "Reward", + 2: "Transfer", + 3: "Create Token", + 4: "Create NFT", + 5: "Marketing", + 6: "Swap", + 7: "Create Loan", + 8: "Loan Payment", + 9: "Collateral Claim", + 10: "Burn", + 11: "Issue Token", + 12: "Vanity Address" + }[type] || "Transaction"; +} + +function parseLoanContractSummary(transaction) { + if (transaction && typeof transaction === "object" && !Array.isArray(transaction)) { + const unsigned = transaction.unsigned_loan_contract; + if (!unsigned) { + return null; + } + return { + loanCoin: unsigned.loan_coin || "Loan", + loanAmount: unsigned.loan_amount || 0, + lender: unsigned.lender || "", + collateral: unsigned.collateral || "Collateral", + collateralAmount: unsigned.collateral_amount || 0, + borrower: unsigned.borrower || "" + }; + } + + const bytes = Array.from(transaction || []); + if (Number(bytes[0] || 0) !== 7) { + return null; + } + return { + loanCoin: bytesToText(bytes.slice(5, 20)) || "Loan", + loanAmount: readU64LE(bytes, 20), + lender: bytesToAddressHex(bytes.slice(28, 50)), + collateral: bytesToText(bytes.slice(50, 71)) || "Collateral", + collateralAmount: readU64LE(bytes, 71), + borrower: bytesToAddressHex(bytes.slice(79, 101)) + }; +} + diff --git a/frontend/js/history_page.js b/frontend/js/history_page.js new file mode 100644 index 0000000..87436dc --- /dev/null +++ b/frontend/js/history_page.js @@ -0,0 +1,639 @@ +function setHistoryState(message, mode = "") { + if (!historyState) { + return; + } + + historyState.textContent = message; + historyState.classList.toggle("hidden", !message); + historyState.classList.toggle("loading", mode === "loading"); +} + +function clearHistoryRows() { + if (historyTransactionsBody) { + historyTransactionsBody.innerHTML = ""; + } + if (historyCards) { + historyCards.innerHTML = ""; + } +} + +function setHistoryFooter( + visibleCount = 0, + totalCount = 0, + overallCount = totalCount, + searchTerm = "", + page = 1, + totalPages = 1, + pageStart = 0 +) { + if (historyCount) { + const normalizedSearchTerm = String(searchTerm || "").trim(); + const rangeStart = visibleCount ? pageStart + 1 : 0; + const rangeEnd = pageStart + visibleCount; + if (normalizedSearchTerm && overallCount !== totalCount) { + historyCount.textContent = totalCount + ? `Showing ${formatNumber(rangeStart)}-${formatNumber(rangeEnd)} of ${formatNumber(totalCount)} matches (${formatNumber(overallCount)} total)` + : `No matches (${formatNumber(overallCount)} total)`; + } else { + historyCount.textContent = totalCount + ? `Showing ${formatNumber(rangeStart)}-${formatNumber(rangeEnd)} of ${formatNumber(totalCount)}` + : ""; + } + } + if (historyPagination) { + historyPagination.hidden = totalPages <= 1; + historyPagination.querySelectorAll("[data-history-page]").forEach((button) => { + const action = button.dataset.historyPage; + button.disabled = action === "first" || action === "previous" + ? page <= 1 + : page >= totalPages; + }); + } + if (historyPageInput) { + historyPageInput.value = String(page); + historyPageInput.max = String(Math.max(1, totalPages)); + historyPageInput.disabled = totalPages <= 1; + } + if (historyPageTotal) { + historyPageTotal.textContent = formatNumber(Math.max(1, totalPages)); + } +} + +function normalizedHistorySearchTerm(value) { + return String(value || "").trim().toLowerCase(); +} + +function compactHistorySearchText(value) { + return normalizedHistorySearchTerm(value).replace(/[\s_-]+/g, ""); +} + +function historyTypeSearchLabels(type) { + const numeric = Number(type || 0); + const key = txTypeKey(numeric); + const aliases = { + 0: ["genesis"], + 1: ["reward", "mining reward"], + 2: ["transfer", "transfer in", "transfer out"], + 3: ["token create", "create token"], + 4: ["nft create", "create nft"], + 5: ["marketing"], + 6: ["swap"], + 7: ["loan create", "loan contract"], + 8: ["loan payment", "contract payment"], + 9: ["collateral claim"], + 10: ["burn"], + 11: ["token issue", "issue token"], + 12: ["vanity", "vanity address"] + }; + return [String(numeric), key, key.replace(/_/g, " "), ...(aliases[numeric] || [])]; +} + +function historyTransactionMatchesSearch(index, txid, searchTerm) { + const term = normalizedHistorySearchTerm(searchTerm); + if (!term) { + return true; + } + + const compactTerm = compactHistorySearchText(term); + if (String(txid || "").toLowerCase().includes(term)) { + return true; + } + + const type = Number(index?.txid_to_type?.[txid] ?? 0); + return historyTypeSearchLabels(type).some((label) => { + const normalized = normalizedHistorySearchTerm(label); + return normalized.includes(term) || compactHistorySearchText(normalized).includes(compactTerm); + }); +} + +function historyTxidsForSearch(index, orderedTxids, searchTerm) { + const term = normalizedHistorySearchTerm(searchTerm); + if (!term) { + return orderedTxids; + } + return orderedTxids.filter((txid) => historyTransactionMatchesSearch(index, txid, term)); +} + +function historyExportRows(transactions) { + return transactions.map((transaction) => ({ + date: formatHistoryDate(transaction.timestamp), + timestamp: Number(transaction.timestamp || 0), + type: recentTransactionField(transaction, ["type", "tx_type", "transactionType"], "Transaction"), + asset: recentTransactionField(transaction, ["asset", "coin", "token"], activeBaseCoin()), + counterparty: recentTransactionField(transaction, ["counterparty", "to", "from", "address"], ""), + status: recentTransactionField(transaction, ["status"], "Pending"), + amount: recentTransactionField(transaction, ["amount", "value", "display_amount"], ""), + fee: recentTransactionField(transaction, ["fee", "txfee", "display_fee"], "--"), + txid: recentTransactionField(transaction, ["txid"], ""), + block: Number(transaction.block_height || transaction.blockHeight || transaction.height || 0) || null + })); +} + +function csvCell(value) { + const text = value === null || value === undefined ? "" : String(value); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function historyRowsToCsv(rows) { + const headers = ["date", "timestamp", "type", "asset", "counterparty", "status", "amount", "fee", "txid", "block"]; + return [ + headers.join(","), + ...rows.map((row) => headers.map((header) => csvCell(row[header])).join(",")) + ].join("\r\n"); +} + +function xmlEscape(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function historyRowsToXml(rows) { + const body = rows.map((row) => ` + ${xmlEscape(row.date)} + ${xmlEscape(row.timestamp)} + ${xmlEscape(row.type)} + ${xmlEscape(row.asset)} + ${xmlEscape(row.counterparty)} + ${xmlEscape(row.status)} + ${xmlEscape(row.amount)} + ${xmlEscape(row.fee)} + ${xmlEscape(row.txid)} + ${xmlEscape(row.block ?? "")} + `).join("\n"); + return `\n\n${body}\n\n`; +} + +function historyRowsToExport(rows, format) { + const normalizedFormat = String(format || "").toLowerCase(); + if (normalizedFormat === "json") { + return JSON.stringify({ + exported_at: new Date().toISOString(), + address: activeWalletAddressShort || activeWalletAddressHex || "", + transactions: rows + }, null, 2); + } + if (normalizedFormat === "xml") { + return historyRowsToXml(rows); + } + if (normalizedFormat === "csv") { + return historyRowsToCsv(rows); + } + throw new Error("Unsupported export format."); +} + +function setHistoryExportMessage(message, mode = "") { + if (!historyExportMessage) { + return; + } + historyExportMessage.textContent = message; + historyExportMessage.classList.toggle("error", mode === "error"); + historyExportMessage.classList.toggle("success", mode === "success"); +} + +function toggleHistoryExportModal(open) { + if (!historyExportModal) { + return; + } + historyExportModal.classList.toggle("hidden", !open); + if (open) { + setHistoryExportMessage(""); + } +} + +async function exportFullHistory(format) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !walletLoaded || !registrationRegistered) { + setHistoryExportMessage("Load a registered wallet before exporting history.", "error"); + return; + } + + const normalizedFormat = String(format || "").toLowerCase(); + const walletToken = activeWalletCacheToken(); + setHistoryExportMessage("Preparing export. This may take several minutes."); + historyExportFormatButtons.forEach((button) => { + button.disabled = true; + }); + + try { + const cache = await readTransactionCacheObject(walletToken); + if (!isActiveWalletCacheToken(walletToken)) { + throw new Error("Wallet changed before export completed."); + } + const rows = historyExportRows(normalizeHistoryTransactions(cache)); + if (!rows.length) { + throw new Error("No local transaction history is available to export."); + } + + const contents = historyRowsToExport(rows, normalizedFormat); + const address = activeWalletAddressShort || activeWalletAddressHex || "address"; + const safeAddress = String(address).replace(/[^a-z0-9._-]/gi, "_"); + const savedPath = await invoke("save_history_export", { + defaultFileName: `${safeAddress}-history.${normalizedFormat}`, + format: normalizedFormat, + contents + }); + + if (savedPath) { + setHistoryExportMessage(`Saved to ${savedPath}`, "success"); + } else { + setHistoryExportMessage("Export cancelled."); + } + } catch (error) { + setHistoryExportMessage(String(error), "error"); + } finally { + historyExportFormatButtons.forEach((button) => { + button.disabled = false; + }); + } +} + +function historyTransactionTimestamp(record) { + const transaction = record?.transaction; + if (Array.isArray(transaction)) { + const timestamp = Number(readU32LE(transaction, 1)); + return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0; + } + + const candidates = [ + transaction?.unsigned?.timestamp, + transaction?.unsigned?.time, + transaction?.unsigned_transfer?.timestamp, + transaction?.unsigned_transfer?.time, + transaction?.unsigned_create_token?.timestamp, + transaction?.unsigned_create_token?.time, + transaction?.unsigned_create_nft?.timestamp, + transaction?.unsigned_create_nft?.time, + transaction?.unsigned_marketing?.timestamp, + transaction?.unsigned_marketing?.time, + transaction?.unsigned_swap?.timestamp, + transaction?.unsigned_swap?.time, + transaction?.unsigned_loan_contract?.timestamp, + transaction?.unsigned_loan_contract?.time, + transaction?.unsigned_contract_payment?.timestamp, + transaction?.unsigned_contract_payment?.time, + transaction?.unsigned_collateral_claim?.timestamp, + transaction?.unsigned_collateral_claim?.time, + transaction?.unsigned_burn?.timestamp, + transaction?.unsigned_burn?.time, + transaction?.unsigned_issue_token?.timestamp, + transaction?.unsigned_issue_token?.time, + transaction?.unsigned_vanity_address?.timestamp, + transaction?.unsigned_vanity_address?.time + ]; + const timestamp = candidates.map(Number).find((value) => Number.isFinite(value) && value > 0); + return timestamp || 0; +} + +function formatHistoryDate(timestamp) { + const numeric = Number(timestamp || 0); + if (!numeric) { + return "--"; + } + + return new Date(numeric * 1000).toLocaleString(); +} + +function historyStatusClass(status) { + return String(status || "").trim().toLowerCase() === "confirmed" ? "confirmed" : "pending"; +} + +function historyBlockLabel(block) { + const numeric = Number(block || 0); + return numeric > 0 ? formatNumber(numeric) : "Pending"; +} + +function normalizeHistoryTransactions(cache) { + const normalized = normalizeTransactionCache(cache); + const orderedTxids = normalizeOrderedTxids(normalized); + const records = transactionRecordsForTxids(normalized, orderedTxids); + + return records + .flatMap((record, recordIndex) => { + const repaired = repairCachedTransactionRecord(record.txid, record); + const block = blockValueFromRecord(repaired); + const timestamp = historyTransactionTimestamp(repaired); + const status = repaired.status || walletStatusForBlock(block); + return parseDashboardTransactionRows( + repaired.txid, + block, + repaired.transaction, + repaired.linked_transactions || {}, + repaired.miner_earnings || [] + ).map((row, rowIndex) => ({ + ...row, + txid: row.txid || repaired.txid, + block_height: row.block_height ?? block, + status, + timestamp, + __order: recordIndex + rowIndex / 1000 + })); + }) + .sort((left, right) => { + const leftPending = historyStatusClass(left.status) === "pending" ? 1 : 0; + const rightPending = historyStatusClass(right.status) === "pending" ? 1 : 0; + if (rightPending !== leftPending) { + return rightPending - leftPending; + } + + const leftBlock = Number(left.block_height || 0); + const rightBlock = Number(right.block_height || 0); + if (rightBlock !== leftBlock) { + return rightBlock - leftBlock; + } + + const leftTimestamp = Number(left.timestamp || 0); + const rightTimestamp = Number(right.timestamp || 0); + if (rightTimestamp !== leftTimestamp) { + return rightTimestamp - leftTimestamp; + } + + return right.__order - left.__order; + }) + .map(({ __order: _order, ...transaction }) => transaction); +} + +function renderHistoryTransactions(transactions) { + clearHistoryRows(); + + if (!historyTransactionsBody || !historyCards) { + return; + } + + transactions.forEach((transaction) => { + const type = recentTransactionField(transaction, ["type", "tx_type", "transactionType"], "Transaction"); + const asset = recentTransactionField(transaction, ["asset", "coin", "token"], activeBaseCoin()); + const counterparty = recentTransactionField(transaction, ["counterparty", "to", "from", "address"], shortenAddress(transaction.txid)); + const status = recentTransactionField(transaction, ["status"], "Pending"); + const amount = recentTransactionField(transaction, ["amount", "value", "display_amount"], ""); + const fee = recentTransactionField(transaction, ["fee", "txfee", "display_fee"], "--"); + const txid = recentTransactionField(transaction, ["txid"], ""); + const block = historyBlockLabel(recentTransactionField(transaction, ["block_height", "blockHeight", "height"], "0")); + const date = formatHistoryDate(transaction.timestamp); + const statusClass = historyStatusClass(status); + const shortTxid = shortenAddress(txid); + + const row = document.createElement("tr"); + row.innerHTML = ` + + + + + + + + + + `; + row.children[0].textContent = date; + row.children[1].textContent = type; + row.children[2].textContent = asset; + row.children[3].textContent = counterparty; + row.querySelector(".table-status").textContent = status; + row.children[5].textContent = amount; + row.children[6].textContent = fee; + row.querySelector("[data-copy-history-hash]").textContent = shortTxid; + row.querySelector("[data-copy-history-hash]").dataset.hash = txid; + row.children[8].textContent = block; + historyTransactionsBody.appendChild(row); + + const card = document.createElement("article"); + card.className = "history-card"; + card.innerHTML = ` +
+ + + + `; + card.querySelector("strong").textContent = `${type} · ${asset}`; + card.querySelector(".table-status").textContent = status; + card.querySelector("b").textContent = amount || "--"; + card.querySelector("span:not(.table-status)").textContent = `${counterparty} · Block ${block} · ${date}`; + card.querySelector("[data-copy-history-hash]").textContent = shortTxid; + card.querySelector("[data-copy-history-hash]").dataset.hash = txid; + card.querySelector("strong").textContent = `${type} - ${asset}`; + card.querySelector("span:not(.table-status)").textContent = `${counterparty} - Fee ${fee} - Block ${block} - ${date}`; + historyCards.appendChild(card); + }); +} + +async function refreshHistoryFromCache() { + if (!walletLoaded || currentView !== "history") { + return; + } + if (historyRefreshInFlight) { + historyRefreshPending = true; + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + clearHistoryRows(); + setHistoryState("Transaction history requires the desktop app build."); + return; + } + + const readId = ++historyRefreshReadId; + if (!registrationRegistered) { + clearHistoryRows(); + setHistoryFooter(0, 0); + setHistoryState("Address registration required before transactions can appear."); + return; + } + + historyRefreshInFlight = true; + historyRefreshPending = false; + try { + const walletToken = activeWalletCacheToken(); + const index = await readTransactionIndexObject(walletToken); + if (readId !== historyRefreshReadId || currentView !== "history") { + return; + } + + const orderedTxids = normalizeOrderedTxids(index); + const searchTerm = normalizedHistorySearchTerm(historySearch?.value || ""); + const matchedTxids = historyTxidsForSearch(index, orderedTxids, searchTerm); + const totalCount = orderedTxids.length; + const resultCount = matchedTxids.length; + const totalPages = Math.max(1, Math.ceil(resultCount / HISTORY_PAGE_SIZE)); + historyCurrentPage = Math.min(Math.max(1, historyCurrentPage), totalPages); + const newestOffset = (historyCurrentPage - 1) * HISTORY_PAGE_SIZE; + const pageEnd = Math.max(0, resultCount - newestOffset); + const pageStart = Math.max(0, pageEnd - HISTORY_PAGE_SIZE); + const visibleTxids = matchedTxids.slice(pageStart, pageEnd); + const visibleCount = visibleTxids.length; + const visibleStart = visibleTxids[0] || ""; + const visibleEnd = visibleTxids[visibleTxids.length - 1] || ""; + const signature = `${searchTerm}:${totalCount}:${resultCount}:${historyCurrentPage}:${visibleStart}:${visibleEnd}:${index.updated_at || ""}`; + + if (signature === historyLastRenderSignature) { + setHistoryFooter(visibleCount, resultCount, totalCount, searchTerm, historyCurrentPage, totalPages, newestOffset); + return; + } + + const cache = await readTransactionCacheRecords(index, visibleTxids, walletToken); + if (readId !== historyRefreshReadId || currentView !== "history") { + return; + } + const broadcastNode = activeBroadcastNode(); + if (broadcastNode) { + for (const txid of visibleTxids) { + const record = cache.transactions?.[txid]; + if (!cachedRecordNeedsRefresh(record)) { + continue; + } + const repaired = await hydrateLinkedTransactionsForRecord( + txid, + record, + invoke, + broadcastNode, + cache, + () => readId === historyRefreshReadId && currentView === "history" && walletLoaded, + 0 + ); + if (readId !== historyRefreshReadId || currentView !== "history") { + return; + } + cache.transactions[txid] = repaired; + const fileName = index.txid_to_file?.[txid]; + if (fileName) { + await writeTransactionRecordFile(fileName, repaired, walletToken); + } + } + } + + const transactions = normalizeHistoryTransactions(cache); + renderHistoryTransactions(transactions); + historyLastRenderSignature = signature; + setHistoryFooter(visibleCount, resultCount, totalCount, searchTerm, historyCurrentPage, totalPages, newestOffset); + setHistoryState(transactions.length ? "" : (searchTerm ? "No transactions match this search." : "No transaction history has synced yet.")); + } catch (error) { + if (readId !== historyRefreshReadId || currentView !== "history") { + return; + } + clearHistoryRows(); + setHistoryState(String(error) || "Unable to read local transaction history."); + } finally { + historyRefreshInFlight = false; + if (historyRefreshPending && walletLoaded && currentView === "history") { + historyRefreshPending = false; + refreshHistoryFromCache(); + } + } +} + +function startHistoryRefresh() { + if (historyRefreshTimer || currentView !== "history") { + return; + } + + historyCurrentPage = Math.max(1, historyCurrentPage); + refreshHistoryFromCache(); + historyRefreshTimer = setInterval(refreshHistoryFromCache, HISTORY_REFRESH_MS); +} + +function stopHistoryRefresh() { + historyRefreshReadId += 1; + historyRefreshInFlight = false; + historyRefreshPending = false; + historyCurrentPage = 1; + historyLastRenderSignature = ""; + if (historySearchDebounceTimer) { + clearTimeout(historySearchDebounceTimer); + historySearchDebounceTimer = null; + } + setHistoryFooter(0, 0); + if (historyRefreshTimer) { + clearInterval(historyRefreshTimer); + historyRefreshTimer = null; + } +} + +function updateHistoryRefreshState() { + if (walletLoaded && currentView === "history") { + startHistoryRefresh(); + return; + } + + stopHistoryRefresh(); +} + +if (historySearch) { + historySearch.addEventListener("input", () => { + historyCurrentPage = 1; + historyLastRenderSignature = ""; + if (historySearchDebounceTimer) { + clearTimeout(historySearchDebounceTimer); + } + historySearchDebounceTimer = setTimeout(() => { + historySearchDebounceTimer = null; + refreshHistoryFromCache(); + }, 180); + }); +} + +historyExportOpen?.addEventListener("click", () => toggleHistoryExportModal(true)); +historyExportClose?.addEventListener("click", () => toggleHistoryExportModal(false)); +historyExportModal?.addEventListener("click", (event) => { + if (event.target === historyExportModal) { + toggleHistoryExportModal(false); + } +}); +historyExportFormatButtons.forEach((button) => { + button.addEventListener("click", () => exportFullHistory(button.dataset.historyExportFormat)); +}); + +document.addEventListener("click", async (event) => { + const pageButton = event.target.closest("[data-history-page]"); + if (pageButton) { + const totalPages = Math.max(1, Number(historyPageInput?.max || 1)); + const action = pageButton.dataset.historyPage; + if (action === "first") { + historyCurrentPage = 1; + } else if (action === "previous") { + historyCurrentPage = Math.max(1, historyCurrentPage - 1); + } else if (action === "next") { + historyCurrentPage = Math.min(totalPages, historyCurrentPage + 1); + } else if (action === "last") { + historyCurrentPage = totalPages; + } + historyLastRenderSignature = ""; + await refreshHistoryFromCache(); + return; + } + + const button = event.target.closest("[data-copy-history-hash]"); + if (!button) { + return; + } + + const hash = button.dataset.hash || button.textContent; + if (hash) { + await writeClipboardText(hash); + } + const original = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { + button.textContent = original; + }, 1200); +}); + +historyPageInput?.addEventListener("change", async () => { + const totalPages = Math.max(1, Number(historyPageInput.max || 1)); + const requestedPage = Number.parseInt(historyPageInput.value, 10); + historyCurrentPage = Number.isFinite(requestedPage) + ? Math.min(Math.max(1, requestedPage), totalPages) + : 1; + historyLastRenderSignature = ""; + await refreshHistoryFromCache(); +}); + +historyPageInput?.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + historyPageInput.blur(); + } +}); diff --git a/frontend/js/issue_token_form.js b/frontend/js/issue_token_form.js new file mode 100644 index 0000000..4f15cd2 --- /dev/null +++ b/frontend/js/issue_token_form.js @@ -0,0 +1,281 @@ +const ISSUE_TOKEN_FEE_ATOMIC = 10000000000n; +let issueTokenEligibility = null; +let issueTokenLookupId = 0; + +function setIssueTokenMessage(message, type = "") { + setScopedTransactionMessage("Issue More Tokens", issueTokenMessage, message, type); +} + +function setIssueTokenButtonsDisabled(disabled) { + document.querySelector("[data-issue-token-save]")?.toggleAttribute("disabled", disabled); + document.querySelector("[data-issue-token-broadcast]")?.toggleAttribute("disabled", disabled); +} + +function updateIssueTokenFee() { + const displayFee = tokenAtomicToDecimal(ISSUE_TOKEN_FEE_ATOMIC); + if (issueTokenFeeInput) { + issueTokenFeeInput.value = displayFee; + } + if (issueTokenFeeLabel) { + issueTokenFeeLabel.textContent = `${displayFee} ${activeBaseCoin()}`; + } +} + +function resetIssueTokenDetails() { + issueTokenEligibility = null; + if (issueTokenCreator) { + issueTokenCreator.textContent = "--"; + } + if (issueTokenPolicy) { + issueTokenPolicy.textContent = "--"; + } + setIssueTokenButtonsDisabled(true); +} + +async function refreshIssueTokenEligibility() { + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + const walletAddress = activeWalletAddressShort || activeWalletAddressHex || ""; + const ticker = String(issueTokenTickerSelect?.value || "").trim(); + const lookupId = ++issueTokenLookupId; + resetIssueTokenDetails(); + + if (!ticker || !invoke || !broadcastNode || !walletLoaded) { + return; + } + + setIssueTokenMessage("Checking token issuance policy..."); + try { + const details = await invokeWalletRpcWithBackoff( + "Token details lookup", + () => invoke("issue_token_details", { broadcastNode, ticker }), + () => + lookupId === issueTokenLookupId && + walletLoaded && + (activeWalletAddressShort || activeWalletAddressHex || "") === walletAddress && + activeBroadcastNode() === broadcastNode && + issueTokenTickerSelect?.value === ticker + ); + if (lookupId !== issueTokenLookupId) { + return; + } + + issueTokenEligibility = details; + if (issueTokenCreator) { + issueTokenCreator.textContent = details.creator || "--"; + } + if (issueTokenPolicy) { + issueTokenPolicy.textContent = details.hard_limit + ? "Hard capped" + : "Future issuance allowed"; + } + + if (!details.eligible) { + setIssueTokenMessage( + details.reason || "This token cannot be issued by the active wallet.", + "error" + ); + return; + } + + setIssueTokenMessage("The active wallet may issue more of this token.", "success"); + setIssueTokenButtonsDisabled(false); + } catch (error) { + if (lookupId !== issueTokenLookupId) { + return; + } + setIssueTokenMessage(String(error), "error"); + } +} + +async function refreshIssueTokenList() { + issueTokenLookupId += 1; + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + const walletAddress = activeWalletAddressShort || activeWalletAddressHex || ""; + resetIssueTokenDetails(); + if (!issueTokenTickerSelect) { + return; + } + + issueTokenTickerSelect.innerHTML = ""; + const loadingOption = document.createElement("option"); + loadingOption.value = ""; + loadingOption.textContent = "Loading tokens..."; + issueTokenTickerSelect.appendChild(loadingOption); + + if (!invoke || !walletLoaded || !broadcastNode) { + loadingOption.textContent = "Load a wallet and select a node"; + return; + } + + try { + const tokens = await invokeWalletRpcWithBackoff( + "Token list lookup", + () => invoke("token_list", { broadcastNode }), + () => + walletLoaded && + (activeWalletAddressShort || activeWalletAddressHex || "") === walletAddress && + activeBroadcastNode() === broadcastNode + ); + if ( + !walletLoaded || + (activeWalletAddressShort || activeWalletAddressHex || "") !== walletAddress || + activeBroadcastNode() !== broadcastNode + ) { + return; + } + issueTokenTickerSelect.innerHTML = ""; + const tokenItems = Array.isArray(tokens) ? tokens : []; + if (!tokenItems.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No tokens exist"; + issueTokenTickerSelect.appendChild(option); + setIssueTokenMessage("No tokens are available to issue.", "error"); + return; + } + + tokenItems.forEach((token) => { + const ticker = String(token?.ticker || "").trim(); + if (!ticker) { + return; + } + const option = document.createElement("option"); + option.value = ticker; + option.textContent = ticker; + issueTokenTickerSelect.appendChild(option); + }); + await refreshIssueTokenEligibility(); + } catch (error) { + issueTokenTickerSelect.innerHTML = ""; + const option = document.createElement("option"); + option.value = ""; + option.textContent = "Token list unavailable"; + issueTokenTickerSelect.appendChild(option); + setIssueTokenMessage(String(error), "error"); + } +} + +function validateIssueTokenForm() { + if (!walletLoaded) { + return "Load a wallet before issuing tokens."; + } + if (!registrationRegistered) { + return "Address registration is required before issuing tokens."; + } + if (!issueTokenEligibility?.eligible) { + return issueTokenEligibility?.reason || "Select a token the active wallet may issue."; + } + + const number = tokenDecimalToAtomic(issueTokenNumberInput?.value); + if (number === null || number <= 0n) { + return "Enter a valid additional supply with up to 8 decimal places."; + } + + const baseCoin = activeBaseCoin(); + const baseBalance = (Array.isArray(activeWalletBalances) ? activeWalletBalances : []).find( + (balance) => + String(balance.asset || "").trim().toUpperCase() === baseCoin && + !Number(balance.nft_series || 0) + ); + if (baseBalance && balanceAtomicValue(baseBalance) < ISSUE_TOKEN_FEE_ATOMIC) { + return `This transaction requires ${tokenAtomicToDecimal(ISSUE_TOKEN_FEE_ATOMIC)} ${baseCoin}.`; + } + return ""; +} + +function issueTokenPayload() { + return { + broadcastNode: activeBroadcastNode(), + ticker: String(issueTokenTickerSelect?.value || "").trim(), + number: String(tokenDecimalToAtomic(issueTokenNumberInput?.value) || 0n), + txfee: String(ISSUE_TOKEN_FEE_ATOMIC) + }; +} + +function clearIssueTokenForm() { + if (issueTokenNumberInput) { + issueTokenNumberInput.value = ""; + } +} + +async function handleIssueTokenAction(action) { + const error = validateIssueTokenForm(); + if (error) { + setIssueTokenMessage(error, "error"); + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setIssueTokenMessage("Wallet bridge is not available.", "error"); + return; + } + + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setIssueTokenMessage("Signing cancelled."); + return; + } + + setIssueTokenButtonsDisabled(true); + try { + if (action === "save") { + setIssueTokenMessage("Saving issue-token transaction..."); + const review = await invoke("save_issue_token_transaction", { + ...issueTokenPayload(), + walletKey + }); + clearIssueTokenForm(); + setIssueTokenMessage(`Issue-token transaction saved: ${review.file_name}`, "success"); + if (typeof refreshUnbroadcastTransactions === "function") { + refreshUnbroadcastTransactions(); + } + } else { + setIssueTokenMessage("Broadcasting issue-token transaction..."); + const result = await invoke("broadcast_issue_token_transaction", { + ...issueTokenPayload(), + walletKey + }); + if (typeof invalidateAssetsCatalog === "function") { + invalidateAssetsCatalog(); + } + clearIssueTokenForm(); + setIssueTokenMessage(result.response || "Issue-token transaction broadcast.", "success"); + } + } catch (actionError) { + setIssueTokenMessage(String(actionError), "error"); + } finally { + setIssueTokenButtonsDisabled(!issueTokenEligibility?.eligible); + } +} + +function prepareIssueTokenForm() { + updateIssueTokenFee(); + setIssueTokenMessage(""); + refreshIssueTokenList(); +} + +function resetIssueTokenSession() { + issueTokenLookupId += 1; + resetIssueTokenDetails(); + if (issueTokenTickerSelect) { + issueTokenTickerSelect.innerHTML = ''; + } + clearIssueTokenForm(); + setIssueTokenMessage(""); +} + +issueTokenTickerSelect?.addEventListener("change", refreshIssueTokenEligibility); +issueTokenNumberInput?.addEventListener("input", () => { + if (issueTokenEligibility?.eligible) { + setIssueTokenMessage(""); + } +}); +document + .querySelector("[data-issue-token-save]") + ?.addEventListener("click", () => handleIssueTokenAction("save")); +document + .querySelector("[data-issue-token-broadcast]") + ?.addEventListener("click", () => handleIssueTokenAction("broadcast")); diff --git a/frontend/js/loan_offer_form.js b/frontend/js/loan_offer_form.js new file mode 100644 index 0000000..e6b44bd --- /dev/null +++ b/frontend/js/loan_offer_form.js @@ -0,0 +1,465 @@ +const LOAN_FEE_ATOMIC = 300000000n; +let loanOfferLoadPromise = null; +let loanCollateralAssets = []; +let loanNftKeys = new Set(); +let loanOfferAssetsKey = ""; + +function setLoanOfferMessage(message, type = "") { + setScopedTransactionMessage("Create Loan", loanOfferMessage, message, type); +} + +function loanNftKey(asset, series) { + return `${String(asset || "").trim().toLowerCase()}|${Number(series || 0)}`; +} + +function selectedLoanAsset() { + const option = loanAssetSelect?.selectedOptions?.[0]; + if (!option?.value) { + return null; + } + return { + asset: option.dataset.asset || "", + balance: BigInt(option.dataset.balance || "0") + }; +} + +function selectedLoanCollateral() { + const option = loanCollateralSelect?.selectedOptions?.[0]; + if (!option?.value) { + return null; + } + return { + asset: option.dataset.asset || "", + kind: option.dataset.kind || "token", + series: Number(option.dataset.series || 0), + wireName: option.dataset.wireName || option.dataset.asset || "" + }; +} + +function loanLocalInputValue(timestampMs) { + const date = new Date(timestampMs); + const pad = (value) => String(value).padStart(2, "0"); + return [ + date.getFullYear(), + "-", + pad(date.getMonth() + 1), + "-", + pad(date.getDate()), + "T", + pad(date.getHours()), + ":", + pad(date.getMinutes()) + ].join(""); +} + +function loanStartTimestamp() { + const value = String(loanStartInput?.value || ""); + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/); + if (!match) { + return null; + } + const timestamp = new Date( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + Number(match[4]), + Number(match[5]), + 0 + ).getTime(); + if (!Number.isFinite(timestamp) || timestamp < 0) { + return null; + } + return Math.floor(timestamp / 1000); +} + +function populateLoanAssetSelect() { + if (!loanAssetSelect) { + return; + } + const previous = loanAssetSelect.value; + const assets = (Array.isArray(activeWalletBalances) ? activeWalletBalances : []) + .filter((balance) => { + const asset = String(balance.asset || "").trim(); + const series = Number(balance.nft_series || 0); + return asset + && balanceAtomicValue(balance) > 0n + && !loanNftKeys.has(loanNftKey(asset, series)); + }) + .sort((left, right) => String(left.asset).localeCompare(String(right.asset))); + + loanAssetSelect.innerHTML = ""; + assets.forEach((balance) => { + const option = document.createElement("option"); + option.value = String(balance.asset || "").trim(); + option.textContent = + `${option.value} (${formatAtomicBalance(String(balance.balance || "0"))})`; + option.dataset.asset = option.value; + option.dataset.balance = String(balance.balance || "0"); + loanAssetSelect.appendChild(option); + }); + if (!assets.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = walletLoaded ? "No lendable assets available" : "Load a wallet first"; + loanAssetSelect.appendChild(option); + } else if ([...loanAssetSelect.options].some((option) => option.value === previous)) { + loanAssetSelect.value = previous; + } +} + +function populateLoanCollateralSelect() { + if (!loanCollateralSelect) { + return; + } + const previous = loanCollateralSelect.value; + loanCollateralSelect.innerHTML = ""; + loanCollateralAssets.forEach((asset) => { + const option = document.createElement("option"); + option.value = `${asset.kind}|${asset.wireName}`; + option.textContent = asset.label; + option.dataset.asset = asset.asset; + option.dataset.kind = asset.kind; + option.dataset.series = String(asset.series || 0); + option.dataset.wireName = asset.wireName; + loanCollateralSelect.appendChild(option); + }); + if (!loanCollateralAssets.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No collateral assets available"; + loanCollateralSelect.appendChild(option); + } else if ([...loanCollateralSelect.options].some((option) => option.value === previous)) { + loanCollateralSelect.value = previous; + } +} + +function refreshLoanOfferSelects() { + populateLoanAssetSelect(); + populateLoanCollateralSelect(); + updateLoanOfferDerivedFields(); +} + +async function loadLoanOfferAssets() { + if (loanOfferLoadPromise) { + return loanOfferLoadPromise; + } + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) { + return; + } + const key = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`; + if (loanOfferAssetsKey === key && loanCollateralAssets.length) { + refreshLoanOfferSelects(); + return; + } + + loanOfferLoadPromise = (async () => { + setLoanOfferMessage("Loading loan assets..."); + const [balances, tokens, nfts] = await Promise.all([ + activeWalletBalances.length + ? Promise.resolve(activeWalletBalances) + : invoke("get_active_wallet_balances", { broadcastNode }), + invoke("token_list", { broadcastNode }), + invoke("nft_list", { broadcastNode }) + ]); + activeWalletBalances = Array.isArray(balances) ? balances : []; + const tokenRows = Array.isArray(tokens) ? tokens : []; + const nftRows = Array.isArray(nfts) ? nfts : []; + loanNftKeys = new Set(nftRows.map((nft) => loanNftKey(nft.nft_name, nft.series))); + + loanCollateralAssets = [ + { + asset: activeBaseCoin(), + wireName: activeBaseCoin(), + series: 0, + kind: "coin", + label: `${activeBaseCoin()} (Base Coin)` + }, + ...tokenRows + .filter((token) => { + return String(token.ticker || "").trim().toLowerCase() + !== activeBaseCoin().toLowerCase(); + }) + .map((token) => { + const asset = String(token.ticker || "").trim(); + return { asset, wireName: asset, series: 0, kind: "token", label: `${asset} (Token)` }; + }), + ...nftRows.map((nft) => { + const asset = String(nft.nft_name || "").trim(); + const series = Number(nft.series || 0); + return { + asset, + wireName: series > 0 ? `${asset}_${series}` : asset, + series, + kind: "nft", + label: series > 0 ? `${asset} #${series} (NFT)` : `${asset} (NFT)` + }; + }) + ].filter((asset) => { + return asset.asset + && asset.wireName.length <= 21 + && (asset.kind !== "nft" || asset.series <= 99999); + }); + loanCollateralAssets.sort((left, right) => left.label.localeCompare(right.label)); + loanOfferAssetsKey = key; + refreshLoanOfferSelects(); + setLoanOfferMessage(""); + })().catch((error) => { + setLoanOfferMessage(String(error), "error"); + throw error; + }).finally(() => { + loanOfferLoadPromise = null; + }); + + return loanOfferLoadPromise; +} + +function updateLoanOfferDerivedFields() { + const loanAsset = selectedLoanAsset(); + const collateral = selectedLoanCollateral(); + if (loanLender) { + loanLender.textContent = activeWalletAddressShort || activeWalletAddressHex || "--"; + } + if (loanAvailable) { + loanAvailable.textContent = loanAsset + ? `${formatAtomicBalance(String(loanAsset.balance))} ${loanAsset.asset}` + : "--"; + } + if (loanFeeInput) { + loanFeeInput.value = atomicToDecimal(LOAN_FEE_ATOMIC); + } + if (collateral?.kind === "nft") { + loanCollateralAmountInput.value = "1"; + loanCollateralAmountInput.disabled = true; + } else if (loanCollateralAmountInput) { + loanCollateralAmountInput.disabled = false; + } + + const paymentAmount = decimalToAtomic(loanPaymentAmountInput?.value); + const paymentNumber = Number.parseInt(loanPaymentNumberInput?.value || "0", 10); + if (loanTotalRepayment) { + loanTotalRepayment.textContent = + paymentAmount !== null && paymentAmount > 0n && paymentNumber > 0 + ? `${atomicToDecimal(paymentAmount * BigInt(paymentNumber))} ${loanAsset?.asset || ""}`.trim() + : "--"; + } +} + +function validateLoanOfferForm() { + if (!walletLoaded) { + return "Load a wallet before creating a loan offer."; + } + if (!registrationRegistered) { + return "Address registration is required before creating a loan offer."; + } + const loanAsset = selectedLoanAsset(); + const collateral = selectedLoanCollateral(); + if (!loanAsset) { + return "Select an asset to lend."; + } + if (!collateral) { + return "Select the collateral required from the borrower."; + } + const borrower = String(loanBorrowerInput?.value || "").trim(); + if (!borrower) { + return "Enter the borrower wallet address."; + } + if ( + borrower.toLowerCase() === String(activeWalletAddressShort || "").toLowerCase() + || borrower.toLowerCase() === String(activeWalletAddressHex || "").toLowerCase() + ) { + return "Borrower cannot be the active lender wallet."; + } + + const loanAmount = decimalToAtomic(loanAmountInput?.value); + const collateralAmount = collateral.kind === "nft" + ? ATOMIC_UNITS_PER_COIN + : decimalToAtomic(loanCollateralAmountInput?.value); + const paymentAmount = decimalToAtomic(loanPaymentAmountInput?.value); + const maxLateValue = decimalToAtomic(loanMaxLateValueInput?.value); + if (loanAmount === null || loanAmount <= 0n) { + return "Enter a valid loan amount."; + } + if (collateralAmount === null || collateralAmount <= 0n) { + return "Enter a valid collateral amount."; + } + if (paymentAmount === null || paymentAmount <= 0n) { + return "Enter a valid payment amount."; + } + if (maxLateValue === null) { + return "Enter a valid maximum overdue value."; + } + if (paymentAmount > loanAmount) { + return "A single payment cannot exceed the loan amount."; + } + + const paymentNumber = Number.parseInt(loanPaymentNumberInput?.value || "0", 10); + const gracePeriod = Number.parseInt(loanGracePeriodInput?.value || "0", 10); + if (!Number.isInteger(paymentNumber) || paymentNumber < 1 || paymentNumber > 255) { + return "Number of payments must be between 1 and 255."; + } + if (!Number.isInteger(gracePeriod) || gracePeriod < 0 || gracePeriod > paymentNumber) { + return "Missed payments allowed must be between 0 and the payment count."; + } + if (paymentAmount * BigInt(paymentNumber) < loanAmount) { + return "Scheduled payments must repay at least the loan principal."; + } + if (maxLateValue > loanAmount) { + return "Maximum overdue value cannot exceed the loan amount."; + } + + const startTimestamp = loanStartTimestamp(); + if (startTimestamp === null || startTimestamp > 4294967295) { + return "Enter a valid loan start date and time."; + } + if (loanAmount > loanAsset.balance) { + return "Loan amount exceeds the available asset balance."; + } + const baseBalance = baseCoinBalanceAtomic(); + if (isBaseTransferAsset(loanAsset.asset)) { + if (loanAmount + LOAN_FEE_ATOMIC > loanAsset.balance) { + return `The loan amount and ${atomicToDecimal(LOAN_FEE_ATOMIC)} ${activeBaseCoin()} fee exceed the available balance.`; + } + } else if (baseBalance < LOAN_FEE_ATOMIC) { + return `Creating a loan requires ${atomicToDecimal(LOAN_FEE_ATOMIC)} ${activeBaseCoin()} for the lender fee.`; + } + return ""; +} + +function loanOfferPayload() { + const loanAsset = selectedLoanAsset(); + const collateral = selectedLoanCollateral(); + const collateralAmount = collateral.kind === "nft" + ? ATOMIC_UNITS_PER_COIN + : decimalToAtomic(loanCollateralAmountInput.value); + return { + broadcastNode: activeBroadcastNode(), + loanCoin: loanAsset.asset, + loanAmount: String(decimalToAtomic(loanAmountInput.value)), + borrower: String(loanBorrowerInput.value || "").trim(), + collateral: collateral.wireName, + collateralAmount: String(collateralAmount), + paymentPeriod: String(loanPaymentPeriodSelect.value || "d"), + paymentNumber: Number.parseInt(loanPaymentNumberInput.value, 10), + paymentAmount: String(decimalToAtomic(loanPaymentAmountInput.value)), + gracePeriod: Number.parseInt(loanGracePeriodInput.value, 10), + maxLateValue: String(decimalToAtomic(loanMaxLateValueInput.value)), + startTimestamp: loanStartTimestamp() + }; +} + +function clearLoanOfferForm() { + loanAmountInput.value = ""; + loanBorrowerInput.value = ""; + loanCollateralAmountInput.value = ""; + loanPaymentPeriodSelect.value = "d"; + loanPaymentNumberInput.value = "1"; + loanPaymentAmountInput.value = ""; + loanGracePeriodInput.value = "0"; + loanMaxLateValueInput.value = ""; + loanStartInput.value = loanLocalInputValue(Date.now() + 24 * 60 * 60 * 1000); + updateLoanOfferDerivedFields(); +} + +function setSavedLoanOfferPath(path = "") { + if (loanSavedPathInput) { + loanSavedPathInput.value = path; + } + loanSavedPathPanel?.classList.toggle("hidden", !path); +} + +async function saveLoanOffer() { + const validationError = validateLoanOfferForm(); + if (validationError) { + setLoanOfferMessage(validationError, "error"); + return; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setLoanOfferMessage("Wallet bridge is not available.", "error"); + return; + } + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setLoanOfferMessage("Signing cancelled."); + return; + } + + const button = document.querySelector("[data-loan-offer-save]"); + button.disabled = true; + setSavedLoanOfferPath(""); + setLoanOfferMessage("Signing and saving loan offer..."); + try { + const result = await invoke("save_loan_offer", { + ...loanOfferPayload(), + walletKey + }); + clearLoanOfferForm(); + setSavedLoanOfferPath(result.file_path || ""); + setLoanOfferMessage( + `Loan offer signed and saved as ${result.review?.file_name || "a local transaction file"}. Send this file to the borrower for review and signature.`, + "success" + ); + if (typeof refreshUnbroadcastTransactions === "function") { + refreshUnbroadcastTransactions(); + } + } catch (error) { + setLoanOfferMessage(String(error), "error"); + } finally { + button.disabled = false; + } +} + +function prepareLoanOfferForm() { + if (!loanStartInput.value) { + loanStartInput.value = loanLocalInputValue(Date.now() + 24 * 60 * 60 * 1000); + } + setLoanOfferMessage(""); + loadLoanOfferAssets().catch(() => { + // The form displays the lookup error. + }); + updateLoanOfferDerivedFields(); +} + +function resetLoanOfferSession() { + loanOfferLoadPromise = null; + loanCollateralAssets = []; + loanNftKeys = new Set(); + loanOfferAssetsKey = ""; + clearLoanOfferForm(); + setSavedLoanOfferPath(""); + loanAssetSelect.innerHTML = ""; + loanCollateralSelect.innerHTML = ""; + setLoanOfferMessage(""); +} + +[ + loanAssetSelect, + loanCollateralSelect, + loanAmountInput, + loanCollateralAmountInput, + loanPaymentPeriodSelect, + loanPaymentNumberInput, + loanPaymentAmountInput, + loanGracePeriodInput, + loanMaxLateValueInput, + loanStartInput +].forEach((element) => element?.addEventListener("input", () => { + setLoanOfferMessage(""); + updateLoanOfferDerivedFields(); +})); +loanBorrowerInput?.addEventListener("input", () => setLoanOfferMessage("")); +document.querySelector("[data-loan-offer-save]")?.addEventListener("click", saveLoanOffer); +loanCopyPathButton?.addEventListener("click", async () => { + const path = String(loanSavedPathInput?.value || ""); + if (!path || !(await writeClipboardText(path))) { + setLoanOfferMessage("Unable to copy the saved loan offer path.", "error"); + return; + } + const original = loanCopyPathButton.textContent; + loanCopyPathButton.textContent = "Copied"; + setTimeout(() => { + loanCopyPathButton.textContent = original; + }, 1200); +}); diff --git a/frontend/js/loan_payment_form.js b/frontend/js/loan_payment_form.js new file mode 100644 index 0000000..2949549 --- /dev/null +++ b/frontend/js/loan_payment_form.js @@ -0,0 +1,287 @@ +const LOAN_PAYMENT_FEE_ATOMIC = 1000000n; +let loanPaymentContracts = []; +let loanPaymentLoadPromise = null; +let loanPaymentContractsKey = ""; + +function setLoanPaymentMessage(message, type = "") { + setScopedTransactionMessage("Loan Payment", loanPaymentMessage, message, type); +} + +function selectedLoanPaymentContract() { + const hash = String(loanPaymentContractSelect?.value || ""); + return loanPaymentContracts.find((contract) => contract.contract === hash) || null; +} + +function loanPaymentPeriodLabel(period) { + return { + daily: "Daily", + weekly: "Weekly", + monthly: "Monthly" + }[String(period || "").toLowerCase()] || String(period || "Payment"); +} + +function populateLoanPaymentContracts() { + if (!loanPaymentContractSelect) { + return; + } + const previous = loanPaymentContractSelect.value; + loanPaymentContractSelect.innerHTML = ""; + if (loanPaymentContracts.length) { + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Select an active loan"; + loanPaymentContractSelect.appendChild(placeholder); + } + loanPaymentContracts.forEach((contract) => { + const option = document.createElement("option"); + option.value = contract.contract; + const remaining = formatAtomicBalance(contract.remaining_after_pending || "0"); + option.textContent = + `${contract.loan_asset} loan from ${truncateMiddle(contract.lender, 8, 8)} (${remaining} remaining)`; + loanPaymentContractSelect.appendChild(option); + }); + if (!loanPaymentContracts.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No active borrower loans"; + loanPaymentContractSelect.appendChild(option); + } else if ([...loanPaymentContractSelect.options].some((option) => option.value === previous)) { + loanPaymentContractSelect.value = previous; + } + updateLoanPaymentDerivedFields(true); +} + +async function loadLoanPaymentContracts(force = false) { + if (loanPaymentLoadPromise) { + return loanPaymentLoadPromise; + } + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) { + return; + } + const key = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`; + if (!force && loanPaymentContractsKey === key) { + populateLoanPaymentContracts(); + return; + } + + loanPaymentLoadPromise = (async () => { + setLoanPaymentMessage("Loading active loans..."); + const contracts = await invoke("active_loan_contracts", { broadcastNode }); + loanPaymentContracts = Array.isArray(contracts) ? contracts : []; + loanPaymentContractsKey = key; + populateLoanPaymentContracts(); + setLoanPaymentMessage( + loanPaymentContracts.length ? "" : "No active loans require payment." + ); + })().catch((error) => { + loanPaymentContracts = []; + populateLoanPaymentContracts(); + setLoanPaymentMessage(String(error), "error"); + throw error; + }).finally(() => { + loanPaymentLoadPromise = null; + }); + return loanPaymentLoadPromise; +} + +function expectedLoanPayment(contract) { + if (!contract) { + return 0n; + } + const scheduled = BigInt(contract.payment_amount || "0"); + const remaining = BigInt(contract.remaining_after_pending || "0"); + return scheduled < remaining ? scheduled : remaining; +} + +function updateLoanPaymentWarning(contract, amount) { + if (!loanPaymentWarning) { + return; + } + const expected = expectedLoanPayment(contract); + const show = contract && amount !== null && amount > 0n && amount < expected; + loanPaymentWarning.classList.toggle("hidden", !show); + loanPaymentWarning.textContent = show + ? `This is less than the expected ${atomicToDecimal(expected)} ${contract.loan_asset} payment. Partial payments are allowed, but the unpaid amount still counts toward the loan's overdue balance.` + : ""; +} + +function updateLoanPaymentDerivedFields(setSuggestedAmount = false) { + const contract = selectedLoanPaymentContract(); + if (!contract) { + loanPaymentLender.textContent = "--"; + loanPaymentAsset.textContent = "--"; + loanPaymentScheduled.textContent = "--"; + loanPaymentConfirmed.textContent = "--"; + loanPaymentPending.textContent = "--"; + loanPaymentRemaining.textContent = "--"; + loanPaymentTipInput.value = ""; + loanPaymentFeeInput.value = atomicToDecimal(LOAN_PAYMENT_FEE_ATOMIC); + updateLoanPaymentWarning(null, null); + return; + } + + const expected = expectedLoanPayment(contract); + if (setSuggestedAmount || !loanPaymentValueInput.value) { + loanPaymentValueInput.value = atomicToDecimal(expected); + } + const amount = decimalToAtomic(loanPaymentValueInput.value); + const tip = amount !== null && amount > 0n ? (amount + 99n) / 100n : 0n; + + loanPaymentLender.textContent = contract.lender; + loanPaymentAsset.textContent = contract.loan_asset; + loanPaymentScheduled.textContent = + `${loanPaymentPeriodLabel(contract.payment_period)}: ${atomicToDecimal(BigInt(contract.payment_amount || "0"))} ${contract.loan_asset}`; + loanPaymentConfirmed.textContent = + `${atomicToDecimal(BigInt(contract.confirmed_paid || "0"))} ${contract.loan_asset}`; + loanPaymentPending.textContent = + `${atomicToDecimal(BigInt(contract.pending_paid || "0"))} ${contract.loan_asset}`; + loanPaymentRemaining.textContent = + `${atomicToDecimal(BigInt(contract.remaining_after_pending || "0"))} ${contract.loan_asset}`; + loanPaymentTipInput.value = atomicToDecimal(tip); + loanPaymentFeeInput.value = atomicToDecimal(LOAN_PAYMENT_FEE_ATOMIC); + updateLoanPaymentWarning(contract, amount); +} + +function loanPaymentBalance(asset) { + return activeWalletBalances.find((balance) => { + return String(balance.asset || "").trim().toLowerCase() === String(asset || "").trim().toLowerCase() + && Number(balance.nft_series || 0) === 0; + }); +} + +function validateLoanPaymentForm() { + if (!walletLoaded) { + return "Load a wallet before making a loan payment."; + } + if (!registrationRegistered) { + return "Address registration is required before making a loan payment."; + } + const contract = selectedLoanPaymentContract(); + if (!contract) { + return "Select an active loan."; + } + const amount = decimalToAtomic(loanPaymentValueInput?.value); + if (amount === null || amount <= 0n) { + return "Enter a valid payment amount."; + } + const remaining = BigInt(contract.remaining_after_pending || "0"); + if (amount > remaining) { + return `Payment cannot exceed the remaining ${atomicToDecimal(remaining)} ${contract.loan_asset}.`; + } + const tip = (amount + 99n) / 100n; + const available = balanceAtomicValue(loanPaymentBalance(contract.loan_asset)); + if (amount + tip > available) { + return `This wallet cannot cover the payment and 1% miner tip in ${contract.loan_asset}.`; + } + const baseBalance = baseCoinBalanceAtomic(); + if (isBaseTransferAsset(contract.loan_asset)) { + if (amount + tip + LOAN_PAYMENT_FEE_ATOMIC > available) { + return `This wallet cannot cover the payment, tip, and ${atomicToDecimal(LOAN_PAYMENT_FEE_ATOMIC)} ${activeBaseCoin()} fee.`; + } + } else if (baseBalance < LOAN_PAYMENT_FEE_ATOMIC) { + return `This payment requires ${atomicToDecimal(LOAN_PAYMENT_FEE_ATOMIC)} ${activeBaseCoin()} for the transaction fee.`; + } + return ""; +} + +function loanPaymentPayload() { + const contract = selectedLoanPaymentContract(); + return { + broadcastNode: activeBroadcastNode(), + contractHash: contract.contract, + paybackAmount: String(decimalToAtomic(loanPaymentValueInput.value)) + }; +} + +function setLoanPaymentButtonsDisabled(disabled) { + document.querySelector("[data-loan-payment-save]")?.toggleAttribute("disabled", disabled); + document.querySelector("[data-loan-payment-broadcast]")?.toggleAttribute("disabled", disabled); +} + +function clearLoanPaymentForm() { + loanPaymentContractSelect.value = ""; + loanPaymentValueInput.value = ""; + updateLoanPaymentDerivedFields(false); +} + +async function handleLoanPaymentAction(action) { + const validationError = validateLoanPaymentForm(); + if (validationError) { + setLoanPaymentMessage(validationError, "error"); + return; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setLoanPaymentMessage("Wallet bridge is not available.", "error"); + return; + } + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setLoanPaymentMessage("Signing cancelled."); + return; + } + + setLoanPaymentButtonsDisabled(true); + try { + if (action === "save") { + setLoanPaymentMessage("Saving loan payment transaction..."); + const review = await invoke("save_loan_payment_transaction", { + ...loanPaymentPayload(), + walletKey + }); + clearLoanPaymentForm(); + setLoanPaymentMessage(`Loan payment saved: ${review.file_name}`, "success"); + if (typeof refreshUnbroadcastTransactions === "function") { + refreshUnbroadcastTransactions(); + } + } else { + setLoanPaymentMessage("Broadcasting loan payment..."); + const result = await invoke("broadcast_loan_payment_transaction", { + ...loanPaymentPayload(), + walletKey + }); + await loadLoanPaymentContracts(true); + clearLoanPaymentForm(); + setLoanPaymentMessage(result.response || "Loan payment broadcast.", "success"); + } + } catch (error) { + setLoanPaymentMessage(String(error), "error"); + await loadLoanPaymentContracts(true).catch(() => {}); + } finally { + setLoanPaymentButtonsDisabled(false); + } +} + +function prepareLoanPaymentForm() { + setLoanPaymentMessage(""); + loadLoanPaymentContracts(false).catch(() => { + // The form displays the lookup error. + }); +} + +function resetLoanPaymentSession() { + loanPaymentContracts = []; + loanPaymentLoadPromise = null; + loanPaymentContractsKey = ""; + loanPaymentContractSelect.innerHTML = ""; + loanPaymentValueInput.value = ""; + updateLoanPaymentDerivedFields(false); + setLoanPaymentMessage(""); +} + +loanPaymentContractSelect?.addEventListener("change", () => { + setLoanPaymentMessage(""); + updateLoanPaymentDerivedFields(true); +}); +loanPaymentValueInput?.addEventListener("input", () => { + setLoanPaymentMessage(""); + updateLoanPaymentDerivedFields(false); +}); +document + .querySelector("[data-loan-payment-save]") + ?.addEventListener("click", () => handleLoanPaymentAction("save")); +document + .querySelector("[data-loan-payment-broadcast]") + ?.addEventListener("click", () => handleLoanPaymentAction("broadcast")); diff --git a/frontend/js/loans_page.js b/frontend/js/loans_page.js new file mode 100644 index 0000000..b7b1d97 --- /dev/null +++ b/frontend/js/loans_page.js @@ -0,0 +1,488 @@ +let loansContracts = []; +let loansLoadedForKey = ""; +let loansLoadInFlight = false; +let selectedLoanContractHash = ""; +let activeLoansStatusFilter = "all"; +let loansStateMessage = ""; + +function loanFormatAtomic(value) { + if (typeof atomicToDecimal === "function") { + return atomicToDecimal(BigInt(value || "0")); + } + if (typeof formatAtomicBalance === "function") { + return formatAtomicBalance(value || "0"); + } + return String(value || "0"); +} + +function loanShort(value, left = 8, right = 8) { + if (typeof truncateMiddle === "function") { + return truncateMiddle(value, left, right); + } + const text = String(value || ""); + return text.length > left + right + 3 ? `${text.slice(0, left)}...${text.slice(-right)}` : text; +} + +function escapeLoanText(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function activeLoanRole(contract) { + const active = String(activeWalletAddressShort || "").trim().toLowerCase(); + if (String(contract.lender || "").trim().toLowerCase() === active) { + return "lender"; + } + if (String(contract.borrower || "").trim().toLowerCase() === active) { + return "borrower"; + } + return "unknown"; +} + +function loanRoleLabel(role) { + return role === "lender" ? "Lending" : role === "borrower" ? "Borrowing" : "Related"; +} + +function loanCounterparty(contract) { + return activeLoanRole(contract) === "lender" ? contract.borrower : contract.lender; +} + +function loanStatusClass(status) { + const normalized = String(status || "").toLowerCase(); + if (normalized === "delinquent") { + return "due"; + } + if (normalized === "inactive") { + return "claimable"; + } + return "active"; +} + +function loanStatusLabel(status) { + const normalized = String(status || "").toLowerCase(); + if (normalized === "inactive") { + return "Completed"; + } + return normalized ? normalized.charAt(0).toUpperCase() + normalized.slice(1) : "Unknown"; +} + +function loanPaymentCount(contract) { + return Array.isArray(contract.payments) ? contract.payments.length : 0; +} + +function loanAmountLabel(value, asset) { + return `${loanFormatAtomic(value)} ${String(asset || "").trim()}`.trim(); +} + +function addUtcMonths(timestampSeconds, monthsToAdd) { + const start = new Date(Number(timestampSeconds || 0) * 1000); + if (Number.isNaN(start.getTime())) { + return null; + } + const year = start.getUTCFullYear(); + const month = start.getUTCMonth() + Number(monthsToAdd || 0); + const day = start.getUTCDate(); + const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + return new Date(Date.UTC( + year, + month, + Math.min(day, lastDay), + start.getUTCHours(), + start.getUTCMinutes(), + start.getUTCSeconds() + )); +} + +function loanDueDateForPayment(contract, paymentIndex) { + const startTimestamp = Number(contract.start_timestamp || 0); + if (!startTimestamp || !paymentIndex) { + return null; + } + const period = String(contract.payment_period || "").trim().toLowerCase(); + if (period === "daily") { + return new Date((startTimestamp + paymentIndex * 86_400) * 1000); + } + if (period === "weekly") { + return new Date((startTimestamp + paymentIndex * 7 * 86_400) * 1000); + } + if (period === "monthly") { + return addUtcMonths(startTimestamp, paymentIndex); + } + return null; +} + +function formatLoanDueDate(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return "--"; + } + return date.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short" + }); +} + +function nextLoanPaymentDueLabel(contract) { + if (String(contract.status || "").toLowerCase() === "inactive") { + return "No remaining payments"; + } + const paymentAmount = BigInt(contract.payment_amount || "0"); + const confirmedPaid = BigInt(contract.confirmed_paid || "0"); + const paymentNumber = Number(contract.payment_number || 0); + if (!paymentNumber || paymentAmount <= 0n) { + return "--"; + } + const completedPayments = Number(confirmedPaid / paymentAmount); + if (completedPayments >= paymentNumber) { + return "No remaining payments"; + } + return formatLoanDueDate(loanDueDateForPayment(contract, completedPayments + 1)); +} + +function sortedLoans() { + const query = String(loansSearch?.value || "").trim().toLowerCase(); + const roleFilter = loansRoleFilter?.value || "all"; + + return loansContracts + .filter((contract) => { + const role = activeLoanRole(contract); + const status = String(contract.status || "").toLowerCase(); + const searchText = [ + contract.contract, + contract.lender, + contract.borrower, + contract.loan_asset, + contract.collateral, + status, + role + ].join(" ").toLowerCase(); + + if (roleFilter !== "all" && role !== roleFilter) { + return false; + } + if (activeLoansStatusFilter !== "all" && status !== activeLoansStatusFilter) { + return false; + } + return !query || searchText.includes(query); + }) + .sort((left, right) => Number(right.start_timestamp || 0) - Number(left.start_timestamp || 0)); +} + +function renderLoanCard(contract) { + const role = activeLoanRole(contract); + const selected = contract.contract === selectedLoanContractHash; + const counterparty = loanCounterparty(contract); + const principal = loanAmountLabel(contract.loan_amount, contract.loan_asset); + const remaining = loanAmountLabel(contract.remaining_after_pending, contract.loan_asset); + const payments = `${loanPaymentCount(contract)} / ${contract.payment_number || 0}`; + const nextDue = nextLoanPaymentDueLabel(contract); + + return ` + + `; +} + +function selectedLoanContract() { + return loansContracts.find((contract) => contract.contract === selectedLoanContractHash) || null; +} + +function detailItem(label, value) { + return `
${escapeLoanText(label)}${escapeLoanText(value || "--")}
`; +} + +function renderLoanPaymentHistory(contract) { + if (!loanPaymentHistory) { + return; + } + const payments = Array.isArray(contract?.payments) ? contract.payments : []; + if (!payments.length) { + loanPaymentHistory.innerHTML = '
No confirmed payments recorded for this loan.
'; + return; + } + loanPaymentHistory.innerHTML = ` + + + + + + + + + + + ${payments.map((payment) => ` + + + + + + + `).join("")} + +
DateAmountPayerTx Hash
${escapeLoanText(payment.date || "--")}${escapeLoanText(payment.amount || "0")} ${escapeLoanText(contract.loan_asset || "")}${escapeLoanText(loanShort(payment.payer || "", 8, 8))}${escapeLoanText(loanShort(payment.txid || "", 8, 8))}
+ `; +} + +function renderLoanDetail() { + const contract = selectedLoanContract(); + if (!loanDetailGrid) { + return; + } + if (!contract) { + loanDetailGrid.innerHTML = detailItem("Selected Loan", "Select a loan to view details."); + if (loanPaymentHistory) { + loanPaymentHistory.innerHTML = '
No loan selected.
'; + } + loanCopyContractButton?.toggleAttribute("disabled", true); + loanMakePaymentButton?.toggleAttribute("disabled", true); + loanClaimCollateralButton?.toggleAttribute("disabled", true); + return; + } + + const role = activeLoanRole(contract); + const collateral = loanAmountLabel(contract.collateral_amount, contract.collateral); + const payment = loanAmountLabel(contract.payment_amount, contract.loan_asset); + const confirmed = loanAmountLabel(contract.confirmed_paid, contract.loan_asset); + const pending = loanAmountLabel(contract.pending_paid, contract.loan_asset); + const remaining = loanAmountLabel(contract.remaining_after_pending, contract.loan_asset); + + loanDetailGrid.innerHTML = [ + detailItem("Contract Hash", contract.contract), + detailItem("Role", loanRoleLabel(role)), + detailItem("Status", loanStatusLabel(contract.status)), + detailItem("Principal", loanAmountLabel(contract.loan_amount, contract.loan_asset)), + detailItem("Payment Amount", payment), + detailItem("Collateral", collateral), + detailItem("Payment Schedule", `${contract.payment_number || 0} ${contract.payment_period || "payments"}`), + detailItem("Confirmed Paid", confirmed), + detailItem("Pending Total", pending), + detailItem("Remaining", remaining), + detailItem("Lender", contract.lender), + detailItem("Borrower", contract.borrower) + ].join(""); + + renderLoanPaymentHistory(contract); + loanCopyContractButton?.toggleAttribute("disabled", false); + loanMakePaymentButton?.toggleAttribute( + "disabled", + !(role === "borrower" && String(contract.status || "").toLowerCase() !== "inactive") + ); + loanClaimCollateralButton?.toggleAttribute("disabled", String(contract.status || "").toLowerCase() === "inactive"); +} + +function renderLoansPage() { + if (!loansList) { + return; + } + const rows = sortedLoans(); + if (!selectedLoanContractHash && rows.length) { + selectedLoanContractHash = rows[0].contract; + } + if (selectedLoanContractHash && !loansContracts.some((contract) => contract.contract === selectedLoanContractHash)) { + selectedLoanContractHash = rows[0]?.contract || ""; + } + + loansList.innerHTML = rows.map(renderLoanCard).join(""); + if (!walletLoaded) { + loansState && (loansState.textContent = "Load a wallet to view loans."); + } else if (loansLoadInFlight && !loansContracts.length) { + loansState && (loansState.textContent = "Loading loans..."); + } else if (loansStateMessage) { + loansState && (loansState.textContent = loansStateMessage); + } else if (!loansContracts.length) { + loansState && (loansState.textContent = "No loan contracts found for this wallet."); + } else if (!rows.length) { + loansState && (loansState.textContent = "No loans match the current filters."); + } else { + loansState && (loansState.textContent = `${rows.length} loan${rows.length === 1 ? "" : "s"} shown`); + } + renderLoanDetail(); +} + +async function loadLoansPage({ force = false } = {}) { + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!walletLoaded) { + loansContracts = []; + selectedLoanContractHash = ""; + loansStateMessage = "Load a wallet to view loans."; + renderLoansPage(); + return; + } + if (!invoke) { + loansContracts = []; + selectedLoanContractHash = ""; + loansStateMessage = "Loan lookup skipped: Tauri invoke is unavailable."; + renderLoansPage(); + return; + } + if (!broadcastNode) { + loansContracts = []; + selectedLoanContractHash = ""; + loansStateMessage = "Loan lookup skipped: no broadcast node is selected."; + renderLoansPage(); + return; + } + const key = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`; + if (!force && loansLoadedForKey === key) { + renderLoansPage(); + return; + } + if (loansLoadInFlight) { + return; + } + + loansLoadInFlight = true; + loansStateMessage = "Loading loans..."; + renderLoansPage(); + try { + const contracts = await invokeWalletRpcWithBackoff( + "loan contracts", + () => invoke("loan_contracts", { broadcastNode }), + () => currentView === "loans" && activeBroadcastNode() === broadcastNode + ); + loansContracts = Array.isArray(contracts) ? contracts : []; + loansLoadedForKey = key; + selectedLoanContractHash = loansContracts[0]?.contract || ""; + loansStateMessage = loansContracts.length + ? "" + : "Loan contract command returned 0 contracts for this wallet."; + } catch (error) { + loansContracts = []; + selectedLoanContractHash = ""; + loansStateMessage = `Loan contract lookup failed: ${String(error)}`; + } finally { + loansLoadInFlight = false; + renderLoansPage(); + } +} + +function prepareLoansPage({ force = false } = {}) { + renderLoansPage(); + loadLoansPage({ force }); +} + +function resetLoansPage() { + loansContracts = []; + loansLoadedForKey = ""; + loansLoadInFlight = false; + selectedLoanContractHash = ""; + activeLoansStatusFilter = "all"; + loansStateMessage = ""; + loansSearch && (loansSearch.value = ""); + loansRoleFilter && (loansRoleFilter.value = "all"); + loansStatusButtons.forEach((button) => { + button.classList.toggle("active", button.dataset.loansStatusFilter === "all"); + }); + renderLoansPage(); +} + +loansSearch?.addEventListener("input", renderLoansPage); +loansRoleFilter?.addEventListener("change", renderLoansPage); +loansRefreshButton?.addEventListener("click", () => loadLoansPage({ force: true })); +loansCreateButton?.addEventListener("click", () => { + setActiveView("send"); + setActiveTransactionType("Create Loan"); +}); +loansStatusButtons.forEach((button) => { + button.addEventListener("click", () => { + activeLoansStatusFilter = button.dataset.loansStatusFilter || "all"; + loansStatusButtons.forEach((item) => item.classList.toggle("active", item === button)); + selectedLoanContractHash = ""; + renderLoansPage(); + }); +}); +loansList?.addEventListener("click", (event) => { + const card = event.target.closest("[data-loan-contract]"); + if (!card) { + return; + } + selectedLoanContractHash = card.dataset.loanContract || ""; + renderLoansPage(); +}); +loanCopyContractButton?.addEventListener("click", async () => { + const contract = selectedLoanContract(); + if (!contract) { + return; + } + const copied = typeof writeClipboardText === "function" + ? await writeClipboardText(contract.contract) + : false; + if (!copied) { + return; + } + const original = loanCopyContractButton.textContent; + loanCopyContractButton.textContent = "Copied"; + setTimeout(() => { + loanCopyContractButton.textContent = original; + }, 900); +}); +loanMakePaymentButton?.addEventListener("click", () => { + const contract = selectedLoanContract(); + if (typeof openTransactionType === "function") { + openTransactionType("Loan Payment"); + } else { + setActiveView("send"); + setActiveTransactionType("Loan Payment"); + showTransactionForm("Loan Payment"); + } + const selectContract = () => { + if (contract?.contract && loanPaymentContractSelect) { + loanPaymentContractSelect.value = contract.contract; + updateLoanPaymentDerivedFields(true); + } + }; + if (typeof loadLoanPaymentContracts === "function") { + loadLoanPaymentContracts(false).then(selectContract).catch(() => {}); + } else { + setTimeout(selectContract, 0); + } +}); +loanClaimCollateralButton?.addEventListener("click", () => { + const contract = selectedLoanContract(); + if (typeof openTransactionType === "function") { + openTransactionType("Collateral Claim"); + } else { + setActiveView("send"); + setActiveTransactionType("Collateral Claim"); + showTransactionForm("Collateral Claim"); + } + const selectContract = () => { + if (contract?.contract && collateralClaimContractSelect) { + collateralClaimContractSelect.value = contract.contract; + updateCollateralClaimDetails(); + } + }; + if (typeof loadCollateralClaimContracts === "function") { + loadCollateralClaimContracts(false).then(selectContract).catch(() => {}); + } else { + setTimeout(selectContract, 0); + } +}); diff --git a/frontend/js/marketing_campaign_page.js b/frontend/js/marketing_campaign_page.js new file mode 100644 index 0000000..a07c3ec --- /dev/null +++ b/frontend/js/marketing_campaign_page.js @@ -0,0 +1,290 @@ +const MARKETING_CAMPAIGN_PAGE_SIZE = 100; + +let marketingCampaignPage = 1; +let marketingCampaignTotalRows = 0; +let marketingCampaignLastQuery = null; +let marketingCampaignLoading = false; + +function setMarketingCampaignState(message, mode = "") { + if (!marketingCampaignState) { + return; + } + marketingCampaignState.textContent = message; + marketingCampaignState.classList.toggle("hidden", !message); + marketingCampaignState.classList.toggle("loading", mode === "loading"); + marketingCampaignState.classList.toggle("error", mode === "error"); +} + +function clearMarketingCampaignRows() { + if (marketingCampaignRows) { + marketingCampaignRows.innerHTML = ""; + } + if (marketingCampaignCards) { + marketingCampaignCards.innerHTML = ""; + } +} + +function marketingBigInt(value) { + try { + return BigInt(String(value || "0")); + } catch (_) { + return 0n; + } +} + +function formatMarketingInteger(value) { + const text = marketingBigInt(value).toString(); + return text.replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +function formatMarketingCents(value) { + const cents = marketingBigInt(value); + const sign = cents < 0n ? "-" : ""; + const absolute = cents < 0n ? -cents : cents; + const whole = absolute / 100n; + const fraction = String(absolute % 100n).padStart(2, "0"); + return `${sign}${formatMarketingInteger(whole)}.${fraction}`; +} + +function formatMarketingAverage(totalValue, count) { + const total = marketingBigInt(totalValue); + const divisor = marketingBigInt(count); + if (divisor === 0n) { + return "--"; + } + return formatMarketingCents(total / divisor); +} + +function setMarketingCampaignSummary(data = null) { + const totalRecords = marketingBigInt(data?.total_records); + const totalImpressions = marketingBigInt(data?.total_impressions); + const totalClicks = marketingBigInt(data?.total_clicks); + const totalImpressionValue = marketingBigInt(data?.total_impression_value); + const totalClickValue = marketingBigInt(data?.total_click_value); + const totalValue = totalImpressionValue + totalClickValue; + + if (marketingTotalRecords) { + marketingTotalRecords.textContent = data ? formatMarketingInteger(totalRecords) : "--"; + } + if (marketingTotalImpressions) { + marketingTotalImpressions.textContent = data ? formatMarketingInteger(totalImpressions) : "--"; + } + if (marketingTotalClicks) { + marketingTotalClicks.textContent = data ? formatMarketingInteger(totalClicks) : "--"; + } + if (marketingAvgImpressionValue) { + marketingAvgImpressionValue.textContent = data + ? formatMarketingAverage(totalImpressionValue, totalImpressions) + : "--"; + } + if (marketingAvgClickValue) { + marketingAvgClickValue.textContent = data + ? formatMarketingAverage(totalClickValue, totalClicks) + : "--"; + } + if (marketingTotalValue) { + marketingTotalValue.textContent = data ? formatMarketingCents(totalValue) : "--"; + } +} + +function setMarketingCampaignFooter(visibleCount = 0, totalCount = 0, page = 1) { + const totalPages = Math.max(1, Math.ceil(totalCount / MARKETING_CAMPAIGN_PAGE_SIZE)); + const pageStart = totalCount && visibleCount ? (page - 1) * MARKETING_CAMPAIGN_PAGE_SIZE : 0; + if (marketingCampaignCount) { + marketingCampaignCount.textContent = totalCount + ? `Showing ${formatMarketingInteger(pageStart + 1)}-${formatMarketingInteger(pageStart + visibleCount)} of ${formatMarketingInteger(totalCount)}` + : ""; + } + if (marketingCampaignPagination) { + marketingCampaignPagination.hidden = totalPages <= 1; + marketingCampaignPagination.querySelectorAll("[data-marketing-campaign-page]").forEach((button) => { + const action = button.dataset.marketingCampaignPage; + button.disabled = action === "first" || action === "previous" + ? page <= 1 + : page >= totalPages; + }); + } + if (marketingCampaignPageInput) { + marketingCampaignPageInput.value = String(page); + marketingCampaignPageInput.max = String(totalPages); + marketingCampaignPageInput.disabled = totalPages <= 1; + } + if (marketingCampaignPageTotal) { + marketingCampaignPageTotal.textContent = formatNumber(totalPages); + } +} + +function marketingStatusClass(status) { + return String(status || "").toLowerCase() === "pending" ? "pending" : "confirmed"; +} + +function renderMarketingCampaignRows(rows = []) { + clearMarketingCampaignRows(); + const tableFragment = document.createDocumentFragment(); + const cardFragment = document.createDocumentFragment(); + + rows.forEach((row) => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${formatHistoryDate(row.time)} + ${htmlEscape(row.campaign)} + ${htmlEscape(row.ad_type)} + ${htmlEscape(row.keyword)} + ${htmlEscape(row.displayed)} + ${formatMarketingInteger(row.impression || 0)} + ${formatMarketingInteger(row.click || 0)} + ${formatMarketingCents(row.impression_value || 0)} + ${formatMarketingCents(row.click_value || 0)} + ${htmlEscape(row.status)} + `; + tableFragment.appendChild(tr); + + const card = document.createElement("article"); + card.className = "history-card"; + card.innerHTML = ` +
Time${formatHistoryDate(row.time)}
+
Campaign${htmlEscape(row.campaign)}
+
Ad Type${htmlEscape(row.ad_type)}
+
Keyword${htmlEscape(row.keyword)}
+
Displayed${htmlEscape(row.displayed)}
+
Impressions${formatMarketingInteger(row.impression || 0)}
+
Clicks${formatMarketingInteger(row.click || 0)}
+
Status${htmlEscape(row.status)}
+ `; + cardFragment.appendChild(card); + }); + + marketingCampaignRows?.appendChild(tableFragment); + marketingCampaignCards?.appendChild(cardFragment); +} + +function validateMarketingCampaignQuery() { + const advertiser = String(marketingCampaignAdvertiserInput?.value || "").trim(); + const campaign = String(marketingCampaignIdInput?.value || "").trim(); + if (!walletLoaded) { + return { error: "Load a wallet before viewing campaign data." }; + } + if (!activeBroadcastNode()) { + return { error: "Select a broadcast node before viewing campaign data." }; + } + if (!advertiser) { + return { error: "Agency wallet address is required." }; + } + if (!/^\d+$/.test(campaign)) { + return { error: "Campaign ID must be a whole number." }; + } + return { advertiser, campaign }; +} + +async function loadMarketingCampaignPage(page = marketingCampaignPage) { + if (marketingCampaignLoading) { + return; + } + const query = validateMarketingCampaignQuery(); + if (query.error) { + clearMarketingCampaignRows(); + setMarketingCampaignSummary(null); + setMarketingCampaignFooter(0, 0, 1); + setMarketingCampaignState(query.error, "error"); + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setMarketingCampaignState("Wallet bridge is not available.", "error"); + return; + } + + marketingCampaignLoading = true; + marketingCampaignLoadButton?.toggleAttribute("disabled", true); + marketingCampaignRefreshButton?.toggleAttribute("disabled", true); + setMarketingCampaignState("Loading campaign activity...", "loading"); + + try { + const normalizedPage = Math.max(1, Number.parseInt(page || 1, 10)); + const skip = (normalizedPage - 1) * MARKETING_CAMPAIGN_PAGE_SIZE; + const data = await invoke("marketing_campaign_history", { + broadcastNode: activeBroadcastNode(), + advertiser: query.advertiser, + campaign: query.campaign, + skip, + limit: MARKETING_CAMPAIGN_PAGE_SIZE + }); + + marketingCampaignLastQuery = query; + marketingCampaignPage = normalizedPage; + marketingCampaignTotalRows = Number(marketingBigInt(data?.total_records)); + setMarketingCampaignSummary(data); + renderMarketingCampaignRows(data?.rows || []); + setMarketingCampaignFooter((data?.rows || []).length, marketingCampaignTotalRows, marketingCampaignPage); + setMarketingCampaignState((data?.rows || []).length ? "" : "No campaign records found."); + } catch (error) { + setMarketingCampaignState(String(error), "error"); + } finally { + marketingCampaignLoading = false; + marketingCampaignLoadButton?.toggleAttribute("disabled", false); + marketingCampaignRefreshButton?.toggleAttribute("disabled", false); + } +} + +function prepareMarketingCampaignPage() { + if (!marketingCampaignLastQuery) { + setMarketingCampaignSummary(null); + setMarketingCampaignFooter(0, 0, 1); + if (!marketingCampaignRows?.children.length) { + setMarketingCampaignState("Enter an agency wallet and campaign ID to load campaign activity."); + } + } +} + +function resetMarketingCampaignPage() { + marketingCampaignPage = 1; + marketingCampaignTotalRows = 0; + marketingCampaignLastQuery = null; + marketingCampaignLoading = false; + if (marketingCampaignAdvertiserInput) { + marketingCampaignAdvertiserInput.value = ""; + } + if (marketingCampaignIdInput) { + marketingCampaignIdInput.value = ""; + } + clearMarketingCampaignRows(); + setMarketingCampaignSummary(null); + setMarketingCampaignFooter(0, 0, 1); + setMarketingCampaignState("Enter an agency wallet and campaign ID to load campaign activity."); +} + +marketingCampaignForm?.addEventListener("submit", (event) => { + event.preventDefault(); + marketingCampaignPage = 1; + loadMarketingCampaignPage(1); +}); + +marketingCampaignRefreshButton?.addEventListener("click", () => { + loadMarketingCampaignPage(marketingCampaignPage); +}); + +marketingCampaignPagination?.querySelectorAll("[data-marketing-campaign-page]").forEach((button) => { + button.addEventListener("click", () => { + const action = button.dataset.marketingCampaignPage; + const totalPages = Math.max(1, Math.ceil(marketingCampaignTotalRows / MARKETING_CAMPAIGN_PAGE_SIZE)); + if (action === "first") { + loadMarketingCampaignPage(1); + } else if (action === "previous") { + loadMarketingCampaignPage(Math.max(1, marketingCampaignPage - 1)); + } else if (action === "next") { + loadMarketingCampaignPage(Math.min(totalPages, marketingCampaignPage + 1)); + } else if (action === "last") { + loadMarketingCampaignPage(totalPages); + } + }); +}); + +marketingCampaignPageInput?.addEventListener("change", () => { + const totalPages = Math.max(1, Math.ceil(marketingCampaignTotalRows / MARKETING_CAMPAIGN_PAGE_SIZE)); + const requestedPage = Math.min( + totalPages, + Math.max(1, Number.parseInt(marketingCampaignPageInput.value || "1", 10)) + ); + loadMarketingCampaignPage(requestedPage); +}); diff --git a/frontend/js/marketing_form.js b/frontend/js/marketing_form.js new file mode 100644 index 0000000..2de95d2 --- /dev/null +++ b/frontend/js/marketing_form.js @@ -0,0 +1,188 @@ +const MARKETING_FEE_ATOMIC = 100000000n; + +function setMarketingMessage(message, type = "") { + setScopedTransactionMessage("Marketing", marketingMessage, message, type); +} + +function decimalToCents(value) { + const text = String(value || "").trim(); + if (!/^\d+(\.\d{0,2})?$/.test(text)) { + return null; + } + const [whole, fraction = ""] = text.split("."); + const cents = Number.parseInt(whole || "0", 10) * 100 + + Number.parseInt(fraction.padEnd(2, "0") || "0", 10); + return Number.isInteger(cents) && cents >= 0 && cents <= 65535 ? cents : null; +} + +function updateMarketingDerivedFields() { + if (marketingFeeInput) { + marketingFeeInput.value = atomicToDecimal(MARKETING_FEE_ATOMIC); + } + if (marketingAdvertiser) { + marketingAdvertiser.textContent = activeWalletAddressShort || activeWalletAddressHex || "--"; + } + if (marketingFeeLabel) { + marketingFeeLabel.textContent = `${atomicToDecimal(MARKETING_FEE_ATOMIC)} ${activeBaseCoin()}`; + } +} + +function marketingCountValue(input) { + const value = Number.parseInt(input?.value || "0", 10); + return Number.isInteger(value) && value >= 0 && value <= 255 ? value : null; +} + +function validateMarketingForm() { + if (!walletLoaded) { + return "Load a wallet before creating a marketing transaction."; + } + if (!registrationRegistered) { + return "Address registration is required before creating a marketing transaction."; + } + const campaign = String(marketingCampaignInput?.value || "").trim(); + if (!/^\d+$/.test(campaign)) { + return "Campaign ID must be a whole number."; + } + const keyword = String(marketingKeywordInput?.value || "").trim(); + if (!keyword) { + return "Keyword is required."; + } + if (!/^[\x20-\x7E]+$/.test(keyword) || keyword.length > 40) { + return "Keyword must be ASCII and no longer than 40 characters."; + } + const displayed = String(marketingDisplayedInput?.value || "").trim(); + if (!displayed) { + return "Displayed location is required."; + } + if (!/^[\x20-\x7E]+$/.test(displayed) || displayed.length > 100) { + return "Displayed location must be ASCII and no longer than 100 characters."; + } + if (marketingCountValue(marketingImpressionInput) === null) { + return "Impressions must be between 0 and 255."; + } + if (marketingCountValue(marketingClickInput) === null) { + return "Clicks must be between 0 and 255."; + } + if (decimalToCents(marketingImpressionValueInput?.value) === null) { + return "Impression value must be between 0.00 and 655.35."; + } + if (decimalToCents(marketingClickValueInput?.value) === null) { + return "Click value must be between 0.00 and 655.35."; + } + if (baseCoinBalanceAtomic() < MARKETING_FEE_ATOMIC) { + return `Marketing transactions require ${atomicToDecimal(MARKETING_FEE_ATOMIC)} ${activeBaseCoin()} for the fee.`; + } + return ""; +} + +function marketingPayload() { + return { + campaign: String(marketingCampaignInput.value || "0").trim(), + adType: marketingAdTypeSelect.value, + keyword: String(marketingKeywordInput.value || "").trim(), + displayed: String(marketingDisplayedInput.value || "").trim(), + impression: marketingCountValue(marketingImpressionInput), + click: marketingCountValue(marketingClickInput), + impressionValue: decimalToCents(marketingImpressionValueInput.value), + clickValue: decimalToCents(marketingClickValueInput.value), + txfee: String(MARKETING_FEE_ATOMIC) + }; +} + +function clearMarketingForm() { + marketingCampaignInput.value = ""; + marketingAdTypeSelect.value = "banner"; + marketingKeywordInput.value = ""; + marketingDisplayedInput.value = ""; + marketingImpressionInput.value = "0"; + marketingClickInput.value = "0"; + marketingImpressionValueInput.value = ""; + marketingClickValueInput.value = ""; + updateMarketingDerivedFields(); +} + +function setMarketingButtonsDisabled(disabled) { + document.querySelector("[data-marketing-save]")?.toggleAttribute("disabled", disabled); + document.querySelector("[data-marketing-broadcast]")?.toggleAttribute("disabled", disabled); +} + +async function handleMarketingAction(action) { + const validationError = validateMarketingForm(); + if (validationError) { + setMarketingMessage(validationError, "error"); + return; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setMarketingMessage("Wallet bridge is not available.", "error"); + return; + } + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setMarketingMessage("Signing cancelled."); + return; + } + + setMarketingButtonsDisabled(true); + try { + if (action === "save") { + setMarketingMessage("Saving marketing transaction..."); + const review = await invoke("save_marketing_transaction", { + ...marketingPayload(), + walletKey + }); + clearMarketingForm(); + setMarketingMessage(`Marketing transaction saved: ${review.file_name}`, "success"); + if (typeof refreshUnbroadcastTransactions === "function") { + refreshUnbroadcastTransactions(); + } + } else { + const broadcastNode = activeBroadcastNode(); + if (!broadcastNode) { + throw new Error("Select a broadcast node before broadcasting."); + } + setMarketingMessage("Broadcasting marketing transaction..."); + const result = await invoke("broadcast_marketing_transaction", { + ...marketingPayload(), + broadcastNode, + walletKey + }); + clearMarketingForm(); + setMarketingMessage(result.response || "Marketing transaction broadcast.", "success"); + } + } catch (error) { + setMarketingMessage(String(error), "error"); + } finally { + setMarketingButtonsDisabled(false); + } +} + +function prepareMarketingForm() { + updateMarketingDerivedFields(); + setMarketingMessage(""); +} + +function resetMarketingSession() { + clearMarketingForm(); + setMarketingMessage(""); +} + +[ + marketingCampaignInput, + marketingAdTypeSelect, + marketingKeywordInput, + marketingDisplayedInput, + marketingImpressionInput, + marketingClickInput, + marketingImpressionValueInput, + marketingClickValueInput +].forEach((element) => { + element?.addEventListener("input", () => setMarketingMessage("")); + element?.addEventListener("change", () => setMarketingMessage("")); +}); +document + .querySelector("[data-marketing-save]") + ?.addEventListener("click", () => handleMarketingAction("save")); +document + .querySelector("[data-marketing-broadcast]") + ?.addEventListener("click", () => handleMarketingAction("broadcast")); diff --git a/frontend/js/modals_events.js b/frontend/js/modals_events.js new file mode 100644 index 0000000..cabd9af --- /dev/null +++ b/frontend/js/modals_events.js @@ -0,0 +1,154 @@ +const nodeInfoButton = document.querySelector("[data-node-info]"); +const nodeModal = document.querySelector("[data-node-modal]"); +const nodeCloseButton = document.querySelector("[data-node-close]"); +const nftInstructionsOpenButton = document.querySelector("[data-nft-instructions-open]"); +const nftInstructionsModal = document.querySelector("[data-nft-instructions-modal]"); +const nftInstructionsCloseButton = document.querySelector("[data-nft-instructions-close]"); + +function toggleNodeModal(open) { + if (!nodeModal) { + return; + } + nodeModal.classList.toggle("hidden", !open); + if (open) { + startNodeInfoRefresh(); + return; + } + stopNodeInfoRefresh(); +} + +function toggleNftInstructionsModal(open) { + nftInstructionsModal?.classList.toggle("hidden", !open); +} + +nodeInfoButton?.addEventListener("click", () => toggleNodeModal(true)); +nodeCloseButton?.addEventListener("click", () => toggleNodeModal(false)); +nodeModal?.addEventListener("click", (event) => { + if (event.target === nodeModal) { + toggleNodeModal(false); + } +}); + +nftInstructionsOpenButton?.addEventListener("click", () => toggleNftInstructionsModal(true)); +nftInstructionsCloseButton?.addEventListener("click", () => toggleNftInstructionsModal(false)); +nftInstructionsModal?.addEventListener("click", (event) => { + if (event.target === nftInstructionsModal) { + toggleNftInstructionsModal(false); + } +}); + +document.querySelectorAll(".nft-hosting-links a").forEach((link) => { + link.addEventListener("click", async (event) => { + event.preventDefault(); + const url = link.href; + const invoke = window.__TAURI__?.core?.invoke; + if (invoke) { + try { + await invoke("open_external_url", { url }); + return; + } catch (error) { + console.error(error); + } + } + window.open(url, "_blank", "noopener,noreferrer"); + }); +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + toggleNodeModal(false); + toggleNftInstructionsModal(false); + if (!walletSwitchModal?.classList.contains("hidden")) { + closeWalletSwitchModal(); + } + if (!historyExportModal?.classList.contains("hidden")) { + toggleHistoryExportModal(false); + } + if (!backupKeyModal?.classList.contains("hidden")) { + closeBackupKeyModal(null); + } + } +}); + +topWalletListSelect?.addEventListener("change", () => { + const selectedWalletPath = topWalletListSelect.value; + + if (!selectedWalletPath || selectedWalletPath === activeWalletPath) { + restoreActiveWalletSelection(); + return; + } + + openWalletSwitchModal(selectedWalletPath); +}); + +walletSwitchForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!pendingWalletSwitch) { + closeWalletSwitchModal(); + return; + } + + const walletKey = walletSwitchKeyInput?.value || ""; + if (!walletKey.trim()) { + setWalletSwitchMessage("Wallet key cannot be empty.", "error"); + walletSwitchKeyInput?.focus(); + return; + } + + if (walletSwitchSubmit) { + walletSwitchSubmit.disabled = true; + } + setWalletSwitchMessage("Unlocking wallet..."); + + try { + const wallet = await loadWalletByPath(pendingWalletSwitch.walletPath, walletKey); + updateActiveWallet(wallet); + document.body.classList.add("wallet-open"); + closeWalletSwitchModal({ restoreSelection: false }); + await populateTopWalletList(); + } catch (error) { + setWalletSwitchMessage(String(error), "error"); + walletSwitchKeyInput?.focus(); + } finally { + if (walletSwitchSubmit) { + walletSwitchSubmit.disabled = false; + } + } +}); + +walletSwitchCloseButton?.addEventListener("click", () => closeWalletSwitchModal()); +walletSwitchCancelButton?.addEventListener("click", () => closeWalletSwitchModal()); +walletSwitchModal?.addEventListener("click", (event) => { + if (event.target === walletSwitchModal) { + closeWalletSwitchModal(); + } +}); + +broadcastNodeButton?.addEventListener("change", () => { + if (!walletLoaded) { + return; + } + + if (currentView === "send" && !issueTokenForm?.classList.contains("hidden")) { + refreshIssueTokenList(); + } + + if (registrationRegistered) { + resetLatestDashboardTransactionsMemory(); + stopLatestDashboardTransactionsPolling(); + refreshReceivePage(); + startRegisteredDashboardLoadSequence({ resetTransactions: true }); + stopNetworkInfoRefresh(); + updateNetworkInfoRefreshState(); + if (!nodeModal?.classList.contains("hidden")) { + refreshNetworkInfo({ target: "node", force: true }); + } + return; + } + + refreshNetworkInfo({ target: "dashboard", force: !nodeModal?.classList.contains("hidden") }); + refreshReceivePage(); + checkWalletRegistrationOnce(); +}); + +populateBroadcastNodeList(); diff --git a/frontend/js/navigation_actions.js b/frontend/js/navigation_actions.js new file mode 100644 index 0000000..92ff80e --- /dev/null +++ b/frontend/js/navigation_actions.js @@ -0,0 +1,401 @@ +function setActiveView(view) { + if (view === "transactions") { + view = "history"; + } + + const previousView = currentView; + if (previousView === "send" && view !== "send" && typeof resetTransactionForms === "function") { + resetTransactionForms(); + } + if (previousView === "marketing" && view !== "marketing" && typeof resetMarketingCampaignPage === "function") { + resetMarketingCampaignPage(); + } + if (previousView === "nfts" && view !== "nfts" && typeof resetNftsPage === "function") { + resetNftsPage(); + } + + const [title, subtitle] = titles[view] || titles.dashboard; + currentView = view; + + navButtons.forEach((button) => { + button.classList.toggle("active", button.dataset.view === view); + }); + closeMobileMoreMenu(); + document.querySelector("[data-mobile-more]")?.classList.toggle( + "active", + ["assets", "marketing", "addressbook", "settings"].includes(view) + ); + + if (viewTitle) { + viewTitle.textContent = title; + } + if (viewSubtitle) { + viewSubtitle.textContent = subtitle; + } + + if (view === "dashboard") { + dashboardView.classList.remove("hidden"); + transactionView.classList.add("hidden"); + receiveView.classList.add("hidden"); + historyView.classList.add("hidden"); + assetsView.classList.add("hidden"); + nftsView.classList.add("hidden"); + loansView.classList.add("hidden"); + marketingView.classList.add("hidden"); + addressbookView.classList.add("hidden"); + settingsView.classList.add("hidden"); + updateNetworkInfoRefreshState(); + if (registrationRegistered) { + startRegisteredDashboardLoadSequence({ quiet: true }); + } else { + updateBalanceRefreshState(); + updateLatestDashboardTransactionsPolling(); + } + updateHistoryRefreshState(); + return; + } + + updateBalanceRefreshState(); + updateNetworkInfoRefreshState(); + updateLatestDashboardTransactionsPolling(); + updateHistoryRefreshState(); + dashboardView.classList.add("hidden"); + transactionView.classList.add("hidden"); + receiveView.classList.add("hidden"); + historyView.classList.add("hidden"); + assetsView.classList.add("hidden"); + nftsView.classList.add("hidden"); + loansView.classList.add("hidden"); + marketingView.classList.add("hidden"); + addressbookView.classList.add("hidden"); + settingsView.classList.add("hidden"); + + if (view === "receive") { + receiveView.classList.remove("hidden"); + refreshReceivePage(); + return; + } + + if (view === "history") { + historyView.classList.remove("hidden"); + updateHistoryRefreshState(); + return; + } + + if (view === "assets") { + assetsView.classList.remove("hidden"); + prepareAssetsPage(); + return; + } + + if (view === "nfts") { + nftsView.classList.remove("hidden"); + prepareNftsPage(); + return; + } + + if (view === "loans") { + loansView.classList.remove("hidden"); + prepareLoansPage(); + return; + } + + if (view === "marketing") { + marketingView.classList.remove("hidden"); + prepareMarketingCampaignPage(); + return; + } + + if (view === "addressbook") { + addressbookView.classList.remove("hidden"); + prepareAddressBookPage(); + return; + } + + if (view === "settings") { + settingsView.classList.remove("hidden"); + loadSettingsPage(); + return; + } + + transactionView.classList.remove("hidden"); + + if (view === "send") { + setActiveTransactionType("Transfer"); + showTransactionList(); + return; + } + + showTransactionList(title, subtitle); +} + +navButtons.forEach((button) => { + button.addEventListener("click", () => setActiveView(button.dataset.view)); +}); + +function setMobileMoreMenuOpen(open) { + const moreButton = document.querySelector("[data-mobile-more]"); + const backdrop = document.querySelector("[data-mobile-more-backdrop]"); + const sheet = document.querySelector("[data-mobile-more-sheet]"); + moreButton?.setAttribute("aria-expanded", open ? "true" : "false"); + backdrop?.classList.toggle("hidden", !open); + sheet?.classList.toggle("hidden", !open); +} + +function closeMobileMoreMenu() { + setMobileMoreMenuOpen(false); +} + +document.querySelector("[data-mobile-more]")?.addEventListener("click", () => { + const moreButton = document.querySelector("[data-mobile-more]"); + setMobileMoreMenuOpen(moreButton?.getAttribute("aria-expanded") !== "true"); +}); + +document.querySelector("[data-mobile-more-backdrop]")?.addEventListener("click", closeMobileMoreMenu); + +function showTransactionList(title = "Unbroadcast Transactions", copy = "Transactions saved locally and waiting for review.") { + currentTransactionType = ""; + transactionTitle.textContent = title; + transactionCopy.textContent = copy; + transactionList.classList.remove("hidden"); + formPanel.classList.add("hidden"); + reviewPanel.classList.add("hidden"); + unbroadcastListMessage?.classList.remove("hidden"); + if (typeof refreshUnbroadcastTransactions === "function") { + refreshUnbroadcastTransactions(); + } +} + +function setActiveTransactionType(type = "Transfer") { + document + .querySelectorAll("[data-transaction-type]") + .forEach((item) => item.classList.toggle("active", item.dataset.transactionType === type)); +} + +function refreshActiveTransactionFormAfterWalletData() { + if (currentView !== "send" || !currentTransactionType || !walletLoaded || !registrationRegistered) { + return; + } + + if (currentTransactionType === "Transfer" && typeof refreshTransferBalancesIfNeeded === "function") { + refreshTransferBalancesIfNeeded().catch(() => {}); + } else if (currentTransactionType === "Issue More Tokens" && typeof prepareIssueTokenForm === "function") { + prepareIssueTokenForm(); + } else if (currentTransactionType === "Create NFT" && typeof prepareCreateNftForm === "function") { + prepareCreateNftForm(); + } else if (currentTransactionType === "Swap" && typeof prepareSwapOfferForm === "function") { + prepareSwapOfferForm(); + } else if (currentTransactionType === "Create Loan" && typeof prepareLoanOfferForm === "function") { + prepareLoanOfferForm(); + } else if (currentTransactionType === "Loan Payment" && typeof prepareLoanPaymentForm === "function") { + prepareLoanPaymentForm(); + } else if (currentTransactionType === "Collateral Claim" && typeof prepareCollateralClaimForm === "function") { + prepareCollateralClaimForm(); + } else if (currentTransactionType === "Burn" && typeof prepareBurnForm === "function") { + prepareBurnForm(); + } +} + +function isActiveTransactionType(type) { + return currentView === "send" && currentTransactionType === type; +} + +function clearInactiveTransactionMessages(activeType) { + const messageByType = { + "Transfer": transferMessage, + "Create Token": createTokenMessage, + "Issue More Tokens": issueTokenMessage, + "Create NFT": createNftMessage, + "Swap": swapOfferMessage, + "Create Loan": loanOfferMessage, + "Loan Payment": loanPaymentMessage, + "Collateral Claim": collateralClaimMessage, + "Burn": burnMessage, + "Marketing": marketingMessage, + "Vanity Address": vanityMessage + }; + Object.entries(messageByType).forEach(([type, element]) => { + if (!element || type === activeType) { + return; + } + element.textContent = ""; + element.classList.add("hidden"); + element.classList.remove("error", "success"); + }); +} + +function showTransactionForm(type) { + currentTransactionType = type; + clearInactiveTransactionMessages(type); + transactionTitle.textContent = type; + transactionCopy.textContent = "Create, save, or broadcast this transaction type."; + transactionList.classList.add("hidden"); + unbroadcastListMessage?.classList.add("hidden"); + reviewPanel.classList.add("hidden"); + formPanel.classList.remove("hidden"); + + const isTransfer = type === "Transfer"; + const isCreateToken = type === "Create Token"; + const isIssueToken = type === "Issue More Tokens"; + const isCreateNft = type === "Create NFT"; + const isSwap = type === "Swap"; + const isLoan = type === "Create Loan"; + const isLoanPayment = type === "Loan Payment"; + const isCollateralClaim = type === "Collateral Claim"; + const isBurn = type === "Burn"; + const isMarketing = type === "Marketing"; + const isVanity = type === "Vanity Address"; + document.querySelector("[data-transfer-form]")?.classList.toggle("hidden", !isTransfer); + transferActions?.classList.toggle("hidden", !isTransfer); + transferMessage?.classList.toggle("hidden", !isTransfer); + createTokenForm?.classList.toggle("hidden", !isCreateToken); + createTokenActions?.classList.toggle("hidden", !isCreateToken); + createTokenMessage?.classList.toggle("hidden", !isCreateToken); + issueTokenForm?.classList.toggle("hidden", !isIssueToken); + issueTokenActions?.classList.toggle("hidden", !isIssueToken); + issueTokenMessage?.classList.toggle("hidden", !isIssueToken); + createNftForm?.classList.toggle("hidden", !isCreateNft); + createNftActions?.classList.toggle("hidden", !isCreateNft); + createNftMessage?.classList.toggle("hidden", !isCreateNft); + swapOfferForm?.classList.toggle("hidden", !isSwap); + swapOfferActions?.classList.toggle("hidden", !isSwap); + swapOfferMessage?.classList.toggle("hidden", !isSwap); + loanOfferForm?.classList.toggle("hidden", !isLoan); + loanOfferActions?.classList.toggle("hidden", !isLoan); + loanSavedPathPanel?.classList.toggle("hidden", !isLoan || !loanSavedPathInput?.value); + loanOfferMessage?.classList.toggle("hidden", !isLoan); + loanPaymentForm?.classList.toggle("hidden", !isLoanPayment); + loanPaymentActions?.classList.toggle("hidden", !isLoanPayment); + loanPaymentMessage?.classList.toggle("hidden", !isLoanPayment); + collateralClaimForm?.classList.toggle("hidden", !isCollateralClaim); + collateralClaimActions?.classList.toggle("hidden", !isCollateralClaim); + collateralClaimMessage?.classList.toggle("hidden", !isCollateralClaim); + burnForm?.classList.toggle("hidden", !isBurn); + burnActions?.classList.toggle("hidden", !isBurn); + burnMessage?.classList.toggle("hidden", !isBurn); + marketingForm?.classList.toggle("hidden", !isMarketing); + marketingActions?.classList.toggle("hidden", !isMarketing); + marketingMessage?.classList.toggle("hidden", !isMarketing); + vanityForm?.classList.toggle("hidden", !isVanity); + vanityActions?.classList.toggle("hidden", !isVanity); + vanityMessage?.classList.toggle("hidden", !isVanity); + + if (isTransfer && typeof prepareTransferForm === "function") { + prepareTransferForm(); + } else if (isCreateToken && typeof prepareCreateTokenForm === "function") { + prepareCreateTokenForm(); + } else if (isIssueToken && typeof prepareIssueTokenForm === "function") { + prepareIssueTokenForm(); + } else if (isCreateNft && typeof prepareCreateNftForm === "function") { + prepareCreateNftForm(); + } else if (isSwap && typeof prepareSwapOfferForm === "function") { + prepareSwapOfferForm(); + } else if (isLoan && typeof prepareLoanOfferForm === "function") { + prepareLoanOfferForm(); + } else if (isLoanPayment && typeof prepareLoanPaymentForm === "function") { + prepareLoanPaymentForm(); + } else if (isCollateralClaim && typeof prepareCollateralClaimForm === "function") { + prepareCollateralClaimForm(); + } else if (isBurn && typeof prepareBurnForm === "function") { + prepareBurnForm(); + } else if (isMarketing && typeof prepareMarketingForm === "function") { + prepareMarketingForm(); + } else if (isVanity && typeof prepareVanityAddressForm === "function") { + prepareVanityAddressForm(); + } +} + +function openTransactionType(type) { + setActiveView("send"); + setActiveTransactionType(type); + showTransactionForm(type); +} + +function openImportTransaction() { + setActiveView("send"); + if (typeof showUnbroadcastList === "function") { + showUnbroadcastList(); + return; + } + showTransactionList(); +} + +function showTransactionReview() { + transactionTitle.textContent = "Review Transaction"; + transactionCopy.textContent = "Inspect this local transaction before broadcasting."; + transactionList.classList.add("hidden"); + formPanel.classList.add("hidden"); + reviewPanel.classList.remove("hidden"); +} + +document.querySelectorAll("[data-transaction-type]").forEach((button) => { + button.addEventListener("click", () => { + setActiveTransactionType(button.dataset.transactionType); + showTransactionForm(button.dataset.transactionType); + }); +}); + +document.querySelector("[data-open-create-transaction]")?.addEventListener("click", () => { + openTransactionType("Transfer"); +}); + +document.querySelector("[data-view-all-transactions]")?.addEventListener("click", () => { + setActiveView("history"); +}); + +document.querySelector("[data-open-import-transaction]")?.addEventListener("click", () => { + openImportTransaction(); +}); + +document.querySelector("[data-open-vanity-transaction]")?.addEventListener("click", () => { + openTransactionType("Vanity Address"); +}); + +document.querySelector("[data-open-address-book]")?.addEventListener("click", () => { + setActiveView("addressbook"); +}); + +function flashCopyButton(button) { + if (!button) { + return; + } + const original = button.textContent || "Copy"; + button.textContent = "Copied"; + setTimeout(() => { + button.textContent = original; + }, 1200); +} + +async function copyReceiveValue(selector, button) { + const value = document.querySelector(selector)?.textContent?.trim(); + if (value && value !== "N/A" && await writeClipboardText(value)) { + flashCopyButton(button); + } +} + +document.querySelector("[data-copy-address]")?.addEventListener("click", async (event) => { + await copyReceiveValue("[data-active-address]", event.currentTarget); +}); + +document.querySelector("[data-copy-vanity]")?.addEventListener("click", async (event) => { + await copyReceiveValue("[data-active-vanity]", event.currentTarget); +}); + +document.querySelectorAll("[data-copy-hash]").forEach((button) => { + button.addEventListener("click", () => { + const original = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { + button.textContent = original; + }, 1200); + }); +}); + +document.querySelectorAll("[data-loan-card]").forEach((button) => { + button.addEventListener("click", () => { + document + .querySelectorAll("[data-loan-card]") + .forEach((item) => item.classList.toggle("active", item === button)); + }); +}); + diff --git a/frontend/js/nfts_page.js b/frontend/js/nfts_page.js new file mode 100644 index 0000000..da4b392 --- /dev/null +++ b/frontend/js/nfts_page.js @@ -0,0 +1,597 @@ +let ownedNfts = []; +let ownedNftRows = []; +let selectedNftKey = ""; +let nftsLoadId = 0; +let nftsLoading = false; +let nftsPage = 1; +let nftsCachingVisible = false; +const NFTS_PAGE_SIZE = 30; +const nftMetadataCache = new Map(); + +function nftKey(nft) { + return `${String(nft.nft_name || nft.asset || "").trim().toLowerCase()}|${Number(nft.series || nft.nft_series || 0)}`; +} + +function nftCacheKey(nft) { + return `${String(nft.nft_name || nft.asset || "").trim().toLowerCase()}_${Number(nft.series || nft.nft_series || 0)}`; +} + +function escapeNftText(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function shortenNftTx(txid) { + const value = String(txid || ""); + return value.length > 16 ? `${value.slice(0, 6)}...${value.slice(-8)}` : value || "--"; +} + +function nftDisplayName(nft) { + const metadataName = String(nft.metadata?.name || "").trim(); + if (metadataName) { + return metadataName; + } + const name = String(nft.nft_name || nft.asset || "NFT").trim(); + return Number(nft.series || 0) > 0 ? `${name} #${Number(nft.series)}` : name; +} + +function nftImageUri(nft) { + const cachedImage = String(nft.image_data_url || nft.imageDataUrl || "").trim(); + if (cachedImage) { + return cachedImage; + } + return String( + nft.metadata?.image + || nft.metadata?.image_url + || nft.metadata?.animation_url + || "" + ).trim(); +} + +function normalizeIpfsGateway(gateway) { + const fallback = "https://gateway.pinata.cloud/ipfs/"; + const clean = String(gateway || "").trim() || fallback; + return clean.endsWith("/") ? clean : `${clean}/`; +} + +function normalizeIpfsPath(value) { + let path = String(value || "").trim(); + let previous = ""; + while (path && path !== previous) { + previous = path; + path = path + .replace(/^ipfs:\/\//i, "") + .replace(/^ipfs:\//i, "") + .replace(/^ipfs:/i, "") + .replace(/^\/?ipfs\//i, "") + .replace(/^\/+/, ""); + } + return path; +} + +function ipfsToGatewayUrl(uri, gateway) { + const value = String(uri || "").trim(); + if (!value) { + return ""; + } + if (value.startsWith("http://") || value.startsWith("https://")) { + return value; + } + if (value.startsWith("data:image/")) { + return value; + } + const path = normalizeIpfsPath(value); + return `${normalizeIpfsGateway(gateway)}${path}`; +} + +function nftTypeLabel(nft) { + const metadataType = String(nft.metadata?.contractless?.metadata_type || "").trim(); + if (metadataType.toLowerCase() === "rwa") { + return "RWA"; + } + if (nftImageUri(nft)) { + return "Image NFT"; + } + return "NFT"; +} + +function setNftsState(message) { + if (nftsState) { + nftsState.textContent = message || ""; + } +} + +function setNftDetailText(element, value) { + if (element) { + element.textContent = value || "--"; + } +} + +function resetNftDetails(message = "Select an owned NFT.") { + if (nftDetailTitle) { + nftDetailTitle.textContent = "NFT Details"; + } + if (nftDetailSubtitle) { + nftDetailSubtitle.textContent = message; + } + setNftDetailText(nftDetailName, ""); + setNftDetailText(nftDetailAsset, ""); + setNftDetailText(nftDetailCreator, ""); + setNftDetailText(nftDetailGenesis, ""); + setNftDetailText(nftDetailMetadata, ""); + if (nftMetadataSummary) { + nftMetadataSummary.innerHTML = ""; + } + if (nftHistory) { + nftHistory.innerHTML = ""; + } +} + +function hasOwnedBalance(balance) { + try { + return BigInt(String(balance?.balance || "0")) > 0n; + } catch (_) { + return Number(balance?.balance || 0) > 0; + } +} + +function ownedNftRowsFromBalances(balances, registry) { + const normalizedBalances = Array.isArray(balances) ? balances : []; + const registryRows = Array.isArray(registry) ? registry : []; + const balanceByAsset = new Map(); + normalizedBalances.forEach((balance) => { + if (!hasOwnedBalance(balance)) { + return; + } + const asset = String(balance.asset || "").trim().toLowerCase(); + const series = Number(balance.nft_series || 0); + balanceByAsset.set(`${asset}|${series}`, balance); + }); + + return registryRows + .map((nft) => { + const nftName = String(nft.nft_name || "").trim(); + const series = Number(nft.series || 0); + const balance = balanceByAsset.get(`${nftName.toLowerCase()}|${series}`); + if (!balance) { + return null; + } + return { + asset: nftName, + nft_name: nftName, + series, + balance: String(balance.balance || "0"), + origin_txid: nft.origin_txid || "" + }; + }) + .filter(Boolean) + .filter((nft) => nft.nft_name); +} + +function legacyOwnedNftRowsFromBalances(balances) { + return (Array.isArray(balances) ? balances : []) + .filter((balance) => { + const series = Number(balance.nft_series || 0); + let owned = false; + try { + owned = BigInt(String(balance.balance || "0")) > 0n; + } catch (_) { + owned = Number(balance.balance || 0) > 0; + } + return series > 0 && owned; + }) + .map((balance) => ({ + asset: String(balance.asset || "").trim(), + nft_name: String(balance.asset || "").trim(), + series: Number(balance.nft_series || 0), + balance: String(balance.balance || "0") + })) + .filter((nft) => nft.nft_name); +} + +function metadataDescription(metadata) { + return String(metadata?.description || metadata?.summary || "").trim(); +} + +function renderNftCard(nft) { + const key = nftKey(nft); + const name = nftDisplayName(nft); + const image = nftImageUri(nft); + const type = nftTypeLabel(nft); + const active = key === selectedNftKey ? " active" : ""; + const preview = image && nft.gateway + ? `` + : `
${escapeNftText(assetInitial(name))}
`; + + return ` +
+
${preview}
+
+
+ ${escapeNftText(name)} + ${escapeNftText(type)} - ${escapeNftText(nft.asset_name || `${nft.nft_name}_${nft.series}`)} +
+
+ +
+ +
+
+ `; +} + +function renderNftHistoryEntry(entry) { + const asset = entry.received_asset + ? `${entry.received_asset}${Number(entry.received_series || 0) > 0 ? ` #${entry.received_series}` : ""}` + : ""; + return ` +
+
+ ${escapeNftText(entry.action || "Unknown")} + Block ${escapeNftText(formatNumber(entry.block || 0))} +
+ ${escapeNftText(shortenNftTx(entry.txid || ""))} + ${entry.from ? `
From${escapeNftText(shortenAddress(entry.from))}
` : ""} + ${entry.to ? `
To${escapeNftText(shortenAddress(entry.to))}
` : ""} + ${asset ? `
Received${escapeNftText(asset)}
` : ""} +
+ `; +} + +function renderNftMetadataSummary(nft) { + const metadata = nft.metadata || {}; + const description = metadataDescription(metadata); + const attributes = Array.isArray(metadata.attributes) ? metadata.attributes : []; + const contractless = metadata.contractless && typeof metadata.contractless === "object" + ? metadata.contractless + : null; + + const parts = []; + if (description) { + parts.push(`

${escapeNftText(description)}

`); + } + if (attributes.length) { + parts.push(` +
+ ${attributes.slice(0, 8).map((attribute) => ` +
+ ${escapeNftText(attribute.trait_type || attribute.type || "Trait")} + ${escapeNftText(attribute.value ?? "")} +
+ `).join("")} +
+ `); + } + if (contractless) { + parts.push(` +
+ ${Object.entries(contractless).slice(0, 6).map(([key, value]) => ` +
${escapeNftText(key)}${escapeNftText(value)}
+ `).join("")} +
+ `); + } + return parts.join("") || "

No metadata summary available.

"; +} + +function renderNftDetails(nft) { + if (!nft) { + resetNftDetails(); + return; + } + + const name = nftDisplayName(nft); + if (nftDetailTitle) { + nftDetailTitle.textContent = name; + } + if (nftDetailSubtitle) { + nftDetailSubtitle.textContent = nftTypeLabel(nft); + } + + setNftDetailText(nftDetailName, name); + setNftDetailText(nftDetailAsset, nft.asset_name || `${nft.nft_name}_${nft.series}`); + setNftDetailText(nftDetailCreator, nft.creator ? shortenAddress(nft.creator) : ""); + setNftDetailText(nftDetailGenesis, nft.genesis_txid ? shortenNftTx(nft.genesis_txid) : ""); + setNftDetailText(nftDetailMetadata, nft.metadata_uri || nft.item_ipfs || ""); + + if (nftMetadataSummary) { + nftMetadataSummary.innerHTML = renderNftMetadataSummary(nft); + } + if (nftHistory) { + nftHistory.innerHTML = Array.isArray(nft.history) && nft.history.length + ? nft.history.map(renderNftHistoryEntry).join("") + : '
No provenance records returned.
'; + } +} + +function renderNftsPage() { + if (!nftsList) { + return; + } + nftsList.innerHTML = ownedNfts.map(renderNftCard).join(""); + const selected = ownedNfts.find((nft) => nftKey(nft) === selectedNftKey) || ownedNfts[0]; + selectedNftKey = selected ? nftKey(selected) : ""; + renderNftDetails(selected); + + if (!walletLoaded) { + setNftsState("Load a wallet to view NFTs."); + } else if (!ownedNftRows.length) { + setNftsState(nftsLoading ? "Loading owned NFTs..." : "No owned NFTs found for this address."); + } else if (!ownedNfts.length) { + const start = (nftsPage - 1) * NFTS_PAGE_SIZE + 1; + const end = Math.min(start + NFTS_PAGE_SIZE - 1, ownedNftRows.length); + const suffix = nftsCachingVisible ? " - Caching media..." : ""; + setNftsState(`Loading ${start}-${end} of ${ownedNftRows.length} NFTs${suffix}`); + } else { + const start = (nftsPage - 1) * NFTS_PAGE_SIZE + 1; + const end = Math.min(start + ownedNfts.length - 1, ownedNftRows.length); + const suffix = nftsCachingVisible ? " - Caching media..." : ""; + setNftsState(`Showing ${start}-${end} of ${ownedNftRows.length} NFTs${suffix}`); + } + renderNftsPagination(); +} + +async function loadNftMetadata(invoke, detail, gateway) { + const metadataUri = detail.metadata_uri || detail.item_ipfs || ""; + if (!metadataUri) { + return null; + } + const cacheKey = `${gateway}|${metadataUri}`; + if (nftMetadataCache.has(cacheKey)) { + return nftMetadataCache.get(cacheKey); + } + const metadata = await invoke("fetch_ipfs_metadata", { + ipfsPath: metadataUri, + gateway + }); + nftMetadataCache.set(cacheKey, metadata); + return metadata; +} + +function nftFromCache(row, cached, gateway) { + const detail = cached?.detail && typeof cached.detail === "object" ? cached.detail : {}; + const metadata = cached?.metadata && typeof cached.metadata === "object" ? cached.metadata : {}; + return { + ...row, + ...detail, + metadata, + image_data_url: cached?.image_data_url || "", + gateway + }; +} + +function renderNftsPagination() { + if (!nftsPagination) { + return; + } + const totalPages = Math.max(1, Math.ceil(ownedNftRows.length / NFTS_PAGE_SIZE)); + const showPagination = ownedNftRows.length > NFTS_PAGE_SIZE; + nftsPagination.hidden = !showPagination; + if (nftsPageTotal) { + nftsPageTotal.textContent = String(totalPages); + } + if (nftsPageInput && Number(nftsPageInput.value) !== nftsPage) { + nftsPageInput.value = String(nftsPage); + } + document.querySelectorAll("[data-nfts-page]").forEach((button) => { + const action = button.dataset.nftsPage; + button.disabled = !showPagination + || (["first", "previous"].includes(action) && nftsPage <= 1) + || (["next", "last"].includes(action) && nftsPage >= totalPages); + }); +} + +function clampNftsPage(page) { + const totalPages = Math.max(1, Math.ceil(ownedNftRows.length / NFTS_PAGE_SIZE)); + return Math.min(Math.max(1, Number(page) || 1), totalPages); +} + +function visibleNftRows() { + const start = (nftsPage - 1) * NFTS_PAGE_SIZE; + return ownedNftRows.slice(start, start + NFTS_PAGE_SIZE); +} + +async function hydrateVisibleNftPage({ loadId, gateway, broadcastNode, force }) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || loadId !== nftsLoadId) { + return; + } + const rows = visibleNftRows(); + const loaded = []; + nftsCachingVisible = false; + for (const row of rows) { + if (loadId !== nftsLoadId || currentView !== "nfts") { + return; + } + try { + if (!force) { + const cached = await invoke("read_nft_cache", { + nftName: row.nft_name, + series: row.series + }); + if (cached) { + loaded.push(nftFromCache(row, cached, gateway)); + ownedNfts = loaded.slice(); + renderNftsPage(); + continue; + } + } + + nftsCachingVisible = true; + renderNftsPage(); + const detail = await invoke("nft_details", { + broadcastNode, + nftName: row.nft_name, + series: row.series + }); + let metadata = null; + try { + metadata = await loadNftMetadata(invoke, detail, gateway); + } catch (error) { + metadata = { description: `Metadata unavailable: ${String(error)}` }; + } + const imageUrl = ipfsToGatewayUrl( + String(metadata?.image || metadata?.image_url || metadata?.animation_url || ""), + gateway + ); + let cacheRecord = null; + try { + cacheRecord = await invoke("write_nft_cache", { + nftName: row.nft_name, + series: row.series, + detailJson: JSON.stringify(detail), + metadataJson: JSON.stringify(metadata || {}), + imageUrl + }); + } catch (_) { + cacheRecord = null; + } + + loaded.push(cacheRecord ? nftFromCache(row, cacheRecord, gateway) : { + ...row, + ...detail, + metadata, + gateway + }); + ownedNfts = loaded.slice(); + renderNftsPage(); + } catch (error) { + loaded.push({ + ...row, + metadata: { description: `NFT details unavailable: ${String(error)}` }, + gateway + }); + ownedNfts = loaded.slice(); + renderNftsPage(); + } + } + nftsCachingVisible = false; + ownedNfts = loaded; + renderNftsPage(); +} + +async function prepareNftsPage({ force = false } = {}) { + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!walletLoaded) { + ownedNfts = []; + ownedNftRows = []; + renderNftsPage(); + return; + } + if (!invoke || !broadcastNode) { + ownedNfts = []; + ownedNftRows = []; + setNftsState("NFT lookup skipped: wallet bridge or broadcast node is unavailable."); + renderNftsPage(); + return; + } + if (nftsLoading && !force) { + return; + } + + const loadId = ++nftsLoadId; + nftsLoading = true; + nftsCachingVisible = false; + renderNftsPage(); + + try { + const [balances, settings, nftRegistry] = await Promise.all([ + invoke("get_active_wallet_balances", { broadcastNode }), + invoke("read_gui_settings"), + invoke("nft_list", { broadcastNode }) + ]); + if (loadId !== nftsLoadId || currentView !== "nfts") { + return; + } + + const gateway = normalizeIpfsGateway(settings?.ipfs_gateway); + ownedNftRows = ownedNftRowsFromBalances(balances, nftRegistry); + if (!ownedNftRows.length && !Array.isArray(nftRegistry)) { + ownedNftRows = legacyOwnedNftRowsFromBalances(balances); + } + nftsPage = clampNftsPage(nftsPage); + invoke("prune_nft_cache", { + keepKeys: ownedNftRows.map(nftCacheKey) + }).catch(() => {}); + + ownedNfts = []; + renderNftsPage(); + await hydrateVisibleNftPage({ loadId, gateway, broadcastNode, force }); + } catch (error) { + ownedNfts = []; + ownedNftRows = []; + setNftsState(String(error)); + } finally { + if (loadId === nftsLoadId) { + nftsLoading = false; + renderNftsPage(); + } + } +} + +function resetNftsPage() { + nftsLoadId += 1; + ownedNfts = []; + ownedNftRows = []; + selectedNftKey = ""; + nftsPage = 1; + nftsLoading = false; + nftsCachingVisible = false; + resetNftDetails(); + renderNftsPage(); +} + +nftsList?.addEventListener("click", (event) => { + const button = event.target.closest("[data-nft-select]"); + const card = event.target.closest("[data-nft-key]"); + const key = button?.dataset.nftSelect || card?.dataset.nftKey || ""; + if (!key) { + return; + } + selectedNftKey = key; + renderNftsPage(); +}); + +nftsRefreshButton?.addEventListener("click", () => { + nftMetadataCache.clear(); + prepareNftsPage({ force: true }); +}); + +function reloadNftPageFromControls(force = false) { + nftsLoadId += 1; + nftsLoading = false; + nftsCachingVisible = false; + ownedNfts = []; + selectedNftKey = ""; + prepareNftsPage({ force }); +} + +document.querySelectorAll("[data-nfts-page]").forEach((button) => { + button.addEventListener("click", () => { + const totalPages = Math.max(1, Math.ceil(ownedNftRows.length / NFTS_PAGE_SIZE)); + const action = button.dataset.nftsPage; + if (action === "first") { + nftsPage = 1; + } else if (action === "previous") { + nftsPage = clampNftsPage(nftsPage - 1); + } else if (action === "next") { + nftsPage = clampNftsPage(nftsPage + 1); + } else if (action === "last") { + nftsPage = totalPages; + } + reloadNftPageFromControls(); + }); +}); + +nftsPageInput?.addEventListener("change", () => { + nftsPage = clampNftsPage(nftsPageInput.value); + reloadNftPageFromControls(); +}); diff --git a/frontend/js/receive_page.js b/frontend/js/receive_page.js new file mode 100644 index 0000000..d70b1e0 --- /dev/null +++ b/frontend/js/receive_page.js @@ -0,0 +1,433 @@ +let receiveTokenListRequestId = 0; + +function setPaymentMessage(message, mode = "") { + if (!paymentMessage) { + return; + } + paymentMessage.textContent = message; + paymentMessage.classList.toggle("error", mode === "error"); + paymentMessage.classList.toggle("success", mode === "success"); +} + +function receiveAddress() { + return activeWalletAddressShort || activeWalletAddressHex || ""; +} + +function paymentRequestUri() { + const address = receiveAddress(); + if (!address) { + return ""; + } + + const params = new URLSearchParams(); + const asset = paymentAssetSelect?.value || activeBaseCoin(); + const amount = paymentAmountInput?.value?.trim() || ""; + const label = paymentLabelInput?.value?.trim() || ""; + + if (asset) { + params.set("asset", asset); + } + if (amount) { + params.set("amount", amount); + } + if (label) { + params.set("label", label); + } + + const query = params.toString(); + return `contractless:${address}${query ? `?${query}` : ""}`; +} + +function qrGaloisTables() { + const exp = new Array(512).fill(0); + const log = new Array(256).fill(0); + let value = 1; + for (let index = 0; index < 255; index += 1) { + exp[index] = value; + log[value] = index; + value <<= 1; + if (value & 0x100) { + value ^= 0x11d; + } + } + for (let index = 255; index < 512; index += 1) { + exp[index] = exp[index - 255]; + } + return { exp, log }; +} + +function qrMultiply(left, right, tables) { + if (!left || !right) { + return 0; + } + return tables.exp[tables.log[left] + tables.log[right]]; +} + +function qrGeneratorPolynomial(degree, tables) { + let poly = [1]; + for (let index = 0; index < degree; index += 1) { + const next = new Array(poly.length + 1).fill(0); + poly.forEach((coefficient, coefficientIndex) => { + next[coefficientIndex] ^= coefficient; + next[coefficientIndex + 1] ^= qrMultiply(coefficient, tables.exp[index], tables); + }); + poly = next; + } + return poly; +} + +function qrReedSolomon(data, eccLength) { + const tables = qrGaloisTables(); + const generator = qrGeneratorPolynomial(eccLength, tables); + const ecc = new Array(eccLength).fill(0); + + data.forEach((byte) => { + const factor = byte ^ ecc.shift(); + ecc.push(0); + for (let index = 0; index < eccLength; index += 1) { + ecc[index] ^= qrMultiply(generator[index + 1], factor, tables); + } + }); + return ecc; +} + +function qrAppendBits(bits, value, length) { + for (let bit = length - 1; bit >= 0; bit -= 1) { + bits.push((value >>> bit) & 1); + } +} + +function qrEncodeBytes(text) { + const bytes = Array.from(new TextEncoder().encode(text)); + if (bytes.length > 134) { + throw new Error("Payment URI is too long for the QR code. Shorten the label or amount."); + } + + const bits = []; + qrAppendBits(bits, 0b0100, 4); + qrAppendBits(bits, bytes.length, 8); + bytes.forEach((byte) => qrAppendBits(bits, byte, 8)); + const dataBitCapacity = 136 * 8; + const terminator = Math.min(4, dataBitCapacity - bits.length); + qrAppendBits(bits, 0, terminator); + while (bits.length % 8) { + bits.push(0); + } + + const data = []; + for (let index = 0; index < bits.length; index += 8) { + data.push(bits.slice(index, index + 8).reduce((value, bit) => (value << 1) | bit, 0)); + } + for (let pad = 0xec; data.length < 136; pad = pad === 0xec ? 0x11 : 0xec) { + data.push(pad); + } + + const block1 = data.slice(0, 68); + const block2 = data.slice(68, 136); + const ecc1 = qrReedSolomon(block1, 18); + const ecc2 = qrReedSolomon(block2, 18); + const codewords = []; + for (let index = 0; index < 68; index += 1) { + codewords.push(block1[index], block2[index]); + } + for (let index = 0; index < 18; index += 1) { + codewords.push(ecc1[index], ecc2[index]); + } + return codewords.flatMap((byte) => { + const out = []; + qrAppendBits(out, byte, 8); + return out; + }); +} + +function qrFormatBits() { + let data = 0b01000; // ECC level L, mask 0. + let value = data << 10; + const generator = 0x537; + for (let bit = 14; bit >= 10; bit -= 1) { + if ((value >>> bit) & 1) { + value ^= generator << (bit - 10); + } + } + return ((data << 10) | value) ^ 0x5412; +} + +function qrSet(matrix, reserved, row, col, value, isReserved = true) { + if (row < 0 || col < 0 || row >= matrix.length || col >= matrix.length) { + return; + } + matrix[row][col] = Boolean(value); + if (isReserved) { + reserved[row][col] = true; + } +} + +function qrFinder(matrix, reserved, row, col) { + for (let y = -1; y <= 7; y += 1) { + for (let x = -1; x <= 7; x += 1) { + const r = row + y; + const c = col + x; + const dark = x >= 0 && x <= 6 && y >= 0 && y <= 6 + && (x === 0 || x === 6 || y === 0 || y === 6 || (x >= 2 && x <= 4 && y >= 2 && y <= 4)); + qrSet(matrix, reserved, r, c, dark); + } + } +} + +function qrAlignment(matrix, reserved, row, col) { + for (let y = -2; y <= 2; y += 1) { + for (let x = -2; x <= 2; x += 1) { + qrSet(matrix, reserved, row + y, col + x, Math.max(Math.abs(x), Math.abs(y)) !== 1); + } + } +} + +function qrBuildMatrix(text) { + const size = 41; + const matrix = Array.from({ length: size }, () => new Array(size).fill(false)); + const reserved = Array.from({ length: size }, () => new Array(size).fill(false)); + + qrFinder(matrix, reserved, 0, 0); + qrFinder(matrix, reserved, 0, size - 7); + qrFinder(matrix, reserved, size - 7, 0); + qrAlignment(matrix, reserved, 34, 34); + + for (let index = 8; index < size - 8; index += 1) { + qrSet(matrix, reserved, 6, index, index % 2 === 0); + qrSet(matrix, reserved, index, 6, index % 2 === 0); + } + qrSet(matrix, reserved, 33, 8, true); + + for (let index = 0; index < 9; index += 1) { + if (index !== 6) { + reserved[8][index] = true; + reserved[index][8] = true; + } + } + for (let index = 0; index < 8; index += 1) { + reserved[8][size - 1 - index] = true; + reserved[size - 1 - index][8] = true; + } + + const bits = qrEncodeBytes(text); + let bitIndex = 0; + let upward = true; + for (let col = size - 1; col > 0; col -= 2) { + if (col === 6) { + col -= 1; + } + for (let step = 0; step < size; step += 1) { + const row = upward ? size - 1 - step : step; + for (let offset = 0; offset < 2; offset += 1) { + const c = col - offset; + if (reserved[row][c]) { + continue; + } + const dark = bitIndex < bits.length ? bits[bitIndex] === 1 : false; + matrix[row][c] = ((row + c) % 2 === 0) ? !dark : dark; + bitIndex += 1; + } + } + upward = !upward; + } + + const format = qrFormatBits(); + for (let index = 0; index < 15; index += 1) { + const bit = ((format >>> index) & 1) === 1; + const first = [ + [8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 7], [8, 8], + [7, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8] + ][index]; + const second = index < 8 ? [size - 1 - index, 8] : [8, size - 15 + index]; + qrSet(matrix, reserved, first[0], first[1], bit); + qrSet(matrix, reserved, second[0], second[1], bit); + } + + return matrix; +} + +function drawPaymentQr(canvas, text, label) { + if (!canvas) { + return; + } + + const size = canvas.width || 256; + const context = canvas.getContext("2d"); + if (!context) { + return; + } + + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, size, size); + context.fillStyle = "#101820"; + + try { + const matrix = qrBuildMatrix(text); + const modules = matrix.length; + const quiet = 4; + const cell = Math.floor(size / (modules + quiet * 2)); + const offset = Math.floor((size - cell * modules) / 2); + matrix.forEach((row, y) => { + row.forEach((dark, x) => { + if (dark) { + context.fillRect(offset + x * cell, offset + y * cell, cell, cell); + } + }); + }); + setPaymentMessage(""); + } catch (error) { + context.fillStyle = "#101820"; + context.font = "16px sans-serif"; + context.textAlign = "center"; + context.fillText("QR unavailable", size / 2, size / 2 - 8); + context.font = "12px sans-serif"; + context.fillText("Shorten request text", size / 2, size / 2 + 14); + setPaymentMessage(String(error), "error"); + } + + if (label) { + const footerHeight = 28; + context.fillStyle = "rgba(255,255,255,0.92)"; + context.fillRect(0, size - footerHeight, size, footerHeight); + context.fillStyle = "#101820"; + context.font = "13px sans-serif"; + context.textAlign = "center"; + const trimmed = label.length > 30 ? `${label.slice(0, 27)}...` : label; + context.fillText(trimmed, size / 2, size - 10); + } +} + +function drawEmptyPaymentQr(canvas) { + if (!canvas) { + return; + } + const size = canvas.width || 256; + const context = canvas.getContext("2d"); + if (!context) { + return; + } + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, size, size); + context.fillStyle = "#101820"; + context.font = "16px sans-serif"; + context.textAlign = "center"; + context.fillText("Load wallet", size / 2, size / 2); +} + +function renderPaymentQr(uri, label) { + if (!uri) { + drawEmptyPaymentQr(paymentQrCanvas); + return; + } + drawPaymentQr(paymentQrCanvas, uri, label); +} + +function updateReceiveDetails() { + if (receiveRegistration) { + receiveRegistration.textContent = registrationRegistered ? "Registered" : "Unregistered"; + } +} + +function updatePaymentRequest() { + const uri = paymentRequestUri(); + if (paymentUriInput) { + paymentUriInput.value = uri; + } + if (paymentQrLabel) { + paymentQrLabel.textContent = paymentLabelInput?.value?.trim() || "Payment Request"; + } + renderPaymentQr(uri, paymentQrLabel?.textContent || ""); + updateReceiveDetails(); +} + +async function refreshReceiveTokenList() { + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !walletLoaded || !broadcastNode || !paymentAssetSelect) { + return; + } + + const requestId = ++receiveTokenListRequestId; + const selected = paymentAssetSelect.value || activeBaseCoin(); + paymentAssetSelect.innerHTML = ""; + const baseOption = document.createElement("option"); + baseOption.value = activeBaseCoin(); + baseOption.textContent = activeBaseCoin(); + paymentAssetSelect.appendChild(baseOption); + + try { + const tokens = await invokeWalletRpcWithBackoff( + "Token list lookup", + () => invoke("token_list", { broadcastNode }), + () => requestId === receiveTokenListRequestId && walletLoaded + ); + if (requestId !== receiveTokenListRequestId || !walletLoaded) { + return; + } + + (Array.isArray(tokens) ? tokens : []).forEach((token) => { + const ticker = String(token?.ticker || "").trim(); + if (!ticker || ticker.toLowerCase() === activeBaseCoin().toLowerCase()) { + return; + } + const option = document.createElement("option"); + option.value = ticker; + option.textContent = ticker; + paymentAssetSelect.appendChild(option); + }); + paymentAssetSelect.value = [...paymentAssetSelect.options].some((option) => option.value === selected) + ? selected + : activeBaseCoin(); + } catch (error) { + setPaymentMessage(String(error), "error"); + } finally { + updatePaymentRequest(); + } +} + +async function copyPaymentUri() { + const uri = paymentRequestUri(); + if (!uri || !(await writeClipboardText(uri))) { + setPaymentMessage("Payment URI is not available to copy.", "error"); + return; + } + setPaymentMessage("Payment URI copied.", "success"); +} + +async function copyPaymentQr() { + if (!paymentQrCanvas || !navigator.clipboard || typeof ClipboardItem === "undefined") { + await copyPaymentUri(); + return; + } + + paymentQrCanvas.toBlob(async (blob) => { + if (!blob) { + setPaymentMessage("Payment QR is not available to copy.", "error"); + return; + } + try { + await navigator.clipboard.write([ + new ClipboardItem({ + [blob.type]: blob + }) + ]); + setPaymentMessage("Payment request QR copied.", "success"); + } catch (_) { + await copyPaymentUri(); + } + }, "image/png"); +} + +function refreshReceivePage() { + updateReceiveDetails(); + updatePaymentRequest(); + refreshReceiveTokenList(); +} + +[paymentAssetSelect, paymentAmountInput, paymentLabelInput].forEach((input) => { + input?.addEventListener("input", updatePaymentRequest); + input?.addEventListener("change", updatePaymentRequest); +}); + +copyPaymentUriButton?.addEventListener("click", copyPaymentUri); +copyPaymentQrButton?.addEventListener("click", copyPaymentQr); diff --git a/frontend/js/settings_page.js b/frontend/js/settings_page.js new file mode 100644 index 0000000..4129538 --- /dev/null +++ b/frontend/js/settings_page.js @@ -0,0 +1,574 @@ +let settingsLoaded = false; + +function durationSettingMs(value) { + const text = String(value || "").trim().toLowerCase(); + if (!text || text === "never") { + return 0; + } + const amount = Number.parseFloat(text); + if (!Number.isFinite(amount) || amount <= 0) { + return 0; + } + if (text.includes("second")) { + return amount * 1000; + } + if (text.includes("minute")) { + return amount * 60 * 1000; + } + return 0; +} + +function resetAutoLockTimer() { + if (autoLockTimer) { + clearTimeout(autoLockTimer); + autoLockTimer = null; + } + if (!walletLoaded) { + return; + } + + const timeoutMs = durationSettingMs(guiRuntimeSettings.auto_lock_timeout); + if (!timeoutMs) { + return; + } + + autoLockTimer = setTimeout(async () => { + if (!walletLoaded) { + return; + } + try { + await window.__TAURI__?.core?.invoke?.("lock_wallet"); + } catch (_) { + // Local UI lock is still the important user-facing behavior. + } + lockWalletSession(); + }, timeoutMs); +} + +function applyRuntimeGuiSettings(settings) { + guiRuntimeSettings = { + ...guiRuntimeSettings, + auto_lock_timeout: settings?.auto_lock_timeout || guiRuntimeSettings.auto_lock_timeout, + clipboard_clear_timeout: settings?.clipboard_clear_timeout || guiRuntimeSettings.clipboard_clear_timeout, + require_key_before_signing: settings?.require_key_before_signing !== false + }; + resetAutoLockTimer(); +} + +async function loadRuntimeGuiSettings() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + return; + } + try { + applyRuntimeGuiSettings(await invoke("read_gui_settings")); + } catch (_) { + resetAutoLockTimer(); + } +} + +function resetAutoLockOnActivity() { + if (walletLoaded) { + resetAutoLockTimer(); + } +} + +function scheduleClipboardClear(copiedText) { + if (clipboardClearTimer) { + clearTimeout(clipboardClearTimer); + clipboardClearTimer = null; + } + lastClipboardText = copiedText || ""; + const timeoutMs = durationSettingMs(guiRuntimeSettings.clipboard_clear_timeout); + if (!timeoutMs || !lastClipboardText || !navigator.clipboard) { + return; + } + + clipboardClearTimer = setTimeout(async () => { + lastClipboardText = ""; + try { + await navigator.clipboard.writeText(""); + } catch (_) { + // Clipboard writes can fail outside a user gesture; never prompt or block. + } + }, timeoutMs); +} + +async function clearWalletClipboard() { + if (clipboardClearTimer) { + clearTimeout(clipboardClearTimer); + clipboardClearTimer = null; + } + lastClipboardText = ""; + + const invoke = window.__TAURI__?.core?.invoke; + if (invoke) { + try { + await invoke("clear_system_clipboard"); + return; + } catch (_) { + // Fall back to the Web Clipboard API when the native bridge is unavailable. + } + } + + if (!navigator.clipboard) { + return; + } + try { + await navigator.clipboard.writeText(""); + } catch (_) {} +} + +async function writeClipboardText(text) { + if (!text || !navigator.clipboard) { + return false; + } + await navigator.clipboard.writeText(text); + scheduleClipboardClear(text); + return true; +} + +function setSettingsMessage(message, mode = "") { + if (!settingsMessage) { + return; + } + settingsMessage.textContent = message; + settingsMessage.classList.toggle("error", mode === "error"); + settingsMessage.classList.toggle("success", mode === "success"); +} + +function settingsFieldMap() { + return { + wallet_path: settingsWalletPath, + default_node: settingsDefaultNode, + unbroadcast_transactions_path: settingsUnbroadcastPath, + address_book_path: settingsAddressBookPath, + ipfs_gateway: settingsIpfsGateway, + auto_lock_timeout: settingsAutoLockTimeout, + clipboard_clear_timeout: settingsClipboardTimeout + }; +} + +function applyGuiSettings(settings) { + applyRuntimeGuiSettings(settings); + const fields = settingsFieldMap(); + Object.entries(fields).forEach(([key, input]) => { + if (input) { + input.value = settings?.[key] || ""; + } + }); + + if (settingsSavedNodes) { + settingsSavedNodes.value = Array.isArray(settings?.saved_nodes) + ? settings.saved_nodes.join("\n") + : ""; + } + if (settingsRequireKey) { + settingsRequireKey.checked = settings?.require_key_before_signing !== false; + } +} + +function collectGuiSettings() { + return { + wallet_path: settingsWalletPath?.value?.trim() || "", + unbroadcast_transactions_path: settingsUnbroadcastPath?.value?.trim() || "", + address_book_path: settingsAddressBookPath?.value?.trim() || "", + default_node: settingsDefaultNode?.value?.trim() || "", + saved_nodes: (settingsSavedNodes?.value || "") + .split(/\r?\n/) + .map((node) => node.trim()) + .filter(Boolean), + ipfs_gateway: settingsIpfsGateway?.value?.trim() || "", + auto_load_trusted_nft_images: true, + auto_lock_timeout: settingsAutoLockTimeout?.value || "15 minutes", + clipboard_clear_timeout: settingsClipboardTimeout?.value || "60 seconds", + require_key_before_signing: Boolean(settingsRequireKey?.checked), + settings_path: "" + }; +} + +async function loadSettingsPage({ force = false } = {}) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || (settingsLoaded && !force)) { + return; + } + + try { + const settings = await invoke("read_gui_settings"); + applyGuiSettings(settings); + settingsLoaded = true; + setSettingsMessage(""); + } catch (error) { + setSettingsMessage(String(error), "error"); + } +} + +async function saveSettingsPage() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setSettingsMessage("Settings can only be saved from the desktop app.", "error"); + return; + } + + if (settingsSaveButton) { + settingsSaveButton.disabled = true; + } + setSettingsMessage("Saving settings..."); + try { + await invoke("save_gui_settings", { settings: collectGuiSettings() }); + settingsLoaded = false; + await loadSettingsPage({ force: true }); + await populateBroadcastNodeList(); + setSettingsMessage("Settings saved.", "success"); + } catch (error) { + setSettingsMessage(String(error), "error"); + } finally { + if (settingsSaveButton) { + settingsSaveButton.disabled = false; + } + } +} + +async function browseSettingsPath(button) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + return; + } + + const key = button.dataset.settingsBrowse; + const input = settingsFieldMap()[key]; + if (!input) { + return; + } + + try { + const selected = await invoke("select_settings_path", { + kind: button.dataset.settingsBrowseKind || "folder", + currentPath: input.value || "" + }); + if (selected) { + input.value = selected; + } + } catch (error) { + setSettingsMessage(String(error), "error"); + } +} + +function setNodeTestStatus(message, mode = "") { + if (!settingsNodeTestStatus) { + return; + } + settingsNodeTestStatus.textContent = message || ""; + settingsNodeTestStatus.classList.toggle("hidden", !message); + settingsNodeTestStatus.classList.toggle("error", mode === "error"); + settingsNodeTestStatus.classList.toggle("success", mode === "success"); +} + +async function testSelectedSettingsNode() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setNodeTestStatus("Node testing is only available in the desktop app.", "error"); + return; + } + const broadcastNode = settingsDefaultNode?.value?.trim() || activeBroadcastNode(); + if (!broadcastNode) { + setNodeTestStatus("Select a node before testing.", "error"); + return; + } + + settingsTestNodeButton && (settingsTestNodeButton.disabled = true); + setNodeTestStatus("Testing node..."); + try { + const timestamp = await invoke("test_broadcast_node_time", { broadcastNode }); + setNodeTestStatus(`Active - node time ${formatNetworkTime(timestamp)}`, "success"); + } catch (error) { + setNodeTestStatus(String(error), "error"); + } finally { + settingsTestNodeButton && (settingsTestNodeButton.disabled = false); + } +} + +function setCacheClearMessage(message, mode = "") { + if (!cacheClearMessage) { + return; + } + cacheClearMessage.textContent = message || ""; + cacheClearMessage.classList.toggle("error", mode === "error"); + cacheClearMessage.classList.toggle("success", mode === "success"); +} + +function setCacheClearModalOpen(open) { + cacheClearModal?.classList.toggle("hidden", !open); + if (open) { + setCacheClearMessage(""); + } +} + +async function clearSelectedWalletCache(kind, button) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !walletLoaded) { + setCacheClearMessage("Load a wallet before clearing local cache.", "error"); + return; + } + + button && (button.disabled = true); + setCacheClearMessage("Clearing cache..."); + try { + await invoke("clear_wallet_cache", { cacheKind: kind }); + if (kind === "history") { + clearHistoryRows(); + resetLatestDashboardTransactionsMemory(); + if (typeof resetHistoryPage === "function") { + resetHistoryPage(); + } + } else if (kind === "nft" || kind === "nft_details") { + if (typeof resetNftsPage === "function") { + resetNftsPage(); + } + if (currentView === "nfts" && typeof prepareNftsPage === "function") { + prepareNftsPage({ force: true }); + } + } else if (kind === "address_book") { + if (typeof resetAddressBookPage === "function") { + resetAddressBookPage(); + } + if (currentView === "addressbook" && typeof prepareAddressBookPage === "function") { + prepareAddressBookPage(); + } + } + setCacheClearMessage("Cache cleared.", "success"); + } catch (error) { + setCacheClearMessage(String(error), "error"); + } finally { + button && (button.disabled = false); + } +} + +function setBackupKeyMessage(message, mode = "") { + if (!backupKeyMessage) { + return; + } + backupKeyMessage.textContent = message; + backupKeyMessage.classList.toggle("error", mode === "error"); + backupKeyMessage.classList.toggle("success", mode === "success"); +} + +function closeBackupKeyModal(value = null) { + if (backupKeyModal) { + backupKeyModal.classList.add("hidden"); + } + if (backupKeyInput) { + backupKeyInput.value = ""; + } + setBackupKeyMessage(""); + if (pendingBackupKeyRequest) { + pendingBackupKeyRequest.resolve(value); + pendingBackupKeyRequest = null; + } +} + +function requestWalletBackupKey(copy, title = "Wallet Backup") { + if (!backupKeyModal) { + return Promise.resolve(null); + } + + if (pendingBackupKeyRequest) { + closeBackupKeyModal(null); + } + + backupKeyTitle && (backupKeyTitle.textContent = title); + backupKeyCopy && (backupKeyCopy.textContent = copy || "Enter the wallet encryption key."); + backupKeyInput && (backupKeyInput.value = ""); + setBackupKeyMessage(""); + backupKeyModal.classList.remove("hidden"); + setTimeout(() => backupKeyInput?.focus(), 0); + + return new Promise((resolve) => { + pendingBackupKeyRequest = { resolve }; + }); +} + +async function requestSigningWalletKey() { + if (guiRuntimeSettings.require_key_before_signing === false) { + return null; + } + + return requestWalletBackupKey( + "Enter the wallet encryption key to sign this transaction.", + "Sign Transaction" + ); +} + +function htmlEscape(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function printWalletBackup(data) { + const printFrame = document.createElement("iframe"); + printFrame.setAttribute("title", "Contractless Wallet Backup Print"); + printFrame.style.position = "fixed"; + printFrame.style.right = "0"; + printFrame.style.bottom = "0"; + printFrame.style.width = "0"; + printFrame.style.height = "0"; + printFrame.style.border = "0"; + printFrame.style.opacity = "0"; + printFrame.style.pointerEvents = "none"; + document.body.appendChild(printFrame); + + const printDocument = printFrame.contentWindow?.document; + if (!printDocument) { + printFrame.remove(); + setSettingsMessage("Unable to prepare print document.", "error"); + return; + } + + printDocument.open(); + printDocument.write(` + + + Contractless Wallet Backup + + + +

Contractless Wallet Backup

+

Keep this page private. Anyone with the private key and encryption key can control this wallet.

+

Wallet

${htmlEscape(data.wallet_name)}
+

Private Key

${htmlEscape(data.private_key)}
+

Encryption Key

${htmlEscape(data.encryption_key)}
+ +`); + printDocument.close(); + + const cleanup = () => { + setTimeout(() => printFrame.remove(), 500); + }; + printFrame.contentWindow.onafterprint = cleanup; + setTimeout(() => { + try { + printFrame.contentWindow.focus(); + printFrame.contentWindow.print(); + setSettingsMessage("Print dialog opened.", "success"); + } catch (error) { + cleanup(); + setSettingsMessage(`Unable to open print dialog: ${error}`, "error"); + } + }, 100); +} + +async function backupWalletImage() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !walletLoaded) { + setSettingsMessage("Load a wallet before backing it up.", "error"); + return; + } + + try { + setSettingsMessage("Preparing wallet image backup..."); + const savedPath = await invoke("backup_active_wallet_image"); + setSettingsMessage(savedPath ? `Wallet image saved to ${savedPath}` : "Wallet image backup cancelled.", savedPath ? "success" : ""); + } catch (error) { + setSettingsMessage(String(error), "error"); + } +} + +async function backupWalletPrivateKey() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !walletLoaded) { + setSettingsMessage("Load a wallet before backing it up.", "error"); + return; + } + + const walletKey = await requestWalletBackupKey("Enter the wallet encryption key to decrypt the private key for backup."); + if (!walletKey) { + setSettingsMessage("Private key backup cancelled."); + return; + } + + try { + setSettingsMessage("Decrypting private key for backup..."); + const savedPath = await invoke("backup_active_wallet_private_key", { walletKey }); + setSettingsMessage(savedPath ? `Private key saved to ${savedPath}` : "Private key backup cancelled.", savedPath ? "success" : ""); + } catch (error) { + setSettingsMessage(String(error), "error"); + } +} + +async function printWalletPrivateKeyBackup() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !walletLoaded) { + setSettingsMessage("Load a wallet before printing backup details.", "error"); + return; + } + + const walletKey = await requestWalletBackupKey("Enter the wallet encryption key to print the private key and encryption key."); + if (!walletKey) { + setSettingsMessage("Print backup cancelled."); + return; + } + + try { + setSettingsMessage("Decrypting wallet backup for printing..."); + const data = await invoke("printable_wallet_backup", { walletKey }); + printWalletBackup(data); + } catch (error) { + setSettingsMessage(String(error), "error"); + } +} + +settingsSaveButton?.addEventListener("click", saveSettingsPage); +settingsTestNodeButton?.addEventListener("click", testSelectedSettingsNode); +settingsCacheOpenButton?.addEventListener("click", () => setCacheClearModalOpen(true)); +cacheClearCloseButton?.addEventListener("click", () => setCacheClearModalOpen(false)); +cacheClearModal?.addEventListener("click", (event) => { + if (event.target === cacheClearModal) { + setCacheClearModalOpen(false); + } +}); +document.querySelectorAll("[data-cache-clear-kind]").forEach((button) => { + button.addEventListener("click", () => clearSelectedWalletCache(button.dataset.cacheClearKind, button)); +}); +settingsBrowseButtons.forEach((button) => { + button.addEventListener("click", () => browseSettingsPath(button)); +}); + +backupKeyForm?.addEventListener("submit", (event) => { + event.preventDefault(); + const walletKey = backupKeyInput?.value || ""; + if (!walletKey.trim()) { + setBackupKeyMessage("Wallet encryption key cannot be empty.", "error"); + backupKeyInput?.focus(); + return; + } + closeBackupKeyModal(walletKey); +}); +backupKeyCloseButton?.addEventListener("click", () => closeBackupKeyModal(null)); +backupKeyCancelButton?.addEventListener("click", () => closeBackupKeyModal(null)); +backupKeyModal?.addEventListener("click", (event) => { + if (event.target === backupKeyModal) { + closeBackupKeyModal(null); + } +}); + +document.querySelector("[data-backup-wallet-image]")?.addEventListener("click", backupWalletImage); +document.querySelector("[data-backup-private-key]")?.addEventListener("click", backupWalletPrivateKey); +document.querySelector("[data-print-private-key]")?.addEventListener("click", printWalletPrivateKeyBackup); + +["click", "keydown", "mousemove", "pointerdown", "touchstart"].forEach((eventName) => { + document.addEventListener(eventName, resetAutoLockOnActivity, { passive: true }); +}); + +loadRuntimeGuiSettings(); diff --git a/frontend/js/startup_forms.js b/frontend/js/startup_forms.js new file mode 100644 index 0000000..376f72a --- /dev/null +++ b/frontend/js/startup_forms.js @@ -0,0 +1,384 @@ +async function loadTauriStatus() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + return; + } + + try { + const status = await invoke("get_app_status"); + if (bridgeStatus) { + setBridgeStatus(`${status.network === "mainnet" ? "Mainnet" : "Testnet"} disconnected`, "error"); + } + } catch (error) { + setBridgeStatus(`${activeNetwork === "mainnet" ? "Mainnet" : "Testnet"} unavailable`, "error"); + console.error(error); + } +} + +loadTauriStatus(); + +networkButtons.forEach((button) => { + button.addEventListener("click", () => { + setActiveNetwork(button.dataset.networkToggle); + if (!loadWalletForm?.classList.contains("hidden")) { + refreshWalletList(); + } + }); +}); + +document.querySelectorAll("[data-start-mode]").forEach((button) => { + button.addEventListener("click", () => { + const mode = button.dataset.startMode; + setActiveStartMode(mode); + if (mode === "load") { + createWalletForm?.classList.add("hidden"); + importKeyForm?.classList.add("hidden"); + importImageForm?.classList.add("hidden"); + loadWalletForm?.classList.remove("hidden"); + setStartupMessage(""); + setImportMessage(""); + setImportImageMessage(""); + loadWalletKeyInput?.focus(); + refreshWalletList(); + return; + } + + if (mode === "import-key") { + createWalletForm?.classList.add("hidden"); + loadWalletForm?.classList.add("hidden"); + importImageForm?.classList.add("hidden"); + importKeyForm?.classList.remove("hidden"); + setStartupMessage(""); + setLoadMessage(""); + setImportImageMessage(""); + setImportMessage("Paste the private key and choose a new encrypted wallet file name."); + importWalletNameInput?.focus(); + return; + } + + if (mode === "import-image") { + createWalletForm?.classList.add("hidden"); + loadWalletForm?.classList.add("hidden"); + importKeyForm?.classList.add("hidden"); + importImageForm?.classList.remove("hidden"); + setStartupMessage(""); + setLoadMessage(""); + setImportMessage(""); + setImportImageMessage("Select the private key PNG image and choose a new encrypted wallet file name."); + importImageWalletNameInput?.focus(); + return; + } + + if (mode !== "create") { + createWalletForm?.classList.add("hidden"); + loadWalletForm?.classList.add("hidden"); + importKeyForm?.classList.add("hidden"); + importImageForm?.classList.add("hidden"); + setStartupMessage("This wallet action is not wired yet.", "error"); + return; + } + + loadWalletForm?.classList.add("hidden"); + importKeyForm?.classList.add("hidden"); + importImageForm?.classList.add("hidden"); + createWalletForm?.classList.remove("hidden"); + walletNameInput?.focus(); + setLoadMessage(""); + setImportMessage(""); + setImportImageMessage(""); + setStartupMessage("Name this wallet and choose the key that will encrypt it."); + }); +}); + +document.querySelectorAll("[data-cancel-startup-form]").forEach((button) => { + button.addEventListener("click", () => { + createWalletForm?.classList.add("hidden"); + loadWalletForm?.classList.add("hidden"); + importKeyForm?.classList.add("hidden"); + importImageForm?.classList.add("hidden"); + setStartupMessage(""); + setLoadMessage(""); + setImportMessage(""); + setImportImageMessage(""); + }); +}); + +createWalletForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + + const walletName = walletNameInput?.value || ""; + const walletKey = walletKeyInput?.value || ""; + const walletKeyConfirm = walletKeyConfirmInput?.value || ""; + if (!walletName.trim()) { + setStartupMessage("Wallet name cannot be empty.", "error"); + walletNameInput?.focus(); + return; + } + if (!walletKey.trim()) { + setStartupMessage("Wallet key cannot be empty.", "error"); + walletKeyInput?.focus(); + return; + } + if (walletKey !== walletKeyConfirm) { + setStartupMessage("Wallet keys do not match.", "error"); + walletKeyConfirmInput?.focus(); + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setStartupMessage("Wallet creation requires the desktop app build.", "error"); + return; + } + + if (createWalletSubmit) { + createWalletSubmit.disabled = true; + } + setStartupMessage("Creating encrypted wallet..."); + + try { + const wallet = await invoke("create_wallet", { + network: activeNetwork, + walletName, + walletKey + }); + updateActiveWallet(wallet); + walletNameInput.value = ""; + walletKeyInput.value = ""; + walletKeyConfirmInput.value = ""; + setStartupMessage(`Wallet saved to ${wallet.wallet_path}`, "success"); + document.body.classList.add("wallet-open"); + } catch (error) { + setStartupMessage(String(error), "error"); + } finally { + if (createWalletSubmit) { + createWalletSubmit.disabled = false; + } + } +}); + +document.querySelector("[data-refresh-wallet-list]")?.addEventListener("click", () => { + if (loadWalletKeyInput) { + loadWalletKeyInput.value = ""; + } + refreshWalletList(); +}); + +browseWalletButton?.addEventListener("click", async () => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setLoadMessage("Wallet browsing requires the desktop app build.", "error"); + return; + } + + setLoadMessage("Opening wallet file picker..."); + try { + const wallet = await invoke("select_wallet_file", { network: activeNetwork }); + if (!wallet) { + setLoadMessage("Wallet selection cancelled."); + return; + } + + selectWalletOption(wallet); + setLoadMessage(`Selected ${wallet.wallet_name}.`); + loadWalletKeyInput?.focus(); + } catch (error) { + setLoadMessage(String(error), "error"); + } +}); + +browsePrivateKeyImageButton?.addEventListener("click", async () => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setImportImageMessage("Private key image browsing requires the desktop app build.", "error"); + return; + } + + setImportImageMessage("Opening private key image picker..."); + try { + const selected = await invoke("select_private_key_image", { network: activeNetwork }); + if (!selected) { + setImportImageMessage("Private key image selection cancelled."); + return; + } + + importImagePathInput.value = selected.file_path; + setImportImageMessage(`Selected ${selected.file_name}.`); + importImageWalletKeyInput?.focus(); + } catch (error) { + setImportImageMessage(String(error), "error"); + } +}); + +loadWalletForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + + const walletPath = walletListSelect?.value || ""; + const walletKey = loadWalletKeyInput?.value || ""; + if (!walletPath.trim()) { + setLoadMessage("Choose a saved wallet or browse for a wallet file.", "error"); + walletListSelect?.focus(); + return; + } + if (!walletKey.trim()) { + setLoadMessage("Wallet key cannot be empty.", "error"); + loadWalletKeyInput?.focus(); + return; + } + + if (loadWalletSubmit) { + loadWalletSubmit.disabled = true; + } + setLoadMessage("Loading wallet..."); + + try { + const wallet = await loadWalletByPath(walletPath, walletKey); + updateActiveWallet(wallet); + loadWalletKeyInput.value = ""; + setLoadMessage(`Wallet loaded from ${wallet.wallet_path}`, "success"); + document.body.classList.add("wallet-open"); + } catch (error) { + setLoadMessage(String(error), "error"); + } finally { + if (loadWalletSubmit) { + loadWalletSubmit.disabled = false; + } + } +}); + +importKeyForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + + const walletName = importWalletNameInput?.value || ""; + const privateKey = importPrivateKeyInput?.value || ""; + const walletKey = importWalletKeyInput?.value || ""; + const walletKeyConfirm = importWalletKeyConfirmInput?.value || ""; + + if (!walletName.trim()) { + setImportMessage("Wallet name cannot be empty.", "error"); + importWalletNameInput?.focus(); + return; + } + if (!privateKey.trim()) { + setImportMessage("Private key cannot be empty.", "error"); + importPrivateKeyInput?.focus(); + return; + } + if (!walletKey.trim()) { + setImportMessage("Wallet key cannot be empty.", "error"); + importWalletKeyInput?.focus(); + return; + } + if (walletKey !== walletKeyConfirm) { + setImportMessage("Wallet keys do not match.", "error"); + importWalletKeyConfirmInput?.focus(); + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setImportMessage("Private key import requires the desktop app build.", "error"); + return; + } + + if (importKeySubmit) { + importKeySubmit.disabled = true; + } + setImportMessage("Importing encrypted wallet..."); + + try { + const wallet = await invoke("import_private_key_wallet", { + network: activeNetwork, + walletName, + walletKey, + privateKey + }); + updateActiveWallet(wallet); + importWalletNameInput.value = ""; + importPrivateKeyInput.value = ""; + importWalletKeyInput.value = ""; + importWalletKeyConfirmInput.value = ""; + setImportMessage(`Wallet saved to ${wallet.wallet_path}`, "success"); + document.body.classList.add("wallet-open"); + } catch (error) { + setImportMessage(String(error), "error"); + } finally { + if (importKeySubmit) { + importKeySubmit.disabled = false; + } + } +}); + +importImageForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + + const walletName = importImageWalletNameInput?.value || ""; + const imagePath = importImagePathInput?.value || ""; + const walletKey = importImageWalletKeyInput?.value || ""; + const walletKeyConfirm = importImageWalletKeyConfirmInput?.value || ""; + + if (!walletName.trim()) { + setImportImageMessage("Wallet name cannot be empty.", "error"); + importImageWalletNameInput?.focus(); + return; + } + if (!imagePath.trim()) { + setImportImageMessage("Select a private key PNG image.", "error"); + browsePrivateKeyImageButton?.focus(); + return; + } + if (!walletKey.trim()) { + setImportImageMessage("Wallet key cannot be empty.", "error"); + importImageWalletKeyInput?.focus(); + return; + } + if (walletKey !== walletKeyConfirm) { + setImportImageMessage("Wallet keys do not match.", "error"); + importImageWalletKeyConfirmInput?.focus(); + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setImportImageMessage("Private key image import requires the desktop app build.", "error"); + return; + } + + if (importImageSubmit) { + importImageSubmit.disabled = true; + } + setImportImageMessage("Importing encrypted wallet from image..."); + + try { + const wallet = await invoke("import_private_key_image_wallet", { + network: activeNetwork, + walletName, + walletKey, + imagePath + }); + updateActiveWallet(wallet); + importImageWalletNameInput.value = ""; + importImagePathInput.value = ""; + importImageWalletKeyInput.value = ""; + importImageWalletKeyConfirmInput.value = ""; + setImportImageMessage(`Wallet saved to ${wallet.wallet_path}`, "success"); + document.body.classList.add("wallet-open"); + } catch (error) { + setImportImageMessage(String(error), "error"); + } finally { + if (importImageSubmit) { + importImageSubmit.disabled = false; + } + } +}); + +lockWalletButton?.addEventListener("click", async () => { + const invoke = window.__TAURI__?.core?.invoke; + try { + await invoke?.("lock_wallet"); + } finally { + lockWalletSession(); + } +}); + +registrationAction?.addEventListener("click", registerActiveWallet); diff --git a/frontend/js/swap_offer_form.js b/frontend/js/swap_offer_form.js new file mode 100644 index 0000000..f0a5f49 --- /dev/null +++ b/frontend/js/swap_offer_form.js @@ -0,0 +1,403 @@ +const SWAP_FEE_ATOMIC = 100000000n; +let swapOfferLoadPromise = null; +let swapNetworkAssets = []; +let swapNftKeys = new Set(); +let swapOfferAssetsKey = ""; + +function setSwapOfferMessage(message, type = "") { + setScopedTransactionMessage("Swap", swapOfferMessage, message, type); +} + +function swapAssetKey(asset) { + return `${asset.kind}|${asset.asset}|${Number(asset.nftSeries || 0)}`; +} + +function swapAssetLabel(asset, includeBalance = false) { + const series = Number(asset.nftSeries || 0); + const name = asset.kind === "nft" && series > 0 + ? `${asset.asset} #${series}` + : asset.asset; + if (!includeBalance) { + return asset.kind === "nft" ? `${name} (NFT)` : name; + } + if (asset.kind === "nft") { + return `${name} (1 item)`; + } + return `${name} (${formatAtomicBalance(String(asset.balance || "0"))})`; +} + +function selectedSwapAsset(select) { + const option = select?.selectedOptions?.[0]; + if (!option?.value) { + return null; + } + return { + asset: option.dataset.asset || "", + nftSeries: Number(option.dataset.nftSeries || 0), + kind: option.dataset.kind || "token", + balance: BigInt(option.dataset.balance || "0") + }; +} + +function swapMinimumTip(value, asset) { + if (asset?.kind === "nft") { + return 0n; + } + return (value + 99n) / 100n; +} + +function populateSwapAssetSelect(select, assets, includeBalance) { + if (!select) { + return; + } + const previous = select.value; + select.innerHTML = ""; + assets.forEach((asset) => { + const option = document.createElement("option"); + option.value = swapAssetKey(asset); + option.textContent = swapAssetLabel(asset, includeBalance); + option.dataset.asset = asset.asset; + option.dataset.nftSeries = String(Number(asset.nftSeries || 0)); + option.dataset.kind = asset.kind; + option.dataset.balance = String(asset.balance || "0"); + select.appendChild(option); + }); + if (!assets.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = walletLoaded ? "No assets available" : "Load a wallet first"; + select.appendChild(option); + } else if ([...select.options].some((option) => option.value === previous)) { + select.value = previous; + } +} + +function swapRegistryNftKeys(nfts) { + return new Set(nfts.map((nft) => { + return `${String(nft.nft_name || "").trim().toLowerCase()}|${Number(nft.series || 0)}`; + })); +} + +function refreshSwapOfferSelects() { + const offered = activeWalletBalances + .filter((balance) => String(balance.asset || "").trim() && balanceAtomicValue(balance) > 0n) + .map((balance) => { + const asset = String(balance.asset || "").trim(); + const nftSeries = Number(balance.nft_series || 0); + const isNft = swapNftKeys.has(`${asset.toLowerCase()}|${nftSeries}`); + return { + asset, + nftSeries, + kind: isNft ? "nft" : (isBaseTransferAsset(asset) ? "coin" : "token"), + balance: String(balance.balance || "0") + }; + }); + offered.sort((left, right) => swapAssetLabel(left).localeCompare(swapAssetLabel(right))); + populateSwapAssetSelect(swapOfferAssetSelect, offered, true); + populateSwapAssetSelect(swapRequestAssetSelect, swapNetworkAssets, false); + updateSwapOfferDerivedFields(); +} + +async function loadSwapOfferAssets() { + if (swapOfferLoadPromise) { + return swapOfferLoadPromise; + } + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) { + return; + } + const assetsKey = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`; + if (swapOfferAssetsKey === assetsKey && swapNetworkAssets.length) { + refreshSwapOfferSelects(); + return; + } + + swapOfferLoadPromise = (async () => { + setSwapOfferMessage("Loading swap assets..."); + const [balances, tokens, nfts] = await Promise.all([ + activeWalletBalances.length + ? Promise.resolve(activeWalletBalances) + : invoke("get_active_wallet_balances", { broadcastNode }), + invoke("token_list", { broadcastNode }), + invoke("nft_list", { broadcastNode }) + ]); + activeWalletBalances = Array.isArray(balances) ? balances : []; + const tokenRows = Array.isArray(tokens) ? tokens : []; + const nftRows = Array.isArray(nfts) ? nfts : []; + swapNftKeys = swapRegistryNftKeys(nftRows); + + swapNetworkAssets = [ + { asset: activeBaseCoin(), nftSeries: 0, kind: "coin", balance: "0" }, + ...tokenRows.map((token) => ({ + asset: String(token.ticker || "").trim(), + nftSeries: 0, + kind: "token", + balance: "0" + })), + ...nftRows.map((nft) => ({ + asset: String(nft.nft_name || "").trim(), + nftSeries: Number(nft.series || 0), + kind: "nft", + balance: "0" + })) + ]; + + swapNetworkAssets.sort((left, right) => swapAssetLabel(left).localeCompare(swapAssetLabel(right))); + swapOfferAssetsKey = assetsKey; + refreshSwapOfferSelects(); + setSwapOfferMessage(""); + })().catch((error) => { + setSwapOfferMessage(String(error), "error"); + throw error; + }).finally(() => { + swapOfferLoadPromise = null; + }); + + return swapOfferLoadPromise; +} + +function updateSwapSide(asset, amountInput, tipInput) { + if (!asset) { + amountInput.disabled = false; + tipInput.disabled = false; + tipInput.value = ""; + return; + } + if (asset.kind === "nft") { + amountInput.value = "1"; + amountInput.disabled = true; + tipInput.value = "0"; + tipInput.disabled = true; + return; + } + amountInput.disabled = false; + tipInput.disabled = false; + const amount = decimalToAtomic(amountInput.value); + if (!amount || amount <= 0n) { + if (!tipInput.value) { + tipInput.value = ""; + } + return; + } + const minimum = swapMinimumTip(amount, asset); + const current = decimalToAtomic(tipInput.value); + if (current === null || current < minimum) { + tipInput.value = atomicToDecimal(minimum); + } +} + +function updateSwapOfferDerivedFields() { + const offered = selectedSwapAsset(swapOfferAssetSelect); + const requested = selectedSwapAsset(swapRequestAssetSelect); + updateSwapSide(offered, swapOfferAmountInput, swapOfferTipInput); + updateSwapSide(requested, swapRequestAmountInput, swapRequestTipInput); + if (swapSender) { + swapSender.textContent = activeWalletAddressShort || activeWalletAddressHex || "--"; + } + if (swapAvailable) { + swapAvailable.textContent = offered + ? (offered.kind === "nft" + ? "1 NFT" + : `${formatAtomicBalance(String(offered.balance))} ${offered.asset}`) + : "--"; + } + if (swapFees) { + swapFees.textContent = `${atomicToDecimal(SWAP_FEE_ATOMIC)} ${activeBaseCoin()} each`; + } +} + +function validateSwapSide(asset, amountInput, tipInput, label) { + if (!asset) { + return `Select the asset ${label}.`; + } + const amount = asset.kind === "nft" ? 1n : decimalToAtomic(amountInput.value); + if (amount === null || amount <= 0n) { + return `Enter a valid amount ${label}.`; + } + const tip = decimalToAtomic(tipInput.value || "0"); + if (tip === null) { + return `Enter a valid miner tip ${label}.`; + } + if (asset.kind === "nft" && tip !== 0n) { + return `NFT miner tips must be zero ${label}.`; + } + if (asset.kind !== "nft" && tip < swapMinimumTip(amount, asset)) { + return `Miner tip must be at least 1% of the amount ${label}.`; + } + return ""; +} + +function validateSwapOfferForm() { + if (!walletLoaded) { + return "Load a wallet before creating a swap offer."; + } + if (!registrationRegistered) { + return "Address registration is required before creating a swap offer."; + } + const offered = selectedSwapAsset(swapOfferAssetSelect); + const requested = selectedSwapAsset(swapRequestAssetSelect); + let error = validateSwapSide(offered, swapOfferAmountInput, swapOfferTipInput, "you offer"); + if (error) { + return error; + } + error = validateSwapSide(requested, swapRequestAmountInput, swapRequestTipInput, "you request"); + if (error) { + return error; + } + const counterparty = String(swapCounterpartyInput?.value || "").trim(); + if (!counterparty) { + return "Enter the counterparty wallet address."; + } + if ( + counterparty.toLowerCase() === String(activeWalletAddressShort || "").toLowerCase() || + counterparty.toLowerCase() === String(activeWalletAddressHex || "").toLowerCase() + ) { + return "Counterparty cannot be the active wallet address."; + } + const expiration = Number.parseInt(swapExpirationInput?.value || "0", 10); + if (!Number.isInteger(expiration) || expiration < 1 || expiration > 720) { + return "Offer expiration must be between 1 and 720 hours."; + } + + const amount = offered.kind === "nft" ? 1n : decimalToAtomic(swapOfferAmountInput.value); + const tip = offered.kind === "nft" ? 0n : decimalToAtomic(swapOfferTipInput.value); + if (amount + tip > offered.balance) { + return "Offered amount plus miner tip exceeds the available asset balance."; + } + const baseBalance = baseCoinBalanceAtomic(); + if (isBaseTransferAsset(offered.asset)) { + if (amount + tip + SWAP_FEE_ATOMIC > offered.balance) { + return `The offered amount, tip, and ${atomicToDecimal(SWAP_FEE_ATOMIC)} ${activeBaseCoin()} fee exceed the available balance.`; + } + } else if (baseBalance < SWAP_FEE_ATOMIC) { + return `Creating this offer requires ${atomicToDecimal(SWAP_FEE_ATOMIC)} ${activeBaseCoin()} for your fee.`; + } + return ""; +} + +function swapOfferPayload() { + const offered = selectedSwapAsset(swapOfferAssetSelect); + const requested = selectedSwapAsset(swapRequestAssetSelect); + const value1 = offered.kind === "nft" ? 1n : decimalToAtomic(swapOfferAmountInput.value); + const value2 = requested.kind === "nft" ? 1n : decimalToAtomic(swapRequestAmountInput.value); + const tip1 = offered.kind === "nft" ? 0n : decimalToAtomic(swapOfferTipInput.value); + const tip2 = requested.kind === "nft" ? 0n : decimalToAtomic(swapRequestTipInput.value); + return { + ticker1: offered.asset, + nftSeries1: offered.nftSeries, + value1: String(value1), + ticker2: requested.asset, + nftSeries2: requested.nftSeries, + value2: String(value2), + sender2: String(swapCounterpartyInput.value || "").trim(), + tip1: String(tip1), + tip2: String(tip2), + expirationHours: Number.parseInt(swapExpirationInput.value, 10) + }; +} + +function clearSwapOfferForm() { + swapOfferAmountInput.value = ""; + swapOfferTipInput.value = ""; + swapRequestAmountInput.value = ""; + swapRequestTipInput.value = ""; + swapCounterpartyInput.value = ""; + swapExpirationInput.value = "72"; + updateSwapOfferDerivedFields(); +} + +function setSavedSwapOfferPath(path = "") { + if (swapSavedPathInput) { + swapSavedPathInput.value = path; + } + swapSavedPathPanel?.classList.toggle("hidden", !path); +} + +async function saveSwapOffer() { + const validationError = validateSwapOfferForm(); + if (validationError) { + setSwapOfferMessage(validationError, "error"); + return; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setSwapOfferMessage("Wallet bridge is not available.", "error"); + return; + } + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setSwapOfferMessage("Signing cancelled."); + return; + } + + const button = document.querySelector("[data-swap-offer-save]"); + button.disabled = true; + setSavedSwapOfferPath(""); + setSwapOfferMessage("Signing and saving swap offer..."); + try { + const result = await invoke("save_swap_offer", { + ...swapOfferPayload(), + broadcastNode: activeBroadcastNode(), + walletKey + }); + clearSwapOfferForm(); + setSavedSwapOfferPath(result.file_path || ""); + setSwapOfferMessage( + `Swap offer signed and saved as ${result.review?.file_name || "a local transaction file"}. Send this file to the counterparty.`, + "success" + ); + if (typeof refreshUnbroadcastTransactions === "function") { + refreshUnbroadcastTransactions(); + } + } catch (error) { + setSwapOfferMessage(String(error), "error"); + } finally { + button.disabled = false; + } +} + +function prepareSwapOfferForm() { + setSwapOfferMessage(""); + loadSwapOfferAssets().catch(() => { + // The form already displays the lookup error. + }); +} + +function resetSwapOfferSession() { + swapOfferLoadPromise = null; + swapNetworkAssets = []; + swapNftKeys = new Set(); + swapOfferAssetsKey = ""; + clearSwapOfferForm(); + setSavedSwapOfferPath(""); + if (swapOfferAssetSelect) { + swapOfferAssetSelect.innerHTML = ""; + } + if (swapRequestAssetSelect) { + swapRequestAssetSelect.innerHTML = ""; + } + setSwapOfferMessage(""); +} + +swapOfferAssetSelect?.addEventListener("change", updateSwapOfferDerivedFields); +swapRequestAssetSelect?.addEventListener("change", updateSwapOfferDerivedFields); +swapOfferAmountInput?.addEventListener("input", updateSwapOfferDerivedFields); +swapRequestAmountInput?.addEventListener("input", updateSwapOfferDerivedFields); +swapOfferTipInput?.addEventListener("input", () => setSwapOfferMessage("")); +swapRequestTipInput?.addEventListener("input", () => setSwapOfferMessage("")); +swapCounterpartyInput?.addEventListener("input", () => setSwapOfferMessage("")); +swapExpirationInput?.addEventListener("input", () => setSwapOfferMessage("")); +document.querySelector("[data-swap-offer-save]")?.addEventListener("click", saveSwapOffer); +swapCopyPathButton?.addEventListener("click", async () => { + const path = String(swapSavedPathInput?.value || ""); + if (!path || !(await writeClipboardText(path))) { + setSwapOfferMessage("Unable to copy the saved offer path.", "error"); + return; + } + const original = swapCopyPathButton.textContent; + swapCopyPathButton.textContent = "Copied"; + setTimeout(() => { + swapCopyPathButton.textContent = original; + }, 1200); +}); diff --git a/frontend/js/transaction_history.js b/frontend/js/transaction_history.js new file mode 100644 index 0000000..2a6c9a6 --- /dev/null +++ b/frontend/js/transaction_history.js @@ -0,0 +1,1527 @@ +function decodedTransactionType(transaction) { + if (!transaction || typeof transaction !== "object" || Array.isArray(transaction)) { + return 0; + } + return Number( + transaction.unsigned?.txtype ?? + transaction.unsigned_transfer?.txtype ?? + transaction.unsigned_create_token?.txtype ?? + transaction.unsigned_create_nft?.txtype ?? + transaction.unsigned_marketing?.txtype ?? + transaction.unsigned_swap?.txtype ?? + transaction.unsigned_loan_contract?.txtype ?? + transaction.unsigned_contract_payment?.txtype ?? + transaction.unsigned_collateral_claim?.txtype ?? + transaction.unsigned_burn?.txtype ?? + transaction.unsigned_issue_token?.txtype ?? + transaction.unsigned_vanity_address?.txtype ?? + 0 + ); +} + +function txTypeKey(type) { + return { + 0: "genesis", + 1: "reward", + 2: "transfer", + 3: "token_create", + 4: "nft_create", + 5: "marketing", + 6: "swap", + 7: "loan_create", + 8: "loan_payment", + 9: "collateral_claim", + 10: "burn", + 11: "token_issue", + 12: "vanity" + }[Number(type)] || "transaction"; +} + +function minerFeeDisplay(minerEarnings) { + const fee = (Array.isArray(minerEarnings) ? minerEarnings : []) + .filter((earning) => Number(earning?.earning_type) === 0) + .reduce((total, earning) => total + BigInt(earning?.amount || 0), 0n); + return signedBaseFee("+", fee); +} + +function minerTipRows(base, minerEarnings) { + return (Array.isArray(minerEarnings) ? minerEarnings : []) + .map((earning) => { + const amount = earning?.amount ?? 0; + const asset = assetWithSeries(earning?.asset || activeBaseCoin(), Number(earning?.nft_series || 0)); + if (Number(earning?.earning_type) === 1) { + return dashboardRow(base, "Miner Tip", asset, "Network", signedAmount("+", amount), "--"); + } + return null; + }) + .filter((row) => row && row.amount !== "+0"); +} + +function loanSummaryFromLinkedTransactions(linkedTransactions = {}, contractHash = "") { + const direct = linkedTransactions?.[contractHash]; + const directLoan = parseLoanContractSummary(direct?.transaction || direct); + if (directLoan) { + return directLoan; + } + + for (const linked of Object.values(linkedTransactions || {})) { + const loan = parseLoanContractSummary(linked?.transaction || linked); + if (loan) { + return loan; + } + } + + return null; +} + +function parseDecodedDashboardTransactionRows( + txid, + blockHeight, + transaction, + linkedTransactions = {}, + minerEarnings = [] +) { + const type = decodedTransactionType(transaction); + const base = { + txid, + block_height: blockHeight || null, + status: walletStatusForBlock(blockHeight) + }; + const fallback = [dashboardRow(base, txTypeName(type), activeBaseCoin(), shortenAddress(txid), "")]; + + try { + if (type === 1) { + const value = transaction.unsigned?.value || 0; + return [ + dashboardRow( + base, + "Mining Reward", + activeBaseCoin(), + "Network", + signedAmount("+", value), + minerFeeDisplay(minerEarnings) + ), + ...minerTipRows(base, minerEarnings) + ]; + } + + if (type === 2) { + const tx = transaction.unsigned_transfer; + const asset = assetWithSeries(tx.coin, tx.nft_series); + if (isActiveWalletAddress(tx.sender)) { + return [dashboardRow(base, "Transfer Out", asset, addressLabel(tx.receiver), signedAmount("-", tx.value), signedBaseFee("-", tx.txfee || 0))]; + } + if (isActiveWalletAddress(tx.receiver)) { + return [dashboardRow(base, "Transfer In", asset, addressLabel(tx.sender), signedAmount("+", tx.value))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.sender), signedAmount("+", tx.txfee || 0))]; + } + + if (type === 3) { + const tx = transaction.unsigned_create_token; + if (isActiveWalletAddress(tx.creator)) { + return [dashboardRow(base, "Token Created", tx.ticker || "Token", "Self", signedAmount("+", tx.number), signedBaseFee("-", tx.txfee || 0))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.creator), signedAmount("+", tx.txfee || 0))]; + } + + if (type === 4) { + const tx = transaction.unsigned_create_nft; + if (isActiveWalletAddress(tx.creator)) { + return [dashboardRow(base, "NFT Created", assetWithSeries(tx.nft_name || "NFT", tx.series), "Self", `+${tx.count || 0}`, signedBaseFee("-", tx.txfee || 0))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.creator), signedAmount("+", tx.txfee || 0))]; + } + + if (type === 5) { + const tx = transaction.unsigned_marketing; + if (isActiveWalletAddress(tx.advertiser)) { + return [dashboardRow(base, "Marketing", "Advertisement", "Network", "", signedBaseFee("-", tx.txfee || 0))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.advertiser), signedAmount("+", tx.txfee || 0))]; + } + + if (type === 6) { + const tx = transaction.unsigned_swap; + const asset1 = assetWithSeries(tx.ticker1, tx.nft_series1); + const asset2 = assetWithSeries(tx.ticker2, tx.nft_series2); + if (isActiveWalletAddress(tx.sender1)) { + return [ + dashboardRow(base, "Swap Out", asset1, addressLabel(tx.sender2), signedAmount("-", tx.value1), signedBaseFee("-", tx.txfee1 || 0)), + dashboardRow(base, "Miner Tip Paid", asset1, addressLabel(tx.sender2), signedAmount("-", tx.tip1 || 0), "--"), + dashboardRow(base, "Swap In", asset2, addressLabel(tx.sender2), signedAmount("+", tx.value2)) + ].filter((row) => row.amount !== "0" && row.amount !== "-0"); + } + if (isActiveWalletAddress(tx.sender2)) { + return [ + dashboardRow(base, "Swap Out", asset2, addressLabel(tx.sender1), signedAmount("-", tx.value2), signedBaseFee("-", tx.txfee2 || 0)), + dashboardRow(base, "Miner Tip Paid", asset2, addressLabel(tx.sender1), signedAmount("-", tx.tip2 || 0), "--"), + dashboardRow(base, "Swap In", asset1, addressLabel(tx.sender1), signedAmount("+", tx.value1)) + ].filter((row) => row.amount !== "0" && row.amount !== "-0"); + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), "Swap", signedAmount("+", (tx.txfee1 || 0) + (tx.txfee2 || 0)))]; + } + + if (type === 7) { + const tx = transaction.unsigned_loan_contract; + if (isActiveWalletAddress(tx.lender)) { + return [dashboardRow(base, "Loan Created", tx.loan_coin, addressLabel(tx.borrower), signedAmount("-", tx.loan_amount), signedBaseFee("-", tx.txfee || 0))]; + } + if (isActiveWalletAddress(tx.borrower)) { + return [ + dashboardRow(base, "Loan Received", tx.loan_coin, addressLabel(tx.lender), signedAmount("+", tx.loan_amount)), + dashboardRow(base, "Collateral Locked", tx.collateral, addressLabel(tx.lender), signedAmount("-", tx.collateral_amount)) + ]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.lender), signedAmount("+", tx.txfee || 0))]; + } + + if (type === 8) { + const tx = transaction.unsigned_contract_payment; + const loan = loanSummaryFromLinkedTransactions(linkedTransactions, tx.contract_hash); + if (isActiveWalletAddress(tx.address)) { + const asset = loan?.loanCoin || "Loan"; + const counterparty = loan?.lender ? addressLabel(loan.lender) : shortenAddress(tx.contract_hash); + return [ + dashboardRow(base, "Loan Payment Sent", asset, counterparty, signedAmount("-", tx.payback_amount), signedBaseFee("-", tx.txfee || 0)), + dashboardRow(base, "Miner Tip Paid", asset, counterparty, signedAmount("-", tx.tip || 0), "--") + ].filter((row) => row.amount !== "0" && row.amount !== "-0"); + } + if (loan?.lender && isActiveWalletAddress(loan.lender)) { + return [dashboardRow(base, "Loan Payment Received", loan.loanCoin, addressLabel(tx.address), signedAmount("+", tx.payback_amount))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.address), signedAmount("+", tx.txfee || 0))]; + } + + if (type === 9) { + const tx = transaction.unsigned_collateral_claim; + if (isActiveWalletAddress(tx.address)) { + return [dashboardRow(base, "Collateral Claimed", "Collateral", shortenAddress(tx.contract_hash), "", signedBaseFee("-", tx.txfee || 0))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.address), signedAmount("+", tx.txfee || 0))]; + } + + if (type === 10) { + const tx = transaction.unsigned_burn; + if (isActiveWalletAddress(tx.address)) { + return [dashboardRow(base, "Burn", assetWithSeries(tx.coin, tx.nft_series), "Network", signedAmount("-", tx.value), signedBaseFee("-", tx.txfee || 0))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.address), signedAmount("+", tx.txfee || 0))]; + } + + if (type === 11) { + const tx = transaction.unsigned_issue_token; + if (isActiveWalletAddress(tx.creator)) { + return [dashboardRow(base, "Token Issued", tx.ticker || "Token", "Self", signedAmount("+", tx.number), signedBaseFee("-", tx.txfee || 0))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.creator), signedAmount("+", tx.txfee || 0))]; + } + + if (type === 12) { + const tx = transaction.unsigned_vanity_address; + if (isActiveWalletAddress(tx.address)) { + return [dashboardRow(base, "Vanity Registered", "Vanity", "Self", "", signedBaseFee("-", tx.txfee || tx.fee || 0))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.address), signedAmount("+", tx.txfee || tx.fee || 0))]; + } + } catch (_) { + return fallback; + } + + return fallback; +} + +function parseDashboardTransactionRows( + txid, + blockHeight, + transaction, + linkedTransactions = {}, + minerEarnings = [] +) { + if (transaction && typeof transaction === "object" && !Array.isArray(transaction)) { + return parseDecodedDashboardTransactionRows( + txid, + blockHeight, + transaction, + linkedTransactions, + minerEarnings + ); + } + + const bytes = Array.from(transaction || []); + const type = Number(bytes[0] || 0); + const base = { + txid, + block_height: blockHeight, + status: statusForBlock(blockHeight) + }; + const fallback = [dashboardRow(base, txTypeName(type), activeBaseCoin(), shortenAddress(txid), "")]; + + try { + if (type === 1) { + const value = readU64LE(bytes, 5); + return [ + dashboardRow( + base, + "Mining Reward", + activeBaseCoin(), + "Network", + signedAmount("+", value), + minerFeeDisplay(minerEarnings) + ), + ...minerTipRows(base, minerEarnings) + ]; + } + + if (type === 2) { + const value = readU64LE(bytes, 5); + const asset = assetWithSeries(bytesToText(bytes.slice(13, 28)), readU32LE(bytes, 28)); + const sender = bytesToAddressHex(bytes.slice(32, 54)); + const receiver = bytesToAddressHex(bytes.slice(54, 76)); + const fee = readU64LE(bytes, 76); + if (isActiveAddress(sender)) { + return [dashboardRow(base, "Transfer Out", asset, addressLabel(receiver), signedAmount("-", value), signedBaseFee("-", fee))]; + } + if (isActiveAddress(receiver)) { + return [dashboardRow(base, "Transfer In", asset, addressLabel(sender), signedAmount("+", value))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(sender), signedAmount("+", fee))]; + } + + if (type === 3) { + const creator = bytesToAddressHex(bytes.slice(5, 27)); + const ticker = bytesToText(bytes.slice(27, 42)); + const number = readU64LE(bytes, 42); + const fee = readU64LE(bytes, 51); + if (isActiveAddress(creator)) { + return [dashboardRow(base, "Token Created", ticker || "Token", "Self", signedAmount("+", number), signedBaseFee("-", fee))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(creator), signedAmount("+", fee))]; + } + + if (type === 4) { + const creator = bytesToAddressHex(bytes.slice(5, 27)); + const series = Number(bytes[27] || 0); + const nftName = bytesToText(bytes.slice(28, 43)) || "NFT"; + const count = readU32LE(bytes, 143); + const fee = readU64LE(bytes, 247); + if (isActiveAddress(creator)) { + return [dashboardRow(base, "NFT Created", assetWithSeries(nftName, series), "Self", `+${count}`, signedBaseFee("-", fee))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(creator), signedAmount("+", fee))]; + } + + if (type === 5) { + const advertiser = bytesToAddressHex(bytes.slice(165, 187)); + const fee = readU64LE(bytes, 187); + if (isActiveAddress(advertiser)) { + return [dashboardRow(base, "Marketing", "Advertisement", "Network", "", signedBaseFee("-", fee))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(advertiser), signedAmount("+", fee))]; + } + + if (type === 6) { + const ticker1 = bytesToText(bytes.slice(9, 24)); + const nftSeries1 = readU32LE(bytes, 24); + const value1 = readU64LE(bytes, 28); + const ticker2 = bytesToText(bytes.slice(36, 51)); + const nftSeries2 = readU32LE(bytes, 51); + const value2 = readU64LE(bytes, 55); + const sender1 = bytesToAddressHex(bytes.slice(63, 85)); + const sender2 = bytesToAddressHex(bytes.slice(85, 107)); + const tip1 = readU64LE(bytes, 107); + const tip2 = readU64LE(bytes, 115); + const txfee1 = readU64LE(bytes, 123); + const txfee2 = readU64LE(bytes, 131); + const asset1 = assetWithSeries(ticker1, nftSeries1); + const asset2 = assetWithSeries(ticker2, nftSeries2); + if (isActiveAddress(sender1)) { + return [ + dashboardRow(base, "Swap Out", asset1, addressLabel(sender2), signedAmount("-", value1), signedBaseFee("-", txfee1)), + dashboardRow(base, "Miner Tip Paid", asset1, addressLabel(sender2), signedAmount("-", tip1), "--"), + dashboardRow(base, "Swap In", asset2, addressLabel(sender2), signedAmount("+", value2)) + ].filter((row) => row.amount !== "0" && row.amount !== "-0"); + } + if (isActiveAddress(sender2)) { + return [ + dashboardRow(base, "Swap Out", asset2, addressLabel(sender1), signedAmount("-", value2), signedBaseFee("-", txfee2)), + dashboardRow(base, "Miner Tip Paid", asset2, addressLabel(sender1), signedAmount("-", tip2), "--"), + dashboardRow(base, "Swap In", asset1, addressLabel(sender1), signedAmount("+", value1)) + ].filter((row) => row.amount !== "0" && row.amount !== "-0"); + } + return [ + dashboardRow(base, "Fee Earned", activeBaseCoin(), "Swap", signedAmount("+", txfee1 + txfee2)), + dashboardRow(base, "Tip Earned", asset1, "Swap", signedAmount("+", tip1)), + dashboardRow(base, "Tip Earned", asset2, "Swap", signedAmount("+", tip2)) + ].filter((row) => row.amount !== "+0"); + } + + if (type === 7) { + const loanCoin = bytesToText(bytes.slice(5, 20)); + const loanAmount = readU64LE(bytes, 20); + const lender = bytesToAddressHex(bytes.slice(28, 50)); + const collateral = bytesToText(bytes.slice(50, 71)); + const collateralAmount = readU64LE(bytes, 71); + const borrower = bytesToAddressHex(bytes.slice(79, 101)); + const fee = readU64LE(bytes, 120); + if (isActiveAddress(lender)) { + return [dashboardRow(base, "Loan Created", loanCoin, addressLabel(borrower), signedAmount("-", loanAmount), signedBaseFee("-", fee))]; + } + if (isActiveAddress(borrower)) { + return [ + dashboardRow(base, "Loan Received", loanCoin, addressLabel(lender), signedAmount("+", loanAmount)), + dashboardRow(base, "Collateral Locked", collateral, addressLabel(lender), signedAmount("-", collateralAmount)) + ]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(lender), signedAmount("+", fee))]; + } + + if (type === 8) { + const paybackAmount = readU64LE(bytes, 5); + const contractHash = bytesToHex(bytes.slice(13, 45)); + const borrower = bytesToAddressHex(bytes.slice(45, 67)); + const tip = readU64LE(bytes, 67); + const fee = readU64LE(bytes, 75); + const loan = loanSummaryFromLinkedTransactions(linkedTransactions, contractHash); + if (isActiveAddress(borrower)) { + const asset = loan?.loanCoin || "Loan"; + const counterparty = loan?.lender ? addressLabel(loan.lender) : `${contractHash.slice(0, 8)}...${contractHash.slice(-8)}`; + return [ + dashboardRow(base, "Loan Payment Sent", asset, counterparty, signedAmount("-", paybackAmount), signedBaseFee("-", fee)), + dashboardRow(base, "Miner Tip Paid", asset, counterparty, signedAmount("-", tip), "--") + ].filter((row) => row.amount !== "0" && row.amount !== "-0"); + } + if (loan?.lender && isActiveAddress(loan.lender)) { + return [dashboardRow(base, "Loan Payment Received", loan.loanCoin, addressLabel(borrower), signedAmount("+", paybackAmount))]; + } + return [ + dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(borrower), signedAmount("+", fee)), + dashboardRow(base, "Tip Earned", loan?.loanCoin || "Loan", addressLabel(borrower), signedAmount("+", tip)) + ].filter((row) => row.amount !== "0" && row.amount !== "+0"); + } + + if (type === 9) { + const contractHash = bytesToHex(bytes.slice(5, 37)); + const claimant = bytesToAddressHex(bytes.slice(37, 59)); + const fee = readU64LE(bytes, 59); + if (isActiveAddress(claimant)) { + return [dashboardRow(base, "Collateral Claimed", "Collateral", `${contractHash.slice(0, 8)}...${contractHash.slice(-8)}`, "", signedBaseFee("-", fee))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(claimant), signedAmount("+", fee))]; + } + + if (type === 10) { + const address = bytesToAddressHex(bytes.slice(5, 27)); + const asset = assetWithSeries(bytesToText(bytes.slice(27, 42)), readU32LE(bytes, 42)); + const value = readU64LE(bytes, 46); + const fee = readU64LE(bytes, 54); + if (isActiveAddress(address)) { + return [dashboardRow(base, "Burn", asset, "Network", signedAmount("-", value), signedBaseFee("-", fee))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(address), signedAmount("+", fee))]; + } + + if (type === 11) { + const creator = bytesToAddressHex(bytes.slice(5, 27)); + const ticker = bytesToText(bytes.slice(27, 42)); + const number = readU64LE(bytes, 42); + const fee = readU64LE(bytes, 50); + if (isActiveAddress(creator)) { + return [dashboardRow(base, "Token Issued", ticker || "Token", "Self", signedAmount("+", number), signedBaseFee("-", fee))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(creator), signedAmount("+", fee))]; + } + + if (type === 12) { + const address = bytesToAddressHex(bytes.slice(5, 27)); + const fee = readU64LE(bytes, 49); + if (isActiveAddress(address)) { + return [dashboardRow(base, "Vanity Registered", "Vanity", "Self", "", signedBaseFee("-", fee))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(address), signedAmount("+", fee))]; + } + } catch (_) { + return fallback; + } + + return fallback; +} + +function decodeLightweightTransaction(bytesLike) { + const bytes = Array.from(bytesLike || []); + const type = Number(bytes[0] || 0); + const time = () => readU32LE(bytes, 1); + if (type === 1) { + return { unsigned: { txtype: type, timestamp: time(), value: Number(readU64LE(bytes, 5)) } }; + } + if (type === 2) { + return { + unsigned_transfer: { + txtype: type, + time: time(), + value: Number(readU64LE(bytes, 5)), + coin: bytesToText(bytes.slice(13, 28)), + nft_series: readU32LE(bytes, 28), + sender: bytesToAddressHex(bytes.slice(32, 54)), + receiver: bytesToAddressHex(bytes.slice(54, 76)), + txfee: Number(readU64LE(bytes, 76)) + } + }; + } + if (type === 3) { + return { + unsigned_create_token: { + txtype: type, + time: time(), + creator: bytesToAddressHex(bytes.slice(5, 27)), + ticker: bytesToText(bytes.slice(27, 42)), + number: Number(readU64LE(bytes, 42)), + hard_limit: Number(bytes[50] || 0), + txfee: Number(readU64LE(bytes, 51)) + } + }; + } + if (type === 4) { + return { + unsigned_create_nft: { + txtype: type, + time: time(), + creator: bytesToAddressHex(bytes.slice(5, 27)), + series: Number(bytes[27] || 0), + nft_name: bytesToText(bytes.slice(28, 43)), + item_ipfs: bytesToText(bytes.slice(43, 143)), + count: readU32LE(bytes, 143), + description: bytesToText(bytes.slice(147, 247)), + txfee: Number(readU64LE(bytes, 247)) + } + }; + } + if (type === 5) { + return { + unsigned_marketing: { + txtype: type, + time: time(), + ad_data: bytesToText(bytes.slice(5, 165)), + advertiser: bytesToAddressHex(bytes.slice(165, 187)), + txfee: Number(readU64LE(bytes, 187)) + } + }; + } + if (type === 6) { + return { + unsigned_swap: { + txtype: type, + time: time(), + ticker1: bytesToText(bytes.slice(9, 24)), + nft_series1: readU32LE(bytes, 24), + value1: Number(readU64LE(bytes, 28)), + ticker2: bytesToText(bytes.slice(36, 51)), + nft_series2: readU32LE(bytes, 51), + value2: Number(readU64LE(bytes, 55)), + sender1: bytesToAddressHex(bytes.slice(63, 85)), + sender2: bytesToAddressHex(bytes.slice(85, 107)), + tip1: Number(readU64LE(bytes, 107)), + tip2: Number(readU64LE(bytes, 115)), + txfee1: Number(readU64LE(bytes, 123)), + txfee2: Number(readU64LE(bytes, 131)) + } + }; + } + if (type === 7) { + return { + unsigned_loan_contract: { + txtype: type, + time: time(), + loan_coin: bytesToText(bytes.slice(5, 20)), + loan_amount: Number(readU64LE(bytes, 20)), + lender: bytesToAddressHex(bytes.slice(28, 50)), + collateral: bytesToText(bytes.slice(50, 71)), + collateral_amount: Number(readU64LE(bytes, 71)), + borrower: bytesToAddressHex(bytes.slice(79, 101)), + payment_period: bytesToText(bytes.slice(101, 102)), + payment_number: Number(bytes[102] || 0), + payment_amount: Number(readU64LE(bytes, 103)), + grace_period: Number(bytes[111] || 0), + max_late_value: Number(readU64LE(bytes, 112)), + txfee: Number(readU64LE(bytes, 120)) + } + }; + } + if (type === 8) { + return { + unsigned_contract_payment: { + txtype: type, + time: time(), + payback_amount: Number(readU64LE(bytes, 5)), + contract_hash: bytesToHex(bytes.slice(13, 45)), + address: bytesToAddressHex(bytes.slice(45, 67)), + tip: Number(readU64LE(bytes, 67)), + txfee: Number(readU64LE(bytes, 75)) + } + }; + } + if (type === 9) { + return { + unsigned_collateral_claim: { + txtype: type, + time: time(), + contract_hash: bytesToHex(bytes.slice(5, 37)), + address: bytesToAddressHex(bytes.slice(37, 59)), + txfee: Number(readU64LE(bytes, 59)) + } + }; + } + if (type === 10) { + return { + unsigned_burn: { + txtype: type, + time: time(), + address: bytesToAddressHex(bytes.slice(5, 27)), + coin: bytesToText(bytes.slice(27, 42)), + nft_series: readU32LE(bytes, 42), + value: Number(readU64LE(bytes, 46)), + txfee: Number(readU64LE(bytes, 54)) + } + }; + } + if (type === 11) { + return { + unsigned_issue_token: { + txtype: type, + time: time(), + creator: bytesToAddressHex(bytes.slice(5, 27)), + ticker: bytesToText(bytes.slice(27, 42)), + number: Number(readU64LE(bytes, 42)), + txfee: Number(readU64LE(bytes, 50)) + } + }; + } + if (type === 12) { + return { + unsigned_vanity_address: { + txtype: type, + time: time(), + address: bytesToAddressHex(bytes.slice(5, 27)), + vanity_address: bytesToAddressHex(bytes.slice(27, 49)), + fee: Number(readU64LE(bytes, 49)), + txfee: Number(readU64LE(bytes, 49)) + } + }; + } + + return { unknown: { txtype: type } }; +} + +function normalizeTransactionCache(cache) { + if (!cache || typeof cache !== "object" || Array.isArray(cache)) { + return { transactions: {}, ordered_txids: [] }; + } + + if (cache.transactions && typeof cache.transactions === "object" && !Array.isArray(cache.transactions)) { + return { + ...cache, + transactions: { ...cache.transactions }, + ordered_txids: normalizeOrderedTxids(cache) + }; + } + + const transactions = {}; + Object.entries(cache).forEach(([txid, transaction]) => { + if (transaction && typeof transaction === "object") { + transactions[txid] = { txid, ...transaction }; + } + }); + return { + transactions, + ordered_txids: [] + }; +} + +function normalizeOrderedTxids(cache) { + const txids = Array.isArray(cache?.ordered_txids) + ? cache.ordered_txids + : (Array.isArray(cache?.address_history_txids) ? cache.address_history_txids : []); + const seen = new Set(); + return txids.filter((txid) => { + if (!txid || seen.has(txid)) { + return false; + } + seen.add(txid); + return true; + }); +} + +function addOrderedTxids(cache, txids) { + cache.ordered_txids = normalizeOrderedTxids(cache); + const known = new Set(cache.ordered_txids || []); + txids.forEach((txid) => { + if (txid && !known.has(txid)) { + known.add(txid); + cache.ordered_txids.push(txid); + } + }); +} + +function replaceOrderedTxids(cache, txids) { + cache.ordered_txids = normalizeOrderedTxids({ ordered_txids: txids }); +} + +function normalizeTransactionIndex(index) { + const source = index && typeof index === "object" && !Array.isArray(index) ? index : {}; + const orderedTxids = Array.isArray(source.ordered_txids) ? source.ordered_txids.filter(Boolean) : []; + const txidToFile = source.txid_to_file && typeof source.txid_to_file === "object" ? { ...source.txid_to_file } : {}; + const txidToType = source.txid_to_type && typeof source.txid_to_type === "object" ? { ...source.txid_to_type } : {}; + const typeToTxids = source.type_to_txids && typeof source.type_to_txids === "object" ? { ...source.type_to_txids } : {}; + const nextId = Math.max(1, Number(source.next_id || 1)); + + return { + next_id: nextId, + ordered_txids: orderedTxids, + txid_to_file: txidToFile, + txid_to_type: txidToType, + type_to_txids: typeToTxids, + address_history_last_checked_at: source.address_history_last_checked_at, + address_history_synced_at: source.address_history_synced_at, + updated_at: source.updated_at + }; +} + +function transactionRecordFileName(id) { + return `${String(id).padStart(8, "0")}.json`; +} + +function addIndexTransaction(index, txid, txtype) { + if (!index.txid_to_file[txid]) { + index.txid_to_file[txid] = transactionRecordFileName(index.next_id); + index.next_id += 1; + } + if (!index.ordered_txids.includes(txid)) { + index.ordered_txids.push(txid); + } + index.txid_to_type[txid] = Number(txtype || 0); + const typeKey = String(Number(txtype || 0)); + const txidsForType = Array.isArray(index.type_to_txids[typeKey]) ? index.type_to_txids[typeKey] : []; + if (!txidsForType.includes(txid)) { + txidsForType.push(txid); + } + index.type_to_txids[typeKey] = txidsForType; + return index.txid_to_file[txid]; +} + +function transactionRecordFromRpc(record) { + const transactionBytes = Array.from(record?.transaction_bytes || []); + const txtype = Number(record?.transaction_type ?? transactionBytes[0] ?? 0); + const block = Number(record?.block_height || 0) || null; + const transaction = decodeLightweightTransaction(transactionBytes); + return { + type: txTypeKey(txtype), + txtype, + block, + status: walletStatusForBlock(block), + transaction, + miner_earnings: Array.isArray(record?.miner_earnings) ? record.miner_earnings : [] + }; +} + +function chainStatusForBlock(blockHeight, currentHeight = latestKnownBlockHeight) { + const height = Number(blockHeight || 0); + const tip = Number(currentHeight || 0); + if (!height || !tip || tip < height) { + return CHAIN_STATUS_PENDING; + } + return tip - height >= CONFIRMED_AFTER_BLOCKS ? CHAIN_STATUS_FINALIZED : CHAIN_STATUS_PENDING; +} + +function updateTransactionCacheChainStatus(cache, currentHeight = latestKnownBlockHeight) { + Object.entries(cache.transactions || {}).forEach(([, record]) => { + if (!record || typeof record !== "object") { + return; + } + record.status = walletStatusForBlock(blockValueFromRecord(record), currentHeight); + }); + cache.finalized_through_height = Math.max(0, Number(currentHeight || 0) - CONFIRMED_AFTER_BLOCKS); + return cache; +} + +function recentTransactionField(transaction, names, fallback = "") { + for (const name of names) { + const value = transaction?.[name]; + if (value !== undefined && value !== null && String(value).trim() !== "") { + return String(value); + } + } + return fallback; +} + +function transactionRecordsForTxids(cache, txids) { + if (!cache || typeof cache !== "object") { + return []; + } + + return (txids || []) + .map((txid) => { + const transaction = cache.transactions?.[txid]; + if (!transaction || typeof transaction !== "object") { + return null; + } + return { txid, ...transaction }; + }) + .filter(Boolean); +} + +function normalizeRecentTransactionRecords(records, options = {}) { + const source = Array.isArray(records) ? records : []; + + const normalized = source + .filter((transaction) => transaction && typeof transaction === "object") + .flatMap((transaction, recordIndex) => { + if (!transaction.transaction && Array.isArray(transaction.rows)) { + return transaction.rows.map((row, rowIndex) => ({ + ...row, + txid: row.txid || transaction.txid, + block_height: row.block_height ?? blockValueFromRecord(transaction), + __order: recordIndex + rowIndex / 1000 + })); + } + const blockHeight = blockValueFromRecord(transaction); + return parseDashboardTransactionRows( + transaction.txid, + blockHeight, + transaction.transaction, + transaction.linked_transactions || {}, + transaction.miner_earnings || [] + ).map((row, rowIndex) => ({ + ...row, + status: walletStatusForBlock(blockHeight), + __order: recordIndex + rowIndex / 1000 + })); + }) + .map((transaction) => ({ + ...transaction, + status: walletStatusForBlock(Number(recentTransactionField(transaction, ["block_height", "blockHeight", "height"], "0"))) + })); + + const sorted = options.preserveOrder ? normalized : normalized.sort((left, right) => { + const leftHeight = Number(recentTransactionField(left, ["block_height", "blockHeight", "height"], "0")); + const rightHeight = Number(recentTransactionField(right, ["block_height", "blockHeight", "height"], "0")); + return rightHeight - leftHeight; + }); + + return sorted + .slice(0, 5) + .map(({ __order: _order, ...transaction }) => transaction); +} + +function normalizeCachedRecentTransactions(cache) { + if (!cache || typeof cache !== "object") { + return []; + } + + if (cache.transactions && typeof cache.transactions === "object" && !Array.isArray(cache.transactions)) { + return normalizeRecentTransactionRecords(transactionRecordsForTxids(cache, cache.ordered_txids || [])); + } + + if (Array.isArray(cache)) { + return normalizeRecentTransactionRecords(cache); + } + + if (Array.isArray(cache.transactions)) { + return normalizeRecentTransactionRecords(cache.transactions); + } + + return []; +} + +function renderRecentTransactions(transactions) { + clearRecentTransactionsRows(); + + if (!recentTransactionsBody) { + return; + } + + transactions.forEach((transaction) => { + const type = recentTransactionField(transaction, ["type", "tx_type", "transactionType"], "Transaction"); + const asset = recentTransactionField(transaction, ["asset", "coin", "token"], activeBaseCoin()); + const fallbackCounterparty = shortenAddress(recentTransactionField(transaction, ["txid"], "Unknown")); + const counterparty = recentTransactionField(transaction, ["counterparty", "to", "from", "address"], fallbackCounterparty); + const status = recentTransactionField(transaction, ["status"], "Confirmed"); + const amount = recentTransactionField(transaction, ["amount", "value", "display_amount"], ""); + const statusClass = status.trim().toLowerCase() === "pending" ? "pending" : "confirmed"; + + const row = document.createElement("tr"); + row.innerHTML = ` + + + + + + `; + row.children[0].textContent = type; + row.children[1].textContent = asset; + row.children[2].textContent = counterparty; + row.querySelector(".table-status").textContent = status; + row.children[4].textContent = amount; + recentTransactionsBody.appendChild(row); + }); +} + +async function renderRecentTransactionsFromCache() { + if (!walletLoaded || !registrationRegistered) { + return []; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + clearRecentTransactionsRows(); + setRecentTransactionsState("Transaction cache requires the desktop app build."); + return []; + } + + const readId = ++recentTransactionsCacheReadId; + try { + await refreshCurrentBlockHeight(); + const cache = await readTransactionCacheObject(); + if (readId !== recentTransactionsCacheReadId) { + return []; + } + updateTransactionCacheChainStatus(cache); + const transactions = normalizeCachedRecentTransactions(cache); + renderRecentTransactions(transactions); + setRecentTransactionsState(transactions.length ? "" : "No cached transactions yet."); + return transactions; + } catch (_) { + if (readId !== recentTransactionsCacheReadId) { + return []; + } + clearRecentTransactionsRows(); + setRecentTransactionsState("No cached transactions yet."); + return []; + } +} + +async function readTransactionCacheObject(walletToken = activeWalletCacheToken()) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !isActiveWalletCacheToken(walletToken)) { + return { transactions: {}, ordered_txids: [] }; + } + + const index = await readTransactionIndexObject(walletToken); + return readTransactionCacheRecords(index, index.ordered_txids, walletToken); +} + +async function readTransactionCacheRecords(index, txids, walletToken = activeWalletCacheToken()) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !isActiveWalletCacheToken(walletToken)) { + return { transactions: {}, ordered_txids: [] }; + } + + const normalizedIndex = normalizeTransactionIndex(index); + const cache = { ...normalizedIndex, transactions: {} }; + const recordTxids = normalizeOrderedTxids({ ordered_txids: Array.isArray(txids) ? txids : [] }); + cache.ordered_txids = recordTxids; + + for (const txid of recordTxids) { + if (!isActiveWalletCacheToken(walletToken)) { + return { transactions: {}, ordered_txids: [] }; + } + const fileName = normalizedIndex.txid_to_file?.[txid]; + if (!fileName) { + continue; + } + try { + const recordJson = await invoke("read_local_transaction_record", { fileName }); + const record = JSON.parse(recordJson || "{}"); + if (record && typeof record === "object" && Object.keys(record).length) { + cache.transactions[txid] = repairCachedTransactionRecord(txid, record); + } + } catch (_) { + continue; + } + } + return cache; +} + +async function readTransactionIndexObject(walletToken = activeWalletCacheToken()) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !isActiveWalletCacheToken(walletToken)) { + return normalizeTransactionIndex({}); + } + + const indexJson = await invoke("read_local_transaction_index"); + if (!isActiveWalletCacheToken(walletToken)) { + return normalizeTransactionIndex({}); + } + return normalizeTransactionIndex(JSON.parse(indexJson || "{}")); +} + +function activeWalletCacheToken() { + return activeWalletAddressShort || activeWalletAddressHex || activeWalletPath || ""; +} + +function isActiveWalletCacheToken(token) { + return Boolean(walletLoaded && token && token === activeWalletCacheToken()); +} + +async function refreshCurrentBlockHeight() { + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode || !walletLoaded) { + return latestKnownBlockHeight; + } + + try { + latestKnownBlockHeight = await invokeWalletRpcWithBackoff( + "Block height lookup", + () => invoke("current_block_height", { broadcastNode }), + () => walletLoaded + ); + } catch (_) { + latestKnownBlockHeight = 0; + } + return latestKnownBlockHeight; +} + +function repairCachedTransactionRecord(txid, record) { + if (!record || typeof record !== "object") { + return record; + } + const block = blockValueFromRecord(record); + const transaction = record.transaction && !Array.isArray(record.transaction) + ? record.transaction + : record.decoded_transaction; + const transactionType = Number(record.txtype ?? record.transaction_type ?? decodedTransactionType(transaction)); + return { + txid, + type: record.type || txTypeKey(transactionType), + txtype: transactionType, + block, + status: walletStatusForBlock(block), + transaction: transaction || record.transaction, + linked_transactions: record.linked_transactions || {}, + miner_earnings: Array.isArray(record.miner_earnings) ? record.miner_earnings : [] + }; +} + +function linkedTransactionIdsFor(transaction) { + if (transaction && typeof transaction === "object" && !Array.isArray(transaction)) { + const contractHash = transaction.unsigned_contract_payment?.contract_hash; + return contractHash ? [contractHash] : []; + } + + const bytes = Array.from(transaction || []); + const type = Number(bytes[0] || 0); + if (type === 8) { + return [bytesToHex(bytes.slice(13, 45))]; + } + return []; +} + +function cachedRecordNeedsRefresh(record) { + if (!record || typeof record !== "object") { + return true; + } + if (!record.transaction || Array.isArray(record.transaction)) { + return true; + } + const linkedIds = linkedTransactionIdsFor(record.transaction); + return linkedIds.some((txid) => !record.linked_transactions?.[txid]); +} + +async function hydrateLinkedTransactionsForRecord( + txid, + record, + invoke, + broadcastNode, + cache = { transactions: {} }, + shouldContinue = () => true, + linkedLookupDelayMs = 0, + commandName = "transaction_by_txid" +) { + if (!record || !record.transaction || !cachedRecordNeedsRefresh(record)) { + return record; + } + + const linkedTransactions = { ...(record.linked_transactions || {}) }; + for (const linkedTxid of linkedTransactionIdsFor(record.transaction)) { + if (linkedTransactions[linkedTxid]?.transaction) { + continue; + } + + const cachedLinked = cache.transactions?.[linkedTxid] || cache.transactions?.[txid]?.linked_transactions?.[linkedTxid]; + if (cachedLinked?.transaction && !Array.isArray(cachedLinked.transaction)) { + linkedTransactions[linkedTxid] = repairCachedTransactionRecord(linkedTxid, cachedLinked); + continue; + } + + if (linkedLookupDelayMs > 0) { + await sleepMs(linkedLookupDelayMs); + } + if (!shouldContinue()) { + throw new Error("Linked transaction lookup cancelled"); + } + + const linked = await invokeWalletRpcWithBackoff( + "Linked transaction lookup", + () => invoke(commandName, { + broadcastNode, + txid: linkedTxid + }), + shouldContinue + ); + + linkedTransactions[linkedTxid] = repairCachedTransactionRecord(linkedTxid, { + txid: linkedTxid, + block: linked.block_height || null, + transaction: linked.transaction, + transaction_type: linked.transaction_type, + miner_earnings: Array.isArray(linked.miner_earnings) ? linked.miner_earnings : [] + }); + } + + return repairCachedTransactionRecord(txid, { + ...record, + linked_transactions: linkedTransactions + }); +} + +function sanitizeTransactionCacheForWrite(cache) { + const clean = { + transactions: {}, + ordered_txids: normalizeOrderedTxids(cache), + finalized_through_height: cache.finalized_through_height || 0, + address_history_last_checked_at: cache.address_history_last_checked_at, + address_history_synced_at: cache.address_history_synced_at + }; + + Object.entries(cache.transactions || {}).forEach(([txid, record]) => { + if (!record || typeof record !== "object") { + return; + } + const normalized = repairCachedTransactionRecord(txid, record); + if (!normalized?.transaction || Array.isArray(normalized.transaction)) { + return; + } + const linkedTransactions = {}; + Object.entries(normalized.linked_transactions || {}).forEach(([linkedTxid, linkedRecord]) => { + if (linkedRecord && typeof linkedRecord === "object") { + const linkedNormalized = repairCachedTransactionRecord(linkedTxid, linkedRecord); + if (linkedNormalized?.transaction && !Array.isArray(linkedNormalized.transaction)) { + linkedTransactions[linkedTxid] = linkedNormalized; + } + } + }); + clean.transactions[txid] = { + ...normalized, + linked_transactions: linkedTransactions + }; + }); + + updateTransactionCacheChainStatus(clean); + return clean; +} + +async function writeTransactionCacheObject(cache, walletToken = activeWalletCacheToken()) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !isActiveWalletCacheToken(walletToken)) { + return; + } + + const index = normalizeTransactionIndex(cache); + const indexJson = JSON.stringify({ + ...index, + updated_at: new Date().toISOString() + }); + if (!isActiveWalletCacheToken(walletToken)) { + return; + } + + await invoke("write_local_transaction_index", { + indexJson + }); +} + +async function writeTransactionRecordFile(fileName, record, walletToken = activeWalletCacheToken()) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !isActiveWalletCacheToken(walletToken)) { + return; + } + + await invoke("write_local_transaction_record", { + fileName, + recordJson: JSON.stringify(record) + }); +} + +async function mutateTransactionCache(mutator, walletToken = activeWalletCacheToken()) { + if (!isActiveWalletCacheToken(walletToken)) { + return { transactions: {}, ordered_txids: [] }; + } + + const previous = transactionCacheMutationChain; + let releaseMutation; + transactionCacheMutationChain = new Promise((resolve) => { + releaseMutation = resolve; + }); + + await previous; + try { + if (!isActiveWalletCacheToken(walletToken)) { + return { transactions: {}, ordered_txids: [] }; + } + let cache = await readTransactionCacheObject(walletToken); + Object.entries(cache.transactions).forEach(([txid, record]) => { + cache.transactions[txid] = repairCachedTransactionRecord(txid, record); + }); + updateTransactionCacheChainStatus(cache); + cache = (await mutator(cache)) || cache; + if (!isActiveWalletCacheToken(walletToken)) { + return { transactions: {}, ordered_txids: [] }; + } + updateTransactionCacheChainStatus(cache); + await writeTransactionCacheObject(cache, walletToken); + return cache; + } finally { + releaseMutation(); + } +} + +async function fetchTransactionRecord( + invoke, + broadcastNode, + txid, + cache, + shouldContinue = () => true, + linkedLookupDelayMs = DASHBOARD_INITIAL_TX_FETCH_DELAY_MS, + commandName = "transaction_by_txid" +) { + const tx = await invokeWalletRpcWithBackoff( + "Transaction lookup", + () => invoke(commandName, { + broadcastNode, + txid + }), + shouldContinue + ); + + const linkedTransactions = {}; + const record = repairCachedTransactionRecord(txid, { + txid, + block: tx.block_height || null, + transaction: tx.transaction, + transaction_type: tx.transaction_type, + linked_transactions: linkedTransactions, + miner_earnings: Array.isArray(tx.miner_earnings) ? tx.miner_earnings : [] + }); + return hydrateLinkedTransactionsForRecord( + txid, + record, + invoke, + broadcastNode, + cache, + shouldContinue, + linkedLookupDelayMs, + commandName + ); +} + +async function syncLatestDashboardTransactions() { + if (!walletLoaded || !registrationRegistered || currentView !== "dashboard" || latestTransactionsSyncInFlight) { + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode) { + return; + } + + const syncId = ++latestTransactionsSyncId; + recentTransactionsCacheReadId += 1; + latestTransactionsSyncInFlight = true; + + try { + const hasVisibleTransactions = latestDashboardTransactionRecords.size > 0; + if (!hasVisibleTransactions) { + setRecentTransactionsState("Checking latest transactions...", "loading"); + } + if (hasVisibleTransactions) { + await refreshCurrentBlockHeight(); + } + const shouldContinue = () => ( + syncId === latestTransactionsSyncId && + registrationRegistered && + walletLoaded && + currentView === "dashboard" + ); + const latestRecords = await invokeWalletRpcWithBackoff( + "Latest address transaction lookup", + () => invoke("latest_address_transactions", { + broadcastNode, + limit: DASHBOARD_RECENT_LIMIT + }), + shouldContinue + ); + if (syncId !== latestTransactionsSyncId || !registrationRegistered || currentView !== "dashboard") { + return; + } + + const txids = latestRecords.map((record) => record.txid).filter(Boolean); + if (!latestRecords.length) { + latestDashboardTxids = []; + latestDashboardTransactionRecords = new Map(); + clearRecentTransactionsRows(); + setRecentTransactionsState("No transactions found."); + return; + } + + const memoryCache = { transactions: {} }; + for (const record of latestRecords) { + if (!record?.txid || !Array.isArray(record.transaction_bytes)) { + continue; + } + const chainStatus = chainStatusForBlock(record.block_height); + let transactionRecord = repairCachedTransactionRecord(record.txid, { + txid: record.txid, + block: record.block_height || null, + transaction: record.transaction_bytes, + transaction_type: record.transaction_type, + miner_earnings: Array.isArray(record.miner_earnings) ? record.miner_earnings : [] + }); + transactionRecord = await hydrateLinkedTransactionsForRecord( + record.txid, + transactionRecord, + invoke, + broadcastNode, + memoryCache, + shouldContinue, + 0 + ); + if (!shouldContinue()) { + return; + } + const rows = parseDashboardTransactionRows( + record.txid, + record.block_height, + transactionRecord.transaction, + transactionRecord.linked_transactions || {}, + record.miner_earnings || [] + ).map((row) => ({ + ...row, + chain_status: chainStatus + })); + memoryCache.transactions[record.txid] = { + txid: record.txid, + block_height: record.block_height, + chain_status: chainStatus, + transaction: record.transaction_bytes, + transaction_bytes: record.transaction_bytes, + transaction_type: record.transaction_type, + miner_earnings: Array.isArray(record.miner_earnings) ? record.miner_earnings : [], + linked_transactions: transactionRecord.linked_transactions || {}, + rows, + parser_version: 3 + }; + } + + latestDashboardTxids = txids; + latestDashboardTransactionRecords = new Map( + txids + .map((txid) => [txid, memoryCache.transactions[txid]]) + .filter(([, record]) => record) + ); + renderRecentTransactions(normalizeRecentTransactionRecords( + transactionRecordsForTxids(memoryCache, txids), + { preserveOrder: true } + )); + setRecentTransactionsState(""); + if (!hasVisibleTransactions) { + refreshCurrentBlockHeight() + .then(() => { + if ( + syncId === latestTransactionsSyncId && + registrationRegistered && + walletLoaded && + currentView === "dashboard" + ) { + const latestCache = { transactions: Object.fromEntries(latestDashboardTransactionRecords) }; + updateTransactionCacheChainStatus(latestCache); + latestDashboardTransactionRecords = new Map(Object.entries(latestCache.transactions)); + renderRecentTransactions(normalizeRecentTransactionRecords( + transactionRecordsForTxids(latestCache, latestDashboardTxids), + { preserveOrder: true } + )); + } + }) + .catch(() => {}); + } + } catch (error) { + const message = String(error); + + if (syncId === latestTransactionsSyncId) { + if (latestDashboardTransactionRecords.size === 0) { + clearRecentTransactionsRows(); + setRecentTransactionsState(message || "Unable to load latest transactions right now."); + } + } + } finally { + if ( + syncId === latestTransactionsSyncId && + latestDashboardTransactionRecords.size === 0 && + recentTransactionsState?.classList.contains("loading") + ) { + setRecentTransactionsState("Unable to load latest transactions right now."); + } + latestTransactionsSyncInFlight = false; + } +} + +function resetLatestDashboardTransactionsMemory() { + latestDashboardTxids = []; + latestDashboardTransactionRecords = new Map(); +} + +function shouldPollLatestDashboardTransactions() { + return walletLoaded && registrationRegistered && currentView === "dashboard"; +} + +function startLatestDashboardTransactionsPolling() { + if (latestDashboardTransactionsTimer || !shouldPollLatestDashboardTransactions()) { + return; + } + + syncLatestDashboardTransactions(); + latestDashboardTransactionsTimer = setInterval(() => { + syncLatestDashboardTransactions(); + }, DASHBOARD_LATEST_REFRESH_MS); +} + +function stopLatestDashboardTransactionsPolling() { + latestTransactionsSyncId += 1; + if (latestDashboardTransactionsTimer) { + clearInterval(latestDashboardTransactionsTimer); + latestDashboardTransactionsTimer = null; + } +} + +function updateLatestDashboardTransactionsPolling() { + if (shouldPollLatestDashboardTransactions()) { + startLatestDashboardTransactionsPolling(); + return; + } + + stopLatestDashboardTransactionsPolling(); +} + +function shouldContinueBackgroundTransactionSync(syncId, walletToken = activeWalletCacheToken()) { + return ( + syncId === backgroundTransactionSyncId && + walletLoaded && + registrationRegistered && + isActiveWalletCacheToken(walletToken) + ); +} + +function stopBackgroundTransactionSync() { + backgroundTransactionSyncId += 1; +} + +function startBackgroundTransactionSync() { + if (!walletLoaded || !registrationRegistered) { + return; + } + + const walletToken = activeWalletCacheToken(); + const syncId = ++backgroundTransactionSyncId; + runBackgroundTransactionSync(syncId, 0, walletToken); +} + +async function runBackgroundTransactionSync(syncId, failureCount = 0, walletToken = activeWalletCacheToken()) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + return; + } + + try { + while (shouldContinueBackgroundTransactionSync(syncId, walletToken)) { + const broadcastNode = activeBroadcastNode(); + if (!broadcastNode) { + await sleepMs(BACKGROUND_SYNC_IDLE_MS); + continue; + } + + const currentHeight = await refreshCurrentBlockHeight(); + if (!currentHeight) { + await sleepMs(BACKGROUND_SYNC_IDLE_MS); + continue; + } + const cache = await readTransactionIndexObject(walletToken); + + const skip = normalizeOrderedTxids(cache).length; + const shouldContinue = () => shouldContinueBackgroundTransactionSync(syncId, walletToken); + const records = await invokeWalletRpcWithBackoff( + "Paged address history lookup", + () => invoke("paged_address_history", { + broadcastNode, + skip, + limit: BACKGROUND_HISTORY_PAGE_SIZE + }), + shouldContinue + ); + + if (!shouldContinueBackgroundTransactionSync(syncId, walletToken)) { + return; + } + + if (!records.length) { + cache.address_history_last_checked_at = new Date().toISOString(); + cache.address_history_synced_at = new Date().toISOString(); + await writeTransactionCacheObject(cache, walletToken); + await sleepMs(BACKGROUND_SYNC_IDLE_MS); + continue; + } + + for (const rpcRecord of records) { + if (!shouldContinueBackgroundTransactionSync(syncId, walletToken)) { + return; + } + + const txid = rpcRecord?.txid; + if (!txid || cache.txid_to_file?.[txid]) { + continue; + } + + let record = transactionRecordFromRpc(rpcRecord); + record = await hydrateLinkedTransactionsForRecord( + txid, + record, + invoke, + broadcastNode, + { transactions: {} }, + shouldContinue, + BACKGROUND_TX_FETCH_DELAY_MS + ); + const fileName = addIndexTransaction(cache, txid, record.txtype); + await writeTransactionRecordFile(fileName, record, walletToken); + if (typeof syncAddressBookFromTransactionRecord === "function") { + await syncAddressBookFromTransactionRecord(txid, record); + } + } + + cache.address_history_synced_at = new Date().toISOString(); + await writeTransactionCacheObject(cache, walletToken); + if (typeof flushAddressBookWrite === "function") { + await flushAddressBookWrite(); + } + failureCount = 0; + } + } catch (_) { + if (shouldContinueBackgroundTransactionSync(syncId, walletToken)) { + const backoff = Math.min(BACKGROUND_SYNC_IDLE_MS * 2 ** failureCount, 60000); + await sleepMs(backoff); + if (shouldContinueBackgroundTransactionSync(syncId, walletToken)) { + runBackgroundTransactionSync(syncId, failureCount + 1, walletToken); + return; + } + } + } +} diff --git a/frontend/js/transfer_form.js b/frontend/js/transfer_form.js new file mode 100644 index 0000000..010c670 --- /dev/null +++ b/frontend/js/transfer_form.js @@ -0,0 +1,497 @@ +const transferSaveButton = document.querySelector("[data-transfer-save]"); +const transferBroadcastButton = document.querySelector("[data-transfer-broadcast]"); +const ATOMIC_UNITS_PER_COIN = 100000000n; +const NON_BASE_TRANSFER_MIN_FEE_ATOMIC = 100000000n; +let transferBalancesRefreshPromise = null; + +function setTransferMessage(message, type = "") { + setScopedTransactionMessage("Transfer", transferMessage, message, type); +} + +function balanceAtomicValue(balance) { + try { + return BigInt(balance?.balance || "0"); + } catch (_) { + return 0n; + } +} + +function transferAssetKey(balance) { + return `${balance?.asset || ""}|${Number(balance?.nft_series || 0)}`; +} + +function transferAssetLabel(balance) { + const amount = formatAtomicBalance(balance?.balance || "0"); + const series = Number(balance?.nft_series || 0); + if (series > 0) { + return `${balance.asset} #${series} (${amount})`; + } + return `${balance.asset} (${amount})`; +} + +function decimalToAtomic(value) { + const text = String(value || "").trim(); + if (!/^\d+(\.\d{0,8})?$/.test(text)) { + return null; + } + + const [whole, fraction = ""] = text.split("."); + const paddedFraction = fraction.padEnd(8, "0"); + return BigInt(whole || "0") * ATOMIC_UNITS_PER_COIN + BigInt(paddedFraction || "0"); +} + +function atomicToDecimal(value) { + const atomic = BigInt(value || 0); + const whole = atomic / ATOMIC_UNITS_PER_COIN; + const fraction = String(atomic % ATOMIC_UNITS_PER_COIN).padStart(8, "0"); + return `${whole}.${fraction}`; +} + +function isBaseTransferAsset(asset) { + return String(asset || "").trim().toUpperCase() === activeBaseCoin(); +} + +function minimumTransferFee(amount, asset) { + if (isBaseTransferAsset(asset)) { + return (amount + 99n) / 100n; + } + return NON_BASE_TRANSFER_MIN_FEE_ATOMIC; +} + +function baseCoinBalanceAtomic() { + const baseCoin = activeBaseCoin(); + const baseBalance = (Array.isArray(activeWalletBalances) ? activeWalletBalances : []).find((balance) => { + return String(balance.asset || "").trim().toUpperCase() === baseCoin && !Number(balance.nft_series || 0); + }); + return balanceAtomicValue(baseBalance); +} + +function selectedTransferAsset() { + if (!transferAssetSelect?.value) { + return null; + } + + const option = transferAssetSelect.selectedOptions?.[0]; + if (!option) { + return null; + } + + return { + asset: option.dataset.asset || "", + nftSeries: Number(option.dataset.nftSeries || 0), + balance: BigInt(option.dataset.balance || "0") + }; +} + +function updateTransferDerivedFields() { + const selected = selectedTransferAsset(); + const sender = activeWalletAddressShort || activeWalletAddressHex || "--"; + + if (transferSender) { + transferSender.textContent = sender; + } + + if (!selected) { + if (transferCoin) { + transferCoin.textContent = "--"; + } + if (transferNftSeries) { + transferNftSeries.textContent = "0"; + } + if (transferBalance) { + transferBalance.textContent = "--"; + } + if (transferAmountInput) { + transferAmountInput.disabled = false; + } + if (transferFeeInput) { + transferFeeInput.value = ""; + } + return; + } + + if (transferCoin) { + transferCoin.textContent = selected.asset || "--"; + } + if (transferNftSeries) { + transferNftSeries.textContent = String(selected.nftSeries || 0); + } + if (transferBalance) { + const seriesLabel = selected.nftSeries > 0 ? ` #${selected.nftSeries}` : ""; + transferBalance.textContent = `${formatAtomicBalance(String(selected.balance))} ${selected.asset}${seriesLabel}`; + } + + if (transferAmountInput) { + if (selected.nftSeries > 0) { + transferAmountInput.value = "1"; + transferAmountInput.disabled = true; + } else { + transferAmountInput.disabled = false; + } + } + + updateTransferFee(); +} + +function updateTransferFee() { + if (!transferFeeInput) { + return; + } + + const selected = selectedTransferAsset(); + if (!selected) { + transferFeeInput.value = ""; + return; + } + + const amount = selected.nftSeries > 0 ? 1n : decimalToAtomic(transferAmountInput?.value); + if (amount === null || amount <= 0n) { + transferFeeInput.value = ""; + return; + } + + transferFeeInput.value = atomicToDecimal(minimumTransferFee(amount, selected.asset)); +} + +function refreshTransferAssetOptions() { + if (!transferAssetSelect) { + return; + } + + const previous = transferAssetSelect.value; + transferAssetSelect.innerHTML = ""; + + const spendableBalances = (Array.isArray(activeWalletBalances) ? activeWalletBalances : []) + .filter((balance) => (balance.asset || "").trim() && balanceAtomicValue(balance) > 0n) + .sort((left, right) => { + const leftSeries = Number(left.nft_series || 0); + const rightSeries = Number(right.nft_series || 0); + if ((left.asset || "") !== (right.asset || "")) { + return (left.asset || "").localeCompare(right.asset || ""); + } + return leftSeries - rightSeries; + }); + + if (!spendableBalances.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = walletLoaded ? "No spendable assets found" : "Load a wallet first"; + transferAssetSelect.appendChild(option); + updateTransferDerivedFields(); + return; + } + + spendableBalances.forEach((balance) => { + const option = document.createElement("option"); + option.value = transferAssetKey(balance); + option.textContent = transferAssetLabel(balance); + option.dataset.asset = balance.asset || ""; + option.dataset.nftSeries = String(Number(balance.nft_series || 0)); + option.dataset.balance = String(balance.balance || "0"); + transferAssetSelect.appendChild(option); + }); + + if ([...transferAssetSelect.options].some((option) => option.value === previous)) { + transferAssetSelect.value = previous; + } + + updateTransferDerivedFields(); +} + +async function refreshTransferBalancesIfNeeded() { + if (activeWalletBalances.length || !walletLoaded || !registrationRegistered) { + refreshTransferAssetOptions(); + return; + } + + if (transferBalancesRefreshPromise) { + return transferBalancesRefreshPromise; + } + + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode) { + refreshTransferAssetOptions(); + return; + } + + transferBalancesRefreshPromise = (async () => { + setTransferMessage("Loading spendable assets..."); + try { + const balances = await invoke("get_active_wallet_balances", { broadcastNode }); + activeWalletBalances = Array.isArray(balances) ? balances : []; + refreshTransferAssetOptions(); + setTransferMessage(""); + } catch (error) { + refreshTransferAssetOptions(); + setTransferMessage(String(error), "error"); + throw error; + } finally { + transferBalancesRefreshPromise = null; + } + })(); + + return transferBalancesRefreshPromise; +} + +function parseTransferPaymentUri(value) { + const text = String(value || "").trim(); + let uri; + try { + uri = new URL(text); + } catch (_) { + throw new Error("Enter a valid Contractless payment URI."); + } + + if (uri.protocol.toLowerCase() !== "contractless:") { + throw new Error("Payment URI must begin with contractless:."); + } + + const encodedAddress = uri.hostname || uri.pathname.replace(/^\/+/, ""); + let receiver; + try { + receiver = decodeURIComponent(encodedAddress).trim(); + } catch (_) { + throw new Error("Payment URI contains an invalid receiver address."); + } + if (!receiver) { + throw new Error("Payment URI does not contain a receiver address."); + } + + const asset = (uri.searchParams.get("asset") || activeBaseCoin()).trim(); + if (!asset) { + throw new Error("Payment URI does not contain a valid asset."); + } + + const amount = (uri.searchParams.get("amount") || "").trim(); + if (amount) { + const atomicAmount = decimalToAtomic(amount); + if (atomicAmount === null || atomicAmount <= 0n) { + throw new Error("Payment URI contains an invalid amount."); + } + } + + const label = (uri.searchParams.get("label") || "").trim(); + + return { receiver, asset, amount, label }; +} + +async function loadTransferPaymentUri(value) { + const payment = parseTransferPaymentUri(value); + openTransactionType("Transfer"); + await refreshTransferBalancesIfNeeded(); + + const matchingOption = [...(transferAssetSelect?.options || [])].find((option) => { + return ( + String(option.dataset.asset || "").trim().toLowerCase() === payment.asset.toLowerCase() && + Number(option.dataset.nftSeries || 0) === 0 + ); + }); + + if (!matchingOption) { + throw new Error(`The active wallet does not have a spendable ${payment.asset} balance.`); + } + + transferAssetSelect.value = matchingOption.value; + transferReceiverInput.value = payment.receiver; + transferAmountInput.value = payment.amount; + if (typeof addPaymentUriAddressBookEntry === "function") { + await addPaymentUriAddressBookEntry(payment.receiver, payment.label); + } + updateTransferDerivedFields(); + updateTransferFee(); + setTransferMessage(""); +} + +function validateTransferForm() { + const selected = selectedTransferAsset(); + if (!walletLoaded) { + return "Load a wallet before creating a transfer."; + } + if (!registrationRegistered) { + return "Address registration is required before creating a transfer."; + } + if (!selected) { + return "Select a coin, token, or NFT to transfer."; + } + + const receiver = String(transferReceiverInput?.value || "").trim(); + if (!receiver) { + return "Enter a receiver address."; + } + if ( + receiver.toLowerCase() === String(activeWalletAddressHex || "").toLowerCase() || + receiver.toLowerCase() === String(activeWalletAddressShort || "").toLowerCase() + ) { + return "Receiver cannot be the active wallet address."; + } + + const amount = selected.nftSeries > 0 ? 1n : decimalToAtomic(transferAmountInput?.value); + if (amount === null || amount <= 0n) { + return "Enter a valid amount with up to 8 decimal places."; + } + if (selected.nftSeries > 0 && String(transferAmountInput?.value || "").trim() !== "1") { + return "NFT transfers must send exactly 1 item."; + } + if (amount > selected.balance) { + return "Amount exceeds the available balance for this asset."; + } + + const expectedFee = minimumTransferFee(amount, selected.asset); + const fee = decimalToAtomic(transferFeeInput?.value || "0"); + if (fee === null) { + return "Transaction fee could not be calculated."; + } + if (fee < expectedFee) { + return `Transaction fee must be at least ${atomicToDecimal(expectedFee)} ${activeBaseCoin()}.`; + } + + const baseBalance = baseCoinBalanceAtomic(); + if (isBaseTransferAsset(selected.asset)) { + if (amount + fee > selected.balance) { + return "Amount plus transaction fee exceeds the available base coin balance."; + } + } else if (fee > baseBalance) { + return `This transfer needs ${atomicToDecimal(fee)} ${activeBaseCoin()} for the transaction fee.`; + } + + return ""; +} + +function transferPayload() { + const selected = selectedTransferAsset(); + const value = selected?.nftSeries > 0 ? 1n : decimalToAtomic(transferAmountInput?.value); + const fee = decimalToAtomic(transferFeeInput?.value || "0"); + + return { + receiver: String(transferReceiverInput?.value || "").trim(), + coin: selected?.asset || "", + nftSeries: Number(selected?.nftSeries || 0), + value: String(value || 0n), + txfee: String(fee || 0n) + }; +} + +function setTransferButtonsDisabled(disabled) { + if (transferSaveButton) { + transferSaveButton.disabled = disabled; + } + if (transferBroadcastButton) { + transferBroadcastButton.disabled = disabled; + } +} + +function clearTransferForm() { + if (transferReceiverInput) { + transferReceiverInput.value = ""; + } + if (transferAmountInput) { + transferAmountInput.value = ""; + } + if (transferAssetSelect) { + transferAssetSelect.selectedIndex = -1; + } + updateTransferDerivedFields(); +} + +async function handleTransferAction(action) { + const error = validateTransferForm(); + if (error) { + setTransferMessage(error, "error"); + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setTransferMessage("Wallet bridge is not available.", "error"); + return; + } + + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setTransferMessage("Signing cancelled."); + return; + } + + setTransferButtonsDisabled(true); + try { + if (action === "save") { + const broadcastNode = activeBroadcastNode(); + if (!broadcastNode) { + throw new Error("Select a broadcast node before saving."); + } + setTransferMessage("Saving transfer..."); + const review = await invoke("save_transfer_transaction", { + ...transferPayload(), + broadcastNode, + walletKey + }); + clearTransferForm(); + setTransferMessage(`Transfer saved to unbroadcast transactions: ${review.file_name}`, "success"); + if (typeof refreshUnbroadcastTransactions === "function") { + refreshUnbroadcastTransactions(); + } + } else { + const broadcastNode = activeBroadcastNode(); + if (!broadcastNode) { + throw new Error("Select a broadcast node before broadcasting."); + } + setTransferMessage("Broadcasting transfer..."); + const result = await invoke("broadcast_transfer_transaction", { + ...transferPayload(), + broadcastNode, + walletKey + }); + clearTransferForm(); + setTransferMessage(result.response || "Transfer broadcast.", "success"); + } + } catch (error) { + setTransferMessage(String(error), "error"); + } finally { + setTransferButtonsDisabled(false); + } +} + +function prepareTransferForm() { + setTransferMessage(""); + refreshTransferBalancesIfNeeded().catch(() => { + // The form already displays the RPC error; keep navigation responsive. + }); +} + +transferAssetSelect?.addEventListener("change", () => { + updateTransferDerivedFields(); + setTransferMessage(""); +}); +transferReceiverInput?.addEventListener("input", () => setTransferMessage("")); +transferAmountInput?.addEventListener("input", () => { + updateTransferFee(); + setTransferMessage(""); +}); +transferSaveButton?.addEventListener("click", () => handleTransferAction("save")); +transferBroadcastButton?.addEventListener("click", () => handleTransferAction("broadcast")); + +topbarPaymentUriInput?.addEventListener("input", () => { + topbarPaymentUriInput.setCustomValidity(""); +}); + +topbarPaymentUriForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + topbarPaymentUriInput?.setCustomValidity(""); + + try { + await loadTransferPaymentUri(topbarPaymentUriInput?.value); + if (topbarPaymentUriInput) { + topbarPaymentUriInput.value = ""; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (topbarPaymentUriInput) { + topbarPaymentUriInput.setCustomValidity(message); + topbarPaymentUriInput.reportValidity(); + } + if (currentView === "send") { + setTransferMessage(message, "error"); + } + } +}); diff --git a/frontend/js/unbroadcast_transactions.js b/frontend/js/unbroadcast_transactions.js new file mode 100644 index 0000000..cbe3c5a --- /dev/null +++ b/frontend/js/unbroadcast_transactions.js @@ -0,0 +1,631 @@ +function setUnbroadcastListMessage(message, type = "") { + if (!unbroadcastListMessage) { + return; + } + unbroadcastListMessage.textContent = message; + unbroadcastListMessage.classList.toggle("error", type === "error"); + unbroadcastListMessage.classList.toggle("success", type === "success"); + unbroadcastListMessage.classList.toggle("hidden", !message); +} + +function setUnbroadcastReviewMessage(message, type = "") { + if (!unbroadcastReviewMessage) { + return; + } + unbroadcastReviewMessage.textContent = message; + unbroadcastReviewMessage.classList.toggle("error", type === "error"); + unbroadcastReviewMessage.classList.toggle("success", type === "success"); + unbroadcastReviewMessage.classList.toggle("hidden", !message); +} + +function unbroadcastStatusLabel(item) { + switch (item?.status) { + case "ready": + return "Ready"; + case "needs_active_signature": + return "Needs Your Signature"; + case "needs_other_signature": + return "Needs Other Signature"; + case "invalid_hash": + return "Invalid Hash"; + case "invalid_signature": + return "Invalid Signature"; + default: + return item?.status || "Unknown"; + } +} + +function truncateMiddle(value, left = 12, right = 12) { + const text = String(value || ""); + if (text.length <= left + right + 3) { + return text; + } + return `${text.slice(0, left)}...${text.slice(-right)}`; +} + +function clearUnbroadcastReview() { + activeUnbroadcastTransaction = null; + if (unbroadcastReviewGrid) { + unbroadcastReviewGrid.innerHTML = ""; + } + if (unbroadcastJsonPreview) { + unbroadcastJsonPreview.textContent = ""; + } + setUnbroadcastReviewMessage(""); +} + +function renderUnbroadcastList(items = []) { + if (!transactionList) { + return; + } + + transactionList.innerHTML = ""; + if (!items.length) { + setUnbroadcastListMessage("No unbroadcast transactions found."); + return; + } + + setUnbroadcastListMessage(""); + items.forEach((item) => { + const button = document.createElement("button"); + button.className = "local-transaction"; + button.type = "button"; + button.dataset.unbroadcastFile = item.file_name; + button.classList.toggle( + "active", + activeUnbroadcastTransaction?.file_name === item.file_name + ); + + const type = document.createElement("span"); + type.className = "tx-kind"; + type.textContent = item.type_name || "Transaction"; + + const summary = document.createElement("strong"); + summary.textContent = item.summary || item.file_name; + + const status = document.createElement("span"); + status.textContent = item.status_message || unbroadcastStatusLabel(item); + + const file = document.createElement("span"); + file.className = "muted-file"; + file.textContent = item.txid ? truncateMiddle(item.txid) : item.file_name; + + button.append(type, summary, status, file); + button.addEventListener("click", () => loadUnbroadcastTransaction(item.file_name)); + transactionList.appendChild(button); + }); +} + +async function refreshUnbroadcastTransactions() { + if (unbroadcastRefreshInFlight) { + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setUnbroadcastListMessage("Tauri bridge is not available.", "error"); + return; + } + + unbroadcastRefreshInFlight = true; + setUnbroadcastListMessage("Loading unbroadcast transactions..."); + try { + const items = await invoke("list_unbroadcast_transactions"); + renderUnbroadcastList(Array.isArray(items) ? items : []); + } catch (error) { + transactionList.innerHTML = ""; + setUnbroadcastListMessage(String(error), "error"); + } finally { + unbroadcastRefreshInFlight = false; + } +} + +function appendReviewField(label, value, wide = false) { + if (!unbroadcastReviewGrid) { + return; + } + const row = document.createElement("div"); + row.classList.toggle("wide", wide); + + const key = document.createElement("span"); + key.textContent = label; + + const val = document.createElement("b"); + val.textContent = value === null || value === undefined || value === "" ? "--" : String(value); + + row.append(key, val); + unbroadcastReviewGrid.appendChild(row); +} + +function primitiveTransactionFields(transaction) { + return Object.entries(transaction || {}).filter(([, value]) => { + return value === null || ["string", "number", "boolean"].includes(typeof value); + }); +} + +function atomicReviewAmount(value) { + try { + return atomicToDecimal(BigInt(value || 0)); + } catch (_) { + return String(value || "0"); + } +} + +function swapReviewAsset(transaction, side) { + const ticker = String(transaction?.[`ticker${side}`] || "").trim(); + const series = Number(transaction?.[`nft_series${side}`] || 0); + return series > 0 ? `${ticker} #${series}` : ticker; +} + +function renderSwapReviewFields(review) { + const transaction = review.transaction || {}; + const active = String(activeWalletAddressShort || activeWalletAddressHex || "").trim(); + const sender1 = String(transaction.sender1 || "").trim(); + const sender2 = String(transaction.sender2 || "").trim(); + const activeIsSender1 = active === sender1; + const activeIsSender2 = active === sender2; + const expiration = Number(transaction.offer_expiration || 0); + + if (activeIsSender1 || activeIsSender2) { + const sendSide = activeIsSender2 ? 2 : 1; + const receiveSide = activeIsSender2 ? 1 : 2; + const counterparty = activeIsSender2 ? sender1 : sender2; + appendReviewField("Your Role", activeIsSender1 ? "Offer Creator (Sender 1)" : "Counterparty (Sender 2)"); + appendReviewField( + activeIsSender1 ? "You Offer" : "You Send", + `${atomicReviewAmount(transaction[`value${sendSide}`])} ${swapReviewAsset(transaction, sendSide)}` + ); + appendReviewField( + activeIsSender1 ? "You Request" : "You Receive", + `${atomicReviewAmount(transaction[`value${receiveSide}`])} ${swapReviewAsset(transaction, receiveSide)}` + ); + appendReviewField( + "Your Miner Tip", + `${atomicReviewAmount(transaction[`tip${sendSide}`])} ${swapReviewAsset(transaction, sendSide)}` + ); + appendReviewField( + "Your Fee", + `${atomicReviewAmount(transaction[`txfee${sendSide}`])} ${activeBaseCoin()}` + ); + appendReviewField("Counterparty", counterparty, true); + } else { + appendReviewField("Viewer Role", "Not a signer"); + appendReviewField("Sender 1", sender1, true); + appendReviewField( + "Sender 1 Offers", + `${atomicReviewAmount(transaction.value1)} ${swapReviewAsset(transaction, 1)}` + ); + appendReviewField("Sender 2", sender2, true); + appendReviewField( + "Sender 2 Offers", + `${atomicReviewAmount(transaction.value2)} ${swapReviewAsset(transaction, 2)}` + ); + } + appendReviewField( + "Sender 1 Signature", + transaction.signature1 + ? (activeIsSender1 ? "Your signature is present" : "Signature supplied") + : "Missing" + ); + appendReviewField( + "Sender 2 Signature", + transaction.signature2 + ? (activeIsSender2 ? "Your signature is present" : "Signature supplied") + : (activeIsSender2 ? "Awaiting your signature" : "Awaiting sender2 signature") + ); + appendReviewField( + "Expires", + expiration ? new Date(expiration * 1000).toLocaleString() : "--" + ); +} + +function loanReviewPeriod(period) { + return { + d: "Daily", + w: "Weekly", + m: "Monthly" + }[String(period || "").trim()] || "Unknown"; +} + +function loanReviewCollateral(transaction) { + return String(transaction?.collateral || "").trim(); +} + +function renderLoanReviewFields(review) { + const transaction = review.transaction || {}; + const active = String(activeWalletAddressShort || activeWalletAddressHex || "").trim(); + const lender = String(transaction.lender || "").trim(); + const borrower = String(transaction.borrower || "").trim(); + const activeIsLender = active === lender; + const activeIsBorrower = active === borrower; + const startTimestamp = Number(transaction.timestamp || transaction.time || 0); + const paymentCount = Number(transaction.payment_number || 0); + const paymentAmount = BigInt(transaction.payment_amount || 0); + const scheduledTotal = paymentAmount * BigInt(paymentCount); + + appendReviewField( + "Your Role", + activeIsLender ? "Offer Creator (Lender)" : activeIsBorrower ? "Borrower" : "Not a signer" + ); + appendReviewField("Lender", lender, true); + appendReviewField("Borrower", borrower, true); + appendReviewField( + "Loan", + `${atomicReviewAmount(transaction.loan_amount)} ${String(transaction.loan_coin || "").trim()}` + ); + appendReviewField( + "Collateral", + `${atomicReviewAmount(transaction.collateral_amount)} ${loanReviewCollateral(transaction)}` + ); + appendReviewField( + "Start Date (UTC)", + startTimestamp ? new Date(startTimestamp * 1000).toLocaleString([], { timeZone: "UTC" }) : "--" + ); + appendReviewField("Payment Period", loanReviewPeriod(transaction.payment_period)); + appendReviewField("Number of Payments", paymentCount); + appendReviewField( + "Amount per Payment", + `${atomicReviewAmount(transaction.payment_amount)} ${String(transaction.loan_coin || "").trim()}` + ); + appendReviewField( + "Total Scheduled Repayment", + `${atomicReviewAmount(scheduledTotal)} ${String(transaction.loan_coin || "").trim()}` + ); + appendReviewField("Missed Payments Allowed", Number(transaction.grace_period || 0)); + appendReviewField( + "Maximum Overdue Value", + `${atomicReviewAmount(transaction.max_late_value)} ${String(transaction.loan_coin || "").trim()}` + ); + appendReviewField( + "Lender Fee", + `${atomicReviewAmount(transaction.txfee)} ${activeBaseCoin()}` + ); + appendReviewField( + "Collateral Claim Rule", + "The missed-payment allowance and maximum overdue value must both be exceeded.", + true + ); + appendReviewField( + "Lender Signature", + transaction.signature1 + ? (activeIsLender ? "Your signature is present" : "Signature supplied") + : (activeIsLender ? "Awaiting your signature" : "Missing") + ); + appendReviewField( + "Borrower Signature", + transaction.signature2 + ? (activeIsBorrower ? "Your signature is present" : "Signature supplied") + : (activeIsBorrower ? "Awaiting your signature" : "Awaiting borrower signature") + ); +} + +function renderUnbroadcastReview(review) { + activeUnbroadcastTransaction = review; + if (transactionTitle) { + transactionTitle.textContent = `${review.type_name || "Transaction"} Review`; + } + if (transactionCopy) { + transactionCopy.textContent = review.status_message || "Inspect this local transaction before broadcasting."; + } + if (transactionList) { + transactionList.classList.add("hidden"); + } + formPanel?.classList.add("hidden"); + reviewPanel?.classList.remove("hidden"); + + if (unbroadcastReviewGrid) { + unbroadcastReviewGrid.innerHTML = ""; + } + appendReviewField("Type", review.type_name); + appendReviewField("Status", unbroadcastStatusLabel(review)); + appendReviewField("File", review.file_name, true); + appendReviewField("Transaction Hash", review.txid || review.transaction?.hash || "--", true); + appendReviewField("Summary", review.summary, true); + + if (Number(review.transaction_type) === 6) { + renderSwapReviewFields(review); + } else if (Number(review.transaction_type) === 7) { + renderLoanReviewFields(review); + } else { + primitiveTransactionFields(review.transaction) + .filter(([key]) => !["txtype", "hash"].includes(key)) + .forEach(([key, value]) => { + const isSignature = key.toLowerCase().includes("signature"); + appendReviewField( + key, + isSignature ? (value ? truncateMiddle(value, 18, 18) : "Missing") : value, + String(value || "").length > 28 + ); + }); + } + + if (unbroadcastJsonPreview) { + unbroadcastJsonPreview.textContent = JSON.stringify(review.transaction || {}, null, 2); + } + + if (unbroadcastSignButton) { + unbroadcastSignButton.disabled = !review.can_sign; + unbroadcastSignButton.classList.toggle("hidden", !review.can_sign); + unbroadcastSignButton.textContent = + [6, 7].includes(Number(review.transaction_type)) + ? "Sign and Broadcast" + : "Sign Transaction"; + } + if (unbroadcastBroadcastButton) { + unbroadcastBroadcastButton.disabled = !review.can_broadcast; + } + setUnbroadcastReviewMessage(review.status_message || ""); +} + +async function validateImportedSwapBalance(review) { + if (Number(review?.transaction_type) !== 6) { + return; + } + const transaction = review.transaction || {}; + const active = String(activeWalletAddressShort || activeWalletAddressHex || "").trim(); + if (active !== String(transaction.sender2 || "").trim()) { + throw new Error(`This swap requires sender2 wallet ${transaction.sender2}.`); + } + + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + const [balances, nfts] = await Promise.all([ + invoke("get_active_wallet_balances", { broadcastNode }), + invoke("nft_list", { broadcastNode }) + ]); + activeWalletBalances = Array.isArray(balances) ? balances : []; + const nftKeys = new Set((Array.isArray(nfts) ? nfts : []).map((nft) => { + return `${String(nft.nft_name || "").trim().toLowerCase()}|${Number(nft.series || 0)}`; + })); + const asset = String(transaction.ticker2 || "").trim(); + const series = Number(transaction.nft_series2 || 0); + const isNft = nftKeys.has(`${asset.toLowerCase()}|${series}`); + const balance = activeWalletBalances.find((row) => { + return ( + String(row.asset || "").trim().toLowerCase() === asset.toLowerCase() && + Number(row.nft_series || 0) === series + ); + }); + const available = balanceAtomicValue(balance); + const amount = BigInt(transaction.value2 || 0); + const tip = BigInt(transaction.tip2 || 0); + const fee = BigInt(transaction.txfee2 || 0); + if (isNft && tip !== 0n) { + throw new Error("NFT swap tips must be zero."); + } + if (!isNft && tip < (amount + 99n) / 100n) { + throw new Error("The counterparty miner tip must be at least 1% of the swap amount."); + } + if (amount + tip > available) { + throw new Error(`This wallet does not have enough ${swapReviewAsset(transaction, 2)} for the swap.`); + } + const baseBalance = baseCoinBalanceAtomic(); + if (isBaseTransferAsset(asset)) { + if (amount + tip + fee > available) { + throw new Error(`This wallet cannot cover the requested amount, tip, and ${activeBaseCoin()} fee.`); + } + } else if (fee > baseBalance) { + throw new Error(`This wallet does not have enough ${activeBaseCoin()} for the swap fee.`); + } +} + +async function validateImportedLoanBalance(review) { + if (Number(review?.transaction_type) !== 7) { + return; + } + const transaction = review.transaction || {}; + const active = String(activeWalletAddressShort || activeWalletAddressHex || "").trim(); + const borrower = String(transaction.borrower || "").trim(); + if (active !== borrower) { + throw new Error(`This loan requires borrower wallet ${borrower}.`); + } + + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + const balances = await invoke("get_active_wallet_balances", { broadcastNode }); + activeWalletBalances = Array.isArray(balances) ? balances : []; + + const collateral = loanReviewCollateral(transaction); + const split = collateral.match(/^(.*)_(\d{1,5})$/); + const asset = split ? split[1] : collateral; + const series = split ? Number(split[2]) : 0; + const balance = activeWalletBalances.find((row) => { + return ( + String(row.asset || "").trim().toLowerCase() === asset.toLowerCase() + && Number(row.nft_series || 0) === series + ); + }); + const available = balanceAtomicValue(balance); + const required = BigInt(transaction.collateral_amount || 0); + if (required > available) { + const label = series > 0 ? `${asset} #${series}` : asset; + throw new Error( + `This wallet requires ${atomicReviewAmount(required)} ${label} as collateral, but only ${atomicReviewAmount(available)} is available.` + ); + } +} + +async function importUnbroadcastTransaction() { + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode) { + setUnbroadcastListMessage("Select a broadcast node before importing.", "error"); + return; + } + if (!walletLoaded) { + setUnbroadcastListMessage("Load a wallet before importing a transaction.", "error"); + return; + } + + setUnbroadcastListMessage("Validating imported transaction..."); + try { + const review = await invoke("import_unbroadcast_transaction", { broadcastNode }); + if (!review) { + setUnbroadcastListMessage(""); + return; + } + renderUnbroadcastReview(review); + refreshUnbroadcastTransactions(); + } catch (error) { + setUnbroadcastListMessage(String(error), "error"); + } +} + +async function loadUnbroadcastTransaction(fileName) { + if (unbroadcastReviewInFlight) { + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setUnbroadcastListMessage("Tauri bridge is not available.", "error"); + return; + } + + unbroadcastReviewInFlight = true; + setUnbroadcastListMessage(""); + try { + const review = await invoke("load_unbroadcast_transaction", { fileName }); + renderUnbroadcastReview(review); + refreshUnbroadcastTransactions(); + } catch (error) { + setUnbroadcastListMessage(String(error), "error"); + } finally { + unbroadcastReviewInFlight = false; + } +} + +function showUnbroadcastList() { + if (transactionTitle) { + transactionTitle.textContent = "Unbroadcast Transactions"; + } + if (transactionCopy) { + transactionCopy.textContent = "Transactions saved locally and waiting for review."; + } + transactionList?.classList.remove("hidden"); + formPanel?.classList.add("hidden"); + reviewPanel?.classList.add("hidden"); + clearUnbroadcastReview(); + refreshUnbroadcastTransactions(); +} + +async function signActiveUnbroadcastTransaction() { + if (!activeUnbroadcastTransaction?.file_name) { + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode) { + setUnbroadcastReviewMessage("Select a broadcast node before signing.", "error"); + return; + } + + try { + await validateImportedSwapBalance(activeUnbroadcastTransaction); + await validateImportedLoanBalance(activeUnbroadcastTransaction); + } catch (error) { + setUnbroadcastReviewMessage(String(error), "error"); + return; + } + + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setUnbroadcastReviewMessage("Signing cancelled."); + return; + } + + unbroadcastSignButton.disabled = true; + const transactionType = Number(activeUnbroadcastTransaction.transaction_type); + const signAndBroadcast = [6, 7].includes(transactionType); + const signingType = transactionType === 7 ? "loan" : transactionType === 6 ? "swap" : "transaction"; + setUnbroadcastReviewMessage(`Signing ${signingType}...`); + try { + const review = await invoke("sign_unbroadcast_transaction", { + walletKey, + broadcastNode, + fileName: activeUnbroadcastTransaction.file_name + }); + renderUnbroadcastReview(review); + refreshUnbroadcastTransactions(); + if (signAndBroadcast) { + const typeName = transactionType === 7 ? "Loan" : "Swap"; + setUnbroadcastReviewMessage(`${typeName} signed. Broadcasting...`); + await broadcastActiveUnbroadcastTransaction(); + } else { + setUnbroadcastReviewMessage("Transaction signed.", "success"); + } + } catch (error) { + setUnbroadcastReviewMessage(String(error), "error"); + } finally { + unbroadcastSignButton.disabled = !activeUnbroadcastTransaction?.can_sign; + } +} + +async function broadcastActiveUnbroadcastTransaction() { + if (!activeUnbroadcastTransaction?.file_name) { + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode) { + setUnbroadcastReviewMessage("No broadcast node is selected.", "error"); + return; + } + + unbroadcastBroadcastButton.disabled = true; + setUnbroadcastReviewMessage("Broadcasting transaction..."); + try { + const broadcastedTransaction = activeUnbroadcastTransaction?.transaction; + const result = await invoke("broadcast_unbroadcast_transaction", { + broadcastNode, + fileName: activeUnbroadcastTransaction.file_name + }); + if (typeof applyActiveVanityDisplayFromTransaction === "function") { + applyActiveVanityDisplayFromTransaction(broadcastedTransaction); + } + const successMessage = result?.response || "Broadcast completed."; + transactionTitle.textContent = "Unbroadcast Transactions"; + transactionCopy.textContent = "Transactions saved locally and waiting for review."; + transactionList?.classList.remove("hidden"); + formPanel?.classList.add("hidden"); + reviewPanel?.classList.add("hidden"); + clearUnbroadcastReview(); + await refreshUnbroadcastTransactions(); + setUnbroadcastListMessage(successMessage, "success"); + } catch (error) { + setUnbroadcastReviewMessage(String(error), "error"); + } finally { + unbroadcastBroadcastButton.disabled = !activeUnbroadcastTransaction?.can_broadcast; + } +} + +async function deleteActiveUnbroadcastTransaction() { + if (!activeUnbroadcastTransaction?.file_name) { + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setUnbroadcastReviewMessage("Tauri bridge is not available.", "error"); + return; + } + + const fileName = activeUnbroadcastTransaction.file_name; + setUnbroadcastReviewMessage("Deleting local copy..."); + try { + await invoke("delete_unbroadcast_transaction", { fileName }); + showUnbroadcastList(); + } catch (error) { + setUnbroadcastReviewMessage(String(error), "error"); + } +} + +unbroadcastBackButton?.addEventListener("click", showUnbroadcastList); +unbroadcastSignButton?.addEventListener("click", signActiveUnbroadcastTransaction); +unbroadcastBroadcastButton?.addEventListener("click", broadcastActiveUnbroadcastTransaction); +unbroadcastDeleteButton?.addEventListener("click", deleteActiveUnbroadcastTransaction); +document + .querySelector("[data-import-transaction]") + ?.addEventListener("click", importUnbroadcastTransaction); diff --git a/frontend/js/vanity_address_form.js b/frontend/js/vanity_address_form.js new file mode 100644 index 0000000..5d49ca1 --- /dev/null +++ b/frontend/js/vanity_address_form.js @@ -0,0 +1,241 @@ +const VANITY_ADDRESS_FEE_ATOMIC = 500000000n; + +function setVanityMessage(message, type = "") { + setScopedTransactionMessage("Vanity Address", vanityMessage, message, type); +} + +function vanityNetworkSuffix() { + return activeBaseCoin().toLowerCase() || "cltc"; +} + +function normalizedVanityName() { + return String(vanityNameInput?.value || "").trim().toLowerCase(); +} + +function vanityPreviewValue() { + const name = normalizedVanityName(); + return name ? `${name}.${vanityNetworkSuffix()}` : "--"; +} + +function displayVanityAddress(vanityAddress) { + const value = String(vanityAddress || "").trim(); + const separator = value.lastIndexOf("."); + if (separator < 1) { + return value || "N/A"; + } + const payload = value.slice(0, separator).trimStart(); + const suffix = value.slice(separator + 1); + return payload ? `${payload}.${suffix}` : "N/A"; +} + +function applyActiveVanityDisplay(vanityAddress) { + const display = displayVanityAddress(vanityAddress); + document.querySelectorAll("[data-active-vanity]").forEach((item) => { + item.textContent = display; + }); + document.querySelectorAll("[data-copy-vanity]").forEach((button) => { + button.disabled = display === "N/A"; + button.textContent = "Copy"; + }); +} + +function applyActiveVanityDisplayFromTransaction(transaction) { + const vanityAddress = transaction?.vanity_address; + const owner = transaction?.address; + const active = activeWalletAddressShort || activeWalletAddressHex || ""; + if (vanityAddress && owner && String(owner).trim().toLowerCase() === active.toLowerCase()) { + applyActiveVanityDisplay(vanityAddress); + } +} + +function updateVanityDerivedFields() { + if (vanityFeeInput) { + vanityFeeInput.value = atomicToDecimal(VANITY_ADDRESS_FEE_ATOMIC); + } + if (vanityOwner) { + vanityOwner.textContent = activeWalletAddressShort || activeWalletAddressHex || "--"; + } + if (vanityPreview) { + vanityPreview.textContent = vanityPreviewValue(); + } + if (vanityFeeLabel) { + vanityFeeLabel.textContent = `${atomicToDecimal(VANITY_ADDRESS_FEE_ATOMIC)} ${activeBaseCoin()}`; + } +} + +function validateVanityForm() { + if (!walletLoaded) { + return "Load a wallet before creating a vanity address."; + } + if (!registrationRegistered) { + return "Address registration is required before creating a vanity address."; + } + const name = String(vanityNameInput?.value || "").trim(); + if (!name) { + return "Vanity name is required."; + } + if (name.length > 20) { + return "Vanity name cannot exceed 20 letters."; + } + if (!/^[A-Za-z]+$/.test(name)) { + return "Vanity name can only contain letters."; + } + if (baseCoinBalanceAtomic() < VANITY_ADDRESS_FEE_ATOMIC) { + return `Vanity address registration requires ${atomicToDecimal(VANITY_ADDRESS_FEE_ATOMIC)} ${activeBaseCoin()} for the fee.`; + } + return ""; +} + +function validateVanityAvailabilityCheck() { + if (!walletLoaded) { + return "Load a wallet before checking a vanity address."; + } + if (!activeBroadcastNode()) { + return "Select a broadcast node before checking a vanity address."; + } + const name = String(vanityNameInput?.value || "").trim(); + if (!name) { + return "Vanity name is required."; + } + if (name.length > 20) { + return "Vanity name cannot exceed 20 letters."; + } + if (!/^[A-Za-z]+$/.test(name)) { + return "Vanity name can only contain letters."; + } + return ""; +} + +function vanityPayload() { + return { + vanityName: String(vanityNameInput.value || "").trim(), + txfee: String(VANITY_ADDRESS_FEE_ATOMIC) + }; +} + +function clearVanityForm() { + if (vanityNameInput) { + vanityNameInput.value = ""; + } + updateVanityDerivedFields(); +} + +function setVanityButtonsDisabled(disabled) { + document.querySelector("[data-vanity-save]")?.toggleAttribute("disabled", disabled); + document.querySelector("[data-vanity-broadcast]")?.toggleAttribute("disabled", disabled); + document.querySelector("[data-vanity-check]")?.toggleAttribute("disabled", disabled); +} + +async function checkVanityAvailability() { + const validationError = validateVanityAvailabilityCheck(); + if (validationError) { + setVanityMessage(validationError, "error"); + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setVanityMessage("Wallet bridge is not available.", "error"); + return; + } + + setVanityButtonsDisabled(true); + try { + setVanityMessage("Checking vanity address availability..."); + const result = await invoke("check_vanity_address_availability", { + broadcastNode: activeBroadcastNode(), + vanityName: String(vanityNameInput.value || "").trim() + }); + + if (result?.available) { + setVanityMessage(`${result.vanity_address} is available.`, "success"); + } else { + setVanityMessage(`${result?.vanity_address || vanityPreviewValue()} is already registered.`, "error"); + } + } catch (error) { + setVanityMessage(String(error), "error"); + } finally { + setVanityButtonsDisabled(false); + } +} + +async function handleVanityAction(action) { + const validationError = validateVanityForm(); + if (validationError) { + setVanityMessage(validationError, "error"); + return; + } + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setVanityMessage("Wallet bridge is not available.", "error"); + return; + } + + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setVanityMessage("Signing cancelled."); + return; + } + + setVanityButtonsDisabled(true); + try { + if (action === "save") { + setVanityMessage("Saving vanity address transaction..."); + const review = await invoke("save_vanity_address_transaction", { + ...vanityPayload(), + walletKey + }); + clearVanityForm(); + setVanityMessage(`Vanity address transaction saved: ${review.file_name}`, "success"); + if (typeof refreshUnbroadcastTransactions === "function") { + refreshUnbroadcastTransactions(); + } + } else { + const broadcastNode = activeBroadcastNode(); + if (!broadcastNode) { + throw new Error("Select a broadcast node before broadcasting."); + } + setVanityMessage("Broadcasting vanity address transaction..."); + const result = await invoke("broadcast_vanity_address_transaction", { + ...vanityPayload(), + broadcastNode, + walletKey + }); + applyActiveVanityDisplay(vanityPreviewValue()); + clearVanityForm(); + setVanityMessage(result.response || "Vanity address transaction broadcast.", "success"); + } + } catch (error) { + setVanityMessage(String(error), "error"); + } finally { + setVanityButtonsDisabled(false); + } +} + +function prepareVanityAddressForm() { + updateVanityDerivedFields(); + setVanityMessage(""); +} + +function resetVanityAddressSession() { + clearVanityForm(); + setVanityMessage(""); +} + +vanityNameInput?.addEventListener("input", () => { + setVanityMessage(""); + updateVanityDerivedFields(); +}); + +document + .querySelector("[data-vanity-save]") + ?.addEventListener("click", () => handleVanityAction("save")); + +document + .querySelector("[data-vanity-broadcast]") + ?.addEventListener("click", () => handleVanityAction("broadcast")); + +document + .querySelector("[data-vanity-check]") + ?.addEventListener("click", checkVanityAvailability); diff --git a/frontend/js/wallet_session.js b/frontend/js/wallet_session.js new file mode 100644 index 0000000..7d60c7c --- /dev/null +++ b/frontend/js/wallet_session.js @@ -0,0 +1,371 @@ +function setStartupMessage(message, type = "") { + if (!startupMessage) { + return; + } + + startupMessage.textContent = message; + startupMessage.classList.toggle("error", type === "error"); + startupMessage.classList.toggle("success", type === "success"); +} + +function setLoadMessage(message, type = "") { + if (!loadMessage) { + return; + } + + loadMessage.textContent = message; + loadMessage.classList.toggle("error", type === "error"); + loadMessage.classList.toggle("success", type === "success"); +} + +function setWalletSwitchMessage(message, type = "") { + if (!walletSwitchMessage) { + return; + } + + walletSwitchMessage.textContent = message; + walletSwitchMessage.classList.toggle("error", type === "error"); + walletSwitchMessage.classList.toggle("success", type === "success"); +} + +function setImportMessage(message, type = "") { + if (!importMessage) { + return; + } + + importMessage.textContent = message; + importMessage.classList.toggle("error", type === "error"); + importMessage.classList.toggle("success", type === "success"); +} + +function setImportImageMessage(message, type = "") { + if (!importImageMessage) { + return; + } + + importImageMessage.textContent = message; + importImageMessage.classList.toggle("error", type === "error"); + importImageMessage.classList.toggle("success", type === "success"); +} + +function updateActiveWallet(wallet) { + balanceRefreshRequestId += 1; + registrationRequestId += 1; + balanceRefreshInFlight = false; + registrationCheckInFlight = false; + stopBalanceRefresh(); + stopBackgroundTransactionSync(); + stopLatestDashboardTransactionsPolling(); + stopHistoryRefresh(); + clearHistoryRows(); + if (typeof resetTransactionForms === "function") { + resetTransactionForms(); + } + if (currentView === "send" && typeof showTransactionList === "function") { + setActiveTransactionType("Transfer"); + showTransactionList(); + } + if (typeof resetMarketingCampaignPage === "function") { + resetMarketingCampaignPage(); + } + if (typeof resetAddressBookPage === "function") { + resetAddressBookPage(); + } + if (typeof resetAssetsPage === "function") { + resetAssetsPage(); + } + if (typeof resetNftsPage === "function") { + resetNftsPage(); + } + if (typeof resetLoansPage === "function") { + resetLoansPage(); + } + resetLatestDashboardTransactionsMemory(); + dashboardLastBalanceStatus = ""; + resetBalances(`0 ${activeBaseCoin()}`, "Checking balances for this wallet."); + + activeWalletPath = wallet.wallet_path || ""; + const shortAddress = wallet.short_address; + const vanityAddress = wallet.vanity_address || "N/A"; + const walletName = wallet.wallet_name || shortenAddress(shortAddress); + walletLoaded = true; + activeWalletAddressHex = (wallet.short_address_hex || "").toLowerCase(); + activeWalletAddressShort = (wallet.short_address || "").toLowerCase(); + setWalletImageMarks(wallet.private_key_image); + + document.querySelectorAll("[data-active-address]").forEach((item) => { + item.textContent = shortAddress; + }); + + document.querySelectorAll("[data-active-vanity]").forEach((item) => { + item.textContent = vanityAddress; + }); + document.querySelectorAll("[data-copy-vanity]").forEach((button) => { + button.disabled = vanityAddress === "N/A"; + button.textContent = "Copy"; + }); + + document.querySelectorAll("[data-active-wallet-short]").forEach((item) => { + item.textContent = shortenAddress(shortAddress).replace(".cltc", ""); + }); + + registrationRegistered = false; + setRegistrationState("checking"); + checkWalletRegistrationOnce(); + resetAutoLockTimer(); + populateTopWalletList(); + updateHistoryRefreshState(); + if (typeof refreshAddressBookVanitiesOnce === "function") { + refreshAddressBookVanitiesOnce(); + } +} + +function setActiveNetwork(network) { + activeNetwork = network; + const label = network === "mainnet" ? "Mainnet" : "Testnet"; + + networkButtons.forEach((button) => { + button.classList.toggle("active", button.dataset.networkToggle === network); + }); + + networkLabels.forEach((item) => { + item.textContent = label; + }); + + resetBalances(walletLoaded ? `0 ${activeBaseCoin()}` : "Load wallet"); + updateBalanceRefreshState(); + updateHistoryRefreshState(); + resetNetworkInfoDisplays(); + updateNetworkInfoRefreshState(); +} + +function clearActiveWallet() { + balanceRefreshRequestId += 1; + registrationRequestId += 1; + balanceRefreshInFlight = false; + walletLoaded = false; + resetAutoLockTimer(); + activeWalletAddressHex = ""; + activeWalletAddressShort = ""; + latestKnownBlockHeight = 0; + registrationRegistered = false; + registrationCheckInFlight = false; + stopBalanceRefresh(); + stopNetworkInfoRefresh(); + stopNodeInfoRefresh(); + stopHistoryRefresh(); + if (typeof resetTransactionForms === "function") { + resetTransactionForms(); + } + if (typeof resetIssueTokenSession === "function") { + resetIssueTokenSession(); + } + if (typeof resetCreateNftSession === "function") { + resetCreateNftSession(); + } + if (typeof resetSwapOfferSession === "function") { + resetSwapOfferSession(); + } + if (typeof resetLoanOfferSession === "function") { + resetLoanOfferSession(); + } + if (typeof resetLoanPaymentSession === "function") { + resetLoanPaymentSession(); + } + if (typeof resetCollateralClaimSession === "function") { + resetCollateralClaimSession(); + } + if (typeof resetBurnSession === "function") { + resetBurnSession(); + } + if (typeof resetMarketingSession === "function") { + resetMarketingSession(); + } + if (typeof resetVanityAddressSession === "function") { + resetVanityAddressSession(); + } + if (typeof resetMarketingCampaignPage === "function") { + resetMarketingCampaignPage(); + } + if (typeof resetAddressBookPage === "function") { + resetAddressBookPage(); + } + if (typeof resetAssetsPage === "function") { + resetAssetsPage(); + } + if (typeof resetNftsPage === "function") { + resetNftsPage(); + } + if (typeof resetLoansPage === "function") { + resetLoansPage(); + } + clearHistoryRows(); + setWalletImageMarks(""); + refreshReceivePage(); + resetBalances(); + resetNetworkInfoDisplays(); + setRegistrationState("unregistered"); + setRecentTransactionsForWalletState("none"); + setHistoryState("Load a wallet to view transaction history."); + + document.querySelectorAll("[data-active-address]").forEach((item) => { + item.textContent = "No wallet loaded"; + }); + + document.querySelectorAll("[data-active-vanity]").forEach((item) => { + item.textContent = "N/A"; + }); + document.querySelectorAll("[data-copy-vanity]").forEach((button) => { + button.disabled = true; + button.textContent = "Copy"; + }); + + document.querySelectorAll("[data-active-wallet-short]").forEach((item) => { + item.textContent = "No wallet"; + }); + + const walletLabel = document.querySelector("[data-active-wallet-label]"); + if (walletLabel) { + walletLabel.innerHTML = "No wallet v"; + } + + refreshReceivePage(); +} + +function lockWalletSession() { + clearWalletClipboard(); + walletNameInput && (walletNameInput.value = ""); + walletKeyInput && (walletKeyInput.value = ""); + walletKeyConfirmInput && (walletKeyConfirmInput.value = ""); + loadWalletKeyInput && (loadWalletKeyInput.value = ""); + importWalletNameInput && (importWalletNameInput.value = ""); + importPrivateKeyInput && (importPrivateKeyInput.value = ""); + importWalletKeyInput && (importWalletKeyInput.value = ""); + importWalletKeyConfirmInput && (importWalletKeyConfirmInput.value = ""); + importImageWalletNameInput && (importImageWalletNameInput.value = ""); + importImagePathInput && (importImagePathInput.value = ""); + importImageWalletKeyInput && (importImageWalletKeyInput.value = ""); + importImageWalletKeyConfirmInput && (importImageWalletKeyConfirmInput.value = ""); + createWalletForm?.classList.add("hidden"); + loadWalletForm?.classList.add("hidden"); + importKeyForm?.classList.add("hidden"); + importImageForm?.classList.add("hidden"); + setStartupMessage(""); + setLoadMessage(""); + setImportMessage(""); + setImportImageMessage(""); + toggleNodeModal(false); + closeWalletSwitchModal({ restoreSelection: false }); + clearActiveWallet(); + setActiveView("dashboard"); + document.body.classList.remove("wallet-open"); +} + +async function refreshWalletList() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !walletListSelect) { + setLoadMessage("Wallet loading requires the desktop app build.", "error"); + return; + } + + setLoadMessage("Checking saved wallets..."); + try { + const wallets = await invoke("list_wallets", { network: activeNetwork }); + walletListSelect.innerHTML = ""; + if (!wallets.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No wallets found"; + walletListSelect.appendChild(option); + setLoadMessage("No app-managed wallets found. You can load an external wallet path."); + return; + } + + wallets.forEach((wallet) => { + const option = document.createElement("option"); + option.value = wallet.wallet_path; + option.textContent = wallet.wallet_name; + walletListSelect.appendChild(option); + }); + setLoadMessage("Choose a saved wallet or enter an external wallet path."); + } catch (error) { + setLoadMessage(String(error), "error"); + } + await populateTopWalletList(); +} + +async function loadWalletByPath(walletPath, walletKey) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + throw new Error("Wallet loading requires the desktop app build."); + } + + return invoke("load_wallet", { walletPath, walletKey }); +} + +function restoreActiveWalletSelection() { + if (topWalletListSelect) { + topWalletListSelect.value = activeWalletPath || ""; + } +} + +function closeWalletSwitchModal({ restoreSelection = true } = {}) { + walletSwitchModal?.classList.add("hidden"); + pendingWalletSwitch = null; + if (walletSwitchSubmit) { + walletSwitchSubmit.disabled = false; + } + if (walletSwitchKeyInput) { + walletSwitchKeyInput.value = ""; + } + setWalletSwitchMessage(""); + if (restoreSelection) { + restoreActiveWalletSelection(); + } +} + +function openWalletSwitchModal(walletPath) { + if (!walletPath || walletPath === activeWalletPath) { + restoreActiveWalletSelection(); + return; + } + + const selectedOption = topWalletListSelect?.selectedOptions?.[0]; + pendingWalletSwitch = { + walletPath, + walletName: selectedOption?.textContent?.trim() || "selected wallet" + }; + + if (walletSwitchName) { + walletSwitchName.textContent = `Unlock ${pendingWalletSwitch.walletName}`; + } + setWalletSwitchMessage(""); + if (walletSwitchKeyInput) { + walletSwitchKeyInput.value = ""; + } + walletSwitchModal?.classList.remove("hidden"); + setTimeout(() => walletSwitchKeyInput?.focus(), 0); +} + +function selectWalletOption(wallet) { + if (!walletListSelect || !wallet) { + return; + } + + const existing = Array.from(walletListSelect.options).find((option) => option.value === wallet.wallet_path); + if (existing) { + existing.selected = true; + return; + } + + const placeholder = Array.from(walletListSelect.options).find((option) => !option.value); + if (placeholder) { + placeholder.remove(); + } + + const option = document.createElement("option"); + option.value = wallet.wallet_path; + option.textContent = wallet.wallet_name; + option.selected = true; + walletListSelect.appendChild(option); +} diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..c8a3ad4 --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,3442 @@ +:root { + --bg: #f5f6f3; + --surface: #ffffff; + --surface-soft: #eef3ef; + --text: #17201d; + --muted: #65716c; + --line: #dce3df; + --green: #157347; + --green-soft: #e3f3ea; + --blue: #245b8f; + --amber: #9a6a10; + --ink: #24302c; + --shadow: 0 14px 40px rgba(31, 42, 37, 0.08); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + overflow-x: hidden; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +.app-shell { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + width: min(1450px, 100vw); + min-height: 720px; + margin: 0 auto; + background: var(--surface); + border-left: 1px solid var(--line); + border-right: 1px solid var(--line); +} + +body:not(.wallet-open) .app-shell, +body:not(.wallet-open) .mobile-nav, +body:not(.wallet-open) .mobile-more-backdrop, +body:not(.wallet-open) .mobile-more-sheet { + display: none; +} + +.startup-screen { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: radial-gradient(circle at top, #1d2a36 0, #111820 52%); +} + +.startup-card { + width: min(520px, 100%); + display: grid; + gap: 22px; + padding: 24px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.startup-brand { + border-bottom: 1px solid var(--line); + padding: 0 0 16px; +} + +.startup-copy { + display: grid; + gap: 8px; +} + +.startup-copy h1 { + font-size: 30px; +} + +.startup-copy p { + color: var(--muted); + line-height: 1.45; +} + +.startup-actions { + display: grid; + gap: 10px; +} + +.network-toggle { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + padding: 4px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-soft); +} + +.network-toggle button { + min-height: 40px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-weight: 800; +} + +.network-toggle button.active { + border-color: #3e6fa3; + background: #1f3550; + color: var(--text); +} + +.startup-action { + min-height: 72px; + display: grid; + gap: 5px; + text-align: left; + padding: 13px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121b24; + color: var(--text); +} + +.startup-action:hover, +.startup-action.primary-start { + border-color: #3e6fa3; + background: #1f3550; +} + +.startup-action span { + color: var(--muted); + font-size: 13px; +} + +.startup-form { + display: grid; + gap: 14px; + padding-top: 2px; +} + +.startup-form label { + display: grid; + gap: 7px; + color: var(--muted); + font-weight: 700; +} + +.startup-form input, +.startup-form select, +.startup-form textarea { + width: 100%; + min-height: 48px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-soft); + color: var(--text); +} + +.startup-form textarea { + min-height: 120px; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + line-height: 1.4; +} + +.wallet-picker-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 52px; + gap: 8px; +} + +.browse-wallet-button { + min-height: 48px; + padding: 0; + font-weight: 900; +} + +.startup-form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.startup-message { + min-height: 20px; + color: var(--muted); + font-size: 13px; + line-height: 1.4; +} + +.startup-message.error { + color: #ff9f9f; +} + +.startup-message.success { + color: #73d99f; +} + +body.wallet-open .startup-screen { + display: none; +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 20px 14px; + background: #ffffff; + color: var(--text); + border-right: 1px solid var(--line); + display: flex; + flex-direction: column; + gap: 22px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 8px 12px; + border-bottom: 1px solid var(--line); +} + +.brand-mark { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 8px; + background: #eef5ff; + color: #174f9d; + font-weight: 800; +} + +.brand-mark.wallet-image { + background-image: url("images/iconw.png"); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + color: transparent; + overflow: hidden; +} + +.brand-name { + font-weight: 750; + line-height: 1.1; +} + +.brand-subtitle, +.mini-label { + color: var(--muted); + font-size: 12px; +} + +.nav-list { + display: grid; + gap: 4px; +} + +.nav-item, +.mobile-item { + border: 0; + background: transparent; +} + +.nav-item { + width: 100%; + min-height: 42px; + padding: 0 10px; + color: #33423d; + display: flex; + align-items: center; + gap: 10px; + border-radius: 8px; + text-align: left; +} + +.nav-item:hover, +.nav-item.active { + color: #ffffff; + background: #174f9d; +} + +.nav-icon { + width: 24px; + text-align: center; + color: currentColor; +} + +.sidebar-footer { + margin-top: auto; + padding: 12px; + border-top: 1px solid var(--line); + border-radius: 0; +} + +.network-chip, +.height-pill { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.network-chip { + margin-top: 8px; + color: var(--green); + font-weight: 650; +} + +.bridge-label { + display: block; + margin-top: 10px; +} + +.bridge-label.success { + color: var(--green); +} + +.bridge-label.error { + color: #ff9a8d; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #35b86d; + box-shadow: 0 0 0 3px rgba(53, 184, 109, 0.16); +} + +.main-shell { + min-width: 0; +} + +.topbar { + min-height: 58px; + padding: 9px 20px; + background: rgba(255, 255, 255, 0.88); + backdrop-filter: blur(14px); + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + gap: 14px; + position: sticky; + top: 0; + z-index: 5; +} + +.menu-button { + display: none; + width: 40px; + height: 40px; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--surface); +} + +.topbar-wallet-mark { + display: none; + flex: 0 0 auto; +} + +.topbar-group { + display: grid; + gap: 5px; + min-width: 0; +} + +.topbar-label, +.field-label { + color: var(--muted); + font-size: 12px; + font-weight: 650; +} + +.select-button, +.icon-button, +.secondary-button, +.primary-button, +.text-button, +.quick-actions button { + border-radius: 8px; + border: 1px solid var(--line); + background: var(--surface); + color: var(--text); +} + +.select-button { + min-height: 34px; + padding: 0 10px; + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; + font-weight: 650; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.node-select .select-button { + min-width: 210px; +} + +.wallet-select .select-button { + min-width: 170px; +} + +.payment-uri-entry { + flex: 1 1 260px; + max-width: 430px; +} + +.payment-uri-entry input { + width: 100%; + min-width: 0; + min-height: 34px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + color: var(--text); +} + +.payment-uri-entry input:focus { + border-color: var(--blue); + outline: 2px solid rgba(56, 132, 219, 0.18); + outline-offset: 0; +} + +.payment-uri-entry input:invalid { + border-color: #ef6f6f; +} + +.topbar-spacer { + flex: 1; +} + +.height-pill { + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + background: var(--green-soft); + color: var(--green); + font-weight: 700; +} + +.icon-button { + width: 38px; + height: 38px; +} + +.content { + padding: 22px; + min-width: 0; +} + +.view-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 20px; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 30px; + line-height: 1.1; + letter-spacing: 0; +} + +.view-heading p, +.panel-header p { + color: var(--muted); + margin-top: 6px; +} + +.heading-actions { + display: flex; + gap: 10px; +} + +.secondary-button, +.primary-button { + min-height: 40px; + padding: 0 14px; + font-weight: 750; +} + +.secondary-button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.primary-button { + border-color: #12211b; + background: #17201d; + color: #ffffff; +} + +.dashboard-grid { + display: grid; + grid-template-columns: minmax(330px, 1.25fr) minmax(230px, 0.82fr) minmax(220px, 0.65fr); + grid-template-areas: + "balance registration quick" + "transactions transactions network" + "notice notice notice"; + gap: 14px; + align-items: stretch; + min-width: 0; +} + +.panel { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 12px 34px rgba(31, 42, 37, 0.05); + padding: 14px; + min-width: 0; +} + +.balance-panel { + grid-area: balance; +} + +.transactions-panel { + grid-area: transactions; +} + +.registration-panel { + grid-area: registration; +} + +.quick-panel { + grid-area: quick; +} + +.network-panel { + grid-area: network; +} + +.registration-notice { + grid-area: notice; + border: 2px solid #fff3ee; + border-radius: 4px; + padding: 10px 14px; + background: rgba(255, 240, 237, 0.18); + color: #ffd7cf; + font-size: 14px; + font-weight: 750; + text-align: center; +} + +.registration-notice.checking { + border-color: #d8e0ea; + background: rgba(238, 242, 247, 0.12); + color: #c2cfdd; +} + +.registration-notice.registered { + border-color: rgba(85, 214, 129, 0.45); + background: rgba(85, 214, 129, 0.08); + color: #7ee0a3; +} + +.assets-panel { + grid-area: assets; +} + +.node-panel { + grid-area: node; +} + +.panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.panel h2 { + font-size: 17px; + letter-spacing: 0; +} + +.registration-badge, +.table-status { + border-radius: 999px; + padding: 5px 9px; + font-size: 12px; + font-weight: 750; +} + +.registration-badge, +.table-status.confirmed { + background: var(--green-soft); + color: var(--green); +} + +.registration-badge.unregistered, +.registration-action.unregistered { + background: #fff0ed; + color: #a33b2f; +} + +.registration-badge.checking, +.registration-action.checking { + background: #eef2f7; + color: #526b89; +} + +.table-status.pending { + background: #fff4d8; + color: var(--amber); +} + +.balance-value { + margin-top: 4px; + font-size: 20px; + line-height: 1.1; + font-weight: 800; + overflow-wrap: anywhere; +} + +.balance-main { + display: flex; + align-items: center; + gap: 20px; + min-height: 120px; + min-width: 0; +} + +.balance-split { + display: grid; + grid-template-columns: minmax(190px, 1fr) minmax(170px, 0.8fr); + gap: 18px; + align-items: center; + min-width: 0; +} + +.coin-stack { + width: 58px; + height: 58px; + border-radius: 50%; + display: grid; + place-items: center; + background: #e2f0ff; + color: #1f74c8; + font-weight: 850; + font-size: 24px; +} + +.usd-value { + margin-top: 4px; + color: #526b89; + font-weight: 650; +} + +.usd-value.error { + color: #ffb4a8; +} + +.usd-value.success { + color: #7ee0a3; +} + +.balance-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; +} + +.balance-meta span { + padding: 7px 10px; + border-radius: 999px; + background: var(--surface-soft); + color: var(--muted); + font-size: 13px; + font-weight: 650; +} + +.quick-actions { + display: grid; + gap: 12px; +} + +.quick-actions button { + min-height: 42px; + text-align: center; + padding: 0 12px; + font-weight: 700; +} + +.token-balances { + border-left: 1px solid var(--line); + padding-left: 18px; + display: grid; + gap: 10px; + min-width: 0; +} + +.token-title { + color: var(--muted); + font-size: 12px; + font-weight: 750; +} + +.empty-balance { + color: var(--muted); + font-size: 13px; +} + +.token-row { + display: grid; + grid-template-columns: 28px minmax(48px, 1fr) minmax(0, auto); + gap: 8px; + align-items: center; + font-size: 13px; + min-width: 0; +} + +.token-row span, +.token-row b { + min-width: 0; + overflow-wrap: anywhere; +} + +.asset-mark.mini { + width: 24px; + height: 24px; + border-radius: 50%; + font-size: 12px; +} + +.asset-mark.gold { + background: #fff0cf; + color: var(--amber); +} + +.asset-mark.usdg { + background: #dff1e7; + color: var(--green); +} + +.quick-actions button:nth-child(1) { + background: #eef6ff; + color: #165eb5; + border-color: #bad8f6; +} + +.quick-actions button:nth-child(2) { + background: #eef9f1; + color: #157347; + border-color: #bfdfc9; +} + +.quick-actions button:nth-child(3) { + background: #fff7e7; + color: #9a6a10; + border-color: #ead6a8; +} + +.quick-actions button:nth-child(4) { + background: #f7f0ff; + color: #7432a8; + border-color: #d8c2eb; +} + +.quick-actions button:hover, +.text-button:hover, +.secondary-button:hover { + border-color: #a9b8b1; +} + +.asset-list, +.status-list { + display: grid; + gap: 12px; +} + +.asset-row { + display: grid; + grid-template-columns: 42px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; +} + +.asset-row span { + display: block; + color: var(--muted); + font-size: 12px; + margin-top: 2px; +} + +.asset-mark { + width: 42px; + height: 42px; + display: grid; + place-items: center; + border-radius: 8px; + font-weight: 800; +} + +.asset-mark.clc { + background: #dff1e7; + color: #17613e; +} + +.asset-mark.fun { + background: #e8eef9; + color: var(--blue); +} + +.asset-mark.api { + background: #fff0cf; + color: var(--amber); +} + +.asset-mark.art { + background: #f0e4fb; + color: #8538b5; +} + +.text-button { + padding: 7px 10px; + color: var(--green); + font-weight: 750; +} + +.address-card { + display: grid; + gap: 7px; + padding: 12px; + background: #f8faf8; + border: 1px solid var(--line); + border-radius: 8px; +} + +.registration-action { + justify-self: start; + border: 1px solid currentColor; + border-radius: 8px; + padding: 8px 13px; + background: var(--green-soft); + color: var(--green); + font-size: 12px; + font-weight: 800; +} + +.registration-action:not(:disabled) { + cursor: pointer; +} + +.registration-action:disabled { + cursor: default; + opacity: 1; +} + +code { + white-space: normal; + word-break: break-all; + color: var(--ink); +} + +.status-list div { + display: flex; + justify-content: space-between; + gap: 12px; + padding-bottom: 10px; + border-bottom: 1px solid var(--line); + min-width: 0; +} + +.status-list.compact div { + border-bottom: 0; + padding-bottom: 0; +} + +.status-list b { + text-align: right; + min-width: 0; + overflow-wrap: anywhere; +} + +.status-list span { + color: var(--muted); +} + +.table-wrap { + position: relative; + overflow-x: auto; +} + +.recent-transactions-state { + display: grid; + min-height: 112px; + place-items: center; + padding: 18px; + color: var(--muted); + text-align: center; + border-top: 1px solid var(--line); +} + +.recent-transactions-state.hidden { + display: none; +} + +.recent-transactions-state.loading::before { + content: ""; + width: 26px; + height: 26px; + margin-right: 10px; + border: 3px solid rgba(45, 112, 177, 0.22); + border-top-color: var(--blue); + border-radius: 50%; + animation: recent-spin 0.8s linear infinite; +} + +.recent-transactions-state.loading { + grid-auto-flow: column; + justify-content: center; +} + +@keyframes recent-spin { + to { + transform: rotate(360deg); + } +} + +table { + width: 100%; + min-width: 0; + border-collapse: collapse; +} + +.info-button { + width: 34px; + height: 34px; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--surface); + color: var(--ink); + font-weight: 850; +} + +.icon-button { + width: auto; + min-width: 42px; + padding: 0 10px; + font-size: 12px; + font-weight: 750; +} + +.info-button, +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.button-icon { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: end; + padding: 24px; + background: rgba(23, 32, 29, 0.18); +} + +.modal-backdrop.hidden { + display: none; +} + +.node-modal { + width: min(360px, calc(100vw - 32px)); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 24px 70px rgba(23, 32, 29, 0.22); + padding: 16px; +} + +.nft-instructions-backdrop { + place-items: center; +} + +.nft-instructions-modal { + width: min(900px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + overflow: auto; +} + +.nft-instructions-body { + display: grid; + gap: 16px; + margin-top: 14px; +} + +.nft-instructions-body section { + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121b24; +} + +.nft-instructions-body h3 { + margin: 0; + font-size: 16px; +} + +.nft-instructions-body p { + margin: 0; + color: var(--muted); + line-height: 1.45; +} + +.nft-hosting-links { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.nft-hosting-links a { + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid #2d5f93; + border-radius: 8px; + background: #183557; + color: #8fc3ff; + font-weight: 800; + text-decoration: none; +} + +.nft-instructions-body pre { + max-width: 100%; + margin: 0; + overflow: auto; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #0d151d; + color: var(--text); + font-size: 12px; + line-height: 1.45; +} + +.nft-instructions-body code { + color: var(--text); + white-space: pre; +} + +.modal-form { + margin-top: 14px; +} + +.info-button.close { + width: 30px; + height: 30px; +} + +.asset-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.asset-card { + min-height: 132px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + display: grid; + align-content: start; + gap: 8px; +} + +.asset-card .asset-mark { + width: 40px; + height: 40px; +} + +.asset-card b { + font-size: 20px; +} + +.asset-card span { + color: var(--muted); + font-size: 12px; +} + +.wide-button { + width: 100%; + min-height: 44px; + margin-top: 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: #174f9d; + font-weight: 750; +} + +.status-dot.inline { + display: inline-block; + margin-right: 6px; + vertical-align: 1px; +} + +th, +td { + padding: 12px 10px; + border-bottom: 1px solid var(--line); + text-align: left; +} + +th { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} + +.placeholder-view { + max-width: 980px; +} + +.transaction-view { + min-width: 0; +} + +.receive-view { + min-width: 0; +} + +.history-view { + min-width: 0; +} + +.assets-view { + min-width: 0; +} + +.nfts-view { + min-width: 0; +} + +.loans-view { + min-width: 0; +} + +.addressbook-view { + min-width: 0; +} + +.settings-view { + min-width: 0; +} + +.transaction-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 230px; + gap: 14px; + min-height: 610px; + max-height: 610px; +} + +.transaction-main, +.transaction-types { + min-height: 0; + overflow: hidden; +} + +.transaction-main { + display: flex; + flex-direction: column; + align-items: stretch; +} + +.transaction-header { + margin-bottom: 12px; +} + +.unbroadcast-list { + display: grid; + align-content: start; + gap: 10px; + overflow: auto; + padding-right: 2px; +} + +.local-transaction { + min-height: 82px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121b24; + color: var(--text); + display: grid; + gap: 5px; + padding: 12px; + text-align: left; + cursor: pointer; +} + +.local-transaction.active, +.local-transaction:hover { + border-color: #7db7ff; + background: #183557; +} + +.local-transaction span { + color: var(--muted); + font-size: 12px; +} + +.local-transaction strong { + font-size: 15px; + overflow-wrap: anywhere; +} + +.muted-file { + overflow-wrap: anywhere; +} + +.tx-kind { + width: fit-content; + padding: 4px 8px; + border-radius: 999px; + background: var(--green-soft); + color: var(--green) !important; + font-weight: 800; +} + +.transaction-type-list { + display: grid; + gap: 9px; + max-height: none; + overflow: visible; + padding-right: 2px; +} + +.transaction-type { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121b24; + color: var(--text); + text-align: left; + padding: 0 11px; + font-weight: 750; +} + +.transaction-type.active, +.transaction-type:hover { + border-color: #7db7ff; + background: #183557; + color: #ffffff; +} + +.transaction-form, +.transaction-review { + min-height: 0; + overflow: auto; +} + +.transaction-form { + align-self: stretch; +} + +.transaction-json-preview { + max-height: 180px; + margin: 14px 0 0; + overflow: auto; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #0d151d; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.form-grid.compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.create-nft-description { + grid-column: 1 / -1; +} + +.create-nft-description textarea { + min-height: 82px; + resize: vertical; +} + +.create-nft-help-row { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; +} + +.form-section-title { + grid-column: 1 / -1; + padding-bottom: 6px; + border-bottom: 1px solid var(--line); + color: var(--text); + font-size: 14px; + font-weight: 700; +} + +.loan-condition-note { + grid-column: 1 / -1; + padding: 11px 12px; + border: 1px solid #8a6b26; + border-radius: 8px; + background: #302817; + color: #ffd982; + font-size: 13px; + line-height: 1.45; +} + +.loan-payment-warning { + grid-column: 1 / -1; + padding: 11px 12px; + border: 1px solid #8a6b26; + border-radius: 8px; + background: #302817; + color: #ffd982; + font-size: 13px; + line-height: 1.45; +} + +.input-with-suffix { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + +.input-with-suffix > span { + color: var(--muted); + font-size: 13px; +} + +.saved-transaction-path { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 10px; + margin-top: 12px; +} + +.saved-transaction-path > div { + display: grid; + gap: 6px; + min-width: 0; +} + +.saved-transaction-path span { + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.saved-transaction-path input { + width: 100%; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.review-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.review-grid div { + min-height: 62px; + display: grid; + gap: 5px; + align-content: center; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121b24; +} + +.review-grid span { + color: var(--muted); + font-size: 12px; + font-weight: 750; +} + +.review-grid b { + overflow-wrap: anywhere; +} + +.receive-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 250px; + gap: 14px; + min-height: 552px; + max-height: 552px; +} + +.receive-main, +.receive-side { + min-height: 0; + overflow: hidden; +} + +.receive-address-card { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfcfb; +} + +.copy-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.receive-request { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 16px; + margin-top: 14px; + min-height: 0; +} + +.qr-placeholder { + min-height: 260px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + display: grid; + place-items: center; + gap: 10px; + align-content: center; + color: var(--muted); + font-weight: 750; +} + +.qr-placeholder canvas { + width: 196px; + height: 196px; + border-radius: 8px; + image-rendering: pixelated; +} + +.request-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + align-content: start; +} + +.request-form label { + display: grid; + gap: 7px; +} + +.request-form label:nth-child(3), +.request-form label:nth-child(4), +.request-form .form-actions, +.request-form .startup-message { + grid-column: 1 / -1; +} + +.request-form span { + color: var(--muted); + font-weight: 700; + font-size: 13px; +} + +.request-form input, +.request-form select { + width: 100%; + min-height: 40px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px 10px; + background: #fbfcfb; + color: var(--text); +} + +.compact-actions { + margin-top: 2px; +} + +.history-workspace { + min-height: 552px; + height: calc(100vh - 142px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto auto; + overflow: hidden; +} + +.history-tools { + display: flex; + justify-content: flex-end; + min-width: min(320px, 100%); +} + +.history-tools input { + width: min(320px, 100%); + min-height: 38px; + border-radius: 8px; + border: 1px solid var(--line); + padding: 0 12px; + font-weight: 650; +} + +.marketing-campaign-controls { + display: grid; + grid-template-columns: minmax(260px, 1fr) 150px auto; + gap: 12px; + align-items: end; + min-width: min(640px, 100%); +} + +.marketing-campaign-controls label { + display: grid; + gap: 5px; +} + +.marketing-campaign-controls span { + color: var(--muted); + font-size: 12px; + font-weight: 750; +} + +.marketing-campaign-controls input { + min-height: 38px; + width: 100%; + min-width: 0; + border-radius: 8px; + border: 1px solid var(--line); + padding: 0 12px; + font-weight: 650; +} + +.marketing-workspace { + grid-template-rows: auto auto minmax(0, 1fr) auto auto; +} + +.marketing-summary-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; + margin: 14px 0 12px; +} + +.history-workspace.panel { + display: grid; + grid-template-rows: + auto /* header */ + auto /* summary */ + minmax(0,1fr) /* table */ + auto; /* footer */ +} + +.marketing-table-wrap { + min-height: 0; + overflow: auto; +} + +.marketing-summary-grid > div { + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px 12px; + background: var(--bg); + min-width: 0; + min-height: 72px; + max-height: 72px; + flex-direction: column; + justify-content: center; +} + +.marketing-summary-grid span { + display: block; + color: var(--muted); + font-size: 12px; + font-weight: 750; + margin-bottom: 4px; +} + +.marketing-summary-grid div div { + display: block; +} + +.marketing-summary-grid div div b { + color: var(--text); + font-size: 16px; + overflow-wrap: anywhere; +} + +.marketing-table { + min-width: 1120px; +} + +.marketing-displayed-cell { + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 1240px) { + .marketing-campaign-controls { + grid-template-columns: minmax(240px, 1fr) 140px; + } + + .marketing-campaign-controls .primary-button { + grid-column: 1 / -1; + justify-self: end; + } + + .marketing-summary-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.history-table-wrap { + min-height: 0; + overflow: auto; +} + +.history-table { + min-width: 900px; +} + +.history-table thead { + background: var(--bg); + position: sticky; + top: 0; +} + +.hash-button { + border: 0; + background: transparent; + color: #174f9d; + padding: 0; + font-weight: 750; +} + +.hash-button:hover { + text-decoration: underline; +} + +.history-card-list { + display: none; + min-height: 0; + overflow: auto; + gap: 10px; +} + +.history-card { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + color: var(--text); +} + +.history-card div { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.history-card span { + color: var(--muted); + font-size: 13px; +} + +.history-footer { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 12px; + min-height: 42px; + color: var(--muted); + font-size: 13px; +} + +.history-footer > .secondary-button { + justify-self: end; +} + +.history-footer-center, +.history-export-options { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; +} + +.history-footer-center { + flex-direction: column; + gap: 6px; +} + +.history-pagination { + display: flex; + align-items: center; + gap: 6px; +} + +.history-page-button { + width: 34px; + min-width: 34px; + height: 34px; + min-height: 34px; + padding: 0; + font-weight: 750; +} + +.history-page-label { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.history-page-label input { + width: 58px; + height: 34px; + padding: 0 6px; + text-align: center; +} + +.history-export-options { + margin-top: 16px; +} + +.cache-clear-options { + display: grid; + gap: 10px; + margin-top: 16px; +} + +.secondary-button.danger { + border-color: rgba(239, 111, 111, 0.5); + color: #ff9a9a; +} + +.assets-workspace { + min-height: 552px; + max-height: 552px; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + overflow: hidden; +} + +.assets-header { + align-items: center; +} + +.asset-controls { + display: grid; + grid-template-columns: minmax(160px, 1fr) 150px; + gap: 10px; +} + +.asset-controls input, +.asset-controls select { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px 10px; + background: #fbfcfb; + color: var(--text); +} + +.asset-filter-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.filter-pill { + min-height: 32px; + border: 1px solid var(--line); + border-radius: 999px; + background: #ffffff; + color: var(--muted); + padding: 0 12px; + font-weight: 750; +} + +.filter-pill.active, +.filter-pill:hover { + border-color: #bad8f6; + background: #eef6ff; + color: #165eb5; +} + +.portfolio-grid { + min-height: 0; + overflow: auto; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding-right: 2px; +} + +.portfolio-card { + min-height: 196px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfcfb; + padding: 13px; + display: grid; + gap: 12px; +} + +.portfolio-top { + display: flex; + align-items: center; + gap: 10px; +} + +.portfolio-top span { + display: block; + color: var(--muted); + font-size: 12px; + margin-top: 2px; +} + +.portfolio-card > b { + font-size: 21px; + overflow-wrap: anywhere; +} + +.portfolio-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.portfolio-meta div { + display: grid; + gap: 4px; +} + +.portfolio-meta span { + color: var(--muted); + font-size: 12px; +} + +.portfolio-meta strong { + overflow-wrap: anywhere; +} + +.asset-card-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.asset-card-tag { + display: inline-flex; + align-items: center; + min-height: 24px; + border-radius: 999px; + padding: 0 9px; + background: var(--panel-strong); + color: var(--muted); + border: 1px solid var(--line); + font-size: 12px; + font-weight: 800; +} + +.asset-card-tag.holding { + background: rgba(70, 230, 136, 0.14); + border-color: rgba(70, 230, 136, 0.45); + color: var(--green); +} + +.asset-card-tag.created { + background: rgba(89, 169, 255, 0.14); + border-color: rgba(89, 169, 255, 0.45); + color: var(--blue); +} + +.asset-card-tag.fixed { + background: rgba(255, 193, 96, 0.16); + border-color: rgba(255, 193, 96, 0.44); + color: #ffca68; +} + +.asset-origin { + color: var(--blue); + font-size: 12px; + overflow-wrap: anywhere; +} + +.assets-state { + min-height: 22px; + color: var(--muted); + font-size: 13px; + text-align: right; +} + +.portfolio-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + align-self: end; +} + +.nfts-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 14px; + min-height: 552px; + height: calc(100vh - 142px); + overflow: hidden; +} + +.nft-main, +.nft-config { + min-height: 0; +} + +.nft-main { + display: block; + overflow: auto; + padding-bottom: 14px; +} + +.nft-config { + overflow: auto; +} + +.nft-list { + min-height: auto; + overflow: visible; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: start; + gap: 12px; + padding: 0; +} + +.nft-record { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #111c27; + box-shadow: none; + overflow: hidden; + color: var(--text); + cursor: pointer; +} + +.nft-record.active { + border-color: var(--blue); + background: #17304d; +} + +.nft-card-media, +.nft-preview { + min-height: 150px; + border: 1px solid var(--line); + border-radius: 8px; + background: #0c141d; + display: grid; + place-items: center; + overflow: hidden; +} + +.nft-card-media img, +.nft-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.nft-preview { + min-height: 220px; +} + +.nft-preview-placeholder { + width: 72px; + height: 72px; + border-radius: 8px; + display: grid; + place-items: center; + background: #20354b; + color: #8fc3ff; + font-weight: 900; + font-size: 28px; +} + +.nft-record-top { + display: flex; + align-items: center; + gap: 10px; +} + +.nft-record-top span { + display: block; + color: var(--muted); + font-size: 12px; + margin-top: 2px; +} + +.nft-link-row { + display: grid; + grid-template-columns: 82px minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +.nft-link-row span { + color: var(--muted); + font-size: 12px; + font-weight: 750; +} + +.nft-actions { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; +} + +.nft-actions .secondary-button { + min-height: 34px; + box-shadow: none; +} + +.nft-config { + padding-bottom: 14px; +} + +.nft-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 12px; + flex-wrap: wrap; +} + +.nft-detail-list b, +.nft-link-row code { + overflow-wrap: anywhere; +} + +.nft-metadata-summary { + display: grid; + gap: 10px; + color: var(--muted); + font-size: 13px; +} + +.nft-metadata-summary p { + margin: 0; + line-height: 1.45; +} + +.nft-attributes, +.nft-rwa-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.nft-attributes div, +.nft-rwa-fields div, +.nft-history-row { + border: 1px solid var(--line); + border-radius: 8px; + background: #0c141d; + padding: 10px; +} + +.nft-attributes span, +.nft-rwa-fields span, +.nft-history-row span { + display: block; + color: var(--muted); + font-size: 11px; + font-weight: 800; + margin-bottom: 3px; +} + +.nft-attributes b, +.nft-rwa-fields b, +.nft-history-row strong { + overflow-wrap: anywhere; +} + +.nft-provenance { + display: grid; + gap: 10px; +} + +.nft-provenance h3 { + margin: 0; + font-size: 15px; +} + +.nft-provenance [data-nft-history] { + display: grid; + gap: 8px; +} + +.nft-history-row { + display: grid; + gap: 8px; +} + +.nft-history-row code { + color: var(--blue); + overflow-wrap: anywhere; +} + +.nft-history-empty { + color: var(--muted); + font-size: 13px; +} + +.settings-form { + display: grid; + gap: 12px; +} + +.settings-form label { + display: grid; + gap: 7px; +} + +.settings-form span { + color: var(--muted); + font-weight: 700; + font-size: 13px; +} + +.settings-form input { + width: 100%; + min-height: 40px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px 10px; + background: #fbfcfb; + color: var(--text); +} + +.check-row { + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; +} + +.check-row input { + min-height: auto; +} + +.loans-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 14px; + height: 552px; +} + +.loan-main, +.loan-detail { + min-height: 0; + overflow: auto; +} + +.loans-header { + align-items: center; +} + +.loan-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) 150px; + gap: 10px; + margin-bottom: 10px; +} + +.loan-controls input, +.loan-controls select { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px 10px; + background: #fbfcfb; + color: var(--text); +} + +.loan-filter-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + align-items: center; +} + +.compact-button { + min-height: 32px; + padding: 6px 10px; + margin-left: auto; +} + +.subtle-state { + color: var(--muted); + font-size: 13px; + padding: 10px 2px; +} + +.loan-list { + display: grid; + gap: 10px; +} + +.loan-card { + border: 1px solid var(--line); + border-left-width: 5px; + border-radius: 8px; + background: #ffffff; + color: var(--text); + padding: 12px; + display: grid; + gap: 12px; + text-align: left; +} + +.loan-card:has(.role-badge.lending) { + border-left-color: #165eb5; +} + +.loan-card:has(.role-badge.borrowing) { + border-left-color: #8a5a13; +} + +.loan-card.active, +.loan-card:hover { + border-color: #9fc5ec; + background: #f8fbff; +} + +.loan-card-top { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.loan-badges { + display: flex; + align-items: flex-start; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.loan-card-top span { + color: var(--muted); + font-size: 12px; +} + +.role-badge, +.loan-status { + height: fit-content; + border-radius: 999px; + padding: 5px 8px; + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.role-badge.lending { + background: #eef6ff; + color: #165eb5; +} + +.role-badge.borrowing { + background: #fff4d8; + color: #8a5a13; +} + +.loan-status.active { + background: var(--green-soft); + color: var(--green); +} + +.loan-status.due { + background: #fff4d8; + color: var(--amber); +} + +.loan-status.claimable { + background: #fde8e8; + color: #a13f3f; +} + +.loan-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.loan-next-due { + display: grid; + justify-items: center; + gap: 4px; + padding: 10px 8px 2px; + border-top: 1px solid var(--line); + text-align: center; +} + +.loan-next-due span { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.loan-next-due b { + font-size: 14px; + font-weight: 900; + overflow-wrap: anywhere; +} + +.loan-metrics div, +.loan-detail-grid div, +.loan-timeline div { + display: grid; + gap: 4px; +} + +.loan-metrics span, +.loan-detail-grid span, +.loan-timeline span { + color: var(--muted); + font-size: 12px; + font-weight: 750; +} + +.loan-detail-grid { + display: grid; + gap: 10px; +} + +.loan-detail-grid div { + padding-bottom: 9px; + border-bottom: 1px solid var(--line); +} + +.loan-detail-grid b, +.loan-metrics b, +.loan-timeline b { + overflow-wrap: anywhere; +} + +.loan-timeline { + display: grid; + gap: 9px; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.loan-timeline h3 { + margin: 0; + font-size: 15px; +} + +.loan-timeline div { + grid-template-columns: minmax(0, 1fr) auto; +} + +.loan-payment-table { + overflow: auto; +} + +.loan-payment-table table { + width: 100%; + border-collapse: collapse; + min-width: 460px; +} + +.loan-payment-table th, +.loan-payment-table td { + border-bottom: 1px solid var(--line); + padding: 8px 6px; + text-align: left; + vertical-align: top; + font-size: 13px; +} + +.loan-payment-table th { + color: var(--muted); + font-size: 11px; + text-transform: uppercase; +} + +.loan-actions { + display: grid; + gap: 9px; + margin-top: 18px; +} + +.addressbook-workspace { + min-height: 552px; + max-height: 552px; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + overflow: hidden; +} + +.addressbook-header { + align-items: center; +} + +.addressbook-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) 190px; + gap: 10px; + margin-bottom: 12px; +} + +.addressbook-controls input, +.addressbook-controls select { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px 10px; + background: #fbfcfb; + color: var(--text); +} + +.addressbook-table-wrap { + min-height: 0; + overflow: auto; +} + +.addressbook-card-list { + display: none; + min-height: 0; + overflow: auto; + gap: 10px; +} + +.addressbook-card { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + color: var(--text); +} + +.addressbook-card-top { + display: grid; + grid-template-columns: minmax(0, 1fr) 42px; + gap: 8px; + align-items: center; +} + +.addressbook-card > div:not(.addressbook-card-top) { + display: grid; + gap: 4px; +} + +.addressbook-card span { + color: var(--muted); + font-size: 12px; + font-weight: 750; +} + +.addressbook-card b { + min-width: 0; + overflow-wrap: anywhere; +} + +.addressbook-table { + min-width: 760px; +} + +.addressbook-table code { + font-size: 13px; +} + +.addressbook-copy-value { + border: 0; + background: transparent; + color: var(--accent); + padding: 0; + font: inherit; + font-weight: 750; + cursor: pointer; +} + +.addressbook-copy-value:hover, +.addressbook-copy-value:focus { + text-decoration: underline; + outline: none; +} + +.addressbook-table th:last-child, +.addressbook-table td:last-child { + width: 48px; + text-align: right; +} + +.addressbook-actions { + white-space: nowrap; +} + +.addressbook-delete-button { + color: #ffb4a8; +} + +.addressbook-delete-button:hover, +.addressbook-delete-button:focus { + border-color: rgba(255, 106, 89, 0.65); + color: #ffd0c8; +} + +.addressbook-label-input { + width: 100%; + min-height: 34px; + border: 1px solid transparent; + border-radius: 8px; + padding: 7px 9px; + background: transparent; + color: var(--text); + font-weight: 700; +} + +.addressbook-label-input:hover, +.addressbook-label-input:focus { + border-color: var(--line); + background: var(--panel-strong); + outline: none; +} + +.addressbook-state { + min-height: 22px; + color: var(--muted); + font-size: 13px; + text-align: right; +} + +.addressbook-table td:nth-child(3) { + max-width: 360px; +} + +.settings-workspace { + min-height: 552px; + height: calc(100vh - 142px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; +} + +.settings-header { + align-items: center; +} + +.settings-grid { + min-height: 0; + overflow: auto; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding-right: 2px; +} + +.settings-section { + display: grid; + align-content: start; + gap: 11px; + padding: 13px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; +} + +.settings-section h3 { + margin: 0; + font-size: 16px; +} + +.settings-section label { + display: grid; + gap: 7px; +} + +.settings-section span { + color: var(--muted); + font-size: 13px; + font-weight: 750; +} + +.settings-help { + margin: -3px 0 2px; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.settings-section input, +.settings-section select, +.settings-section textarea { + width: 100%; + min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px 10px; + background: #fbfcfb; + color: var(--text); +} + +.settings-section textarea { + min-height: 82px; + resize: vertical; +} + +.settings-path-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 42px; + gap: 8px; + align-items: center; +} + +.settings-path-row .secondary-button { + min-width: 0; + padding: 0; +} + +/* Dark application theme */ +:root { + --bg: #111820; + --surface: #18222d; + --surface-soft: #202d39; + --text: #edf3f7; + --muted: #9facb8; + --line: #30404e; + --green: #58d08b; + --green-soft: #173a2d; + --blue: #7db7ff; + --amber: #f0bf69; + --ink: #f4f8fb; + --shadow: 0 18px 44px rgba(2, 8, 14, 0.34); +} + +body { + background: var(--bg); +} + +.app-shell, +.sidebar, +.topbar, +.panel, +.node-modal, +.select-button, +.icon-button, +.info-button, +.secondary-button, +.text-button, +.wide-button, +.filter-pill, +.transaction-type, +.quick-actions button, +.address-card, +.local-transaction, +.review-grid div, +.receive-address-card, +.qr-placeholder, +.portfolio-card, +.nft-record, +.loan-card, +.settings-section { + background: var(--surface); + color: var(--text); + border-color: var(--line); +} + +.topbar { + background: rgba(24, 34, 45, 0.92); +} + +.brand-mark, +.coin-stack { + background: #24364a; + color: #8fc3ff; +} + +.nav-item { + color: #cad5df; +} + +.nav-item:hover, +.nav-item.active { + background: #285f9f; + color: #ffffff; +} + +.primary-button { + border-color: #5fbb8a; + background: #1f7a51; + color: #ffffff; +} + +.height-pill, +.registration-badge, +.table-status.confirmed, +.loan-status.active { + background: #173a2d; + color: #75dea0; +} + +.table-status.pending, +.loan-status.due, +.role-badge.borrowing { + background: #3d321e; + color: #f0bf69; +} + +.role-badge.lending, +.transaction-type.active, +.transaction-type:hover, +.filter-pill.active, +.filter-pill:hover, +.local-transaction.active, +.local-transaction:hover, +.loan-card.active, +.loan-card:hover, +.quick-actions button:nth-child(1) { + background: #1f3550; + color: #8fc3ff; + border-color: #3e6fa3; +} + +.quick-actions button:nth-child(2) { + background: #173a2d; + color: #75dea0; + border-color: #2f6d4b; +} + +.quick-actions button:nth-child(3) { + background: #3d321e; + color: #f0bf69; + border-color: #745c29; +} + +.quick-actions button:nth-child(4) { + background: #302849; + color: #c8a8ff; + border-color: #5b4c83; +} + +.asset-mark.clc, +.asset-mark.usdg { + background: #173a2d; + color: #75dea0; +} + +.asset-mark.fun, +.asset-mark.gold, +.asset-mark.api { + background: #3d321e; + color: #f0bf69; +} + +.asset-mark.art { + background: #302849; + color: #c8a8ff; +} + +input, +select, +textarea, +.request-form input, +.request-form select, +.form-grid input, +.form-grid select, +.form-grid textarea, +.asset-controls input, +.asset-controls select, +.loan-controls input, +.loan-controls select, +.addressbook-controls input, +.addressbook-controls select, +.settings-section input, +.settings-section select, +.settings-section textarea, +.settings-form input { + background: #121b24; + color: var(--text); + border-color: var(--line); +} + +input::placeholder { + color: #718190; +} + +code, +.hash-button, +.text-button, +.wide-button { + color: #8fc3ff; +} + +.modal-backdrop { + background: rgba(2, 8, 14, 0.52); +} + +th, +td { + border-bottom-color: var(--line); +} + +tr:hover td { + background: rgba(143, 195, 255, 0.04); +} + +* { + scrollbar-color: #607488 #121b24; +} + +.hidden { + display: none !important; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.form-grid label { + display: grid; + gap: 7px; +} + +.form-grid span { + color: var(--muted); + font-weight: 700; + font-size: 13px; +} + +.form-grid input, +.form-grid select, +.form-grid textarea { + width: 100%; + min-height: 42px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 9px 11px; + background: #121b24; + color: var(--text); +} + +.input-with-button { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.input-with-button .secondary-button { + min-height: 42px; + white-space: nowrap; +} + +.form-grid input:read-only, +.form-grid input:disabled { + background: #0d151d; + color: var(--muted); + cursor: default; +} + +.form-grid .checkbox-row { + grid-template-columns: 18px 1fr; + align-items: center; + gap: 10px; + min-height: 42px; +} + +.form-grid .checkbox-row input[type="checkbox"] { + width: 18px; + min-height: 18px; + accent-color: var(--accent); +} + +.form-grid .checkbox-row span { + color: var(--text); +} + +.form-grid textarea { + min-height: 150px; + resize: vertical; +} + +.form-grid .wide { + grid-column: 1 / -1; +} + +.mobile-nav { + display: none; +} + +.mobile-more-backdrop, +.mobile-more-sheet { + display: none; +} + +@media (max-width: 980px) { + .app-shell { + grid-template-columns: 1fr; + min-height: 100vh; + } + + .sidebar { + display: none; + } + + .topbar-wallet-mark { + display: inline-grid; + } + + .topbar { + flex-wrap: wrap; + padding: 12px 16px; + } + + .topbar-spacer { + display: none; + } + + .topbar-group { + flex: 1 1 180px; + } + + .payment-uri-entry { + max-width: none; + } + + .select-button, + .node-select .select-button, + .wallet-select .select-button { + width: 100%; + min-width: 0; + } + + .content { + padding: 20px 16px 90px; + } + + .dashboard-grid { + grid-template-columns: 1fr; + grid-template-areas: + "balance" + "registration" + "quick" + "transactions" + "network" + "notice"; + } + + .transaction-workspace { + grid-template-columns: 1fr; + min-height: auto; + max-height: none; + } + + .receive-workspace { + grid-template-columns: 1fr; + min-height: auto; + max-height: none; + } + + .history-workspace { + min-height: auto; + height: auto; + max-height: none; + } + + .marketing-campaign-controls { + grid-template-columns: 1fr; + width: 100%; + } + + .marketing-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .assets-workspace { + min-height: auto; + max-height: none; + } + + .nfts-workspace { + grid-template-columns: 1fr; + height: auto; + overflow: visible; + } + + .loans-workspace { + grid-template-columns: 1fr; + height: auto; + } + + .addressbook-workspace { + min-height: auto; + max-height: none; + } + + .settings-workspace { + min-height: auto; + height: auto; + max-height: none; + } + + .nft-main, + .nft-config { + max-height: none; + } + + .transaction-types { + order: -1; + } + + .transaction-type-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + max-height: none; + } + + .receive-request { + grid-template-columns: 190px minmax(0, 1fr); + } + + .portfolio-grid { + grid-template-columns: 1fr; + } + + table { + min-width: 0; + } + + .transactions-panel { + grid-column: auto; + } + + .asset-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mobile-nav { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 10; + display: grid; + grid-template-columns: repeat(5, 1fr); + min-height: 66px; + background: var(--surface); + border-top: 1px solid var(--line); + } + + .mobile-item { + color: var(--muted); + display: grid; + place-items: center; + gap: 3px; + font-size: 18px; + } + + .mobile-item span { + font-size: 11px; + font-weight: 700; + } + + .mobile-item.active { + color: var(--green); + } + + .mobile-more-backdrop { + position: fixed; + inset: 0; + z-index: 11; + display: block; + background: rgba(9, 16, 22, 0.42); + } + + .mobile-more-backdrop.hidden { + display: none; + } + + .mobile-more-sheet { + position: fixed; + left: 10px; + right: 10px; + bottom: 78px; + z-index: 12; + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); + } + + .mobile-more-sheet.hidden { + display: none; + } + + .mobile-more-item { + min-height: 48px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-soft); + color: var(--text); + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px; + text-align: left; + } + + .mobile-more-item span { + width: 22px; + color: var(--green); + text-align: center; + font-weight: 800; + } + + .mobile-more-item b { + font-size: 14px; + } +} + +@media (max-width: 1180px) and (min-width: 981px) { + .balance-split { + grid-template-columns: 1fr; + align-items: start; + } + + .token-balances { + border-left: 0; + border-top: 1px solid var(--line); + padding-left: 0; + padding-top: 14px; + } + + .balance-main { + min-height: auto; + } +} + +@media (max-width: 640px) { + .view-heading { + display: grid; + } + + .heading-actions { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .topbar { + display: grid; + grid-template-columns: 1fr auto auto 1fr; + gap: 10px; + padding: 12px; + } + + .topbar-wallet-mark { + grid-column: 1; + justify-self: start; + } + + .topbar > .info-button { + grid-column: 2; + justify-self: end; + } + + .topbar > .icon-button { + grid-column: 3; + justify-self: start; + } + + .topbar-group { + grid-column: 1 / -1; + width: 100%; + } + + .height-pill { + flex: 1; + justify-content: center; + } + + h1 { + font-size: 25px; + } + + .balance-value { + font-size: 27px; + } + + .panel { + padding: 15px; + } + + #marketing-view .panel-header { + display: grid; + grid-template-columns: 1fr; + align-items: stretch; + gap: 14px; + } + + #marketing-view .marketing-campaign-controls { + grid-template-columns: 1fr; + min-width: 0; + width: 100%; + } + + #marketing-view .marketing-campaign-controls .primary-button { + width: 100%; + justify-self: stretch; + } + + .asset-row { + grid-template-columns: 38px minmax(0, 1fr); + } + + .asset-row b { + grid-column: 2; + } + + .asset-summary-grid { + grid-template-columns: 1fr; + } + + .nft-list, + .nft-attributes, + .nft-rwa-fields { + grid-template-columns: 1fr; + } + + table { + min-width: 0; + } + + th:nth-child(3), + td:nth-child(3) { + display: none; + } + + .balance-split { + grid-template-columns: 1fr; + } + + .token-balances { + border-left: 0; + border-top: 1px solid var(--line); + padding-left: 0; + padding-top: 14px; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .form-grid.compact, + .review-grid { + grid-template-columns: 1fr; + } + + .input-with-button { + grid-template-columns: 1fr; + } + + .create-nft-help-row { + justify-content: stretch; + } + + .create-nft-help-row .secondary-button { + width: 100%; + } + + .nft-instructions-backdrop { + padding: 12px; + } + + .nft-instructions-modal { + width: 100%; + max-height: calc(100vh - 24px); + padding: 12px; + } + + .nft-hosting-links { + grid-template-columns: 1fr; + } + + .nft-instructions-body section { + padding: 12px; + } + + .transaction-type-list { + grid-template-columns: 1fr; + } + + .receive-request, + .request-form { + grid-template-columns: 1fr; + } + + .copy-row { + grid-template-columns: 1fr; + } + + .history-table-wrap { + display: none; + } + + .history-card-list { + display: grid; + } + + .history-footer { + grid-template-columns: 1fr; + } + + .history-footer > .secondary-button { + justify-self: center; + } + + .history-pagination { + flex-wrap: wrap; + justify-content: center; + } + + .assets-header { + display: grid; + } + + .asset-controls, + .portfolio-meta { + grid-template-columns: 1fr; + } + + .nft-actions { + grid-template-columns: 1fr; + } + + .loan-controls, + .loan-metrics, + .addressbook-controls { + grid-template-columns: 1fr; + } + + .addressbook-table-wrap { + display: none; + } + + .addressbook-card-list { + display: grid; + } + + .settings-grid { + grid-template-columns: 1fr; + } + + .nft-link-row { + grid-template-columns: 1fr; + } + + .transaction-header, + .form-actions { + display: grid; + justify-content: stretch; + } +} diff --git a/gen/schemas/acl-manifests.json b/gen/schemas/acl-manifests.json new file mode 100644 index 0000000..0eebfc4 --- /dev/null +++ b/gen/schemas/acl-manifests.json @@ -0,0 +1 @@ +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/gen/schemas/capabilities.json b/gen/schemas/capabilities.json new file mode 100644 index 0000000..8b371fb --- /dev/null +++ b/gen/schemas/capabilities.json @@ -0,0 +1 @@ +{"default":{"identifier":"default","description":"Default wallet window permissions","local":true,"windows":["main"],"permissions":["core:default"]}} \ No newline at end of file diff --git a/gen/schemas/desktop-schema.json b/gen/schemas/desktop-schema.json new file mode 100644 index 0000000..3286645 --- /dev/null +++ b/gen/schemas/desktop-schema.json @@ -0,0 +1,2292 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/gen/schemas/windows-schema.json b/gen/schemas/windows-schema.json new file mode 100644 index 0000000..3286645 --- /dev/null +++ b/gen/schemas/windows-schema.json @@ -0,0 +1,2292 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/icons/icon.ico b/icons/icon.ico new file mode 100644 index 0000000..1992e61 Binary files /dev/null and b/icons/icon.ico differ diff --git a/icons/icon.png b/icons/icon.png new file mode 100644 index 0000000..729e4d7 Binary files /dev/null and b/icons/icon.png differ diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..38447c4 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,221 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use blockchain::blocks::burn::{BurnTransaction, UnsignedBurnTransaction}; +use blockchain::blocks::collateral::{ + CollateralClaimTransaction, UnsignedCollateralClaimTransaction, +}; +use blockchain::blocks::genesis::GenesisTransaction; +use blockchain::blocks::issue_token::{IssueTokenTransaction, UnsignedIssueTokenTransaction}; +use blockchain::blocks::loan_payment::{ + ContractPaymentTransaction, UnsignedContractPaymentTransaction, +}; +use blockchain::blocks::loans::{LoanContractTransaction, UnsignedLoanContractTransaction}; +use blockchain::blocks::marketing::{MarketingTransaction, UnsignedMarketingTransaction}; +use blockchain::blocks::nft::{CreateNftTransaction, UnsignedCreateNftTransaction}; +use blockchain::blocks::rewards::RewardsTransaction; +use blockchain::blocks::swap::{SwapTransaction, UnsignedSwapTransaction}; +use blockchain::blocks::token::{CreateTokenTransaction, UnsignedCreateTokenTransaction}; +use blockchain::blocks::transfer::{TransferTransaction, UnsignedTransferTransaction}; +use blockchain::blocks::vanity::{UnsignedVanityAddressTransaction, VanityAddressTransaction}; +use blockchain::common::skein::skein_256_hash_bytes; +use blockchain::common::types::{ + minimum_transfer_fee, BORROWER_FEE, BURN_FEE, COLLATERAL_FEE, CREATE_NFT_FEE, CREATE_TOKEN_FEE, + ISSUE_TOKEN_FEE, LENDER_FEE, MARKETING_FEE, SWAP_FEE, VANITY_ADDRESS_FEE, +}; +use blockchain::records::memory::mempool::BASECOIN; +use blockchain::records::memory::response_channels::generate_uid; +use blockchain::rpc::command_maps::{ + RPC_ADDRESS_HISTORY, RPC_ADDRESS_TOTAL_BALANCE, RPC_BLOCK_HEIGHT, RPC_CONTRACT_BY_ADDRESS, + RPC_LATEST_ADDRESS_TRANSACTIONS, RPC_LOAN_CONTRACT, RPC_MARKETING_CAMPAIGN_HISTORY, + RPC_NETWORK_INFO, RPC_NFT_DETAILS, RPC_NFT_LIST, RPC_REGISTER_WALLET, RPC_TIME, + RPC_TOKEN_CATALOG, RPC_TOKEN_DETAILS, RPC_TOKEN_LIST, RPC_TRANSACTION_BY_TXID, + RPC_VALIDATE_MESSAGE, RPC_VANITY_LOOKUP, RPC_VANITY_OWNER_LOOKUP, + RPC_WALLET_REGISTRATION_STATUS, +}; +use blockchain::standalone_tools::connections::handshake::{self, HandshakeWallet}; +use blockchain::tokio::fs::{ + create_dir_all, metadata, read, read_dir, read_to_string, remove_dir_all, remove_file, +}; +use blockchain::tokio::sync::{oneshot, Mutex as AsyncMutex}; +use blockchain::tokio::time::sleep; +use blockchain::wallets::structures::{SavedWallet, Wallet}; +use blockchain::{ + create_img, decode_image_and_extract_text, decrypts, encode, encrypts, to_string_pretty, +}; +use native_dialog::FileDialog; +use reqwest; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use std::cmp::Ordering; +use std::collections::{BinaryHeap, HashMap}; +use std::env; +use std::net::{SocketAddr, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tauri::State; + +include!("wallet_app/types.rs"); +include!("wallet_app/transaction_decode.rs"); +include!("wallet_app/state.rs"); +include!("wallet_app/rpc_scheduler.rs"); +include!("wallet_app/wallet_files.rs"); +include!("wallet_app/shared_rpc.rs"); +include!("wallet_app/commands_wallet.rs"); +include!("wallet_app/commands_balance_registration.rs"); +include!("wallet_app/commands_transactions.rs"); +include!("wallet_app/commands_network_cache.rs"); + +fn clear_system_clipboard_now() { + #[cfg(windows)] + { + if let Err(err) = clipboard_win::set_clipboard_string("") { + eprintln!("Failed to clear system clipboard: {err}"); + } + } +} + +#[tauri::command] +fn clear_system_clipboard() { + clear_system_clipboard_now(); +} + +#[tauri::command] +fn open_external_url(url: String) -> Result<(), String> { + const ALLOWED_URLS: [&str; 4] = [ + "https://www.pinata.cloud/", + "https://filebase.com/", + "https://thirdweb.com/storage", + "https://www.lighthouse.storage/", + ]; + + if !ALLOWED_URLS.contains(&url.as_str()) { + return Err("External URL is not allowed.".to_string()); + } + + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new("rundll32"); + command.args(["url.dll,FileProtocolHandler", &url]); + command + }; + + #[cfg(target_os = "macos")] + let mut command = { + let mut command = Command::new("open"); + command.arg(&url); + command + }; + + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = { + let mut command = Command::new("xdg-open"); + command.arg(&url); + command + }; + + command + .spawn() + .map(|_| ()) + .map_err(|err| format!("Failed to open browser: {err}")) +} + +fn main() { + tauri::Builder::default() + .manage(WalletState { + active_wallet: Mutex::new(None), + rpc_scheduler: WalletRpcScheduler::new(), + }) + .on_window_event(|_, event| { + if matches!(event, tauri::WindowEvent::CloseRequested { .. }) { + clear_system_clipboard_now(); + } + }) + .invoke_handler(tauri::generate_handler![ + get_app_status, + create_wallet, + list_wallets, + select_wallet_file, + select_private_key_image, + load_wallet, + import_private_key_wallet, + import_private_key_image_wallet, + lock_wallet, + list_broadcast_nodes, + read_gui_settings, + save_gui_settings, + select_settings_path, + backup_active_wallet_image, + backup_active_wallet_private_key, + printable_wallet_backup, + clear_system_clipboard, + open_external_url, + active_wallet, + get_active_wallet_balances, + check_wallet_registration, + register_active_wallet, + latest_address_transactions, + marketing_campaign_history, + paged_address_history, + transaction_by_txid, + token_list, + token_catalog, + nft_list, + nft_details, + fetch_ipfs_metadata, + issue_token_details, + current_block_height, + network_info, + read_local_transaction_cache, + write_local_transaction_cache, + read_local_transaction_index, + write_local_transaction_index, + read_local_transaction_record, + write_local_transaction_record, + read_nft_cache, + write_nft_cache, + prune_nft_cache, + test_broadcast_node_time, + clear_wallet_cache, + read_address_book, + write_address_book, + lookup_address_vanity, + check_vanity_address_availability, + save_history_export, + list_unbroadcast_transactions, + save_transfer_transaction, + broadcast_transfer_transaction, + save_create_token_transaction, + broadcast_create_token_transaction, + save_issue_token_transaction, + broadcast_issue_token_transaction, + save_create_nft_transaction, + broadcast_create_nft_transaction, + save_burn_transaction, + broadcast_burn_transaction, + save_marketing_transaction, + broadcast_marketing_transaction, + save_vanity_address_transaction, + broadcast_vanity_address_transaction, + save_swap_offer, + save_loan_offer, + active_loan_contracts, + loan_contracts, + save_loan_payment_transaction, + broadcast_loan_payment_transaction, + claimable_collateral_contracts, + save_collateral_claim_transaction, + broadcast_collateral_claim_transaction, + import_unbroadcast_transaction, + load_unbroadcast_transaction, + sign_unbroadcast_transaction, + broadcast_unbroadcast_transaction, + delete_unbroadcast_transaction + ]) + .run(tauri::generate_context!()) + .expect("error while running Contractless Wallet"); +} diff --git a/src/wallet_app/commands_balance_registration.rs b/src/wallet_app/commands_balance_registration.rs new file mode 100644 index 0000000..f42dad8 --- /dev/null +++ b/src/wallet_app/commands_balance_registration.rs @@ -0,0 +1,101 @@ +#[tauri::command] +async fn get_active_wallet_balances( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result, String> { + ensure_gui_settings_file().await?; + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + + let response = serialized_wallet_rpc( + &state, + socket_address, + short_address, + RPC_ADDRESS_TOTAL_BALANCE as usize, + public_key, + private_key, + "Balance lookup failed", + ) + .await?; + + parse_balance_response(response) +} + +#[tauri::command] +async fn check_wallet_registration( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result { + ensure_gui_settings_file().await?; + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + + let response = serialized_wallet_rpc( + &state, + socket_address, + short_address, + RPC_WALLET_REGISTRATION_STATUS as usize, + public_key, + private_key, + "Wallet registration status lookup failed", + ) + .await?; + + let response_text = String::from_utf8_lossy(&response).trim().to_string(); + if response_text.starts_with("error:") { + return Err(response_text); + } + + Ok(RegistrationStatusView { + registered: response_text == "1", + }) +} + +#[tauri::command] +async fn register_active_wallet( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result { + ensure_gui_settings_file().await?; + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let short_address_bytes = Wallet::short_address_to_bytes(&short_address) + .ok_or_else(|| "Active wallet short address is invalid.".to_string())?; + let public_key_bytes = Wallet::normalize_saved_public_key_bytes(&public_key) + .ok_or_else(|| "Active wallet public key is invalid.".to_string())?; + + let mut signed_payload = + Vec::with_capacity(1 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + Wallet::PUBLIC_KEY_LENGTH); + signed_payload.push(RPC_REGISTER_WALLET); + signed_payload.extend_from_slice(&short_address_bytes); + signed_payload.extend_from_slice(&public_key_bytes); + + let payload_hash = skein_256_hash_bytes(&signed_payload); + let signature = Wallet::sign_transaction(&payload_hash, &private_key).await; + let public_key_hex = encode(&public_key_bytes); + let command_input = format!("{short_address}|{public_key_hex}|{signature}"); + + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + RPC_REGISTER_WALLET as usize, + public_key, + private_key, + "Wallet registration failed", + ) + .await?; + + let response_text = String::from_utf8_lossy(&response).trim().to_string(); + if response_text.starts_with("error:") { + return Err(response_text); + } + + Ok(RegistrationStatusView { + registered: response_text == "1", + }) +} + diff --git a/src/wallet_app/commands_network_cache.rs b/src/wallet_app/commands_network_cache.rs new file mode 100644 index 0000000..33ebda9 --- /dev/null +++ b/src/wallet_app/commands_network_cache.rs @@ -0,0 +1,603 @@ +#[tauri::command] +async fn current_block_height( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result { + ensure_gui_settings_file().await?; + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let response = serialized_wallet_rpc( + &state, + socket_address, + String::new(), + RPC_BLOCK_HEIGHT as usize, + public_key, + private_key, + "Block height lookup failed", + ) + .await?; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + if response.len() != 4 { + return Err("Block height response had an unexpected byte length.".to_string()); + } + + Ok(u32::from_le_bytes(response[0..4].try_into().map_err( + |_| "Failed to parse block height response.".to_string(), + )?)) +} + +#[tauri::command] +async fn network_info( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result { + ensure_gui_settings_file().await?; + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let response = serialized_wallet_rpc( + &state, + socket_address, + String::new(), + RPC_NETWORK_INFO as usize, + public_key, + private_key, + "Network info lookup failed", + ) + .await?; + + parse_network_info_response(response) +} + +#[tauri::command] +async fn test_broadcast_node_time( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result { + ensure_gui_settings_file().await?; + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let response = serialized_wallet_rpc( + &state, + socket_address, + String::new(), + RPC_TIME as usize, + public_key, + private_key, + "Node time lookup failed", + ) + .await?; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + if response.len() != 4 { + return Err("Node time response had an unexpected byte length.".to_string()); + } + + Ok(u32::from_le_bytes( + response[0..4] + .try_into() + .map_err(|_| "Failed to parse node time response.".to_string())?, + )) +} + +#[tauri::command] +async fn read_local_transaction_cache(state: State<'_, WalletState>) -> Result { + let cache_path = active_wallet_cache_path(&state)?; + match read(&cache_path).await { + Ok(bytes) => String::from_utf8(bytes) + .map_err(|_| "Local transaction cache is not valid UTF-8.".to_string()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok("{}".to_string()), + Err(err) => Err(format!("Unable to read local transaction cache: {err}")), + } +} + +#[tauri::command] +async fn write_local_transaction_cache( + state: State<'_, WalletState>, + cache_json: String, +) -> Result<(), String> { + let cache_path = active_wallet_cache_path(&state)?; + let parsed: JsonValue = serde_json::from_str(&cache_json) + .map_err(|err| format!("Transaction cache must be valid JSON: {err}"))?; + let normalized = serde_json::to_string_pretty(&parsed) + .map_err(|err| format!("Unable to serialize transaction cache: {err}"))?; + + blockchain::tokio::fs::write(&cache_path, normalized) + .await + .map_err(|err| format!("Unable to write local transaction cache: {err}")) +} + +#[tauri::command] +async fn read_local_transaction_index(state: State<'_, WalletState>) -> Result { + let history_dir = active_wallet_history_dir(&state)?; + let index_path = history_dir.join("index.json"); + match read(&index_path).await { + Ok(bytes) => String::from_utf8(bytes) + .map_err(|_| "Local transaction index is not valid UTF-8.".to_string()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok("{}".to_string()), + Err(err) => Err(format!("Unable to read local transaction index: {err}")), + } +} + +#[tauri::command] +async fn write_local_transaction_index( + state: State<'_, WalletState>, + index_json: String, +) -> Result<(), String> { + let history_dir = active_wallet_history_dir(&state)?; + create_dir_all(&history_dir) + .await + .map_err(|err| format!("Unable to create transaction history directory: {err}"))?; + let parsed: JsonValue = serde_json::from_str(&index_json) + .map_err(|err| format!("Transaction index must be valid JSON: {err}"))?; + let normalized = serde_json::to_string_pretty(&parsed) + .map_err(|err| format!("Unable to serialize transaction index: {err}"))?; + + blockchain::tokio::fs::write(history_dir.join("index.json"), normalized) + .await + .map_err(|err| format!("Unable to write local transaction index: {err}")) +} + +#[tauri::command] +async fn read_local_transaction_record( + state: State<'_, WalletState>, + file_name: String, +) -> Result { + let history_dir = active_wallet_history_dir(&state)?; + let file_name = validate_transaction_record_file(&file_name)?; + match read(history_dir.join(file_name)).await { + Ok(bytes) => String::from_utf8(bytes) + .map_err(|_| "Local transaction record is not valid UTF-8.".to_string()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok("{}".to_string()), + Err(err) => Err(format!("Unable to read local transaction record: {err}")), + } +} + +#[tauri::command] +async fn write_local_transaction_record( + state: State<'_, WalletState>, + file_name: String, + record_json: String, +) -> Result<(), String> { + let history_dir = active_wallet_history_dir(&state)?; + create_dir_all(&history_dir) + .await + .map_err(|err| format!("Unable to create transaction history directory: {err}"))?; + let file_name = validate_transaction_record_file(&file_name)?; + let parsed: JsonValue = serde_json::from_str(&record_json) + .map_err(|err| format!("Transaction record must be valid JSON: {err}"))?; + let normalized = serde_json::to_string_pretty(&parsed) + .map_err(|err| format!("Unable to serialize transaction record: {err}"))?; + + blockchain::tokio::fs::write(history_dir.join(file_name), normalized) + .await + .map_err(|err| format!("Unable to write local transaction record: {err}")) +} + +fn validate_nft_cache_key(nft_name: &str, series: u32) -> Result { + let name = nft_name.trim().to_ascii_lowercase(); + if name.is_empty() { + return Err("NFT cache name cannot be empty.".to_string()); + } + if !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') + { + return Err("NFT cache name contains unsupported characters.".to_string()); + } + Ok(format!("{name}_{series}")) +} + +fn nft_cache_record_from_parts( + detail: JsonValue, + metadata: JsonValue, + image_bytes: Option>, + image_mime: Option, +) -> NftCacheRecordView { + let image_data_url = image_bytes.map(|bytes| { + let mime = image_mime + .filter(|value| value.starts_with("image/")) + .unwrap_or_else(|| "image/png".to_string()); + format!("data:{mime};base64,{}", STANDARD.encode(bytes)) + }); + + NftCacheRecordView { + detail, + metadata, + image_data_url, + } +} + +async fn read_cached_nft_record(record_dir: &Path) -> Result { + let record_path = record_dir.join("record.json"); + let record_bytes = read(&record_path) + .await + .map_err(|err| format!("Unable to read NFT cache record: {err}"))?; + let record: JsonValue = serde_json::from_slice(&record_bytes) + .map_err(|err| format!("NFT cache record is not valid JSON: {err}"))?; + let detail = record.get("detail").cloned().unwrap_or(JsonValue::Null); + let metadata = record.get("metadata").cloned().unwrap_or(JsonValue::Null); + let image_mime = record + .get("image_mime") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()); + let image_bytes = match read(record_dir.join("image.bin")).await { + Ok(bytes) => Some(bytes), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => return Err(format!("Unable to read NFT cached image: {err}")), + }; + + Ok(nft_cache_record_from_parts( + detail, + metadata, + image_bytes, + image_mime, + )) +} + +#[tauri::command] +async fn read_nft_cache( + state: State<'_, WalletState>, + nft_name: String, + series: u32, +) -> Result, String> { + let cache_dir = active_wallet_nft_dir(&state)?; + let key = validate_nft_cache_key(&nft_name, series)?; + let record_dir = cache_dir.join(key); + match read_cached_nft_record(&record_dir).await { + Ok(record) => Ok(Some(record)), + Err(err) if err.contains("os error 2") || err.contains("not find") => Ok(None), + Err(err) => Err(err), + } +} + +#[tauri::command] +async fn write_nft_cache( + state: State<'_, WalletState>, + nft_name: String, + series: u32, + detail_json: String, + metadata_json: String, + image_url: String, +) -> Result { + let cache_dir = active_wallet_nft_dir(&state)?; + let key = validate_nft_cache_key(&nft_name, series)?; + let record_dir = cache_dir.join(key); + create_dir_all(&record_dir) + .await + .map_err(|err| format!("Unable to create NFT cache directory: {err}"))?; + + let detail: JsonValue = serde_json::from_str(&detail_json) + .map_err(|err| format!("NFT detail cache must be valid JSON: {err}"))?; + let metadata: JsonValue = serde_json::from_str(&metadata_json) + .map_err(|err| format!("NFT metadata cache must be valid JSON: {err}"))?; + + let mut image_mime = None; + let mut image_cached = false; + let image_url = image_url.trim(); + if image_url.starts_with("http://") + || image_url.starts_with("https://") + || image_url.starts_with("data:image/") + { + if image_url.starts_with("data:image/") { + if let Some((header, encoded)) = image_url.split_once(',') { + image_mime = header + .strip_prefix("data:") + .and_then(|header| header.split_once(';').map(|(mime, _)| mime.to_string())); + if let Ok(bytes) = base64::Engine::decode(&STANDARD, encoded) { + if bytes.len() <= 10_000_000 { + blockchain::tokio::fs::write(record_dir.join("image.bin"), bytes) + .await + .map_err(|err| format!("Unable to write NFT cached image: {err}"))?; + image_cached = true; + } + } + } + } else { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .map_err(|err| format!("Failed to prepare NFT image request: {err}"))?; + let response = client + .get(image_url) + .send() + .await + .map_err(|err| format!("Failed to fetch NFT image: {err}"))? + .error_for_status() + .map_err(|err| format!("NFT image gateway returned an error: {err}"))?; + image_mime = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(';').next().unwrap_or(value).to_string()); + let bytes = response + .bytes() + .await + .map_err(|err| format!("Failed to read NFT image response: {err}"))?; + if bytes.len() <= 10_000_000 { + blockchain::tokio::fs::write(record_dir.join("image.bin"), &bytes) + .await + .map_err(|err| format!("Unable to write NFT cached image: {err}"))?; + image_cached = true; + } + } + } + + let record = serde_json::json!({ + "detail": detail, + "metadata": metadata, + "image_mime": image_mime, + "image_cached": image_cached, + }); + let normalized = serde_json::to_string_pretty(&record) + .map_err(|err| format!("Unable to serialize NFT cache record: {err}"))?; + blockchain::tokio::fs::write(record_dir.join("record.json"), normalized) + .await + .map_err(|err| format!("Unable to write NFT cache record: {err}"))?; + + read_cached_nft_record(&record_dir).await +} + +#[tauri::command] +async fn prune_nft_cache( + state: State<'_, WalletState>, + keep_keys: Vec, +) -> Result<(), String> { + let cache_dir = active_wallet_nft_dir(&state)?; + let keep: std::collections::HashSet = keep_keys + .into_iter() + .map(|key| key.trim().to_ascii_lowercase()) + .filter(|key| !key.is_empty()) + .collect(); + + let mut entries = match read_dir(&cache_dir).await { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(format!("Unable to read NFT cache directory: {err}")), + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let file_type = match entry.file_type().await { + Ok(file_type) => file_type, + Err(_) => continue, + }; + if !file_type.is_dir() { + continue; + } + let key = entry.file_name().to_string_lossy().to_ascii_lowercase(); + if !keep.contains(&key) { + blockchain::tokio::fs::remove_dir_all(entry.path()) + .await + .map_err(|err| format!("Unable to remove stale NFT cache entry: {err}"))?; + } + } + + Ok(()) +} + +async fn remove_file_if_exists(path: &Path) -> Result<(), String> { + match remove_file(path).await { + Ok(_) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(format!("Unable to remove {}: {err}", path.display())), + } +} + +async fn remove_dir_if_exists(path: &Path) -> Result<(), String> { + match remove_dir_all(path).await { + Ok(_) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(format!("Unable to remove {}: {err}", path.display())), + } +} + +async fn clear_nft_cache_files(cache_dir: &Path, file_name: &str) -> Result<(), String> { + let mut entries = match read_dir(cache_dir).await { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(format!("Unable to read NFT cache directory: {err}")), + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let file_type = match entry.file_type().await { + Ok(file_type) => file_type, + Err(_) => continue, + }; + if file_type.is_dir() { + remove_file_if_exists(&entry.path().join(file_name)).await?; + } + } + + Ok(()) +} + +#[tauri::command] +async fn clear_wallet_cache(state: State<'_, WalletState>, cache_kind: String) -> Result<(), String> { + match cache_kind.trim().to_ascii_lowercase().as_str() { + "history" => { + remove_file_if_exists(&active_wallet_cache_path(&state)?).await?; + remove_dir_if_exists(&active_wallet_history_dir(&state)?).await + } + "nft" | "nft_images" | "nft_media" => { + clear_nft_cache_files(&active_wallet_nft_dir(&state)?, "image.bin").await + } + "nft_details" => { + clear_nft_cache_files(&active_wallet_nft_dir(&state)?, "record.json").await + } + "address_book" => remove_file_if_exists(&active_wallet_address_book_path(&state)?).await, + _ => Err("Unknown cache type.".to_string()), + } +} + +#[tauri::command] +async fn read_address_book(state: State<'_, WalletState>) -> Result { + let address_book_path = active_wallet_address_book_path(&state)?; + match read(&address_book_path).await { + Ok(bytes) => String::from_utf8(bytes) + .map_err(|_| "Address book is not valid UTF-8.".to_string()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Ok("{\"entries\":{}}".to_string()) + } + Err(err) => Err(format!("Unable to read address book: {err}")), + } +} + +#[tauri::command] +async fn write_address_book( + state: State<'_, WalletState>, + address_book_json: String, +) -> Result<(), String> { + let address_book_path = active_wallet_address_book_path(&state)?; + let parsed: JsonValue = serde_json::from_str(&address_book_json) + .map_err(|err| format!("Address book must be valid JSON: {err}"))?; + let normalized = serde_json::to_string_pretty(&parsed) + .map_err(|err| format!("Unable to serialize address book: {err}"))?; + + blockchain::tokio::fs::write(&address_book_path, normalized) + .await + .map_err(|err| format!("Unable to write address book: {err}")) +} + +#[tauri::command] +async fn lookup_address_vanity( + state: State<'_, WalletState>, + broadcast_node: String, + address: String, +) -> Result, String> { + ensure_gui_settings_file().await?; + + let owner_address = address.trim(); + if owner_address.is_empty() { + return Err("Address cannot be empty.".to_string()); + } + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let response = serialized_wallet_rpc( + &state, + socket_address, + owner_address.to_string(), + RPC_VANITY_LOOKUP as usize, + public_key, + private_key, + "Vanity address lookup failed", + ) + .await?; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + + let vanity = String::from_utf8_lossy(&response).trim().to_string(); + if vanity.is_empty() { + Ok(None) + } else { + Ok(Some(vanity)) + } +} + +#[tauri::command] +async fn check_vanity_address_availability( + state: State<'_, WalletState>, + broadcast_node: String, + vanity_name: String, +) -> Result { + ensure_gui_settings_file().await?; + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let vanity_address = vanity_address_for_wallet(&vanity_name, &short_address)?; + let display_vanity = display_vanity_address(&vanity_address) + .ok_or_else(|| "Failed to build vanity address display.".to_string())?; + + let response = serialized_wallet_rpc( + &state, + socket_address, + vanity_address, + RPC_VANITY_OWNER_LOOKUP as usize, + public_key, + private_key, + "Vanity availability lookup failed", + ) + .await?; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + + let owner = String::from_utf8_lossy(&response).trim().to_string(); + if owner.is_empty() { + Ok(VanityAvailabilityView { + vanity_address: display_vanity, + available: true, + owner: None, + }) + } else { + Ok(VanityAvailabilityView { + vanity_address: display_vanity, + available: false, + owner: Some(owner), + }) + } +} + +#[tauri::command] +async fn save_history_export( + default_file_name: String, + format: String, + contents: String, +) -> Result, String> { + let extension = match format.trim().to_ascii_lowercase().as_str() { + "json" => "json", + "xml" => "xml", + "csv" => "csv", + _ => return Err("Export format must be json, xml, or csv.".to_string()), + }; + + let mut file_name = default_file_name.trim().to_string(); + if file_name.is_empty() { + file_name = format!("contractless-history.{extension}"); + } + if !file_name + .to_ascii_lowercase() + .ends_with(&format!(".{extension}")) + { + file_name.push('.'); + file_name.push_str(extension); + } + + let selected = FileDialog::new() + .set_filename(&file_name) + .add_filter( + match extension { + "json" => "JSON", + "xml" => "XML", + "csv" => "CSV", + _ => "Export", + }, + &[extension], + ) + .show_save_single_file() + .map_err(|err| format!("Unable to open export save dialog: {err}"))?; + + let Some(mut path) = selected else { + return Ok(None); + }; + + if path.extension().is_none() { + path.set_extension(extension); + } + + blockchain::tokio::fs::write(&path, contents) + .await + .map_err(|err| format!("Unable to save history export: {err}"))?; + + Ok(Some(path.to_string_lossy().into_owned())) +} diff --git a/src/wallet_app/commands_transactions.rs b/src/wallet_app/commands_transactions.rs new file mode 100644 index 0000000..28a2299 --- /dev/null +++ b/src/wallet_app/commands_transactions.rs @@ -0,0 +1,3976 @@ +#[tauri::command] +async fn latest_address_transactions( + state: State<'_, WalletState>, + broadcast_node: String, + limit: u32, +) -> Result, String> { + ensure_gui_settings_file().await?; + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = format!("{short_address}|{limit}"); + + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + RPC_LATEST_ADDRESS_TRANSACTIONS as usize, + public_key, + private_key, + "Latest address transaction lookup failed", + ) + .await?; + + parse_latest_transaction_response(response) +} + +#[tauri::command] +async fn paged_address_history( + state: State<'_, WalletState>, + broadcast_node: String, + skip: u32, + limit: u32, +) -> Result, String> { + ensure_gui_settings_file().await?; + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = format!("{short_address}|{skip}|{limit}"); + + let response = background_wallet_rpc( + &state, + socket_address, + command_input, + RPC_ADDRESS_HISTORY as usize, + public_key, + private_key, + "Paged address history lookup failed", + ) + .await?; + + parse_latest_transaction_response(response) +} + +fn parse_marketing_text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).trim().to_string() +} + +fn parse_marketing_campaign_response(response: Vec) -> Result { + const ROW_BYTES: usize = 1 + 32 + 4 + 4 + 8 + 6 + 40 + 100 + 1 + 1 + 2 + 2; + const HEADER_BYTES: usize = 8 * 5 + 4; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + if response.len() < HEADER_BYTES { + return Err("Marketing campaign response was truncated.".to_string()); + } + + let mut offset = 0usize; + let read_u64 = |bytes: &[u8], offset: &mut usize, label: &str| -> Result { + let value = u64::from_le_bytes( + bytes[*offset..*offset + 8] + .try_into() + .map_err(|_| format!("Failed to parse {label}."))?, + ); + *offset += 8; + Ok(value) + }; + + let total_records = read_u64(&response, &mut offset, "campaign record count")?; + let total_impressions = read_u64(&response, &mut offset, "campaign impressions")?; + let total_clicks = read_u64(&response, &mut offset, "campaign clicks")?; + let total_impression_value = read_u64(&response, &mut offset, "campaign impression value")?; + let total_click_value = read_u64(&response, &mut offset, "campaign click value")?; + let row_count = u32::from_le_bytes( + response[offset..offset + 4] + .try_into() + .map_err(|_| "Failed to parse campaign row count.".to_string())?, + ) as usize; + offset += 4; + + let expected = HEADER_BYTES + .checked_add( + row_count + .checked_mul(ROW_BYTES) + .ok_or_else(|| "Marketing campaign response length overflowed.".to_string())?, + ) + .ok_or_else(|| "Marketing campaign response length overflowed.".to_string())?; + if response.len() != expected { + return Err("Marketing campaign response had an unexpected byte length.".to_string()); + } + + let mut rows = Vec::with_capacity(row_count); + for _ in 0..row_count { + let status = match response[offset] { + 0 => "Pending", + 1 => "Mined", + _ => "Unknown", + } + .to_string(); + offset += 1; + + let txid = encode(&response[offset..offset + 32]); + offset += 32; + let block_height = u32::from_le_bytes( + response[offset..offset + 4] + .try_into() + .map_err(|_| "Failed to parse campaign block height.".to_string())?, + ); + offset += 4; + let time = u32::from_le_bytes( + response[offset..offset + 4] + .try_into() + .map_err(|_| "Failed to parse campaign timestamp.".to_string())?, + ); + offset += 4; + let campaign = u64::from_le_bytes( + response[offset..offset + 8] + .try_into() + .map_err(|_| "Failed to parse campaign id.".to_string())?, + ); + offset += 8; + let ad_type = parse_marketing_text(&response[offset..offset + 6]); + offset += 6; + let keyword = parse_marketing_text(&response[offset..offset + 40]); + offset += 40; + let displayed = parse_marketing_text(&response[offset..offset + 100]); + offset += 100; + let impression = response[offset]; + offset += 1; + let click = response[offset]; + offset += 1; + let impression_value = u16::from_le_bytes( + response[offset..offset + 2] + .try_into() + .map_err(|_| "Failed to parse impression value.".to_string())?, + ); + offset += 2; + let click_value = u16::from_le_bytes( + response[offset..offset + 2] + .try_into() + .map_err(|_| "Failed to parse click value.".to_string())?, + ); + offset += 2; + + rows.push(MarketingCampaignRowView { + status, + txid, + block_height: (block_height > 0).then_some(block_height), + time, + campaign: campaign.to_string(), + ad_type, + keyword, + displayed, + impression, + click, + impression_value, + click_value, + }); + } + + Ok(MarketingCampaignView { + total_records: total_records.to_string(), + total_impressions: total_impressions.to_string(), + total_clicks: total_clicks.to_string(), + total_impression_value: total_impression_value.to_string(), + total_click_value: total_click_value.to_string(), + rows, + }) +} + +#[tauri::command] +async fn marketing_campaign_history( + state: State<'_, WalletState>, + broadcast_node: String, + advertiser: String, + campaign: String, + skip: u32, + limit: u32, +) -> Result { + ensure_gui_settings_file().await?; + + let advertiser = advertiser.trim(); + if advertiser.is_empty() { + return Err("Agency wallet address is required.".to_string()); + } + let advertiser = + resolve_transaction_address(&state, &broadcast_node, advertiser, "Agency wallet").await?; + let campaign = campaign.trim(); + if !campaign.bytes().all(|byte| byte.is_ascii_digit()) || campaign.is_empty() { + return Err("Campaign ID must be a whole number.".to_string()); + } + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = format!("{advertiser}|{campaign}|{skip}|{limit}"); + + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + RPC_MARKETING_CAMPAIGN_HISTORY as usize, + public_key, + private_key, + "Marketing campaign lookup failed", + ) + .await?; + + parse_marketing_campaign_response(response) +} + +#[tauri::command] +async fn transaction_by_txid( + state: State<'_, WalletState>, + broadcast_node: String, + txid: String, +) -> Result { + transaction_by_txid_with_scheduler(state, broadcast_node, txid).await +} + +async fn transaction_by_txid_with_scheduler( + state: State<'_, WalletState>, + broadcast_node: String, + txid: String, +) -> Result { + ensure_gui_settings_file().await?; + + let normalized_txid = txid.trim().to_string(); + let txid_bytes = blockchain::decode(&normalized_txid) + .map_err(|err| format!("Invalid transaction id: {err}"))?; + if txid_bytes.len() != 32 { + return Err("Transaction id must decode to 32 bytes.".to_string()); + } + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + + let response = background_wallet_rpc( + &state, + socket_address, + normalized_txid.clone(), + RPC_TRANSACTION_BY_TXID as usize, + public_key, + private_key, + "Transaction lookup failed", + ) + .await?; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + if response.len() < 5 { + return Err("Transaction lookup response had an unexpected byte length.".to_string()); + } + + let block_height = u32::from_le_bytes( + response[0..4] + .try_into() + .map_err(|_| "Failed to read transaction block height.".to_string())?, + ); + let transaction_bytes = response[4..].to_vec(); + let transaction_type = *transaction_bytes + .first() + .ok_or_else(|| "Transaction lookup response did not include a transaction.".to_string())?; + let transaction = decode_transaction_json(&transaction_bytes).await?; + + Ok(TransactionBytesView { + txid: normalized_txid, + block_height, + transaction_type, + transaction, + transaction_bytes, + }) +} + +#[tauri::command] +async fn token_list( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result, String> { + ensure_gui_settings_file().await?; + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let response = serialized_wallet_rpc( + &state, + socket_address, + String::new(), + RPC_TOKEN_LIST as usize, + public_key, + private_key, + "Token list lookup failed", + ) + .await?; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + const TOKEN_LIST_ROW_BYTES: usize = 15 + 64; + if response.len() % TOKEN_LIST_ROW_BYTES != 0 { + return Err("Token list response had an unexpected byte length.".to_string()); + } + + let mut tokens = Vec::new(); + for chunk in response.chunks_exact(TOKEN_LIST_ROW_BYTES) { + let ticker = String::from_utf8_lossy(&chunk[0..15]).trim().to_string(); + if ticker.is_empty() { + continue; + } + tokens.push(TokenListItemView { + ticker, + origin_txid: String::from_utf8_lossy(&chunk[15..79]).trim().to_string(), + }); + } + + tokens.sort_by(|left, right| { + left.ticker + .to_ascii_lowercase() + .cmp(&right.ticker.to_ascii_lowercase()) + }); + Ok(tokens) +} + +#[tauri::command] +async fn token_catalog( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result, String> { + ensure_gui_settings_file().await?; + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let response = serialized_wallet_rpc( + &state, + socket_address, + String::new(), + RPC_TOKEN_CATALOG as usize, + public_key, + private_key, + "Token catalog lookup failed", + ) + .await?; + + parse_token_catalog_response(&response) +} + +fn parse_token_catalog_response(response: &[u8]) -> Result, String> { + if let Some(error) = parse_error_response(response) { + return Err(error); + } + if response.len() < 4 { + return Err("Token catalog response had an unexpected byte length.".to_string()); + } + const TOKEN_CATALOG_ROW_BYTES: usize = + 15 + 32 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + 4 + 8 + 8 + 8 + 4 + 1; + let count = u32::from_le_bytes( + response[0..4] + .try_into() + .map_err(|_| "Failed to parse token catalog count.".to_string())?, + ) as usize; + let expected_len = 4usize + .checked_add( + count + .checked_mul(TOKEN_CATALOG_ROW_BYTES) + .ok_or_else(|| "Token catalog response length overflowed.".to_string())?, + ) + .ok_or_else(|| "Token catalog response length overflowed.".to_string())?; + if response.len() != expected_len { + return Err("Token catalog response row count did not match payload size.".to_string()); + } + + let mut offset = 4usize; + let mut tokens = Vec::with_capacity(count); + for _ in 0..count { + let ticker = String::from_utf8_lossy(&response[offset..offset + 15]) + .trim() + .to_string(); + offset += 15; + + let origin_txid = encode(&response[offset..offset + 32]); + offset += 32; + + let creator = + Wallet::bytes_to_short_address(&response[offset..offset + Wallet::SHORT_ADDRESS_BYTES_LENGTH]) + .unwrap_or_default(); + offset += Wallet::SHORT_ADDRESS_BYTES_LENGTH; + + let created_timestamp = u32::from_le_bytes( + response[offset..offset + 4] + .try_into() + .map_err(|_| "Failed to parse token created timestamp.".to_string())?, + ); + offset += 4; + + let initial_supply = u64::from_le_bytes( + response[offset..offset + 8] + .try_into() + .map_err(|_| "Failed to parse token initial supply.".to_string())?, + ); + offset += 8; + + let current_supply = u64::from_le_bytes( + response[offset..offset + 8] + .try_into() + .map_err(|_| "Failed to parse token current supply.".to_string())?, + ); + offset += 8; + + let total_burned = u64::from_le_bytes( + response[offset..offset + 8] + .try_into() + .map_err(|_| "Failed to parse token burned supply.".to_string())?, + ); + offset += 8; + + let holder_count = u32::from_le_bytes( + response[offset..offset + 4] + .try_into() + .map_err(|_| "Failed to parse token holder count.".to_string())?, + ); + offset += 4; + + let hard_limit = response[offset] == 1; + offset += 1; + + if !ticker.is_empty() { + tokens.push(TokenCatalogItemView { + ticker, + origin_txid, + creator, + created_timestamp, + initial_supply: initial_supply.to_string(), + current_supply: current_supply.to_string(), + total_burned: total_burned.to_string(), + holder_count, + hard_limit, + }); + } + } + + Ok(tokens) +} + +#[tauri::command] +async fn nft_list( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result, String> { + ensure_gui_settings_file().await?; + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let response = serialized_wallet_rpc( + &state, + socket_address, + String::new(), + RPC_NFT_LIST as usize, + public_key, + private_key, + "NFT list lookup failed", + ) + .await?; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + const NFT_LIST_ROW_BYTES: usize = 32 + 15 + 4; + if response.len() % NFT_LIST_ROW_BYTES != 0 { + return Err("NFT list response had an unexpected byte length.".to_string()); + } + + let mut nfts = Vec::new(); + for chunk in response.chunks_exact(NFT_LIST_ROW_BYTES) { + let origin_txid = encode(&chunk[0..32]); + let nft_name = String::from_utf8_lossy(&chunk[32..47]).trim().to_string(); + let series = u32::from_le_bytes( + chunk[47..51] + .try_into() + .map_err(|_| "Failed to parse NFT series.".to_string())?, + ); + if nft_name.is_empty() { + continue; + } + nfts.push(NftListItemView { + nft_name, + series, + origin_txid, + }); + } + + nfts.sort_by(|left, right| { + left.nft_name + .to_ascii_lowercase() + .cmp(&right.nft_name.to_ascii_lowercase()) + .then(left.series.cmp(&right.series)) + }); + Ok(nfts) +} + +fn nft_history_action_name(action: u8) -> &'static str { + match action { + 1 => "Create", + 2 => "Transfer", + 3 => "Swap", + 4 => "Loan Locked", + 5 => "Loan Payment", + 6 => "Collateral Claimed", + 7 => "Loan Issued", + 8 => "Burned", + _ => "Unknown", + } +} + +fn decode_nft_wallet_field(bytes: &[u8]) -> String { + if bytes.iter().all(|byte| *byte == 0) { + String::new() + } else { + Wallet::bytes_to_short_address(bytes).unwrap_or_default() + } +} + +fn normalize_ipfs_path(value: &str) -> String { + let mut path = value.trim(); + loop { + let next = path + .strip_prefix("ipfs://") + .or_else(|| path.strip_prefix("ipfs:/")) + .or_else(|| path.strip_prefix("ipfs:")) + .or_else(|| path.strip_prefix("/ipfs/")) + .or_else(|| path.strip_prefix("ipfs/")) + .unwrap_or(path) + .trim_start_matches('/'); + if next == path { + return next.to_string(); + } + path = next; + } +} + +fn format_nft_metadata_uri(item_ipfs: &str, series: u32) -> String { + let item_ipfs = item_ipfs.trim(); + if item_ipfs.starts_with("http://") || item_ipfs.starts_with("https://") { + return item_ipfs.to_string(); + } + + let path = normalize_ipfs_path(item_ipfs); + if path.is_empty() { + return String::new(); + } + + if series > 0 && !path.ends_with(".json") { + format!("ipfs://{}/metadata/{series}.json", path.trim_end_matches('/')) + } else { + format!("ipfs://{path}") + } +} + +fn parse_nft_details_response(response: &[u8]) -> Result { + const NFT_NAME_BYTES: usize = 15; + const SERIES_BYTES: usize = 4; + const GENESIS_HASH_BYTES: usize = 32; + const CREATOR_BYTES: usize = Wallet::SHORT_ADDRESS_BYTES_LENGTH; + const IPFS_BYTES: usize = 100; + const HOLDER_BYTES: usize = Wallet::SHORT_ADDRESS_BYTES_LENGTH; + const HISTORY_COUNT_BYTES: usize = 4; + const HISTORY_ENTRY_SIZE: usize = + 32 + 4 + 1 + 1 + (2 * Wallet::SHORT_ADDRESS_BYTES_LENGTH) + 15 + 4 + 8; + const FIXED_BYTES: usize = NFT_NAME_BYTES + + SERIES_BYTES + + GENESIS_HASH_BYTES + + CREATOR_BYTES + + IPFS_BYTES + + HOLDER_BYTES + + HISTORY_COUNT_BYTES; + + if response.len() < FIXED_BYTES { + return Err("NFT details response was too short.".to_string()); + } + + let mut offset = 0usize; + let nft_name = String::from_utf8_lossy(&response[offset..offset + NFT_NAME_BYTES]) + .trim() + .to_string(); + offset += NFT_NAME_BYTES; + + let series = u32::from_le_bytes( + response[offset..offset + SERIES_BYTES] + .try_into() + .map_err(|_| "Failed to parse NFT series.".to_string())?, + ); + offset += SERIES_BYTES; + + let genesis_txid = encode(&response[offset..offset + GENESIS_HASH_BYTES]); + offset += GENESIS_HASH_BYTES; + + let creator = Wallet::bytes_to_short_address(&response[offset..offset + CREATOR_BYTES]) + .unwrap_or_default(); + offset += CREATOR_BYTES; + + let item_ipfs = String::from_utf8_lossy(&response[offset..offset + IPFS_BYTES]) + .trim() + .to_string(); + offset += IPFS_BYTES; + + let current_holder = decode_nft_wallet_field(&response[offset..offset + HOLDER_BYTES]); + offset += HOLDER_BYTES; + + let history_count = u32::from_le_bytes( + response[offset..offset + HISTORY_COUNT_BYTES] + .try_into() + .map_err(|_| "Failed to parse NFT history count.".to_string())?, + ); + offset += HISTORY_COUNT_BYTES; + + let mut history = Vec::new(); + while offset + HISTORY_ENTRY_SIZE <= response.len() { + let entry = &response[offset..offset + HISTORY_ENTRY_SIZE]; + let txid = encode(&entry[0..32]); + let block = u32::from_le_bytes( + entry[32..36] + .try_into() + .map_err(|_| "Failed to parse NFT history block.".to_string())?, + ); + let txtype = entry[36]; + let action = nft_history_action_name(entry[37]).to_string(); + let from = decode_nft_wallet_field(&entry[38..60]); + let to = decode_nft_wallet_field(&entry[60..82]); + let received_asset = String::from_utf8_lossy(&entry[82..97]).trim().to_string(); + let received_series = u32::from_le_bytes( + entry[97..101] + .try_into() + .map_err(|_| "Failed to parse NFT history received series.".to_string())?, + ); + let received_value = u64::from_le_bytes( + entry[101..109] + .try_into() + .map_err(|_| "Failed to parse NFT history received value.".to_string())?, + ) + .to_string(); + + history.push(NftHistoryEntryView { + txid, + block, + txtype, + action, + from, + to, + received_asset, + received_series, + received_value, + }); + offset += HISTORY_ENTRY_SIZE; + } + + let metadata_uri = format_nft_metadata_uri(&item_ipfs, series); + let asset_name = if series > 0 { + format!("{nft_name}_{series}") + } else { + nft_name.clone() + }; + + Ok(NftDetailsView { + nft_name, + series, + asset_name, + genesis_txid, + creator, + item_ipfs, + metadata_uri, + current_holder, + history_count, + history, + }) +} + +#[tauri::command] +async fn nft_details( + state: State<'_, WalletState>, + broadcast_node: String, + nft_name: String, + series: u32, +) -> Result { + ensure_gui_settings_file().await?; + + let nft_name = nft_name.trim(); + if nft_name.is_empty() { + return Err("NFT name cannot be empty.".to_string()); + } + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let response = serialized_wallet_rpc( + &state, + socket_address, + format!("{nft_name}|{series}"), + RPC_NFT_DETAILS as usize, + public_key, + private_key, + "NFT details lookup failed", + ) + .await?; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + + parse_nft_details_response(&response) +} + +fn gateway_url_for_ipfs_path(gateway: &str, ipfs_path: &str) -> Result { + let gateway = gateway.trim(); + let gateway = if gateway.is_empty() { + "https://gateway.pinata.cloud/ipfs/" + } else { + gateway + }; + if !gateway.starts_with("https://") && !gateway.starts_with("http://") { + return Err("IPFS gateway must start with http:// or https://.".to_string()); + } + + let path = ipfs_path.trim(); + if path.starts_with("https://") || path.starts_with("http://") { + return Ok(path.to_string()); + } + + let path = normalize_ipfs_path(path); + if path.is_empty() { + return Err("IPFS metadata path cannot be empty.".to_string()); + } + + Ok(format!("{}/{}", gateway.trim_end_matches('/'), path)) +} + +#[tauri::command] +async fn fetch_ipfs_metadata(ipfs_path: String, gateway: String) -> Result { + let url = gateway_url_for_ipfs_path(&gateway, &ipfs_path)?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .map_err(|err| format!("Failed to prepare metadata request: {err}"))?; + let response = client + .get(&url) + .send() + .await + .map_err(|err| format!("Failed to fetch NFT metadata: {err}"))? + .error_for_status() + .map_err(|err| format!("NFT metadata gateway returned an error: {err}"))?; + let text = response + .text() + .await + .map_err(|err| format!("Failed to read NFT metadata response: {err}"))?; + if text.len() > 1_000_000 { + return Err("NFT metadata response is too large.".to_string()); + } + serde_json::from_str(&text).map_err(|err| format!("NFT metadata was not valid JSON: {err}")) +} + +async fn lookup_issue_token_details( + state: &State<'_, WalletState>, + broadcast_node: &str, + ticker: &str, +) -> Result { + const TOKEN_NAME_BYTES: usize = 15; + const TOKEN_HASH_BYTES: usize = 32; + const TOKEN_COUNT_BYTES: usize = 8; + const TOKEN_SPREAD_BYTES: usize = 4; + + let ticker = padded_token_ticker(ticker)?; + let socket_address = active_broadcast_socket(broadcast_node)?; + let (active_address, public_key, private_key) = active_wallet_rpc_parts(state)?; + let response = serialized_wallet_rpc( + state, + socket_address, + ticker.clone(), + RPC_TOKEN_DETAILS as usize, + public_key, + private_key, + "Token details lookup failed", + ) + .await?; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + + let creator_offset = TOKEN_NAME_BYTES + TOKEN_HASH_BYTES; + let creator_end = creator_offset + Wallet::SHORT_ADDRESS_BYTES_LENGTH; + let hard_limit_offset = creator_end + TOKEN_COUNT_BYTES + TOKEN_SPREAD_BYTES; + let creator_bytes = response + .get(creator_offset..creator_end) + .ok_or_else(|| "Token details response was truncated before creator.".to_string())?; + let creator = Wallet::bytes_to_short_address(creator_bytes) + .ok_or_else(|| "Token details response contained an invalid creator.".to_string())?; + let hard_limit = *response + .get(hard_limit_offset) + .ok_or_else(|| "Token details response was truncated before cap policy.".to_string())? + == 1; + + let (eligible, reason) = if creator.trim() != active_address.trim() { + ( + false, + "Only the original token creator can issue more tokens.".to_string(), + ) + } else if hard_limit { + ( + false, + "This token has a hard cap and cannot issue more tokens.".to_string(), + ) + } else { + (true, "Additional issuance is allowed.".to_string()) + }; + + Ok(IssueTokenDetailsView { + ticker: ticker.trim().to_string(), + creator, + hard_limit, + eligible, + reason, + }) +} + +#[tauri::command] +async fn issue_token_details( + state: State<'_, WalletState>, + broadcast_node: String, + ticker: String, +) -> Result { + lookup_issue_token_details(&state, &broadcast_node, &ticker).await +} + +fn unbroadcast_type_name(txtype: u8) -> &'static str { + match txtype { + 2 => "Transfer", + 3 => "Create Token", + 4 => "Create NFT", + 5 => "Marketing", + 6 => "Swap", + 7 => "Loan", + 8 => "Loan Payment", + 9 => "Collateral Claim", + 10 => "Burn", + 11 => "Issue Token", + 12 => "Vanity Address", + _ => "Unknown", + } +} + +fn json_u64(transaction: &JsonValue, field: &str) -> Result { + transaction + .get(field) + .and_then(|value| value.as_u64()) + .ok_or_else(|| format!("Missing or invalid {field}.")) +} + +fn json_u32(transaction: &JsonValue, primary: &str, fallback: &str) -> Result { + transaction + .get(primary) + .and_then(|value| value.as_u64()) + .or_else(|| transaction.get(fallback).and_then(|value| value.as_u64())) + .map(|value| value as u32) + .ok_or_else(|| format!("Missing or invalid {primary}.")) +} + +fn json_u8(transaction: &JsonValue, field: &str) -> Result { + json_u64(transaction, field).map(|value| value as u8) +} + +fn json_u16(transaction: &JsonValue, field: &str) -> Result { + json_u64(transaction, field).map(|value| value as u16) +} + +fn json_str(transaction: &JsonValue, field: &str) -> Result { + transaction + .get(field) + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) + .ok_or_else(|| format!("Missing or invalid {field}.")) +} + +fn optional_string(transaction: &JsonValue, field: &str) -> String { + transaction + .get(field) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .trim() + .to_string() +} + +fn valid_signature(signature: &str) -> bool { + blockchain::decode(signature).is_ok_and(|bytes| bytes.len() == Wallet::SIGNATURE_LENGTH) +} + +fn parse_atomic_u64(value: &str, field: &str) -> Result { + value + .trim() + .parse::() + .map_err(|_| format!("{field} must be an unsigned atomic value.")) +} + +fn padded_coin_field(coin: &str) -> Result { + let trimmed = coin.trim(); + if trimmed.is_empty() { + return Err("Transfer asset cannot be empty.".to_string()); + } + if trimmed.eq_ignore_ascii_case(BASECOIN.trim()) { + return Ok(BASECOIN.clone()); + } + blockchain::common::asset_names::padded_normalized_asset_input(trimmed) + .ok_or_else(|| "Transfer asset must normalize to 3-15 alphanumeric characters.".to_string()) +} + +fn padded_token_ticker(ticker: &str) -> Result { + let trimmed = ticker.trim(); + blockchain::common::asset_names::padded_normalized_asset_input(trimmed) + .ok_or_else(|| "Token ticker must normalize to 3-15 alphanumeric characters.".to_string()) +} + +fn padded_fixed_field(value: &str, width: usize, field: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(format!("{field} cannot be empty.")); + } + if !trimmed.is_ascii() { + return Err(format!("{field} must contain ASCII characters only.")); + } + if trimmed.len() > width { + return Err(format!("{field} cannot exceed {width} characters.")); + } + Ok(format!("{trimmed: Result { + let trimmed = value.trim(); + if !trimmed.is_ascii() { + return Err(format!("{field} must contain ASCII characters only.")); + } + if trimmed.len() > width { + return Err(format!("{field} cannot exceed {width} characters.")); + } + Ok(format!("{trimmed: Result { + match value.trim().to_ascii_lowercase().as_str() { + "banner" => Ok("banner".to_string()), + "social" => Ok("social".to_string()), + "text" => Ok(format!("{:<6}", "text")), + _ => Err("Marketing ad type must be banner, social, or text.".to_string()), + } +} + +fn vanity_address_for_wallet(vanity_name: &str, owner_address: &str) -> Result { + let trimmed = vanity_name.trim(); + if trimmed.is_empty() || trimmed.len() > Wallet::SHORT_ADDRESS_HASH_BYTES_LENGTH { + return Err("Vanity name must be 1-20 letters.".to_string()); + } + if !trimmed.chars().all(|ch| ch.is_ascii_alphabetic()) { + return Err("Vanity name can only contain letters.".to_string()); + } + let address_bytes = Wallet::short_address_to_bytes(owner_address) + .ok_or_else(|| "Active wallet short address is invalid.".to_string())?; + let network_byte = address_bytes + .last() + .copied() + .ok_or_else(|| "Could not determine wallet network.".to_string())?; + let network_suffix = Wallet::map_byte_to_wallet(network_byte).to_ascii_lowercase(); + if network_suffix.is_empty() { + return Err("Could not determine wallet network.".to_string()); + } + let padded_payload = format!( + "{:>width$}", + trimmed.to_ascii_lowercase(), + width = Wallet::SHORT_ADDRESS_HASH_BYTES_LENGTH + ); + let vanity_address = format!("{padded_payload}.{network_suffix}"); + if Wallet::vanity_address_to_bytes(&vanity_address).is_none() { + return Err("Failed to build vanity address.".to_string()); + } + Ok(vanity_address) +} + +fn display_vanity_address(vanity_address: &str) -> Option { + let (payload, network_suffix) = vanity_address.rsplit_once('.')?; + let trimmed_payload = payload.trim_start(); + if trimmed_payload.is_empty() { + return None; + } + Some(format!("{trimmed_payload}.{network_suffix}")) +} + +async fn persist_active_wallet_vanity_address( + state: &State<'_, WalletState>, + transaction: &JsonValue, +) { + let txtype = transaction + .get("txtype") + .and_then(|value| value.as_u64()) + .unwrap_or_default() as u8; + if txtype != 12 { + return; + } + + let Some(vanity_address) = transaction.get("vanity_address").and_then(|value| value.as_str()) + else { + return; + }; + let Some(owner_address) = transaction.get("address").and_then(|value| value.as_str()) else { + return; + }; + let Some(display_vanity) = display_vanity_address(vanity_address) else { + return; + }; + + let wallet_path = { + let Ok(mut guard) = state.active_wallet.lock() else { + return; + }; + let Some(active_wallet) = guard.as_mut() else { + return; + }; + if active_wallet.wallet.saved.short_address.trim() != owner_address.trim() { + return; + } + active_wallet.wallet.saved.vanity_address = Some(display_vanity.clone()); + active_wallet.wallet_path.clone() + }; + + let Ok(wallet_bytes) = read(&wallet_path).await else { + return; + }; + let Ok(mut saved_wallet) = blockchain::from_slice::(&wallet_bytes) else { + return; + }; + if saved_wallet.short_address.trim() != owner_address.trim() { + return; + } + saved_wallet.vanity_address = Some(display_vanity); + saved_wallet.save_the_wallet(Path::new(&wallet_path)).await; +} + +async fn resolve_transaction_address( + state: &State<'_, WalletState>, + broadcast_node: &str, + address: &str, + field: &str, +) -> Result { + let trimmed = address.trim(); + if let Some(normalized) = Wallet::normalize_to_short_address(trimmed) { + if Wallet::short_address_to_bytes(&normalized).is_some() { + return Ok(normalized); + } + } + let canonical_vanity = + blockchain::standalone_tools::vanity_resolver::canonical_vanity_address_input(trimmed) + .ok_or_else(|| format!("{field} must be a valid short or vanity address."))?; + + let socket_address = active_broadcast_socket(broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(state)?; + let response = serialized_wallet_rpc( + state, + socket_address, + canonical_vanity, + RPC_VANITY_OWNER_LOOKUP as usize, + public_key, + private_key, + "Vanity address lookup failed", + ) + .await?; + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + if response.is_empty() { + return Err(format!("{field} vanity address is not registered.")); + } + + let response_text = String::from_utf8_lossy(&response).trim().to_string(); + Wallet::normalize_to_short_address(&response_text) + .filter(|resolved| Wallet::short_address_to_bytes(resolved).is_some()) + .ok_or_else(|| format!("{field} vanity lookup returned an invalid short address.")) +} + +async fn active_wallet_signing_parts( + state: &State<'_, WalletState>, + wallet_key: Option, +) -> Result<(String, String), String> { + let settings = read_gui_settings().await?; + let ( + _wallet_name, + _wallet_path, + active_short_address, + _vanity, + _public_key, + active_private_key, + ) = active_wallet_snapshot(state)?; + + if !settings.require_key_before_signing { + return Ok((active_short_address, active_private_key)); + } + + let wallet_key = wallet_key.unwrap_or_default().trim().to_string(); + if wallet_key.is_empty() { + return Err("Wallet encryption key is required before signing.".to_string()); + } + + let (_wallet_name, verified_short_address, _vanity, _public_key, verified_private_key) = + verified_private_key_from_active_wallet(state, wallet_key).await?; + if verified_short_address.trim() != active_short_address.trim() { + return Err("Wallet encryption key did not unlock the active wallet.".to_string()); + } + + Ok((active_short_address, verified_private_key)) +} + +async fn build_transfer_json( + state: &State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + receiver: String, + coin: String, + nft_series: u32, + value: String, + txfee: String, +) -> Result { + let (sender, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + let receiver = + resolve_transaction_address(state, &broadcast_node, &receiver, "Receiver").await?; + if receiver.trim().eq_ignore_ascii_case(sender.trim()) { + return Err("Receiver cannot be the active wallet address.".to_string()); + } + + let coin = padded_coin_field(&coin)?; + let mut value = parse_atomic_u64(&value, "Transfer value")?; + let txfee = parse_atomic_u64(&txfee, "Transfer fee")?; + if value == 0 { + return Err("Transfer value must be greater than zero.".to_string()); + } + if nft_series > 0 { + value = 1; + } + + let is_base_coin = coin.trim().eq_ignore_ascii_case(BASECOIN.trim()); + let minimum_fee = minimum_transfer_fee(value, is_base_coin); + if txfee < minimum_fee { + return Err(format!( + "Transfer transaction fee is below the minimum required fee of {minimum_fee}." + )); + } + + let timestamp = blockchain::Utc::now().timestamp() as u32; + let unsigned_transfer = UnsignedTransferTransaction::new( + 2, timestamp, value, &coin, nft_series, &sender, &receiver, txfee, + ) + .await; + let transfer = TransferTransaction::new(unsigned_transfer.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign transfer transaction: {err}"))?; + let hash = unsigned_transfer.hash().await; + + Ok(serde_json::json!({ + "txtype": 2, + "timestamp": timestamp, + "value": value, + "coin": coin, + "nft_series": nft_series, + "sender": sender, + "receiver": receiver, + "txfee": txfee, + "hash": hash, + "signature": transfer.signature, + })) +} + +async fn build_create_token_json( + state: &State<'_, WalletState>, + wallet_key: Option, + ticker: String, + number: String, + allow_more: bool, + txfee: String, +) -> Result { + let (creator, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + let ticker = padded_token_ticker(&ticker)?; + if ticker.trim().eq_ignore_ascii_case(BASECOIN.trim()) { + return Err("Token ticker is reserved for the base coin.".to_string()); + } + + let number = parse_atomic_u64(&number, "Token amount")?; + if number == 0 { + return Err("Token amount must be greater than zero.".to_string()); + } + + let txfee = parse_atomic_u64(&txfee, "Create token fee")?; + if txfee < CREATE_TOKEN_FEE { + return Err(format!( + "Create token fee is below the minimum required fee of {CREATE_TOKEN_FEE}." + )); + } + + let hard_limit = if allow_more { 0 } else { 1 }; + let timestamp = blockchain::Utc::now().timestamp() as u32; + let unsigned_create_token = UnsignedCreateTokenTransaction::new( + 3, timestamp, &creator, &ticker, number, hard_limit, txfee, + ) + .await; + let create_token = CreateTokenTransaction::new(unsigned_create_token.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign create-token transaction: {err}"))?; + let hash = unsigned_create_token.hash().await; + + Ok(serde_json::json!({ + "txtype": 3, + "timestamp": timestamp, + "creator": creator, + "ticker": ticker, + "number": number, + "hard_limit": hard_limit, + "txfee": txfee, + "hash": hash, + "signature": create_token.signature, + })) +} + +async fn build_create_nft_json( + state: &State<'_, WalletState>, + wallet_key: Option, + series: u8, + nft_name: String, + item_ipfs: String, + count: u32, + description: String, + txfee: String, +) -> Result { + let (creator, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + if series > 1 { + return Err("NFT type must be standalone or collection.".to_string()); + } + if series == 0 && count != 1 { + return Err("A standalone NFT must have a count of 1.".to_string()); + } + if series == 1 && count < 2 { + return Err("An NFT collection must contain at least 2 items.".to_string()); + } + + let nft_name = padded_token_ticker(&nft_name) + .map_err(|_| "NFT name must normalize to 3-15 alphanumeric characters.".to_string())?; + if nft_name.trim().eq_ignore_ascii_case(BASECOIN.trim()) { + return Err("NFT name is reserved for the base coin.".to_string()); + } + + let item_ipfs = item_ipfs.trim(); + if blockchain::Cid::try_from(item_ipfs).is_err() { + return Err("Enter a valid IPFS CID.".to_string()); + } + let item_ipfs = padded_fixed_field(item_ipfs, 100, "IPFS CID")?; + let description = padded_optional_fixed_field(&description, 100, "NFT description")?; + + let txfee = parse_atomic_u64(&txfee, "Create NFT fee")?; + if txfee < CREATE_NFT_FEE { + return Err(format!( + "Create NFT fee is below the minimum required fee of {CREATE_NFT_FEE}." + )); + } + + let timestamp = blockchain::Utc::now().timestamp() as u32; + let unsigned_create_nft = UnsignedCreateNftTransaction::new( + 4, + timestamp, + &creator, + series, + &nft_name, + &item_ipfs, + count, + &description, + txfee, + ) + .await; + let create_nft = CreateNftTransaction::new(unsigned_create_nft.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign create-NFT transaction: {err}"))?; + let hash = unsigned_create_nft.hash().await; + + Ok(serde_json::json!({ + "txtype": 4, + "timestamp": timestamp, + "creator": creator, + "series": series, + "nft_name": nft_name, + "item_ipfs": item_ipfs, + "count": count, + "desc": description, + "txfee": txfee, + "hash": hash, + "signature": create_nft.signature, + })) +} + +async fn build_burn_json( + state: &State<'_, WalletState>, + wallet_key: Option, + coin: String, + nft_series: u32, + value: String, + txfee: String, +) -> Result { + let (address, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + let coin = padded_token_ticker(&coin) + .map_err(|_| "Burn asset must normalize to 3-15 alphanumeric characters.".to_string())?; + if coin.trim().eq_ignore_ascii_case(BASECOIN.trim()) { + return Err("Base coin cannot be burned.".to_string()); + } + + let mut value = parse_atomic_u64(&value, "Burn amount")?; + if value == 0 { + return Err("Burn amount must be greater than zero.".to_string()); + } + if nft_series > 0 { + value = 100_000_000; + } + + let txfee = parse_atomic_u64(&txfee, "Burn fee")?; + if txfee < BURN_FEE { + return Err(format!( + "Burn transaction fee is below the minimum required fee of {BURN_FEE}." + )); + } + + let timestamp = blockchain::Utc::now().timestamp() as u32; + let unsigned_burn = + UnsignedBurnTransaction::new(10, timestamp, &address, &coin, nft_series, value, txfee) + .await; + let burn = BurnTransaction::new(unsigned_burn.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign burn transaction: {err}"))?; + let hash = unsigned_burn.hash().await; + + Ok(serde_json::json!({ + "txtype": 10, + "timestamp": timestamp, + "address": address, + "coin": coin, + "nft_series": nft_series, + "value": value, + "txfee": txfee, + "hash": hash, + "signature": burn.signature, + })) +} + +#[allow(clippy::too_many_arguments)] +async fn build_marketing_json( + state: &State<'_, WalletState>, + wallet_key: Option, + campaign: String, + ad_type: String, + keyword: String, + displayed: String, + impression: u8, + click: u8, + impression_value: u16, + click_value: u16, + txfee: String, +) -> Result { + let (advertiser, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + let campaign = parse_atomic_u64(&campaign, "Campaign id")?; + let ad_type = padded_marketing_ad_type(&ad_type)?; + let keyword = padded_fixed_field(&keyword, 40, "Keyword")?; + let displayed = padded_fixed_field(&displayed, 100, "Display location")?; + let txfee = parse_atomic_u64(&txfee, "Marketing fee")?; + if txfee < MARKETING_FEE { + return Err(format!( + "Marketing transaction fee is below the minimum required fee of {MARKETING_FEE}." + )); + } + + let timestamp = blockchain::Utc::now().timestamp() as u32; + let unsigned_marketing = UnsignedMarketingTransaction::new( + 5, + timestamp, + campaign, + &ad_type, + &keyword, + &displayed, + impression, + click, + impression_value, + click_value, + &advertiser, + txfee, + ) + .await; + let marketing = MarketingTransaction::new(unsigned_marketing.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign marketing transaction: {err}"))?; + let hash = unsigned_marketing.hash().await; + + Ok(serde_json::json!({ + "txtype": 5, + "timestamp": timestamp, + "campaign": campaign, + "ad_type": ad_type, + "keyword": keyword, + "displayed": displayed, + "impression": impression, + "click": click, + "impression_value": impression_value, + "click_value": click_value, + "advertiser": advertiser, + "txfee": txfee, + "hash": hash, + "signature": marketing.signature, + })) +} + +async fn build_vanity_address_json( + state: &State<'_, WalletState>, + wallet_key: Option, + vanity_name: String, + txfee: String, +) -> Result { + let (address, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + let vanity_address = vanity_address_for_wallet(&vanity_name, &address)?; + let txfee = parse_atomic_u64(&txfee, "Vanity address fee")?; + if txfee < VANITY_ADDRESS_FEE { + return Err(format!( + "Vanity address transaction fee is below the minimum required fee of {VANITY_ADDRESS_FEE}." + )); + } + + let timestamp = blockchain::Utc::now().timestamp() as u32; + let unsigned_vanity = UnsignedVanityAddressTransaction::new( + 12, + timestamp, + &address, + &vanity_address, + txfee, + ) + .await; + let vanity = VanityAddressTransaction::new(unsigned_vanity.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign vanity address transaction: {err}"))?; + let hash = unsigned_vanity.hash().await; + + Ok(serde_json::json!({ + "txtype": 12, + "timestamp": timestamp, + "address": address, + "vanity_address": vanity_address, + "txfee": txfee, + "hash": hash, + "signature": vanity.signature, + })) +} + +#[allow(clippy::too_many_arguments)] +async fn build_swap_offer_json( + state: &State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + ticker1: String, + nft_series1: u32, + value1: String, + ticker2: String, + nft_series2: u32, + value2: String, + sender2: String, + tip1: String, + tip2: String, + expiration_hours: u32, +) -> Result { + let (sender1, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + let sender2 = + resolve_transaction_address(state, &broadcast_node, &sender2, "Counterparty").await?; + if sender1.trim() == sender2.trim() { + return Err("Counterparty cannot be the active wallet address.".to_string()); + } + + let ticker1 = padded_coin_field(&ticker1)?; + let ticker2 = padded_coin_field(&ticker2)?; + let value1 = parse_atomic_u64(&value1, "Offered amount")?; + let value2 = parse_atomic_u64(&value2, "Requested amount")?; + let tip1 = parse_atomic_u64(&tip1, "Sender1 tip")?; + let tip2 = parse_atomic_u64(&tip2, "Sender2 tip")?; + if value1 == 0 || value2 == 0 { + return Err("Both swap amounts must be greater than zero.".to_string()); + } + if expiration_hours == 0 || expiration_hours > 30 * 24 { + return Err("Swap expiration must be between 1 and 720 hours.".to_string()); + } + + let timestamp = blockchain::Utc::now().timestamp() as u32; + let expiration_seconds = expiration_hours + .checked_mul(60 * 60) + .ok_or_else(|| "Swap expiration overflowed.".to_string())?; + let offer_expiration = timestamp + .checked_add(expiration_seconds) + .ok_or_else(|| "Swap expiration overflowed.".to_string())?; + let unsigned_swap = UnsignedSwapTransaction::new( + 6, + timestamp, + offer_expiration, + &ticker1, + nft_series1, + value1, + &ticker2, + nft_series2, + value2, + &sender1, + &sender2, + tip1, + tip2, + SWAP_FEE, + SWAP_FEE, + ) + .await; + let hash = unsigned_swap.hash().await; + let signature1 = unsigned_swap + .hash_and_sign(&private_key) + .await + .map_err(|err| format!("Failed to sign swap offer: {err}"))?; + + Ok(serde_json::json!({ + "txtype": 6, + "timestamp": timestamp, + "offer_expiration": offer_expiration, + "ticker1": ticker1, + "nft_series1": nft_series1, + "value1": value1, + "ticker2": ticker2, + "nft_series2": nft_series2, + "value2": value2, + "sender1": sender1, + "sender2": sender2, + "tip1": tip1, + "tip2": tip2, + "txfee1": SWAP_FEE, + "txfee2": SWAP_FEE, + "hash": hash, + "signature1": signature1, + "signature2": "", + })) +} + +#[allow(clippy::too_many_arguments)] +async fn build_loan_offer_json( + state: &State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + loan_coin: String, + loan_amount: String, + borrower: String, + collateral: String, + collateral_amount: String, + payment_period: String, + payment_number: u8, + payment_amount: String, + grace_period: u8, + max_late_value: String, + start_timestamp: u32, +) -> Result { + let (lender, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + let borrower = + resolve_transaction_address(state, &broadcast_node, &borrower, "Borrower").await?; + if lender.trim() == borrower.trim() { + return Err("Borrower cannot be the active lender wallet.".to_string()); + } + + let loan_coin = padded_coin_field(&loan_coin)?; + let collateral = + blockchain::common::asset_names::padded_loan_collateral_input_or_base( + &collateral, + &BASECOIN, + ) + .ok_or_else(|| { + "Collateral must be the base coin, a 3-15 character asset, or a numbered NFT ending in _xxxxx." + .to_string() + })?; + let loan_amount = parse_atomic_u64(&loan_amount, "Loan amount")?; + let collateral_amount = parse_atomic_u64(&collateral_amount, "Collateral amount")?; + let payment_amount = parse_atomic_u64(&payment_amount, "Payment amount")?; + let max_late_value = parse_atomic_u64(&max_late_value, "Maximum overdue value")?; + + if loan_amount == 0 || collateral_amount == 0 || payment_amount == 0 { + return Err("Loan, collateral, and payment amounts must be greater than zero.".to_string()); + } + if payment_number == 0 { + return Err("A loan must require at least one payment.".to_string()); + } + if payment_amount > loan_amount { + return Err("A single payment cannot exceed the loan amount.".to_string()); + } + let scheduled_total = payment_amount + .checked_mul(payment_number as u64) + .ok_or_else(|| "Loan repayment schedule overflowed.".to_string())?; + if scheduled_total < loan_amount { + return Err("Scheduled payments must repay at least the loan principal.".to_string()); + } + if grace_period > payment_number { + return Err("Missed-payment allowance cannot exceed the payment count.".to_string()); + } + if max_late_value > loan_amount { + return Err("Maximum overdue value cannot exceed the loan amount.".to_string()); + } + if !matches!(payment_period.as_str(), "d" | "w" | "m") { + return Err("Payment period must be daily, weekly, or monthly.".to_string()); + } + + let unsigned_loan = UnsignedLoanContractTransaction::new( + 7, + start_timestamp, + &loan_coin, + loan_amount, + &lender, + &collateral, + collateral_amount, + &borrower, + &payment_period, + payment_number, + payment_amount, + grace_period, + max_late_value, + LENDER_FEE, + ) + .await; + let (hash, signature1) = unsigned_loan + .hash_and_sign(&private_key) + .await + .map_err(|err| format!("Failed to sign loan offer: {err}"))?; + + Ok(serde_json::json!({ + "txtype": 7, + "timestamp": start_timestamp, + "loan_coin": loan_coin, + "loan_amount": loan_amount, + "lender": lender, + "collateral": collateral, + "collateral_amount": collateral_amount, + "borrower": borrower, + "payment_period": payment_period, + "payment_number": payment_number, + "payment_amount": payment_amount, + "grace_period": grace_period, + "max_late_value": max_late_value, + "txfee": LENDER_FEE, + "hash": hash, + "signature1": signature1, + "signature2": "", + })) +} + +fn required_contract_field<'a>( + fields: &'a HashMap, + field: &str, +) -> Result<&'a str, String> { + fields + .get(field) + .map(String::as_str) + .ok_or_else(|| format!("Loan contract response is missing {field}.")) +} + +fn parse_contract_u64(fields: &HashMap, field: &str) -> Result { + required_contract_field(fields, field)? + .parse::() + .map_err(|_| format!("Loan contract response has invalid {field}.")) +} + +fn loan_payment_history_views(fields: &HashMap) -> Vec { + let mut payments = Vec::new(); + let mut index = 1; + loop { + let txid_key = format!("payment_{index}_txid"); + let Some(txid) = fields.get(&txid_key) else { + break; + }; + let amount = fields + .get(&format!("payment_{index}_amount")) + .cloned() + .unwrap_or_default(); + let payer = fields + .get(&format!("payment_{index}_payee")) + .cloned() + .unwrap_or_default(); + let date = fields + .get(&format!("payment_{index}_date")) + .cloned() + .unwrap_or_default(); + payments.push(LoanPaymentHistoryView { + txid: txid.to_string(), + amount, + payer, + date, + }); + index += 1; + } + payments +} + +fn loan_contract_view(fields: HashMap) -> Result { + let payments = loan_payment_history_views(&fields); + Ok(LoanContractView { + contract: required_contract_field(&fields, "contract")?.to_string(), + status: required_contract_field(&fields, "status")?.to_string(), + start_timestamp: parse_contract_u64(&fields, "start_timestamp")? as u32, + lender: required_contract_field(&fields, "lender")?.to_string(), + borrower: required_contract_field(&fields, "borrower")?.to_string(), + loan_asset: required_contract_field(&fields, "coin_loaned")?.to_string(), + loan_amount: parse_contract_u64(&fields, "loaned_count_atomic")?.to_string(), + collateral: required_contract_field(&fields, "collateral")?.to_string(), + collateral_amount: parse_contract_u64(&fields, "collateral_count_atomic")?.to_string(), + payment_period: required_contract_field(&fields, "payment_type")?.to_string(), + payment_number: parse_contract_u64(&fields, "number_of_payments")? as u8, + payment_amount: parse_contract_u64(&fields, "payment_value_atomic")?.to_string(), + grace_period: parse_contract_u64(&fields, "max_late_payments")? as u8, + max_late_value: parse_contract_u64(&fields, "max_late_value_atomic")?.to_string(), + confirmed_paid: parse_contract_u64(&fields, "total_value_paid_atomic")?.to_string(), + pending_paid: parse_contract_u64(&fields, "pending_value_atomic")?.to_string(), + remaining_balance: parse_contract_u64(&fields, "remaining_balance_atomic")?.to_string(), + remaining_after_pending: parse_contract_u64(&fields, "remaining_after_pending_atomic")? + .to_string(), + payments, + }) +} + +fn parse_contract_list(response: Vec) -> Result, String> { + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + let text = String::from_utf8(response) + .map_err(|_| "Loan contract response was not valid UTF-8.".to_string())?; + let mut contracts = Vec::new(); + let mut current: Option> = None; + + for line in text.lines() { + let line = line.trim(); + if line.starts_with("contract_") && line.ends_with(':') { + if let Some(fields) = current.take() { + contracts.push(loan_contract_view(fields)?); + } + current = Some(HashMap::new()); + continue; + } + let Some(fields) = current.as_mut() else { + continue; + }; + if let Some((key, value)) = line.split_once('=') { + fields.insert(key.trim().to_string(), value.trim().to_string()); + } + } + if let Some(fields) = current { + contracts.push(loan_contract_view(fields)?); + } + Ok(contracts) +} + +async fn load_active_loan_contracts( + state: &State<'_, WalletState>, + broadcast_node: &str, +) -> Result, String> { + let mut contracts = load_loan_contracts_for_active_wallet(state, broadcast_node).await?; + let active_address = active_wallet_rpc_parts(state)?.0; + contracts.retain(|contract| { + contract.borrower.trim() == active_address.trim() + && contract.status != "inactive" + && contract + .remaining_after_pending + .parse::() + .unwrap_or_default() + > 0 + }); + Ok(contracts) +} + +async fn load_loan_contracts_for_active_wallet( + state: &State<'_, WalletState>, + broadcast_node: &str, +) -> Result, String> { + let socket_address = active_broadcast_socket(broadcast_node)?; + let (active_address, public_key, private_key) = active_wallet_rpc_parts(state)?; + let response = serialized_wallet_rpc( + state, + socket_address, + active_address.clone(), + RPC_CONTRACT_BY_ADDRESS as usize, + public_key, + private_key, + "Loan contract lookup failed", + ) + .await?; + let mut contracts = parse_contract_list(response)?; + contracts.sort_by(|left, right| right.start_timestamp.cmp(&left.start_timestamp)); + Ok(contracts) +} + +fn compact_payment_period(period: &str) -> Result<&'static str, String> { + match period.trim().to_ascii_lowercase().as_str() { + "d" | "daily" => Ok("d"), + "w" | "weekly" => Ok("w"), + "m" | "monthly" => Ok("m"), + _ => Err("Loan contract response has an invalid payment period.".to_string()), + } +} + +fn collateral_claim_view( + contract: LoanContractView, + active_address: &str, + reason: String, +) -> CollateralClaimContractView { + let role = if contract.borrower.trim() == active_address.trim() { + "borrower" + } else { + "lender" + }; + + CollateralClaimContractView { + contract: contract.contract, + role: role.to_string(), + reason, + lender: contract.lender, + borrower: contract.borrower, + loan_asset: contract.loan_asset, + loan_amount: contract.loan_amount, + collateral: contract.collateral, + collateral_amount: contract.collateral_amount, + confirmed_paid: contract.confirmed_paid, + pending_paid: contract.pending_paid, + remaining_balance: contract.remaining_balance, + remaining_after_pending: contract.remaining_after_pending, + fee: COLLATERAL_FEE.to_string(), + } +} + +fn collateral_claim_reason( + contract: &LoanContractView, + active_address: &str, +) -> Result, String> { + if contract.status == "inactive" { + return Ok(None); + } + let is_borrower = contract.borrower.trim() == active_address.trim(); + let is_lender = contract.lender.trim() == active_address.trim(); + if !is_borrower && !is_lender { + return Ok(None); + } + + let remaining_balance = contract + .remaining_balance + .parse::() + .map_err(|_| "Loan contract response has invalid remaining balance.".to_string())?; + if is_borrower { + return Ok((remaining_balance == 0).then(|| { + "Loan is fully repaid, so the borrower can reclaim collateral.".to_string() + })); + } + + let payments_due = blockchain::common::loan_schedule::overdue_payment_count( + contract.start_timestamp, + compact_payment_period(&contract.payment_period)?, + contract.payment_number, + blockchain::Utc::now().timestamp() as u32, + )?; + if payments_due <= contract.grace_period as usize { + return Ok(None); + } + + let confirmed_paid = contract + .confirmed_paid + .parse::() + .map_err(|_| "Loan contract response has invalid confirmed payment total.".to_string())?; + let payment_amount = contract + .payment_amount + .parse::() + .map_err(|_| "Loan contract response has invalid payment amount.".to_string())?; + let max_late_value = contract + .max_late_value + .parse::() + .map_err(|_| "Loan contract response has invalid max late value.".to_string())?; + let should_have_paid = (payments_due as u64) + .checked_mul(payment_amount) + .ok_or_else(|| "Loan delinquency calculation overflowed.".to_string())?; + let overdue_value = should_have_paid.saturating_sub(confirmed_paid); + Ok((overdue_value > max_late_value).then(|| { + format!( + "Borrower is late by {} atomic units beyond the allowed threshold.", + overdue_value.saturating_sub(max_late_value) + ) + })) +} + +async fn load_claimable_collateral_contracts( + state: &State<'_, WalletState>, + broadcast_node: &str, +) -> Result, String> { + let socket_address = active_broadcast_socket(broadcast_node)?; + let (active_address, public_key, private_key) = active_wallet_rpc_parts(state)?; + let response = serialized_wallet_rpc( + state, + socket_address, + active_address.clone(), + RPC_CONTRACT_BY_ADDRESS as usize, + public_key, + private_key, + "Loan contract lookup failed", + ) + .await?; + + let contracts = parse_contract_list(response)?; + let mut claimable = Vec::new(); + for contract in contracts { + if let Some(reason) = collateral_claim_reason(&contract, &active_address)? { + claimable.push(collateral_claim_view(contract, &active_address, reason)); + } + } + claimable.sort_by(|left, right| left.contract.cmp(&right.contract)); + Ok(claimable) +} + +async fn load_loan_contract( + state: &State<'_, WalletState>, + broadcast_node: &str, + contract_hash: &str, +) -> Result { + let socket_address = active_broadcast_socket(broadcast_node)?; + let (_active_address, public_key, private_key) = active_wallet_rpc_parts(state)?; + let response = serialized_wallet_rpc( + state, + socket_address, + contract_hash.to_string(), + RPC_LOAN_CONTRACT as usize, + public_key, + private_key, + "Loan contract lookup failed", + ) + .await?; + let text = String::from_utf8(response) + .map_err(|_| "Loan contract response was not valid UTF-8.".to_string())?; + if text.trim_start().starts_with("error:") { + return Err(text.trim().to_string()); + } + let mut fields = HashMap::new(); + for line in text.lines() { + if let Some((key, value)) = line.trim().split_once('=') { + fields.insert(key.trim().to_string(), value.trim().to_string()); + } + } + loan_contract_view(fields) +} + +async fn build_loan_payment_json( + state: &State<'_, WalletState>, + wallet_key: Option, + broadcast_node: &str, + contract_hash: String, + payback_amount: String, +) -> Result { + let (address, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + let contract = load_loan_contract(state, broadcast_node, contract_hash.trim()).await?; + if contract.borrower.trim() != address.trim() { + return Err("Only the borrower can make a payment on this loan.".to_string()); + } + if contract.status == "inactive" { + return Err("This loan is no longer active.".to_string()); + } + + let payback_amount = parse_atomic_u64(&payback_amount, "Loan payment")?; + if payback_amount == 0 { + return Err("Loan payment must be greater than zero.".to_string()); + } + let remaining = contract + .remaining_after_pending + .parse::() + .map_err(|_| "Loan contract returned an invalid remaining balance.".to_string())?; + if payback_amount > remaining { + return Err("Payment exceeds the remaining balance after pending payments.".to_string()); + } + let tip = payback_amount.div_ceil(100); + let timestamp = blockchain::Utc::now().timestamp() as u32; + let unsigned_payment = UnsignedContractPaymentTransaction::new( + 8, + timestamp, + payback_amount, + contract_hash.trim(), + &address, + tip, + BORROWER_FEE, + ) + .await; + let payment = ContractPaymentTransaction::new(unsigned_payment.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign loan payment: {err}"))?; + let hash = unsigned_payment.hash().await; + + Ok(serde_json::json!({ + "txtype": 8, + "timestamp": timestamp, + "payback_amount": payback_amount, + "contract_hash": contract_hash.trim(), + "address": address, + "tip": tip, + "txfee": BORROWER_FEE, + "hash": hash, + "signature": payment.signature, + })) +} + +async fn build_collateral_claim_json( + state: &State<'_, WalletState>, + wallet_key: Option, + broadcast_node: &str, + contract_hash: String, +) -> Result { + let (address, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + let contract = load_loan_contract(state, broadcast_node, contract_hash.trim()).await?; + let Some(_reason) = collateral_claim_reason(&contract, &address)? else { + return Err("This loan is not currently eligible for a collateral claim.".to_string()); + }; + + let timestamp = blockchain::Utc::now().timestamp() as u32; + let unsigned_claim = UnsignedCollateralClaimTransaction::new( + 9, + timestamp, + contract_hash.trim(), + &address, + COLLATERAL_FEE, + ) + .await; + let claim = CollateralClaimTransaction::new(unsigned_claim.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign collateral claim: {err}"))?; + let hash = unsigned_claim.hash().await; + + Ok(serde_json::json!({ + "txtype": 9, + "timestamp": timestamp, + "contract_hash": contract_hash.trim(), + "address": address, + "txfee": COLLATERAL_FEE, + "hash": hash, + "signature": claim.signature, + })) +} + +async fn build_issue_token_json( + state: &State<'_, WalletState>, + wallet_key: Option, + broadcast_node: &str, + ticker: String, + number: String, + txfee: String, +) -> Result { + let details = lookup_issue_token_details(state, broadcast_node, &ticker).await?; + if !details.eligible { + return Err(details.reason); + } + + let (creator, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + if creator.trim() != details.creator.trim() { + return Err("Only the original token creator can issue more tokens.".to_string()); + } + + let ticker = padded_token_ticker(&details.ticker)?; + let number = parse_atomic_u64(&number, "Issue token amount")?; + if number == 0 { + return Err("Issue token amount must be greater than zero.".to_string()); + } + + let txfee = parse_atomic_u64(&txfee, "Issue token fee")?; + if txfee < ISSUE_TOKEN_FEE { + return Err(format!( + "Issue token fee is below the minimum required fee of {ISSUE_TOKEN_FEE}." + )); + } + + let timestamp = blockchain::Utc::now().timestamp() as u32; + let unsigned_issue_token = + UnsignedIssueTokenTransaction::new(11, timestamp, &creator, &ticker, number, txfee).await; + let issue_token = IssueTokenTransaction::new(unsigned_issue_token.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign issue-token transaction: {err}"))?; + let hash = unsigned_issue_token.hash().await; + + Ok(serde_json::json!({ + "txtype": 11, + "timestamp": timestamp, + "creator": creator, + "ticker": ticker, + "number": number, + "txfee": txfee, + "hash": hash, + "signature": issue_token.signature, + })) +} + +fn unbroadcast_summary(transaction: &JsonValue, txtype: u8) -> String { + match txtype { + 2 => format!( + "{} {} to {}", + json_u64(transaction, "value").unwrap_or_default(), + optional_string(transaction, "coin").trim(), + optional_string(transaction, "receiver") + ), + 3 => format!( + "Create {} {}", + json_u64(transaction, "number").unwrap_or_default(), + optional_string(transaction, "ticker").trim() + ), + 4 => { + let count = json_u64(transaction, "count").unwrap_or_default(); + let name = optional_string(transaction, "nft_name"); + if json_u64(transaction, "series").unwrap_or_default() == 1 { + format!("Create collection of {count} {}", name.trim()) + } else { + format!("Create NFT {}", name.trim()) + } + } + 11 => format!( + "Issue {} more {}", + json_u64(transaction, "number").unwrap_or_default(), + optional_string(transaction, "ticker").trim() + ), + 6 => format!( + "{} {} for {} {}", + json_u64(transaction, "value1").unwrap_or_default(), + optional_string(transaction, "ticker1").trim(), + json_u64(transaction, "value2").unwrap_or_default(), + optional_string(transaction, "ticker2").trim() + ), + 7 => format!( + "Loan {} {} from {} to {}", + json_u64(transaction, "loan_amount").unwrap_or_default(), + optional_string(transaction, "loan_coin").trim(), + optional_string(transaction, "lender"), + optional_string(transaction, "borrower") + ), + 8 => format!( + "Pay {} toward loan {}", + json_u64(transaction, "payback_amount").unwrap_or_default(), + optional_string(transaction, "contract_hash") + ), + 12 => format!("Vanity {}", optional_string(transaction, "vanity_address")), + _ => optional_string(transaction, "hash") + .chars() + .take(16) + .collect::(), + } +} + +async fn unbroadcast_dir() -> Result { + let settings = read_gui_settings().await?; + let path = PathBuf::from(settings.unbroadcast_transactions_path); + create_dir_all(&path) + .await + .map_err(|err| format!("Unable to create unbroadcast transaction directory: {err}"))?; + Ok(path) +} + +fn validate_unbroadcast_file_name(file_name: &str) -> Result { + let trimmed = file_name.trim(); + if trimmed.is_empty() { + return Err("Transaction filename cannot be empty.".to_string()); + } + if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") { + return Err("Transaction filename cannot contain path separators.".to_string()); + } + if !trimmed + .rsplit_once('.') + .is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("json")) + { + return Err("Transaction filename must end with .json.".to_string()); + } + Ok(trimmed.to_string()) +} + +async fn read_unbroadcast_json(file_name: &str) -> Result<(PathBuf, JsonValue), String> { + let file_name = validate_unbroadcast_file_name(file_name)?; + let dir = unbroadcast_dir().await?; + let path = dir.join(&file_name); + let contents = read_to_string(&path) + .await + .map_err(|err| format!("Unable to read unbroadcast transaction: {err}"))?; + let transaction = serde_json::from_str::(&contents) + .map_err(|err| format!("Unable to parse unbroadcast transaction JSON: {err}"))?; + Ok((path, transaction)) +} + +async fn rebuild_swap(transaction: &JsonValue) -> Result { + UnsignedSwapTransaction::new( + 6, + json_u32(transaction, "timestamp", "time")?, + json_u64(transaction, "offer_expiration")? as u32, + &json_str(transaction, "ticker1")?, + transaction + .get("nft_series1") + .and_then(|value| value.as_u64()) + .unwrap_or_default() as u32, + json_u64(transaction, "value1")?, + &json_str(transaction, "ticker2")?, + transaction + .get("nft_series2") + .and_then(|value| value.as_u64()) + .unwrap_or_default() as u32, + json_u64(transaction, "value2")?, + &json_str(transaction, "sender1")?, + &json_str(transaction, "sender2")?, + json_u64(transaction, "tip1")?, + json_u64(transaction, "tip2")?, + json_u64(transaction, "txfee1")?, + json_u64(transaction, "txfee2")?, + ) + .await + .pipe(Ok) +} + +async fn rebuild_transfer(transaction: &JsonValue) -> Result { + UnsignedTransferTransaction::new( + 2, + json_u32(transaction, "timestamp", "time")?, + json_u64(transaction, "value")?, + &json_str(transaction, "coin")?, + transaction + .get("nft_series") + .and_then(|value| value.as_u64()) + .unwrap_or_default() as u32, + &json_str(transaction, "sender")?, + &json_str(transaction, "receiver")?, + json_u64(transaction, "txfee")?, + ) + .await + .pipe(Ok) +} + +async fn rebuild_create_token( + transaction: &JsonValue, +) -> Result { + UnsignedCreateTokenTransaction::new( + 3, + json_u32(transaction, "timestamp", "time")?, + &json_str(transaction, "creator")?, + &json_str(transaction, "ticker")?, + json_u64(transaction, "number")?, + json_u8(transaction, "hard_limit")?, + json_u64(transaction, "txfee")?, + ) + .await + .pipe(Ok) +} + +async fn rebuild_create_nft( + transaction: &JsonValue, +) -> Result { + UnsignedCreateNftTransaction::new( + 4, + json_u32(transaction, "timestamp", "time")?, + &json_str(transaction, "creator")?, + json_u8(transaction, "series")?, + &json_str(transaction, "nft_name")?, + &json_str(transaction, "item_ipfs")?, + json_u64(transaction, "count")? as u32, + &json_str(transaction, "desc")?, + json_u64(transaction, "txfee")?, + ) + .await + .pipe(Ok) +} + +async fn rebuild_issue_token( + transaction: &JsonValue, +) -> Result { + UnsignedIssueTokenTransaction::new( + 11, + json_u32(transaction, "timestamp", "time")?, + &json_str(transaction, "creator")?, + &json_str(transaction, "ticker")?, + json_u64(transaction, "number")?, + json_u64(transaction, "txfee")?, + ) + .await + .pipe(Ok) +} + +async fn verify_registered_signature( + state: &State<'_, WalletState>, + broadcast_node: &str, + hash: &str, + signer: &str, + signature: &str, + label: &str, +) -> Result<(), String> { + if !valid_signature(signature) { + return Err(format!("{label} is missing or malformed.")); + } + let signer = Wallet::normalize_to_short_address(signer) + .filter(|address| Wallet::short_address_to_bytes(address).is_some()) + .ok_or_else(|| format!("{label} signer address is invalid."))?; + let socket_address = active_broadcast_socket(broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(state)?; + let response = serialized_wallet_rpc( + state, + socket_address, + format!("{hash}|{signer}|{signature}"), + RPC_VALIDATE_MESSAGE as usize, + public_key, + private_key, + "Transaction signature verification failed", + ) + .await?; + if String::from_utf8_lossy(&response).trim() != "valid" { + return Err(format!("{label} is not valid for {signer}.")); + } + Ok(()) +} + +async fn validate_imported_transaction( + state: &State<'_, WalletState>, + broadcast_node: &str, + transaction: &JsonValue, +) -> Result { + let txtype = json_u8(transaction, "txtype")?; + let (computed_hash, signatures) = match txtype { + 2 => { + let unsigned = rebuild_transfer(transaction).await?; + ( + unsigned.hash().await, + vec![( + json_str(transaction, "sender")?, + json_str(transaction, "signature")?, + "Transfer signature", + )], + ) + } + 3 => { + let unsigned = rebuild_create_token(transaction).await?; + ( + unsigned.hash().await, + vec![( + json_str(transaction, "creator")?, + json_str(transaction, "signature")?, + "Create-token signature", + )], + ) + } + 4 => { + let unsigned = rebuild_create_nft(transaction).await?; + ( + unsigned.hash().await, + vec![( + json_str(transaction, "creator")?, + json_str(transaction, "signature")?, + "Create-NFT signature", + )], + ) + } + 5 => { + let unsigned = rebuild_marketing(transaction).await?; + if !matches!( + unsigned.ad_type.trim().to_ascii_lowercase().as_str(), + "banner" | "social" | "text" + ) { + return Err("Marketing ad type must be banner, social, or text.".to_string()); + } + if unsigned.keyword.len() != 40 { + return Err("Marketing keyword must use its 40-byte field.".to_string()); + } + if unsigned.displayed.len() != 100 { + return Err("Marketing display location must use its 100-byte field.".to_string()); + } + if unsigned.txfee < MARKETING_FEE { + return Err(format!( + "Marketing transaction fee is below the minimum required fee of {MARKETING_FEE}." + )); + } + ( + unsigned.hash().await, + vec![( + unsigned.advertiser.clone(), + json_str(transaction, "signature")?, + "Marketing signature", + )], + ) + } + 6 => { + let unsigned = rebuild_swap(transaction).await?; + let now = blockchain::Utc::now().timestamp() as u32; + if unsigned.offer_expiration < unsigned.timestamp + || unsigned.offer_expiration > unsigned.timestamp.saturating_add(30 * 24 * 60 * 60) + { + return Err("Swap expiration is outside the valid 30-day window.".to_string()); + } + if now > unsigned.offer_expiration { + return Err("This swap offer has expired.".to_string()); + } + if unsigned.txfee1 < SWAP_FEE || unsigned.txfee2 < SWAP_FEE { + return Err(format!("Each swap signer must pay at least {SWAP_FEE}.")); + } + + let signature1 = json_str(transaction, "signature1")?; + let signature2 = optional_string(transaction, "signature2"); + let mut signatures = vec![( + unsigned.sender1.clone(), + signature1, + "Swap sender1 signature", + )]; + if !signature2.is_empty() { + signatures.push(( + unsigned.sender2.clone(), + signature2, + "Swap sender2 signature", + )); + } + (unsigned.hash().await, signatures) + } + 7 => { + let unsigned = rebuild_loan(transaction).await?; + if unsigned.loan_coin.len() != 15 { + return Err("Loan asset must use its canonical 15-byte field.".to_string()); + } + if !blockchain::common::asset_names::is_canonical_loan_collateral_or_base( + &unsigned.collateral, + &BASECOIN, + ) { + return Err("Loan collateral must use its canonical 21-byte field.".to_string()); + } + if unsigned.loan_amount == 0 + || unsigned.collateral_amount == 0 + || unsigned.payment_amount == 0 + || unsigned.payment_number == 0 + { + return Err( + "Loan, collateral, payment amount, and payment count must be greater than zero." + .to_string(), + ); + } + if !matches!(unsigned.payment_period.as_str(), "d" | "w" | "m") { + return Err("Loan payment period must be daily, weekly, or monthly.".to_string()); + } + if unsigned.payment_amount > unsigned.loan_amount { + return Err("A single payment cannot exceed the loan amount.".to_string()); + } + let scheduled_total = unsigned + .payment_amount + .checked_mul(unsigned.payment_number as u64) + .ok_or_else(|| "Loan repayment schedule overflowed.".to_string())?; + if scheduled_total < unsigned.loan_amount { + return Err("Scheduled payments do not repay the loan principal.".to_string()); + } + if unsigned.grace_period > unsigned.payment_number { + return Err("Missed-payment allowance cannot exceed the payment count.".to_string()); + } + if unsigned.max_late_value > unsigned.loan_amount { + return Err("Maximum overdue value cannot exceed the loan amount.".to_string()); + } + if unsigned.txfee < LENDER_FEE { + return Err(format!( + "Loan lender fee is below the minimum required fee of {LENDER_FEE}." + )); + } + + let signature1 = json_str(transaction, "signature1")?; + let signature2 = optional_string(transaction, "signature2"); + let mut signatures = + vec![(unsigned.lender.clone(), signature1, "Loan lender signature")]; + if !signature2.is_empty() { + signatures.push(( + unsigned.borrower.clone(), + signature2, + "Loan borrower signature", + )); + } + (unsigned.hash().await, signatures) + } + 8 => { + let unsigned = rebuild_loan_payment(transaction).await?; + if unsigned.payback_amount == 0 { + return Err("Loan payment must be greater than zero.".to_string()); + } + if unsigned.txfee < BORROWER_FEE { + return Err(format!( + "Loan payment fee is below the minimum required fee of {BORROWER_FEE}." + )); + } + let minimum_tip = unsigned.payback_amount.div_ceil(100); + if unsigned.tip < minimum_tip { + return Err("Loan payment tip must be at least 1%.".to_string()); + } + ( + unsigned.hash().await, + vec![( + unsigned.address.clone(), + json_str(transaction, "signature")?, + "Loan-payment signature", + )], + ) + } + 9 => { + let unsigned = rebuild_collateral_claim(transaction).await?; + if unsigned.txfee < COLLATERAL_FEE { + return Err(format!( + "Collateral claim fee is below the minimum required fee of {COLLATERAL_FEE}." + )); + } + ( + unsigned.hash().await, + vec![( + unsigned.address.clone(), + json_str(transaction, "signature")?, + "Collateral-claim signature", + )], + ) + } + 10 => { + let unsigned = rebuild_burn(transaction).await?; + if unsigned.coin.len() != 15 + || !blockchain::common::asset_names::is_canonical_padded_asset_identifier( + &unsigned.coin, + ) + { + return Err("Burn asset must use its canonical 15-byte field.".to_string()); + } + if unsigned.coin.trim().eq_ignore_ascii_case(BASECOIN.trim()) { + return Err("Base coin cannot be burned.".to_string()); + } + if unsigned.value == 0 { + return Err("Burn amount must be greater than zero.".to_string()); + } + if unsigned.txfee < BURN_FEE { + return Err(format!( + "Burn transaction fee is below the minimum required fee of {BURN_FEE}." + )); + } + ( + unsigned.hash().await, + vec![( + unsigned.address.clone(), + json_str(transaction, "signature")?, + "Burn signature", + )], + ) + } + 11 => { + let unsigned = rebuild_issue_token(transaction).await?; + ( + unsigned.hash().await, + vec![( + json_str(transaction, "creator")?, + json_str(transaction, "signature")?, + "Issue-token signature", + )], + ) + } + 12 => { + let unsigned = rebuild_vanity_address(transaction).await?; + if unsigned.txfee < VANITY_ADDRESS_FEE { + return Err(format!( + "Vanity address transaction fee is below the minimum required fee of {VANITY_ADDRESS_FEE}." + )); + } + if Wallet::short_address_to_bytes(&unsigned.address).is_none() { + return Err("Vanity owner address is invalid.".to_string()); + } + if Wallet::vanity_address_to_bytes(&unsigned.vanity_address).is_none() { + return Err("Vanity address is invalid.".to_string()); + } + ( + unsigned.hash().await, + vec![( + unsigned.address.clone(), + json_str(transaction, "signature")?, + "Vanity-address signature", + )], + ) + } + _ => { + return Err( + "This wallet currently imports transfer, create-token, create-NFT, marketing, swap, loan, loan-payment, collateral-claim, burn, issue-token, and vanity-address transactions only." + .to_string(), + ); + } + }; + + let supplied_hash = json_str(transaction, "hash")?.trim().to_string(); + if supplied_hash != computed_hash { + return Err("Transaction hash does not match its fields.".to_string()); + } + if !blockchain::decode(&supplied_hash).is_ok_and(|bytes| bytes.len() == 32) { + return Err("Transaction hash must be a 32-byte hexadecimal value.".to_string()); + } + + for (signer, signature, label) in signatures { + verify_registered_signature( + state, + broadcast_node, + &computed_hash, + &signer, + &signature, + label, + ) + .await?; + } + Ok(computed_hash) +} + +async fn rebuild_loan(transaction: &JsonValue) -> Result { + let collateral_amount = transaction + .get("collateral_amount") + .and_then(|value| value.as_u64()) + .or_else(|| { + transaction + .get("collateral_value") + .and_then(|value| value.as_u64()) + }) + .ok_or_else(|| "Missing or invalid collateral amount.".to_string())?; + + UnsignedLoanContractTransaction::new( + 7, + json_u32(transaction, "timestamp", "time")?, + &json_str(transaction, "loan_coin")?, + json_u64(transaction, "loan_amount")?, + &json_str(transaction, "lender")?, + &json_str(transaction, "collateral")?, + collateral_amount, + &json_str(transaction, "borrower")?, + &json_str(transaction, "payment_period")?, + json_u8(transaction, "payment_number")?, + json_u64(transaction, "payment_amount")?, + json_u8(transaction, "grace_period")?, + json_u64(transaction, "max_late_value")?, + json_u64(transaction, "txfee")?, + ) + .await + .pipe(Ok) +} + +async fn rebuild_loan_payment( + transaction: &JsonValue, +) -> Result { + UnsignedContractPaymentTransaction::new( + 8, + json_u32(transaction, "timestamp", "time")?, + json_u64(transaction, "payback_amount")?, + &json_str(transaction, "contract_hash")?, + &json_str(transaction, "address")?, + json_u64(transaction, "tip")?, + json_u64(transaction, "txfee")?, + ) + .await + .pipe(Ok) +} + +async fn rebuild_collateral_claim( + transaction: &JsonValue, +) -> Result { + UnsignedCollateralClaimTransaction::new( + 9, + json_u32(transaction, "timestamp", "time")?, + &json_str(transaction, "contract_hash")?, + &json_str(transaction, "address")?, + json_u64(transaction, "txfee")?, + ) + .await + .pipe(Ok) +} + +async fn rebuild_burn(transaction: &JsonValue) -> Result { + UnsignedBurnTransaction::new( + 10, + json_u32(transaction, "timestamp", "time")?, + &json_str(transaction, "address")?, + &json_str(transaction, "coin")?, + json_u32(transaction, "nft_series", "nft_series")?, + json_u64(transaction, "value")?, + json_u64(transaction, "txfee")?, + ) + .await + .pipe(Ok) +} + +async fn rebuild_marketing(transaction: &JsonValue) -> Result { + UnsignedMarketingTransaction::new( + 5, + json_u32(transaction, "timestamp", "time")?, + json_u64(transaction, "campaign")?, + &json_str(transaction, "ad_type")?, + &json_str(transaction, "keyword")?, + &json_str(transaction, "displayed")?, + json_u8(transaction, "impression")?, + json_u8(transaction, "click")?, + json_u16(transaction, "impression_value")?, + json_u16(transaction, "click_value")?, + &json_str(transaction, "advertiser")?, + json_u64(transaction, "txfee")?, + ) + .await + .pipe(Ok) +} + +async fn rebuild_vanity_address( + transaction: &JsonValue, +) -> Result { + UnsignedVanityAddressTransaction::new( + 12, + json_u32(transaction, "timestamp", "time")?, + &json_str(transaction, "address")?, + &json_str(transaction, "vanity_address")?, + json_u64(transaction, "txfee")?, + ) + .await + .pipe(Ok) +} + +trait Pipe: Sized { + fn pipe(self, f: impl FnOnce(Self) -> T) -> T { + f(self) + } +} + +impl Pipe for T {} + +async fn unbroadcast_state( + transaction: &JsonValue, + active_short_address: Option<&str>, +) -> (String, String, bool, bool) { + let txtype = transaction + .get("txtype") + .and_then(|value| value.as_u64()) + .unwrap_or_default() as u8; + + match txtype { + 6 => { + let Ok(unsigned_swap) = rebuild_swap(transaction).await else { + return ( + "invalid".to_string(), + "Swap transaction fields are incomplete.".to_string(), + false, + false, + ); + }; + let computed_hash = unsigned_swap.hash().await; + let json_hash = optional_string(transaction, "hash"); + if !json_hash.is_empty() && json_hash != computed_hash { + return ( + "invalid_hash".to_string(), + "Swap hash does not match the transaction fields.".to_string(), + false, + false, + ); + } + + let signature1 = optional_string(transaction, "signature1"); + let signature2 = optional_string(transaction, "signature2"); + if valid_signature(&signature1) && valid_signature(&signature2) { + return ( + "ready".to_string(), + "Ready to broadcast.".to_string(), + false, + true, + ); + } + + let active = active_short_address.unwrap_or_default().trim(); + if signature1.is_empty() { + let signer = optional_string(transaction, "sender1"); + let can_sign = !active.is_empty() && active == signer.trim(); + return ( + if can_sign { + "needs_active_signature" + } else { + "needs_other_signature" + } + .to_string(), + format!("Needs sender1 signature from {}.", signer.trim()), + can_sign, + false, + ); + } + if signature2.is_empty() { + let signer = optional_string(transaction, "sender2"); + let can_sign = !active.is_empty() && active == signer.trim(); + let sender1 = optional_string(transaction, "sender1"); + let message = if can_sign { + "Review the swap terms, then sign and broadcast as sender2.".to_string() + } else if !active.is_empty() && active == sender1.trim() { + format!( + "Your sender1 signature is present. Awaiting sender2 signature from {}.", + signer.trim() + ) + } else { + format!("Awaiting sender2 signature from {}.", signer.trim()) + }; + return ( + if can_sign { + "needs_active_signature" + } else { + "needs_other_signature" + } + .to_string(), + message, + can_sign, + false, + ); + } + + ( + "invalid_signature".to_string(), + "Swap has malformed signature data.".to_string(), + false, + false, + ) + } + 7 => { + let Ok(unsigned_loan) = rebuild_loan(transaction).await else { + return ( + "invalid".to_string(), + "Loan transaction fields are incomplete.".to_string(), + false, + false, + ); + }; + let computed_hash = unsigned_loan.hash().await; + let json_hash = optional_string(transaction, "hash"); + if !json_hash.is_empty() && json_hash != computed_hash { + return ( + "invalid_hash".to_string(), + "Loan hash does not match the transaction fields.".to_string(), + false, + false, + ); + } + + let signature1 = optional_string(transaction, "signature1"); + let signature2 = optional_string(transaction, "signature2"); + if valid_signature(&signature1) && valid_signature(&signature2) { + return ( + "ready".to_string(), + "Ready to broadcast.".to_string(), + false, + true, + ); + } + + let active = active_short_address.unwrap_or_default().trim(); + if signature1.is_empty() { + let signer = optional_string(transaction, "lender"); + let can_sign = !active.is_empty() && active == signer.trim(); + return ( + if can_sign { + "needs_active_signature" + } else { + "needs_other_signature" + } + .to_string(), + format!("Needs lender signature from {}.", signer.trim()), + can_sign, + false, + ); + } + if signature2.is_empty() { + let signer = optional_string(transaction, "borrower"); + let can_sign = !active.is_empty() && active == signer.trim(); + let lender = optional_string(transaction, "lender"); + let message = if can_sign { + "Review the loan terms, then sign and broadcast as the borrower.".to_string() + } else if !active.is_empty() && active == lender.trim() { + format!( + "Your lender signature is present. Awaiting borrower signature from {}.", + signer.trim() + ) + } else { + format!("Awaiting borrower signature from {}.", signer.trim()) + }; + return ( + if can_sign { + "needs_active_signature" + } else { + "needs_other_signature" + } + .to_string(), + message, + can_sign, + false, + ); + } + + ( + "invalid_signature".to_string(), + "Loan has malformed signature data.".to_string(), + false, + false, + ) + } + _ => { + let signature = optional_string(transaction, "signature"); + if signature.is_empty() || valid_signature(&signature) { + ( + "ready".to_string(), + "Ready to broadcast.".to_string(), + false, + true, + ) + } else { + ( + "invalid_signature".to_string(), + "Transaction has malformed signature data.".to_string(), + false, + false, + ) + } + } + } +} + +async fn build_unbroadcast_review( + file_name: String, + transaction: JsonValue, + active_short_address: Option<&str>, +) -> UnbroadcastTransactionReview { + let transaction_type = transaction + .get("txtype") + .and_then(|value| value.as_u64()) + .unwrap_or_default() as u8; + let type_name = unbroadcast_type_name(transaction_type).to_string(); + let txid = transaction + .get("hash") + .and_then(|value| value.as_str()) + .map(|hash| hash.trim().to_string()) + .filter(|hash| !hash.is_empty()); + let summary = unbroadcast_summary(&transaction, transaction_type); + let (status, status_message, can_sign, can_broadcast) = + unbroadcast_state(&transaction, active_short_address).await; + + UnbroadcastTransactionReview { + file_name, + transaction_type, + type_name, + txid, + summary, + status, + status_message, + can_sign, + can_broadcast, + transaction, + } +} + +async fn save_json_unbroadcast_transaction( + transaction: JsonValue, + active_short_address: &str, +) -> Result { + let txid = optional_string(&transaction, "hash"); + if txid.is_empty() { + return Err("Transaction hash could not be created.".to_string()); + } + + let file_name = format!("{txid}.json"); + let dir = unbroadcast_dir().await?; + let path = dir.join(&file_name); + let output = serde_json::to_string_pretty(&transaction) + .map_err(|err| format!("Failed to serialize transaction: {err}"))?; + blockchain::tokio::fs::write(&path, output) + .await + .map_err(|err| format!("Failed to save transaction: {err}"))?; + + build_unbroadcast_review(file_name, transaction, Some(active_short_address)) + .await + .pipe(Ok) +} + +#[tauri::command] +async fn import_unbroadcast_transaction( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result, String> { + let source_path = FileDialog::new() + .add_filter("Contractless Transaction", &["json"]) + .show_open_single_file() + .map_err(|err| format!("Unable to open transaction file picker: {err}"))?; + let Some(source_path) = source_path else { + return Ok(None); + }; + if !source_path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + { + return Err("Imported transaction must be a JSON file.".to_string()); + } + + let contents = read_to_string(&source_path) + .await + .map_err(|err| format!("Unable to read imported transaction: {err}"))?; + let transaction = serde_json::from_str::(&contents) + .map_err(|err| format!("Unable to parse imported transaction JSON: {err}"))?; + let txid = validate_imported_transaction(&state, &broadcast_node, &transaction).await?; + let file_name = format!("{txid}.json"); + let destination_dir = unbroadcast_dir().await?; + let destination_path = destination_dir.join(&file_name); + + let source_canonical = std::fs::canonicalize(&source_path) + .map_err(|err| format!("Unable to resolve imported transaction path: {err}"))?; + let destination_exists = metadata(&destination_path).await.is_ok(); + if destination_exists { + let destination_contents = read_to_string(&destination_path) + .await + .map_err(|err| format!("Unable to read existing unbroadcast transaction: {err}"))?; + let destination_transaction = serde_json::from_str::(&destination_contents) + .map_err(|err| format!("Existing unbroadcast transaction is invalid JSON: {err}"))?; + if destination_transaction != transaction { + return Err(format!( + "A different transaction already exists at {}.", + destination_path.display() + )); + } + + let destination_canonical = std::fs::canonicalize(&destination_path) + .map_err(|err| format!("Unable to resolve existing transaction path: {err}"))?; + if source_canonical != destination_canonical { + blockchain::tokio::fs::remove_file(&source_path) + .await + .map_err(|err| { + format!( + "The transaction was already imported, but the duplicate source could not be removed: {err}" + ) + })?; + } + } else if let Err(rename_error) = + blockchain::tokio::fs::rename(&source_path, &destination_path).await + { + blockchain::tokio::fs::copy(&source_path, &destination_path) + .await + .map_err(|copy_error| { + format!( + "Unable to move imported transaction ({rename_error}); copy fallback also failed: {copy_error}" + ) + })?; + if let Err(remove_error) = blockchain::tokio::fs::remove_file(&source_path).await { + let _ = blockchain::tokio::fs::remove_file(&destination_path).await; + return Err(format!( + "Transaction copy succeeded, but its source could not be removed: {remove_error}" + )); + } + } + + let active_short_address = active_wallet_rpc_parts(&state).ok().map(|parts| parts.0); + let review = + build_unbroadcast_review(file_name, transaction, active_short_address.as_deref()).await; + Ok(Some(review)) +} + +#[tauri::command] +async fn save_transfer_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + receiver: String, + coin: String, + nft_series: u32, + value: String, + txfee: String, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state)?.0; + let transaction = build_transfer_json( + &state, + wallet_key, + broadcast_node, + receiver, + coin, + nft_series, + value, + txfee, + ) + .await?; + save_json_unbroadcast_transaction(transaction, &active_short_address).await +} + +#[tauri::command] +async fn broadcast_transfer_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + receiver: String, + coin: String, + nft_series: u32, + value: String, + txfee: String, +) -> Result { + let transaction = build_transfer_json( + &state, + wallet_key, + broadcast_node.clone(), + receiver, + coin, + nft_series, + value, + txfee, + ) + .await?; + let txid = transaction + .get("hash") + .and_then(|value| value.as_str()) + .map(|hash| hash.to_string()); + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = serde_json::to_string(&transaction) + .map_err(|err| format!("Failed to serialize transaction for broadcast: {err}"))?; + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + 8, + public_key, + private_key, + "Transaction broadcast failed", + ) + .await?; + + let response_text = String::from_utf8_lossy(&response).trim().to_string(); + Ok(BroadcastUnbroadcastResult { + response: response_text, + txid, + }) +} + +#[tauri::command] +async fn save_create_token_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + ticker: String, + number: String, + allow_more: bool, + txfee: String, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state)?.0; + let transaction = + build_create_token_json(&state, wallet_key, ticker, number, allow_more, txfee).await?; + save_json_unbroadcast_transaction(transaction, &active_short_address).await +} + +#[tauri::command] +async fn broadcast_create_token_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + ticker: String, + number: String, + allow_more: bool, + txfee: String, +) -> Result { + let transaction = + build_create_token_json(&state, wallet_key, ticker, number, allow_more, txfee).await?; + let txid = transaction + .get("hash") + .and_then(|value| value.as_str()) + .map(|hash| hash.to_string()); + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = serde_json::to_string(&transaction) + .map_err(|err| format!("Failed to serialize transaction for broadcast: {err}"))?; + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + 8, + public_key, + private_key, + "Transaction broadcast failed", + ) + .await?; + + let response_text = String::from_utf8_lossy(&response).trim().to_string(); + Ok(BroadcastUnbroadcastResult { + response: response_text, + txid, + }) +} + +#[tauri::command] +async fn save_create_nft_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + series: u8, + nft_name: String, + item_ipfs: String, + count: u32, + description: String, + txfee: String, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state)?.0; + let transaction = build_create_nft_json( + &state, + wallet_key, + series, + nft_name, + item_ipfs, + count, + description, + txfee, + ) + .await?; + save_json_unbroadcast_transaction(transaction, &active_short_address).await +} + +#[tauri::command] +async fn broadcast_create_nft_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + series: u8, + nft_name: String, + item_ipfs: String, + count: u32, + description: String, + txfee: String, +) -> Result { + let transaction = build_create_nft_json( + &state, + wallet_key, + series, + nft_name, + item_ipfs, + count, + description, + txfee, + ) + .await?; + let txid = transaction + .get("hash") + .and_then(|value| value.as_str()) + .map(str::to_string); + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = serde_json::to_string(&transaction) + .map_err(|err| format!("Failed to serialize transaction for broadcast: {err}"))?; + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + 8, + public_key, + private_key, + "Transaction broadcast failed", + ) + .await?; + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + + Ok(BroadcastUnbroadcastResult { + response: String::from_utf8_lossy(&response).trim().to_string(), + txid, + }) +} + +#[tauri::command] +async fn save_burn_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + coin: String, + nft_series: u32, + value: String, + txfee: String, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state)?.0; + let transaction = build_burn_json(&state, wallet_key, coin, nft_series, value, txfee).await?; + save_json_unbroadcast_transaction(transaction, &active_short_address).await +} + +#[tauri::command] +async fn broadcast_burn_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + coin: String, + nft_series: u32, + value: String, + txfee: String, +) -> Result { + let transaction = build_burn_json(&state, wallet_key, coin, nft_series, value, txfee).await?; + let txid = transaction + .get("hash") + .and_then(|value| value.as_str()) + .map(str::to_string); + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = serde_json::to_string(&transaction) + .map_err(|err| format!("Failed to serialize burn transaction: {err}"))?; + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + 8, + public_key, + private_key, + "Burn transaction broadcast failed", + ) + .await?; + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + + Ok(BroadcastUnbroadcastResult { + response: String::from_utf8_lossy(&response).trim().to_string(), + txid, + }) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +async fn save_marketing_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + campaign: String, + ad_type: String, + keyword: String, + displayed: String, + impression: u8, + click: u8, + impression_value: u16, + click_value: u16, + txfee: String, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state)?.0; + let transaction = build_marketing_json( + &state, + wallet_key, + campaign, + ad_type, + keyword, + displayed, + impression, + click, + impression_value, + click_value, + txfee, + ) + .await?; + save_json_unbroadcast_transaction(transaction, &active_short_address).await +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +async fn broadcast_marketing_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + campaign: String, + ad_type: String, + keyword: String, + displayed: String, + impression: u8, + click: u8, + impression_value: u16, + click_value: u16, + txfee: String, +) -> Result { + let transaction = build_marketing_json( + &state, + wallet_key, + campaign, + ad_type, + keyword, + displayed, + impression, + click, + impression_value, + click_value, + txfee, + ) + .await?; + let txid = transaction + .get("hash") + .and_then(|value| value.as_str()) + .map(str::to_string); + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = serde_json::to_string(&transaction) + .map_err(|err| format!("Failed to serialize marketing transaction: {err}"))?; + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + 8, + public_key, + private_key, + "Marketing transaction broadcast failed", + ) + .await?; + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + + Ok(BroadcastUnbroadcastResult { + response: String::from_utf8_lossy(&response).trim().to_string(), + txid, + }) +} + +#[tauri::command] +async fn save_vanity_address_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + vanity_name: String, + txfee: String, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state)?.0; + let transaction = build_vanity_address_json(&state, wallet_key, vanity_name, txfee).await?; + save_json_unbroadcast_transaction(transaction, &active_short_address).await +} + +#[tauri::command] +async fn broadcast_vanity_address_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + vanity_name: String, + txfee: String, +) -> Result { + let transaction = build_vanity_address_json(&state, wallet_key, vanity_name, txfee).await?; + let txid = transaction + .get("hash") + .and_then(|value| value.as_str()) + .map(str::to_string); + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = serde_json::to_string(&transaction) + .map_err(|err| format!("Failed to serialize vanity address transaction: {err}"))?; + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + 8, + public_key, + private_key, + "Vanity address transaction broadcast failed", + ) + .await?; + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + persist_active_wallet_vanity_address(&state, &transaction).await; + + Ok(BroadcastUnbroadcastResult { + response: String::from_utf8_lossy(&response).trim().to_string(), + txid, + }) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +async fn save_swap_offer( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + ticker1: String, + nft_series1: u32, + value1: String, + ticker2: String, + nft_series2: u32, + value2: String, + sender2: String, + tip1: String, + tip2: String, + expiration_hours: u32, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state)?.0; + let transaction = build_swap_offer_json( + &state, + wallet_key, + broadcast_node, + ticker1, + nft_series1, + value1, + ticker2, + nft_series2, + value2, + sender2, + tip1, + tip2, + expiration_hours, + ) + .await?; + let review = save_json_unbroadcast_transaction(transaction, &active_short_address).await?; + let file_path = unbroadcast_dir() + .await? + .join(&review.file_name) + .to_string_lossy() + .into_owned(); + Ok(SavedSwapOfferView { review, file_path }) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +async fn save_loan_offer( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + loan_coin: String, + loan_amount: String, + borrower: String, + collateral: String, + collateral_amount: String, + payment_period: String, + payment_number: u8, + payment_amount: String, + grace_period: u8, + max_late_value: String, + start_timestamp: u32, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state)?.0; + let transaction = build_loan_offer_json( + &state, + wallet_key, + broadcast_node, + loan_coin, + loan_amount, + borrower, + collateral, + collateral_amount, + payment_period, + payment_number, + payment_amount, + grace_period, + max_late_value, + start_timestamp, + ) + .await?; + let review = save_json_unbroadcast_transaction(transaction, &active_short_address).await?; + let file_path = unbroadcast_dir() + .await? + .join(&review.file_name) + .to_string_lossy() + .into_owned(); + Ok(SavedLoanOfferView { review, file_path }) +} + +#[tauri::command] +async fn active_loan_contracts( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result, String> { + load_active_loan_contracts(&state, &broadcast_node).await +} + +#[tauri::command] +async fn loan_contracts( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result, String> { + load_loan_contracts_for_active_wallet(&state, &broadcast_node).await +} + +#[tauri::command] +async fn save_loan_payment_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + contract_hash: String, + payback_amount: String, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state)?.0; + let transaction = build_loan_payment_json( + &state, + wallet_key, + &broadcast_node, + contract_hash, + payback_amount, + ) + .await?; + save_json_unbroadcast_transaction(transaction, &active_short_address).await +} + +#[tauri::command] +async fn broadcast_loan_payment_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + contract_hash: String, + payback_amount: String, +) -> Result { + let transaction = build_loan_payment_json( + &state, + wallet_key, + &broadcast_node, + contract_hash, + payback_amount, + ) + .await?; + let txid = transaction + .get("hash") + .and_then(|value| value.as_str()) + .map(str::to_string); + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = serde_json::to_string(&transaction) + .map_err(|err| format!("Failed to serialize loan payment: {err}"))?; + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + 8, + public_key, + private_key, + "Loan payment broadcast failed", + ) + .await?; + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + + Ok(BroadcastUnbroadcastResult { + response: String::from_utf8_lossy(&response).trim().to_string(), + txid, + }) +} + +#[tauri::command] +async fn claimable_collateral_contracts( + state: State<'_, WalletState>, + broadcast_node: String, +) -> Result, String> { + load_claimable_collateral_contracts(&state, &broadcast_node).await +} + +#[tauri::command] +async fn save_collateral_claim_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + contract_hash: String, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state)?.0; + let transaction = + build_collateral_claim_json(&state, wallet_key, &broadcast_node, contract_hash).await?; + save_json_unbroadcast_transaction(transaction, &active_short_address).await +} + +#[tauri::command] +async fn broadcast_collateral_claim_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + contract_hash: String, +) -> Result { + let transaction = + build_collateral_claim_json(&state, wallet_key, &broadcast_node, contract_hash).await?; + let txid = transaction + .get("hash") + .and_then(|value| value.as_str()) + .map(str::to_string); + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = serde_json::to_string(&transaction) + .map_err(|err| format!("Failed to serialize collateral claim: {err}"))?; + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + 8, + public_key, + private_key, + "Collateral claim broadcast failed", + ) + .await?; + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + + Ok(BroadcastUnbroadcastResult { + response: String::from_utf8_lossy(&response).trim().to_string(), + txid, + }) +} + +#[tauri::command] +async fn save_issue_token_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + ticker: String, + number: String, + txfee: String, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state)?.0; + let transaction = + build_issue_token_json(&state, wallet_key, &broadcast_node, ticker, number, txfee).await?; + save_json_unbroadcast_transaction(transaction, &active_short_address).await +} + +#[tauri::command] +async fn broadcast_issue_token_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + ticker: String, + number: String, + txfee: String, +) -> Result { + let transaction = + build_issue_token_json(&state, wallet_key, &broadcast_node, ticker, number, txfee).await?; + let txid = transaction + .get("hash") + .and_then(|value| value.as_str()) + .map(|hash| hash.to_string()); + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = serde_json::to_string(&transaction) + .map_err(|err| format!("Failed to serialize transaction for broadcast: {err}"))?; + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + 8, + public_key, + private_key, + "Transaction broadcast failed", + ) + .await?; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + let response_text = String::from_utf8_lossy(&response).trim().to_string(); + Ok(BroadcastUnbroadcastResult { + response: response_text, + txid, + }) +} + +#[tauri::command] +async fn list_unbroadcast_transactions( + state: State<'_, WalletState>, +) -> Result, String> { + let active_short_address = active_wallet_rpc_parts(&state).ok().map(|parts| parts.0); + let dir = unbroadcast_dir().await?; + let mut entries = read_dir(&dir) + .await + .map_err(|err| format!("Unable to read unbroadcast transaction directory: {err}"))?; + let mut transactions = Vec::new(); + + while let Some(entry) = entries + .next_entry() + .await + .map_err(|err| format!("Unable to read unbroadcast transaction entry: {err}"))? + { + let path = entry.path(); + if !path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + { + continue; + } + let Some(file_name) = path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + else { + continue; + }; + let Ok(contents) = read_to_string(&path).await else { + continue; + }; + let Ok(transaction) = serde_json::from_str::(&contents) else { + continue; + }; + let review = + build_unbroadcast_review(file_name, transaction, active_short_address.as_deref()).await; + transactions.push(UnbroadcastTransactionListItem { + file_name: review.file_name, + transaction_type: review.transaction_type, + type_name: review.type_name, + txid: review.txid, + summary: review.summary, + status: review.status, + status_message: review.status_message, + can_sign: review.can_sign, + can_broadcast: review.can_broadcast, + }); + } + + transactions.sort_by(|left, right| left.file_name.cmp(&right.file_name)); + Ok(transactions) +} + +#[tauri::command] +async fn load_unbroadcast_transaction( + state: State<'_, WalletState>, + file_name: String, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state).ok().map(|parts| parts.0); + let (_, transaction) = read_unbroadcast_json(&file_name).await?; + build_unbroadcast_review( + validate_unbroadcast_file_name(&file_name)?, + transaction, + active_short_address.as_deref(), + ) + .await + .pipe(Ok) +} + +#[tauri::command] +async fn sign_unbroadcast_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + file_name: String, +) -> Result { + let (active_short_address, private_key) = + active_wallet_signing_parts(&state, wallet_key).await?; + let (path, mut transaction) = read_unbroadcast_json(&file_name).await?; + let txtype = transaction + .get("txtype") + .and_then(|value| value.as_u64()) + .unwrap_or_default() as u8; + + match txtype { + 6 => { + validate_imported_transaction(&state, &broadcast_node, &transaction).await?; + let unsigned_swap = rebuild_swap(&transaction).await?; + let computed_hash = unsigned_swap.hash().await; + let json_hash = optional_string(&transaction, "hash"); + if !json_hash.is_empty() && json_hash != computed_hash { + return Err("Swap hash does not match the transaction fields.".to_string()); + } + + let signature = unsigned_swap + .hash_and_sign(&private_key) + .await + .map_err(|err| format!("Failed to sign swap: {err}"))?; + let signature1 = optional_string(&transaction, "signature1"); + let signature2 = optional_string(&transaction, "signature2"); + if signature1.is_empty() { + let signer = optional_string(&transaction, "sender1"); + if active_short_address.trim() != signer.trim() { + return Err(format!("This swap needs sender1 signature from {signer}.")); + } + transaction["signature1"] = JsonValue::String(signature); + } else if signature2.is_empty() { + let signer = optional_string(&transaction, "sender2"); + if active_short_address.trim() != signer.trim() { + return Err(format!("This swap needs sender2 signature from {signer}.")); + } + transaction["signature2"] = JsonValue::String(signature); + } else { + return Err("Swap already has both signatures.".to_string()); + } + transaction["hash"] = JsonValue::String(computed_hash); + } + 7 => { + validate_imported_transaction(&state, &broadcast_node, &transaction).await?; + let unsigned_loan = rebuild_loan(&transaction).await?; + let (computed_hash, signature) = unsigned_loan + .hash_and_sign(&private_key) + .await + .map_err(|err| format!("Failed to sign loan: {err}"))?; + let json_hash = optional_string(&transaction, "hash"); + if !json_hash.is_empty() && json_hash != computed_hash { + return Err("Loan hash does not match the transaction fields.".to_string()); + } + + let signature1 = optional_string(&transaction, "signature1"); + let signature2 = optional_string(&transaction, "signature2"); + if signature1.is_empty() { + let signer = optional_string(&transaction, "lender"); + if active_short_address.trim() != signer.trim() { + return Err(format!("This loan needs lender signature from {signer}.")); + } + transaction["signature1"] = JsonValue::String(signature); + } else if signature2.is_empty() { + let signer = optional_string(&transaction, "borrower"); + if active_short_address.trim() != signer.trim() { + return Err(format!("This loan needs borrower signature from {signer}.")); + } + transaction["signature2"] = JsonValue::String(signature); + } else { + return Err("Loan already has both signatures.".to_string()); + } + transaction["hash"] = JsonValue::String(computed_hash); + } + _ => { + return Err("Only swaps and loans support adding a second signature.".to_string()); + } + } + + let output = serde_json::to_string_pretty(&transaction) + .map_err(|err| format!("Failed to serialize signed transaction: {err}"))?; + blockchain::tokio::fs::write(&path, output) + .await + .map_err(|err| format!("Failed to save signed transaction: {err}"))?; + + build_unbroadcast_review( + validate_unbroadcast_file_name(&file_name)?, + transaction, + Some(&active_short_address), + ) + .await + .pipe(Ok) +} + +#[tauri::command] +async fn broadcast_unbroadcast_transaction( + state: State<'_, WalletState>, + broadcast_node: String, + file_name: String, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state).ok().map(|parts| parts.0); + let (_, transaction) = read_unbroadcast_json(&file_name).await?; + let txtype = transaction + .get("txtype") + .and_then(|value| value.as_u64()) + .unwrap_or_default() as u8; + if matches!(txtype, 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12) { + validate_imported_transaction(&state, &broadcast_node, &transaction).await?; + } + let review = build_unbroadcast_review( + validate_unbroadcast_file_name(&file_name)?, + transaction.clone(), + active_short_address.as_deref(), + ) + .await; + if !review.can_broadcast { + return Err(review.status_message); + } + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = serde_json::to_string(&transaction) + .map_err(|err| format!("Failed to serialize transaction for broadcast: {err}"))?; + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + 8, + public_key, + private_key, + "Transaction broadcast failed", + ) + .await?; + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + persist_active_wallet_vanity_address(&state, &transaction).await; + + let response_text = String::from_utf8_lossy(&response).trim().to_string(); + let dir = unbroadcast_dir().await?; + let path = dir.join(validate_unbroadcast_file_name(&file_name)?); + blockchain::tokio::fs::remove_file(&path) + .await + .map_err(|err| { + format!("Transaction broadcast completed, but local copy could not be deleted: {err}") + })?; + + Ok(BroadcastUnbroadcastResult { + response: response_text, + txid: review.txid, + }) +} + +#[tauri::command] +async fn delete_unbroadcast_transaction(file_name: String) -> Result<(), String> { + let file_name = validate_unbroadcast_file_name(&file_name)?; + let dir = unbroadcast_dir().await?; + let path = dir.join(file_name); + blockchain::tokio::fs::remove_file(&path) + .await + .map_err(|err| format!("Unable to delete unbroadcast transaction: {err}")) +} diff --git a/src/wallet_app/commands_wallet.rs b/src/wallet_app/commands_wallet.rs new file mode 100644 index 0000000..7df3fcb --- /dev/null +++ b/src/wallet_app/commands_wallet.rs @@ -0,0 +1,763 @@ +#[tauri::command] +fn get_app_status() -> AppStatus { + AppStatus { + app_name: "Contractless Wallet", + network: "testnet", + bridge: "ready", + } +} + +#[tauri::command] +async fn create_wallet( + state: State<'_, WalletState>, + network: String, + wallet_name: String, + wallet_key: String, +) -> Result { + let wallet_key = wallet_key.trim().to_string(); + if wallet_key.is_empty() { + return Err("Wallet key cannot be empty.".to_string()); + } + let wallet_filename = normalize_wallet_filename(&wallet_name)?; + let network = GuiNetwork::parse(&network)?; + + let wallet_dir = app_wallet_dir(network)?; + + create_dir_all(&wallet_dir) + .await + .map_err(|err| format!("Unable to create wallet directory: {err}"))?; + + let wallet_path = wallet_dir.join(&wallet_filename); + if metadata(&wallet_path).await.is_ok() { + return Err(format!("A wallet named {wallet_filename} already exists.")); + } + + let wallet_path_string = wallet_path.to_string_lossy().into_owned(); + let wallet = create_wallet_file(&wallet_path, wallet_key) + .await + .map_err(|err| format!("Wallet creation failed: {err}"))?; + + let view = wallet_view(wallet_filename.clone(), wallet_path_string.clone(), &wallet).await; + state.set_active(ActiveWallet { + wallet_name: wallet_filename, + wallet_path: wallet_path_string, + wallet, + })?; + + Ok(view) +} + +#[tauri::command] +async fn list_wallets(network: String) -> Result, String> { + let network = GuiNetwork::parse(&network)?; + let wallet_dir = app_wallet_dir(network)?; + create_dir_all(&wallet_dir) + .await + .map_err(|err| format!("Unable to create wallet directory: {err}"))?; + + let mut entries = read_dir(&wallet_dir) + .await + .map_err(|err| format!("Unable to read wallet directory: {err}"))?; + let mut wallets = Vec::new(); + + while let Some(entry) = entries + .next_entry() + .await + .map_err(|err| format!("Unable to read wallet entry: {err}"))? + { + let path = entry.path(); + if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("wallet")) + { + wallets.push(WalletListItem { + wallet_name: wallet_name_from_path(&path)?, + wallet_path: path.to_string_lossy().into_owned(), + }); + } + } + + wallets.sort_by(|left, right| { + left.wallet_name + .to_ascii_lowercase() + .cmp(&right.wallet_name.to_ascii_lowercase()) + }); + Ok(wallets) +} + +#[tauri::command] +async fn select_wallet_file(network: String) -> Result, String> { + let network = GuiNetwork::parse(&network)?; + let wallet_dir = app_wallet_dir(network)?; + create_dir_all(&wallet_dir) + .await + .map_err(|err| format!("Unable to create wallet directory: {err}"))?; + + let selected = FileDialog::new() + .set_location(&wallet_dir) + .add_filter("Contractless Wallet", &["wallet"]) + .show_open_single_file() + .map_err(|err| format!("Unable to open wallet file picker: {err}"))?; + + selected + .map(|path| { + Ok(WalletListItem { + wallet_name: wallet_name_from_path(&path)?, + wallet_path: path.to_string_lossy().into_owned(), + }) + }) + .transpose() +} + +#[tauri::command] +async fn select_private_key_image(network: String) -> Result, String> { + let network = GuiNetwork::parse(&network)?; + let wallet_dir = app_wallet_dir(network)?; + create_dir_all(&wallet_dir) + .await + .map_err(|err| format!("Unable to create wallet directory: {err}"))?; + + let selected = FileDialog::new() + .set_location(&wallet_dir) + .add_filter("PNG Image", &["png"]) + .show_open_single_file() + .map_err(|err| format!("Unable to open private key image picker: {err}"))?; + + selected + .map(|path| { + Ok(SelectedFile { + file_name: wallet_name_from_path(&path)?, + file_path: path.to_string_lossy().into_owned(), + }) + }) + .transpose() +} + +#[tauri::command] +async fn load_wallet( + state: State<'_, WalletState>, + wallet_path: String, + wallet_key: String, +) -> Result { + let wallet_path = wallet_path.trim().to_string(); + let wallet_key = wallet_key.trim().to_string(); + if wallet_path.is_empty() { + return Err("Wallet path cannot be empty.".to_string()); + } + if wallet_key.is_empty() { + return Err("Wallet key cannot be empty.".to_string()); + } + + let path = PathBuf::from(&wallet_path); + if metadata(&path).await.is_err() { + return Err("Wallet file was not found.".to_string()); + } + + let wallet = Wallet::private_key_from_wallet(&path, wallet_key) + .await + .map_err(|err| format!("Wallet loading failed: {err}"))?; + let wallet_name = wallet_name_from_path(&path)?; + let view = wallet_view(wallet_name.clone(), wallet_path.clone(), &wallet).await; + state.set_active(ActiveWallet { + wallet_name, + wallet_path, + wallet, + })?; + + Ok(view) +} + +#[tauri::command] +async fn import_private_key_wallet( + state: State<'_, WalletState>, + network: String, + wallet_name: String, + wallet_key: String, + private_key: String, +) -> Result { + let wallet_key = wallet_key.trim().to_string(); + if wallet_key.is_empty() { + return Err("Wallet key cannot be empty.".to_string()); + } + let wallet_filename = normalize_wallet_filename(&wallet_name)?; + let network = GuiNetwork::parse(&network)?; + let wallet_dir = app_wallet_dir(network)?; + + create_dir_all(&wallet_dir) + .await + .map_err(|err| format!("Unable to create wallet directory: {err}"))?; + + let wallet_path = wallet_dir.join(&wallet_filename); + if metadata(&wallet_path).await.is_ok() { + return Err(format!("A wallet named {wallet_filename} already exists.")); + } + + let wallet_path_string = wallet_path.to_string_lossy().into_owned(); + let wallet = import_private_key_file(&wallet_path, wallet_key, private_key) + .await + .map_err(|err| format!("Private key import failed: {err}"))?; + + let view = wallet_view(wallet_filename.clone(), wallet_path_string.clone(), &wallet).await; + state.set_active(ActiveWallet { + wallet_name: wallet_filename, + wallet_path: wallet_path_string, + wallet, + })?; + + Ok(view) +} + +#[tauri::command] +async fn import_private_key_image_wallet( + state: State<'_, WalletState>, + network: String, + wallet_name: String, + wallet_key: String, + image_path: String, +) -> Result { + let wallet_key = wallet_key.trim().to_string(); + let image_path = image_path.trim().to_string(); + if wallet_key.is_empty() { + return Err("Wallet key cannot be empty.".to_string()); + } + if image_path.is_empty() { + return Err("Private key image path cannot be empty.".to_string()); + } + + let wallet_filename = normalize_wallet_filename(&wallet_name)?; + let network = GuiNetwork::parse(&network)?; + let wallet_dir = app_wallet_dir(network)?; + + create_dir_all(&wallet_dir) + .await + .map_err(|err| format!("Unable to create wallet directory: {err}"))?; + + let wallet_path = wallet_dir.join(&wallet_filename); + if metadata(&wallet_path).await.is_ok() { + return Err(format!("A wallet named {wallet_filename} already exists.")); + } + + let image_path = PathBuf::from(&image_path); + if metadata(&image_path).await.is_err() { + return Err("Private key image file was not found.".to_string()); + } + + let private_key = private_key_from_png_image(&image_path, &wallet_key).await?; + let wallet_path_string = wallet_path.to_string_lossy().into_owned(); + let wallet = import_private_key_file(&wallet_path, wallet_key, private_key) + .await + .map_err(|err| format!("Private key image import failed: {err}"))?; + + let view = wallet_view(wallet_filename.clone(), wallet_path_string.clone(), &wallet).await; + state.set_active(ActiveWallet { + wallet_name: wallet_filename, + wallet_path: wallet_path_string, + wallet, + })?; + + Ok(view) +} + +#[tauri::command] +fn lock_wallet(state: State<'_, WalletState>) -> Result<(), String> { + state.clear() +} + +#[tauri::command] +async fn list_broadcast_nodes() -> Result, String> { + read_piggyback_nodes().await +} + +fn active_wallet_snapshot( + state: &State<'_, WalletState>, +) -> Result<(String, String, String, Option, String, String), String> { + let guard = state + .active_wallet + .lock() + .map_err(|_| "Wallet session lock was poisoned.".to_string())?; + let active_wallet = guard + .as_ref() + .ok_or_else(|| "No wallet is loaded.".to_string())?; + + Ok(( + active_wallet.wallet_name.clone(), + active_wallet.wallet_path.clone(), + active_wallet.wallet.saved.short_address.clone(), + active_wallet.wallet.saved.vanity_address.clone(), + active_wallet.wallet.saved.public_key.clone(), + active_wallet.wallet.saved.private_key.clone(), + )) +} + +async fn verified_private_key_from_active_wallet( + state: &State<'_, WalletState>, + wallet_key: String, +) -> Result<(String, String, Option, String, String), String> { + let wallet_key = wallet_key.trim().to_string(); + if wallet_key.is_empty() { + return Err("Wallet encryption key cannot be empty.".to_string()); + } + + let (wallet_name, wallet_path, short_address, vanity_address, public_key, _) = + active_wallet_snapshot(state)?; + let wallet = Wallet::private_key_from_wallet(&PathBuf::from(&wallet_path), wallet_key) + .await + .map_err(|_| "Wallet encryption key did not decrypt this wallet.".to_string())?; + + Ok(( + wallet_name, + short_address, + vanity_address, + public_key, + wallet.saved.private_key, + )) +} + +#[tauri::command] +async fn backup_active_wallet_image(state: State<'_, WalletState>) -> Result, String> { + let (wallet_name, wallet_path, _short_address, _vanity_address, _public_key, _private_key) = + active_wallet_snapshot(&state)?; + let image_base64 = saved_private_key_image(&wallet_path) + .await + .ok_or_else(|| "This wallet does not contain a private key image.".to_string())?; + let image_bytes = STANDARD + .decode(image_base64.as_bytes()) + .map_err(|err| format!("Private key image data was not valid base64: {err}"))?; + let default_name = format!( + "{}-private-key-image.png", + wallet_name.trim_end_matches(".wallet") + ); + + let selected = FileDialog::new() + .set_filename(&default_name) + .add_filter("PNG Image", &["png"]) + .show_save_single_file() + .map_err(|err| format!("Unable to open image backup save dialog: {err}"))?; + + let Some(mut path) = selected else { + return Ok(None); + }; + if path.extension().is_none() { + path.set_extension("png"); + } + + blockchain::tokio::fs::write(&path, image_bytes) + .await + .map_err(|err| format!("Unable to save wallet image backup: {err}"))?; + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[tauri::command] +async fn backup_active_wallet_private_key( + state: State<'_, WalletState>, + wallet_key: String, +) -> Result, String> { + let (wallet_name, short_address, vanity_address, public_key, private_key) = + verified_private_key_from_active_wallet(&state, wallet_key).await?; + let default_name = format!( + "{}-private-key.txt", + wallet_name.trim_end_matches(".wallet") + ); + let contents = format!( + "Contractless Wallet Private Key Backup\n\nWallet: {wallet_name}\nAddress: {short_address}\nVanity Address: {}\nPublic Key: {public_key}\nPrivate Key: {private_key}\n", + vanity_address.unwrap_or_else(|| "N/A".to_string()) + ); + + let selected = FileDialog::new() + .set_filename(&default_name) + .add_filter("Text", &["txt"]) + .show_save_single_file() + .map_err(|err| format!("Unable to open private key backup save dialog: {err}"))?; + + let Some(mut path) = selected else { + return Ok(None); + }; + if path.extension().is_none() { + path.set_extension("txt"); + } + + blockchain::tokio::fs::write(&path, contents) + .await + .map_err(|err| format!("Unable to save private key backup: {err}"))?; + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[tauri::command] +async fn printable_wallet_backup( + state: State<'_, WalletState>, + wallet_key: String, +) -> Result { + let wallet_key = wallet_key.trim().to_string(); + let (wallet_name, short_address, vanity_address, public_key, private_key) = + verified_private_key_from_active_wallet(&state, wallet_key.clone()).await?; + + Ok(WalletPrintBackupView { + wallet_name, + short_address, + vanity_address, + public_key, + private_key, + encryption_key: wallet_key, + }) +} + +fn unquote_ini_value(value: &str) -> String { + value.trim().trim_matches('"').trim_matches('\'').to_string() +} + +fn quote_ini_value(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "/").replace('"', "\\\"")) +} + +fn ini_value(settings: &str, section: &str, key: &str) -> Option { + let mut in_section = false; + for line in settings.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_section = trimmed[1..trimmed.len() - 1].eq_ignore_ascii_case(section); + continue; + } + if !in_section || trimmed.starts_with(';') || trimmed.starts_with('#') { + continue; + } + let Some((candidate_key, value)) = trimmed.split_once('=') else { + continue; + }; + if candidate_key.trim().eq_ignore_ascii_case(key) { + return Some(unquote_ini_value(value)); + } + } + None +} + +fn bool_ini_value(settings: &str, section: &str, key: &str, default: bool) -> bool { + ini_value(settings, section, key) + .map(|value| matches!(value.to_ascii_lowercase().as_str(), "true" | "1" | "yes")) + .unwrap_or(default) +} + +fn default_storage_path(wallet_path: &str, folder_name: &str) -> String { + let wallet_path = wallet_path.trim(); + if wallet_path.is_empty() { + return folder_name.to_string(); + } + + let path = PathBuf::from(wallet_path); + let base = path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| path.clone()); + base.join(folder_name).to_string_lossy().replace('\\', "/") +} + +fn replace_ini_section(settings: &str, section: &str, entries: &[(String, String)]) -> String { + let mut output = Vec::new(); + let mut in_section = false; + let mut found = false; + let mut wrote = false; + + for line in settings.lines() { + let trimmed = line.trim(); + let is_section = trimmed.starts_with('[') && trimmed.ends_with(']'); + if is_section { + if in_section && !wrote { + for (key, value) in entries { + output.push(format!("{key:<20} = {value}")); + } + wrote = true; + } + in_section = trimmed[1..trimmed.len() - 1].eq_ignore_ascii_case(section); + if in_section { + found = true; + output.push(format!("[{section}]")); + continue; + } + } + + if in_section { + continue; + } + output.push(line.to_string()); + } + + if in_section && !wrote { + for (key, value) in entries { + output.push(format!("{key:<20} = {value}")); + } + } + + if !found { + if !output.last().is_some_and(|line| line.trim().is_empty()) { + output.push(String::new()); + } + output.push(format!("[{section}]")); + for (key, value) in entries { + output.push(format!("{key:<20} = {value}")); + } + } + + output.join("\n") + "\n" +} + +fn replace_ini_keys(settings: &str, section: &str, entries: &[(String, String)]) -> String { + let mut output = Vec::new(); + let mut in_section = false; + let mut found_section = false; + let mut written = std::collections::HashSet::new(); + + for line in settings.lines() { + let trimmed = line.trim(); + let is_section = trimmed.starts_with('[') && trimmed.ends_with(']'); + if is_section { + if in_section { + for (key, value) in entries { + if !written.contains(&key.to_ascii_lowercase()) { + output.push(format!("{key:<20} = {value}")); + } + } + } + in_section = trimmed[1..trimmed.len() - 1].eq_ignore_ascii_case(section); + found_section |= in_section; + output.push(line.to_string()); + continue; + } + + if in_section { + if let Some((candidate_key, _)) = trimmed.split_once('=') { + if let Some((key, value)) = entries + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(candidate_key.trim())) + { + output.push(format!("{key:<20} = {value}")); + written.insert(key.to_ascii_lowercase()); + continue; + } + } + } + output.push(line.to_string()); + } + + if in_section { + for (key, value) in entries { + if !written.contains(&key.to_ascii_lowercase()) { + output.push(format!("{key:<20} = {value}")); + } + } + } + + if !found_section { + if !output.last().is_some_and(|line| line.trim().is_empty()) { + output.push(String::new()); + } + output.push(format!("[{section}]")); + for (key, value) in entries { + output.push(format!("{key:<20} = {value}")); + } + } + + output.join("\n") + "\n" +} + +#[tauri::command] +async fn read_gui_settings() -> Result { + ensure_gui_settings_file().await?; + let settings_path = gui_settings_path()?; + let settings = read_to_string(&settings_path) + .await + .map_err(|err| format!("Unable to read GUI settings.ini: {err}"))?; + let nodes = read_piggyback_nodes().await?; + let default_node = nodes + .first() + .map(|node| node.address.clone()) + .unwrap_or_else(|| "contractless.dev:50050".to_string()); + + let wallet_path = ini_value(&settings, "Paths", "WALLET_PATH").unwrap_or_default(); + let unbroadcast_transactions_path = ini_value(&settings, "Paths", "UNBROADCAST_TRANSACTIONS") + .filter(|path| !path.trim().is_empty()) + .unwrap_or_else(|| default_storage_path(&wallet_path, "unbroadcast")); + let address_book_path = ini_value(&settings, "Paths", "ADDRESS_BOOK") + .filter(|path| !path.trim().is_empty()) + .unwrap_or_else(|| default_storage_path(&wallet_path, "address_book")); + + Ok(GuiSettingsView { + wallet_path, + unbroadcast_transactions_path, + address_book_path, + default_node, + saved_nodes: nodes.into_iter().map(|node| node.address).collect(), + ipfs_gateway: ini_value(&settings, "GUI", "IPFS_GATEWAY") + .or_else(|| ini_value(&settings, "GUI", "METADATA_GATEWAY")) + .or_else(|| ini_value(&settings, "GUI", "IMAGE_GATEWAY")) + .unwrap_or_else(|| "https://gateway.pinata.cloud/ipfs/".to_string()), + auto_load_trusted_nft_images: bool_ini_value( + &settings, + "GUI", + "AUTO_LOAD_TRUSTED_NFT_IMAGES", + false, + ), + auto_lock_timeout: ini_value(&settings, "GUI", "AUTO_LOCK_TIMEOUT") + .unwrap_or_else(|| "15 minutes".to_string()), + clipboard_clear_timeout: ini_value(&settings, "GUI", "CLIPBOARD_CLEAR_TIMEOUT") + .unwrap_or_else(|| "60 seconds".to_string()), + require_key_before_signing: bool_ini_value( + &settings, + "GUI", + "REQUIRE_KEY_BEFORE_SIGNING", + true, + ), + settings_path: settings_path.to_string_lossy().into_owned(), + }) +} + +#[tauri::command] +async fn save_gui_settings(settings: GuiSettingsView) -> Result<(), String> { + ensure_gui_settings_file().await?; + let settings_path = gui_settings_path()?; + let current = read_to_string(&settings_path) + .await + .map_err(|err| format!("Unable to read GUI settings.ini: {err}"))?; + + let mut nodes = Vec::new(); + if !settings.default_node.trim().is_empty() { + nodes.push(settings.default_node.trim().to_string()); + } + for node in settings.saved_nodes { + let node = node.trim(); + if !node.is_empty() && !nodes.iter().any(|known| known.eq_ignore_ascii_case(node)) { + nodes.push(node.to_string()); + } + } + + let wallet_path = settings.wallet_path.trim().to_string(); + let unbroadcast_transactions_path = if settings.unbroadcast_transactions_path.trim().is_empty() + { + default_storage_path(&wallet_path, "unbroadcast") + } else { + settings.unbroadcast_transactions_path.trim().to_string() + }; + let address_book_path = if settings.address_book_path.trim().is_empty() { + default_storage_path(&wallet_path, "address_book") + } else { + settings.address_book_path.trim().to_string() + }; + + let path_entries = vec![ + ("WALLET_PATH".to_string(), quote_ini_value(&wallet_path)), + ( + "UNBROADCAST_TRANSACTIONS".to_string(), + quote_ini_value(&unbroadcast_transactions_path), + ), + ( + "ADDRESS_BOOK".to_string(), + quote_ini_value(&address_book_path), + ), + ]; + let piggyback_entries: Vec<(String, String)> = nodes + .iter() + .enumerate() + .map(|(index, node)| (format!("PIGGYBACK_{}", index + 1), quote_ini_value(node))) + .collect(); + let gui_entries = vec![ + ( + "IPFS_GATEWAY".to_string(), + quote_ini_value(&settings.ipfs_gateway), + ), + ( + "AUTO_LOAD_TRUSTED_NFT_IMAGES".to_string(), + quote_ini_value(if settings.auto_load_trusted_nft_images { + "true" + } else { + "false" + }), + ), + ( + "AUTO_LOCK_TIMEOUT".to_string(), + quote_ini_value(&settings.auto_lock_timeout), + ), + ( + "CLIPBOARD_CLEAR_TIMEOUT".to_string(), + quote_ini_value(&settings.clipboard_clear_timeout), + ), + ( + "REQUIRE_KEY_BEFORE_SIGNING".to_string(), + quote_ini_value(if settings.require_key_before_signing { + "true" + } else { + "false" + }), + ), + ]; + + let updated = replace_ini_keys(¤t, "Paths", &path_entries); + let updated = replace_ini_section(&updated, "Piggyback", &piggyback_entries); + let updated = replace_ini_section(&updated, "GUI", &gui_entries); + + blockchain::tokio::fs::write(&settings_path, updated) + .await + .map_err(|err| format!("Unable to write GUI settings.ini: {err}")) +} + +#[tauri::command] +fn select_settings_path(kind: String, current_path: String) -> Result, String> { + let mut dialog = FileDialog::new(); + let current = PathBuf::from(current_path.trim()); + let mut location_path: Option = None; + if current.exists() { + let location = if current.is_dir() { + current + } else { + current.parent().map(PathBuf::from).unwrap_or(current) + }; + location_path = Some(location); + } + if let Some(location) = &location_path { + dialog = dialog.set_location(location); + } + + let selected = match kind.trim().to_ascii_lowercase().as_str() { + "folder" => dialog + .show_open_single_dir() + .map_err(|err| format!("Unable to open folder picker: {err}"))?, + "file" => dialog + .show_open_single_file() + .map_err(|err| format!("Unable to open file picker: {err}"))?, + _ => return Err("Settings path picker kind must be folder or file.".to_string()), + }; + + Ok(selected.map(|path| path.to_string_lossy().into_owned())) +} + +#[tauri::command] +async fn active_wallet(state: State<'_, WalletState>) -> Result, String> { + let active_wallet = { + let guard = state + .active_wallet + .lock() + .map_err(|_| "Wallet session lock was poisoned.".to_string())?; + + guard.as_ref().map(|active_wallet| { + ( + active_wallet.wallet_name.clone(), + active_wallet.wallet_path.clone(), + active_wallet.wallet.saved.short_address.clone(), + active_wallet.wallet.saved.vanity_address.clone(), + ) + }) + }; + + match active_wallet { + Some((wallet_name, wallet_path, short_address, vanity_address)) => Ok(Some(WalletView { + short_address_hex: Wallet::short_address_to_bytes(&short_address) + .map(encode) + .unwrap_or_default(), + wallet_name, + short_address, + vanity_address, + private_key_image: saved_private_key_image(&wallet_path).await, + wallet_path, + })), + None => Ok(None), + } +} + diff --git a/src/wallet_app/rpc_scheduler.rs b/src/wallet_app/rpc_scheduler.rs new file mode 100644 index 0000000..8cb6b9b --- /dev/null +++ b/src/wallet_app/rpc_scheduler.rs @@ -0,0 +1,298 @@ +#[derive(Clone, Copy, PartialEq, Eq)] +enum WalletRpcPriority { + Foreground, + Background, +} + +impl WalletRpcPriority { + fn rank(self) -> u8 { + match self { + Self::Foreground => 2, + Self::Background => 1, + } + } + + fn minimum_spacing(self) -> Duration { + match self { + Self::Foreground => Duration::from_millis(50), + Self::Background => Duration::from_secs(2), + } + } +} + +struct WalletRpcRequest { + socket_address: SocketAddr, + command_input: String, + rpc_command: usize, + public_key: String, + private_key: String, + error_prefix: String, +} + +struct QueuedWalletRpcRequest { + priority: WalletRpcPriority, + sequence: u64, + generation: u64, + request: WalletRpcRequest, + responder: oneshot::Sender, String>>, +} + +impl PartialEq for QueuedWalletRpcRequest { + fn eq(&self, other: &Self) -> bool { + self.priority == other.priority && self.sequence == other.sequence + } +} + +impl Eq for QueuedWalletRpcRequest {} + +impl PartialOrd for QueuedWalletRpcRequest { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for QueuedWalletRpcRequest { + fn cmp(&self, other: &Self) -> Ordering { + self.priority + .rank() + .cmp(&other.priority.rank()) + .then_with(|| other.sequence.cmp(&self.sequence)) + } +} + +#[derive(Default)] +struct WalletRpcQueueState { + queue: BinaryHeap, + last_completed: Option, + draining: bool, + node_health: HashMap, +} + +struct WalletRpcScheduler { + queue_state: AsyncMutex, + sequence: AtomicU64, + generation: AtomicU64, +} + +#[derive(Default)] +struct NodeRpcHealth { + consecutive_failures: u32, + backoff_until: Option, +} + +impl WalletRpcScheduler { + const BACKGROUND_YIELD_CHECK: Duration = Duration::from_millis(50); + + fn new() -> Self { + Self { + queue_state: AsyncMutex::new(WalletRpcQueueState::default()), + sequence: AtomicU64::new(1), + generation: AtomicU64::new(1), + } + } + + fn bump_generation(&self) { + self.generation.fetch_add(1, AtomicOrdering::SeqCst); + } + + fn current_generation(&self) -> u64 { + self.generation.load(AtomicOrdering::SeqCst) + } + + async fn enqueue( + &self, + priority: WalletRpcPriority, + request: WalletRpcRequest, + ) -> Result, String> { + let sequence = self.sequence.fetch_add(1, AtomicOrdering::SeqCst); + let generation = self.current_generation(); + let (sender, receiver) = oneshot::channel(); + + let should_drain = { + let mut state = self.queue_state.lock().await; + state.queue.push(QueuedWalletRpcRequest { + priority, + sequence, + generation, + request, + responder: sender, + }); + if state.draining { + false + } else { + state.draining = true; + true + } + }; + + if should_drain { + self.drain_queue().await; + } + + receiver + .await + .map_err(|_| "Wallet RPC scheduler dropped the response channel.".to_string())? + } + + async fn drain_queue(&self) { + loop { + let queued = { + let mut state = self.queue_state.lock().await; + match state.queue.pop() { + Some(queued) => Some(queued), + None => { + state.draining = false; + None + } + } + }; + + let Some(queued) = queued else { + break; + }; + + if queued.generation != self.current_generation() { + let _ = queued.responder.send(Err( + "Wallet RPC request was cancelled by wallet state change.".to_string(), + )); + continue; + } + + if self.should_yield_background(queued.priority).await { + self.requeue(queued).await; + continue; + } + if !self.wait_for_spacing(queued.priority).await { + self.requeue(queued).await; + continue; + } + if !self + .wait_for_node_backoff(queued.priority, queued.request.socket_address) + .await + { + self.requeue(queued).await; + continue; + } + let result = run_wallet_rpc_request(&queued.request).await; + + { + let mut state = self.queue_state.lock().await; + state.last_completed = Some(Instant::now()); + state.record_node_result(queued.request.socket_address, result.is_ok()); + } + + let _ = queued.responder.send(result); + } + } + + async fn requeue(&self, queued: QueuedWalletRpcRequest) { + let mut state = self.queue_state.lock().await; + state.queue.push(queued); + } + + async fn should_yield_background(&self, priority: WalletRpcPriority) -> bool { + if priority != WalletRpcPriority::Background { + return false; + } + + let state = self.queue_state.lock().await; + state + .queue + .iter() + .any(|queued| queued.priority == WalletRpcPriority::Foreground) + } + + async fn wait_for_spacing(&self, priority: WalletRpcPriority) -> bool { + loop { + if self.should_yield_background(priority).await { + return false; + } + + let delay = { + let state = self.queue_state.lock().await; + let Some(last_completed) = state.last_completed else { + return true; + }; + priority + .minimum_spacing() + .checked_sub(last_completed.elapsed()) + }; + + let Some(delay) = delay else { + return true; + }; + + if delay.is_zero() { + return true; + } + + sleep(delay.min(Self::BACKGROUND_YIELD_CHECK)).await; + } + } + + async fn wait_for_node_backoff( + &self, + priority: WalletRpcPriority, + socket_address: SocketAddr, + ) -> bool { + loop { + if self.should_yield_background(priority).await { + return false; + } + + let delay = { + let state = self.queue_state.lock().await; + state + .node_health + .get(&socket_address) + .and_then(|health| health.backoff_until) + .and_then(|until| until.checked_duration_since(Instant::now())) + }; + + let Some(delay) = delay else { + return true; + }; + + if delay.is_zero() { + return true; + } + + sleep(delay.min(Self::BACKGROUND_YIELD_CHECK)).await; + } + } +} + +impl WalletRpcQueueState { + fn record_node_result(&mut self, socket_address: SocketAddr, success: bool) { + let health = self.node_health.entry(socket_address).or_default(); + if success { + health.consecutive_failures = 0; + health.backoff_until = None; + return; + } + + health.consecutive_failures = health.consecutive_failures.saturating_add(1); + let seconds = match health.consecutive_failures { + 0 | 1 => 2, + 2 => 5, + 3 => 10, + _ => 20, + }; + health.backoff_until = Some(Instant::now() + Duration::from_secs(seconds)); + } +} + +async fn run_wallet_rpc_request(request: &WalletRpcRequest) -> Result, String> { + handshake::connect_and_handshake( + request.socket_address, + request.command_input.clone(), + request.rpc_command, + HandshakeWallet::WalletParts { + public_key: request.public_key.clone(), + private_key: request.private_key.clone(), + }, + generate_uid(), + ) + .await + .map_err(|err| format!("{}: {err}", request.error_prefix)) +} diff --git a/src/wallet_app/shared_rpc.rs b/src/wallet_app/shared_rpc.rs new file mode 100644 index 0000000..928b5eb --- /dev/null +++ b/src/wallet_app/shared_rpc.rs @@ -0,0 +1,411 @@ +fn parse_balance_response(response: Vec) -> Result, String> { + if response.is_empty() { + return Ok(Vec::new()); + } + + if response.len() % 27 != 0 { + let response_text = String::from_utf8_lossy(&response).trim().to_string(); + + if !response_text.is_empty() { + return Err(response_text); + } + + return Err(format!( + "Balance response had an unexpected byte length: {}", + response.len() + )); + } + + let mut balances = Vec::new(); + + for entry in response.chunks_exact(27) { + let asset = String::from_utf8_lossy(&entry[..15]) + .trim_matches(char::from(0)) + .trim() + .to_string(); + + let nft_series = u32::from_le_bytes( + entry[15..19] + .try_into() + .map_err(|_| "Failed to read NFT series from balance response.".to_string())?, + ); + + let balance = u64::from_le_bytes( + entry[19..27] + .try_into() + .map_err(|_| "Failed to read amount from balance response.".to_string())?, + ); + + balances.push(BalanceView { + asset, + nft_series, + balance: balance.to_string(), + }); + } + + Ok(balances) +} + +fn active_broadcast_socket(broadcast_node: &str) -> Result { + let broadcast_node = broadcast_node.trim(); + if broadcast_node.is_empty() { + return Err("Broadcast node cannot be empty.".to_string()); + } + + if let Ok(socket_address) = broadcast_node.parse::() { + return Ok(socket_address); + } + + let (host, port) = broadcast_node + .rsplit_once(':') + .ok_or_else(|| "Broadcast node must include host and port.".to_string())?; + let port = port + .trim() + .parse::() + .map_err(|err| format!("Invalid broadcast node port: {err}"))?; + + (host.trim(), port) + .to_socket_addrs() + .map_err(|err| format!("Unable to resolve broadcast node: {err}"))? + .next() + .ok_or_else(|| "Broadcast node did not resolve to an address.".to_string()) +} + +fn active_wallet_rpc_parts( + state: &State<'_, WalletState>, +) -> Result<(String, String, String), String> { + let guard = state + .active_wallet + .lock() + .map_err(|_| "Wallet session lock was poisoned.".to_string())?; + let active_wallet = guard + .as_ref() + .ok_or_else(|| "No wallet is loaded.".to_string())?; + + Ok(( + active_wallet.wallet.saved.short_address.clone(), + active_wallet.wallet.saved.public_key.clone(), + active_wallet.wallet.saved.private_key.clone(), + )) +} + +fn active_wallet_cache_path(state: &State<'_, WalletState>) -> Result { + let guard = state + .active_wallet + .lock() + .map_err(|_| "Wallet session lock was poisoned.".to_string())?; + let active_wallet = guard + .as_ref() + .ok_or_else(|| "No wallet is loaded.".to_string())?; + let wallet_path = PathBuf::from(&active_wallet.wallet_path); + let short_address_bytes = + Wallet::short_address_to_bytes(&active_wallet.wallet.saved.short_address) + .ok_or_else(|| "Active wallet short address is invalid.".to_string())?; + let cache_name = format!("{}.address_transactions.json", encode(short_address_bytes)); + + Ok(wallet_path.with_file_name(cache_name)) +} + +fn active_wallet_history_dir(state: &State<'_, WalletState>) -> Result { + let guard = state + .active_wallet + .lock() + .map_err(|_| "Wallet session lock was poisoned.".to_string())?; + let active_wallet = guard + .as_ref() + .ok_or_else(|| "No wallet is loaded.".to_string())?; + let wallet_path = PathBuf::from(&active_wallet.wallet_path); + let short_address_bytes = + Wallet::short_address_to_bytes(&active_wallet.wallet.saved.short_address) + .ok_or_else(|| "Active wallet short address is invalid.".to_string())?; + let dir_name = format!("{}.address_transactions", encode(short_address_bytes)); + + Ok(wallet_path.with_file_name(dir_name)) +} + +fn active_wallet_nft_dir(state: &State<'_, WalletState>) -> Result { + let guard = state + .active_wallet + .lock() + .map_err(|_| "Wallet session lock was poisoned.".to_string())?; + let active_wallet = guard + .as_ref() + .ok_or_else(|| "No wallet is loaded.".to_string())?; + let wallet_path = PathBuf::from(&active_wallet.wallet_path); + let short_address_bytes = + Wallet::short_address_to_bytes(&active_wallet.wallet.saved.short_address) + .ok_or_else(|| "Active wallet short address is invalid.".to_string())?; + let dir_name = format!("{}_nfts", encode(short_address_bytes)); + + Ok(wallet_path.with_file_name(dir_name)) +} + +fn active_wallet_address_book_path(state: &State<'_, WalletState>) -> Result { + let guard = state + .active_wallet + .lock() + .map_err(|_| "Wallet session lock was poisoned.".to_string())?; + let active_wallet = guard + .as_ref() + .ok_or_else(|| "No wallet is loaded.".to_string())?; + let wallet_path = PathBuf::from(&active_wallet.wallet_path); + + Ok(wallet_path.with_file_name("address_book.json")) +} + +fn validate_transaction_record_file(file_name: &str) -> Result { + let trimmed = file_name.trim(); + if trimmed.len() != 13 || !trimmed.ends_with(".json") { + return Err("Transaction record filename must look like 00000001.json.".to_string()); + } + + if !trimmed[..8].bytes().all(|byte| byte.is_ascii_digit()) { + return Err("Transaction record filename must start with 8 digits.".to_string()); + } + + Ok(trimmed.to_string()) +} + +fn parse_error_response(response: &[u8]) -> Option { + let response_text = String::from_utf8_lossy(response).trim().to_string(); + response_text.starts_with("error:").then_some(response_text) +} + +fn parse_latest_transaction_response( + response: Vec, +) -> Result, String> { + const MINER_EARNING_ASSET_BYTES: usize = 15; + const MINER_EARNING_ENTRY_BYTES: usize = 1 + MINER_EARNING_ASSET_BYTES + 4 + 8; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + + if response.len() < 4 { + return Err("Latest transaction response had an unexpected byte length.".to_string()); + } + + let count = u32::from_le_bytes( + response[0..4] + .try_into() + .map_err(|_| "Failed to parse transaction count.".to_string())?, + ) as usize; + let mut offset = 4usize; + let mut transactions = Vec::with_capacity(count); + + for _ in 0..count { + if response.len().saturating_sub(offset) < 40 { + return Err("Latest transaction response entry was truncated.".to_string()); + } + let txid = encode(&response[offset..offset + 32]); + offset += 32; + let block_height = u32::from_le_bytes( + response[offset..offset + 4] + .try_into() + .map_err(|_| "Failed to parse transaction block height.".to_string())?, + ); + offset += 4; + let tx_len = u32::from_le_bytes( + response[offset..offset + 4] + .try_into() + .map_err(|_| "Failed to parse transaction byte length.".to_string())?, + ) as usize; + offset += 4; + + if response.len().saturating_sub(offset) < tx_len { + return Err("Latest transaction payload was truncated.".to_string()); + } + let transaction_bytes = response[offset..offset + tx_len].to_vec(); + offset += tx_len; + let transaction_type = *transaction_bytes + .first() + .ok_or_else(|| "Latest transaction payload was empty.".to_string())?; + + if response.len().saturating_sub(offset) < 4 { + return Err("Latest transaction miner earnings count was truncated.".to_string()); + } + let earnings_count = u32::from_le_bytes( + response[offset..offset + 4] + .try_into() + .map_err(|_| "Failed to parse miner earnings count.".to_string())?, + ) as usize; + offset += 4; + + let earnings_bytes = earnings_count + .checked_mul(MINER_EARNING_ENTRY_BYTES) + .ok_or_else(|| "Miner earnings response length overflowed.".to_string())?; + if response.len().saturating_sub(offset) < earnings_bytes { + return Err("Latest transaction miner earnings were truncated.".to_string()); + } + + let mut miner_earnings = Vec::with_capacity(earnings_count); + for _ in 0..earnings_count { + let earning_type = response[offset]; + offset += 1; + let asset = + String::from_utf8_lossy(&response[offset..offset + MINER_EARNING_ASSET_BYTES]) + .trim() + .to_string(); + offset += MINER_EARNING_ASSET_BYTES; + let nft_series = u32::from_le_bytes( + response[offset..offset + 4] + .try_into() + .map_err(|_| "Failed to parse miner earning NFT series.".to_string())?, + ); + offset += 4; + let amount = u64::from_le_bytes( + response[offset..offset + 8] + .try_into() + .map_err(|_| "Failed to parse miner earning amount.".to_string())?, + ); + offset += 8; + miner_earnings.push(MinerEarningView { + earning_type, + asset, + nft_series, + amount: amount.to_string(), + }); + } + + transactions.push(LatestAddressTransactionView { + txid, + block_height, + transaction_type, + transaction_bytes, + miner_earnings, + }); + } + + if offset != response.len() { + return Err("Latest transaction response had trailing bytes.".to_string()); + } + + Ok(transactions) +} + +#[cfg(test)] +mod latest_transaction_response_tests { + use super::*; + + #[test] + fn parses_reward_miner_earnings() { + let transaction = vec![1u8; 13]; + let mut response = Vec::new(); + response.extend_from_slice(&1u32.to_le_bytes()); + response.extend_from_slice(&[3u8; 32]); + response.extend_from_slice(&123u32.to_le_bytes()); + response.extend_from_slice(&(transaction.len() as u32).to_le_bytes()); + response.extend_from_slice(&transaction); + response.extend_from_slice(&2u32.to_le_bytes()); + response.push(0); + response.extend_from_slice(b"cltc "); + response.extend_from_slice(&0u32.to_le_bytes()); + response.extend_from_slice(&500u64.to_le_bytes()); + response.push(1); + response.extend_from_slice(b"example "); + response.extend_from_slice(&7u32.to_le_bytes()); + response.extend_from_slice(&900u64.to_le_bytes()); + + let records = parse_latest_transaction_response(response).unwrap(); + assert_eq!(records[0].miner_earnings.len(), 2); + assert_eq!(records[0].miner_earnings[0].amount, "500"); + assert_eq!(records[0].miner_earnings[1].asset, "example"); + } + + #[test] + fn parses_transaction_without_miner_earnings() { + let transaction = vec![2u8; 84]; + let mut response = Vec::new(); + response.extend_from_slice(&1u32.to_le_bytes()); + response.extend_from_slice(&[4u8; 32]); + response.extend_from_slice(&10u32.to_le_bytes()); + response.extend_from_slice(&(transaction.len() as u32).to_le_bytes()); + response.extend_from_slice(&transaction); + response.extend_from_slice(&0u32.to_le_bytes()); + + let records = parse_latest_transaction_response(response).unwrap(); + assert!(records[0].miner_earnings.is_empty()); + } +} + +async fn serialized_wallet_rpc( + state: &State<'_, WalletState>, + socket_address: SocketAddr, + command_input: String, + rpc_command: usize, + public_key: String, + private_key: String, + error_prefix: &str, +) -> Result, String> { + state + .rpc_scheduler + .enqueue( + WalletRpcPriority::Foreground, + WalletRpcRequest { + socket_address, + command_input, + rpc_command, + public_key, + private_key, + error_prefix: error_prefix.to_string(), + }, + ) + .await +} + +async fn background_wallet_rpc( + state: &State<'_, WalletState>, + socket_address: SocketAddr, + command_input: String, + rpc_command: usize, + public_key: String, + private_key: String, + error_prefix: &str, +) -> Result, String> { + state + .rpc_scheduler + .enqueue( + WalletRpcPriority::Background, + WalletRpcRequest { + socket_address, + command_input, + rpc_command, + public_key, + private_key, + error_prefix: error_prefix.to_string(), + }, + ) + .await +} + +fn normalize_wallet_filename(wallet_name: &str) -> Result { + let trimmed = wallet_name.trim(); + if trimmed.is_empty() { + return Err("Wallet name cannot be empty.".to_string()); + } + + if trimmed.contains('/') || trimmed.contains('\\') { + return Err("Wallet name cannot contain folders or path separators.".to_string()); + } + + let stem = trimmed.strip_suffix(".wallet").unwrap_or(trimmed).trim(); + if stem.is_empty() { + return Err("Wallet name cannot be empty.".to_string()); + } + + let valid = stem.chars().all(|character| { + character.is_ascii_alphanumeric() + || character == ' ' + || character == '-' + || character == '_' + || character == '.' + }); + if !valid { + return Err( + "Wallet name can only use letters, numbers, spaces, dots, dashes, and underscores." + .to_string(), + ); + } + + Ok(format!("{stem}.wallet")) +} diff --git a/src/wallet_app/state.rs b/src/wallet_app/state.rs new file mode 100644 index 0000000..8de8c52 --- /dev/null +++ b/src/wallet_app/state.rs @@ -0,0 +1,22 @@ +impl WalletState { + fn set_active(&self, active_wallet: ActiveWallet) -> Result<(), String> { + let mut guard = self + .active_wallet + .lock() + .map_err(|_| "Wallet session lock was poisoned.".to_string())?; + *guard = Some(active_wallet); + self.rpc_scheduler.bump_generation(); + Ok(()) + } + + fn clear(&self) -> Result<(), String> { + let mut guard = self + .active_wallet + .lock() + .map_err(|_| "Wallet session lock was poisoned.".to_string())?; + *guard = None; + self.rpc_scheduler.bump_generation(); + Ok(()) + } +} + diff --git a/src/wallet_app/transaction_decode.rs b/src/wallet_app/transaction_decode.rs new file mode 100644 index 0000000..667415a --- /dev/null +++ b/src/wallet_app/transaction_decode.rs @@ -0,0 +1,151 @@ +async fn decode_transaction_json(transaction: &[u8]) -> Result { + let (&txtype, body) = transaction + .split_first() + .ok_or_else(|| "Transaction payload was empty.".to_string())?; + + match txtype { + 0 => serde_json::to_value( + GenesisTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode genesis transaction: {err}"))?, + ), + 1 => serde_json::to_value( + RewardsTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode rewards transaction: {err}"))?, + ), + 2 => serde_json::to_value( + TransferTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode transfer transaction: {err}"))?, + ), + 3 => serde_json::to_value( + CreateTokenTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode token creation transaction: {err}"))?, + ), + 4 => serde_json::to_value( + CreateNftTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode NFT creation transaction: {err}"))?, + ), + 5 => serde_json::to_value( + MarketingTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode marketing transaction: {err}"))?, + ), + 6 => serde_json::to_value( + SwapTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode swap transaction: {err}"))?, + ), + 7 => serde_json::to_value( + LoanContractTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode loan contract transaction: {err}"))?, + ), + 8 => serde_json::to_value( + ContractPaymentTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode loan payment transaction: {err}"))?, + ), + 9 => serde_json::to_value( + CollateralClaimTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode collateral claim transaction: {err}"))?, + ), + 10 => serde_json::to_value( + BurnTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode burn transaction: {err}"))?, + ), + 11 => serde_json::to_value( + IssueTokenTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode token issue transaction: {err}"))?, + ), + 12 => serde_json::to_value( + VanityAddressTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode vanity transaction: {err}"))?, + ), + _ => return Err(format!("Unknown transaction type: {txtype}")), + } + .map_err(|err| format!("Failed to serialize decoded transaction: {err}")) +} + +fn read_network_u32(response: &[u8], offset: &mut usize) -> Result { + let bytes = response + .get(*offset..*offset + 4) + .ok_or_else(|| "Network info response ended while reading u32.".to_string())? + .try_into() + .map_err(|_| "Failed to read network info u32.".to_string())?; + *offset += 4; + Ok(u32::from_le_bytes(bytes)) +} + +fn read_network_u64(response: &[u8], offset: &mut usize) -> Result { + let bytes = response + .get(*offset..*offset + 8) + .ok_or_else(|| "Network info response ended while reading u64.".to_string())? + .try_into() + .map_err(|_| "Failed to read network info u64.".to_string())?; + *offset += 8; + Ok(u64::from_le_bytes(bytes)) +} + +fn parse_network_info_response(response: Vec) -> Result { + const NETWORK_NAME_BYTES: usize = 7; + const FIXED_BYTES_WITHOUT_PREFIX: usize = 40; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + if response.len() <= FIXED_BYTES_WITHOUT_PREFIX { + let text = String::from_utf8_lossy(&response).trim().to_string(); + if !text.is_empty() { + return Err(text); + } + return Err("Network info response had an unexpected byte length.".to_string()); + } + + let wallet_prefix_len = response.len() - FIXED_BYTES_WITHOUT_PREFIX; + let mut offset = 0; + + let version = *response + .get(offset) + .ok_or_else(|| "Network info response did not include version.".to_string())?; + offset += 1; + + let network = String::from_utf8_lossy( + response + .get(offset..offset + NETWORK_NAME_BYTES) + .ok_or_else(|| "Network info response did not include network name.".to_string())?, + ) + .trim() + .to_string(); + offset += NETWORK_NAME_BYTES; + + let time = read_network_u32(&response, &mut offset)?; + + let wallet_prefix = String::from_utf8_lossy( + response + .get(offset..offset + wallet_prefix_len) + .ok_or_else(|| "Network info response did not include wallet prefix.".to_string())?, + ) + .trim() + .to_string(); + offset += wallet_prefix_len; + + Ok(NetworkInfoView { + version, + network, + time, + wallet_prefix, + height: read_network_u32(&response, &mut offset)?, + next_block_difficulty: read_network_u64(&response, &mut offset)?, + total_block_transactions: read_network_u32(&response, &mut offset)?, + total_mempool_transactions: read_network_u32(&response, &mut offset)?, + largest_tx_fee: read_network_u64(&response, &mut offset)?, + }) +} diff --git a/src/wallet_app/types.rs b/src/wallet_app/types.rs new file mode 100644 index 0000000..e92620a --- /dev/null +++ b/src/wallet_app/types.rs @@ -0,0 +1,340 @@ +#[derive(Serialize)] +struct AppStatus { + app_name: &'static str, + network: &'static str, + bridge: &'static str, +} + +#[derive(Serialize)] +struct WalletView { + wallet_name: String, + short_address: String, + short_address_hex: String, + vanity_address: Option, + wallet_path: String, + private_key_image: Option, +} + +#[derive(Serialize)] +struct WalletListItem { + wallet_name: String, + wallet_path: String, +} + +#[derive(Serialize)] +struct SelectedFile { + file_name: String, + file_path: String, +} + +#[derive(Serialize)] +struct BalanceView { + asset: String, + nft_series: u32, + balance: String, +} + +#[derive(Serialize)] +struct RegistrationStatusView { + registered: bool, +} + +#[derive(Serialize)] +struct VanityAvailabilityView { + vanity_address: String, + available: bool, + owner: Option, +} + +#[derive(Serialize)] +struct TransactionBytesView { + txid: String, + block_height: u32, + transaction_type: u8, + transaction: JsonValue, + transaction_bytes: Vec, +} + +#[derive(Serialize)] +struct LatestAddressTransactionView { + txid: String, + block_height: u32, + transaction_type: u8, + transaction_bytes: Vec, + miner_earnings: Vec, +} + +#[derive(Serialize)] +struct MinerEarningView { + earning_type: u8, + asset: String, + nft_series: u32, + amount: String, +} + +#[derive(Serialize)] +struct MarketingCampaignView { + total_records: String, + total_impressions: String, + total_clicks: String, + total_impression_value: String, + total_click_value: String, + rows: Vec, +} + +#[derive(Serialize)] +struct MarketingCampaignRowView { + status: String, + txid: String, + block_height: Option, + time: u32, + campaign: String, + ad_type: String, + keyword: String, + displayed: String, + impression: u8, + click: u8, + impression_value: u16, + click_value: u16, +} + +#[derive(Serialize)] +struct TokenListItemView { + ticker: String, + origin_txid: String, +} + +#[derive(Serialize)] +struct TokenCatalogItemView { + ticker: String, + origin_txid: String, + creator: String, + created_timestamp: u32, + initial_supply: String, + current_supply: String, + total_burned: String, + holder_count: u32, + hard_limit: bool, +} + +#[derive(Serialize)] +struct NftListItemView { + nft_name: String, + series: u32, + origin_txid: String, +} + +#[derive(Serialize)] +struct NftHistoryEntryView { + txid: String, + block: u32, + txtype: u8, + action: String, + from: String, + to: String, + received_asset: String, + received_series: u32, + received_value: String, +} + +#[derive(Serialize)] +struct NftDetailsView { + nft_name: String, + series: u32, + asset_name: String, + genesis_txid: String, + creator: String, + item_ipfs: String, + metadata_uri: String, + current_holder: String, + history_count: u32, + history: Vec, +} + +#[derive(Serialize)] +struct NftCacheRecordView { + detail: JsonValue, + metadata: JsonValue, + image_data_url: Option, +} + +#[derive(Serialize)] +struct IssueTokenDetailsView { + ticker: String, + creator: String, + hard_limit: bool, + eligible: bool, + reason: String, +} + +#[derive(Serialize)] +struct BroadcastNodeView { + label: String, + address: String, +} + +#[derive(Serialize, Deserialize)] +struct GuiSettingsView { + wallet_path: String, + unbroadcast_transactions_path: String, + address_book_path: String, + default_node: String, + saved_nodes: Vec, + ipfs_gateway: String, + auto_load_trusted_nft_images: bool, + auto_lock_timeout: String, + clipboard_clear_timeout: String, + require_key_before_signing: bool, + settings_path: String, +} + +#[derive(Serialize)] +struct WalletPrintBackupView { + wallet_name: String, + short_address: String, + vanity_address: Option, + public_key: String, + private_key: String, + encryption_key: String, +} + +#[derive(Serialize)] +struct NetworkInfoView { + version: u8, + network: String, + time: u32, + wallet_prefix: String, + height: u32, + next_block_difficulty: u64, + total_block_transactions: u32, + total_mempool_transactions: u32, + largest_tx_fee: u64, +} + +#[derive(Serialize)] +struct UnbroadcastTransactionListItem { + file_name: String, + transaction_type: u8, + type_name: String, + txid: Option, + summary: String, + status: String, + status_message: String, + can_sign: bool, + can_broadcast: bool, +} + +#[derive(Serialize)] +struct UnbroadcastTransactionReview { + file_name: String, + transaction_type: u8, + type_name: String, + txid: Option, + summary: String, + status: String, + status_message: String, + can_sign: bool, + can_broadcast: bool, + transaction: JsonValue, +} + +#[derive(Serialize)] +struct SavedSwapOfferView { + review: UnbroadcastTransactionReview, + file_path: String, +} + +#[derive(Serialize)] +struct SavedLoanOfferView { + review: UnbroadcastTransactionReview, + file_path: String, +} + +#[derive(Serialize, Clone)] +struct LoanContractView { + contract: String, + status: String, + start_timestamp: u32, + lender: String, + borrower: String, + loan_asset: String, + loan_amount: String, + collateral: String, + collateral_amount: String, + payment_period: String, + payment_number: u8, + payment_amount: String, + grace_period: u8, + max_late_value: String, + confirmed_paid: String, + pending_paid: String, + remaining_balance: String, + remaining_after_pending: String, + payments: Vec, +} + +#[derive(Serialize, Clone)] +struct LoanPaymentHistoryView { + txid: String, + amount: String, + payer: String, + date: String, +} + +#[derive(Serialize, Clone)] +struct CollateralClaimContractView { + contract: String, + role: String, + reason: String, + lender: String, + borrower: String, + loan_asset: String, + loan_amount: String, + collateral: String, + collateral_amount: String, + confirmed_paid: String, + pending_paid: String, + remaining_balance: String, + remaining_after_pending: String, + fee: String, +} + +#[derive(Serialize)] +struct BroadcastUnbroadcastResult { + response: String, + txid: Option, +} + +#[derive(Clone, Copy)] +enum GuiNetwork { + Mainnet, + Testnet, +} + +impl GuiNetwork { + fn parse(network: &str) -> Result { + match network.trim().to_ascii_lowercase().as_str() { + "mainnet" => Ok(Self::Mainnet), + "testnet" => Ok(Self::Testnet), + _ => Err("Network must be testnet or mainnet.".to_string()), + } + } + + fn folder_name(self) -> &'static str { + match self { + Self::Mainnet => "mainnet", + Self::Testnet => "testnet", + } + } +} + +struct ActiveWallet { + wallet_name: String, + wallet_path: String, + wallet: Wallet, +} + +struct WalletState { + active_wallet: Mutex>, + rpc_scheduler: WalletRpcScheduler, +} diff --git a/src/wallet_app/wallet_files.rs b/src/wallet_app/wallet_files.rs new file mode 100644 index 0000000..845c161 --- /dev/null +++ b/src/wallet_app/wallet_files.rs @@ -0,0 +1,324 @@ +async fn saved_private_key_image(wallet_path: &str) -> Option { + let bytes = read(wallet_path).await.ok()?; + let saved_wallet: SavedWallet = blockchain::from_slice(&bytes).ok()?; + if saved_wallet.private_key.trim().is_empty() { + None + } else { + Some(saved_wallet.private_key) + } +} + +async fn wallet_view(wallet_name: String, wallet_path: String, wallet: &Wallet) -> WalletView { + let private_key_image = saved_private_key_image(&wallet_path).await; + let short_address_hex = Wallet::short_address_to_bytes(&wallet.saved.short_address) + .map(encode) + .unwrap_or_default(); + + WalletView { + wallet_name, + short_address: wallet.saved.short_address.clone(), + short_address_hex, + vanity_address: wallet.saved.vanity_address.clone(), + wallet_path, + private_key_image, + } +} + +fn wallet_name_from_path(wallet_path: &Path) -> Result { + wallet_path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + .ok_or_else(|| "Wallet path does not contain a valid filename.".to_string()) +} + +fn gui_wallet_root() -> Result { + let home = env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or_else(|| "Unable to resolve user home directory.".to_string())?; + Ok(home.join("gui-wallets")) +} + +fn app_wallet_dir(network: GuiNetwork) -> Result { + Ok(gui_wallet_root()?.join(network.folder_name())) +} + +async fn create_wallet_file( + wallet_path: &Path, + wallet_key: String, +) -> Result { + let seed_wallet = Wallet { + saved: SavedWallet { + short_address: String::new(), + vanity_address: None, + public_key: String::new(), + private_key: String::new(), + }, + encryption_key: wallet_key.clone(), + }; + + let wallet = seed_wallet.create_wallet(); + wallet.saved.save_the_wallet(wallet_path).await; + Wallet::private_key_from_wallet(wallet_path, wallet_key).await +} + +async fn import_private_key_file( + wallet_path: &Path, + wallet_key: String, + private_key: String, +) -> Result { + let private_key = private_key.trim().to_string(); + if private_key.is_empty() { + return Err("Private key cannot be empty.".to_string()); + } + + let public_key_bytes = Wallet::regenerate_public_key(&private_key)?; + let public_key = encode(public_key_bytes.clone()); + let short_address_bytes = Wallet::public_key_bytes_to_short_address_bytes(&public_key_bytes) + .ok_or_else(|| "Failed to derive short address bytes.".to_string())?; + let short_address = Wallet::bytes_to_short_address(&short_address_bytes) + .ok_or_else(|| "Failed to encode short address.".to_string())?; + + let encrypted_private_key = encrypts(&private_key, Some(&wallet_key), None) + .ok_or_else(|| "Failed to encrypt private key with the provided wallet key.".to_string())?; + let image_data = create_img( + &encrypted_private_key, + "h", + "empty", + None, + None, + None, + None, + None, + None, + ) + .ok_or_else(|| "Failed to create encrypted private key image.".to_string())?; + + let saved_wallet = SavedWallet { + short_address, + vanity_address: None, + public_key, + private_key: image_data, + }; + let output = to_string_pretty(&saved_wallet) + .map_err(|err| format!("Failed to serialize imported wallet: {err}"))?; + blockchain::tokio::fs::write(wallet_path, output) + .await + .map_err(|err| format!("Failed to write imported wallet file: {err}"))?; + + Wallet::private_key_from_wallet(wallet_path, wallet_key).await +} + +async fn private_key_from_png_image(image_path: &Path, wallet_key: &str) -> Result { + let image_bytes = blockchain::tokio::fs::read(image_path) + .await + .map_err(|err| format!("Failed to read private key image: {err}"))?; + let image_base64 = STANDARD.encode(image_bytes); + let encrypted_text = decode_image_and_extract_text(&image_base64) + .ok_or_else(|| "Failed to decode private key image.".to_string())?; + decrypts(&encrypted_text, Some(wallet_key)) + .ok_or_else(|| "Private key image decryption failed.".to_string()) +} + +async fn lookup_public_ip_from_services() -> Option { + let services = [ + "https://api.ipify.org", + "https://icanhazip.com", + "https://checkip.amazonaws.com", + "https://ifconfig.me/ip", + "https://ident.me", + ]; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .build() + .ok()?; + + for service in services { + let response = match client.get(service).send().await { + Ok(response) => response, + Err(_) => continue, + }; + + let text = match response.text().await { + Ok(text) => text, + Err(_) => continue, + }; + + let ip = text.trim(); + + if ip.parse::().is_ok() { + return Some(ip.to_string()); + } + } + + None +} + +async fn best_effort_local_ip() -> Result { + let public_ip = lookup_public_ip_from_services() + .await + .ok_or_else(|| "Unable to determine public IP from any service.".to_string()); + + return public_ip; +} + +fn gui_settings_path() -> Result { + if let Ok(settings_path) = env::var("SETTINGS_PATH") { + let path = PathBuf::from(settings_path); + if path.exists() { + return Ok(path); + } + } + + if let Ok(exe_path) = env::current_exe() { + if let Some(exe_dir) = exe_path.parent() { + return Ok(exe_dir.join("settings.ini")); + } + } + + Ok(env::current_dir() + .map_err(|err| format!("Unable to resolve current directory: {err}"))? + .join("settings.ini")) +} + +async fn ensure_gui_settings_file() -> Result<(), String> { + let settings_path = gui_settings_path()?; + if settings_path.exists() { + env::set_var("SETTINGS_PATH", &settings_path); + return Ok(()); + } + + let settings_dir = settings_path + .parent() + .ok_or_else(|| "Unable to resolve settings directory.".to_string())?; + create_dir_all(settings_dir) + .await + .map_err(|err| format!("Unable to create settings directory: {err}"))?; + + let runtime_root = gui_wallet_root()?.join("gui-runtime"); + create_dir_all(&runtime_root) + .await + .map_err(|err| format!("Unable to create GUI runtime directory: {err}"))?; + + let public_ip = best_effort_local_ip().await?; + + let settings = format!( + r#"; Auto-generated by Contractless Wallet GUI. + +[Paths] +BLOCK_PATH = "{root}/blocks" +TORRENT_PATH = "{root}/torrents" +DB_PATH = "{root}/state" +BALANCE_SHEET = "{root}/balance_sheet" +LOG_PATH = "{root}/logs" +WALLET_PATH = "{root}/wallets" +UNBROADCAST_TRANSACTIONS = "{root}/unbroadcast" +ADDRESS_BOOK = "{root}/address_book" +WALLET_NAME = "gui.wallet" + +[Settings] +LOG_LEVEL = "info" +PUBLIC_IP = "{public_ip}" +LISTEN_IP = "0.0.0.0" +RPC_PORT = "50050" +TESTNET_RPC_PORT = "50053" +INCOMING_CONNECTIONS = "1" +OUTGOING_CONNECTIONS = "1" +VALIDATOR = "false" +THREADS = "1" + +[Piggyback] +PIGGYBACK_1 = "contractless.dev:50050" + +[Postgres-Testnet] +host = 127.0.0.1 +port = 5432 +user = contractless_gui +password = contractless_gui +dbname = contractless_gui + +[Postgres] +host = 127.0.0.1 +port = 5432 +user = contractless_gui +password = contractless_gui +dbname = contractless_gui +"#, + root = runtime_root.to_string_lossy().replace('\\', "/"), + public_ip = public_ip + ); + + blockchain::tokio::fs::write(&settings_path, settings) + .await + .map_err(|err| format!("Unable to write GUI settings.ini: {err}"))?; + env::set_var("SETTINGS_PATH", &settings_path); + Ok(()) +} + +async fn read_piggyback_nodes() -> Result, String> { + ensure_gui_settings_file().await?; + let settings_path = gui_settings_path()?; + let settings = read_to_string(&settings_path) + .await + .map_err(|err| format!("Unable to read GUI settings.ini: {err}"))?; + + let mut in_piggyback = false; + let mut nodes = Vec::new(); + + for line in settings.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with(';') || trimmed.starts_with('#') { + continue; + } + + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_piggyback = trimmed.eq_ignore_ascii_case("[Piggyback]"); + continue; + } + + if !in_piggyback { + continue; + } + + let Some((key, value)) = trimmed.split_once('=') else { + continue; + }; + let key = key.trim(); + if !key.to_ascii_uppercase().starts_with("PIGGYBACK_") { + continue; + } + + let address = value + .trim() + .trim_matches('"') + .trim_matches('\'') + .trim() + .to_string(); + if address.is_empty() { + continue; + } + + nodes.push(BroadcastNodeView { + label: key.to_string(), + address, + }); + } + + nodes.sort_by(|left, right| { + piggyback_sort_index(&left.label) + .cmp(&piggyback_sort_index(&right.label)) + .then_with(|| left.label.cmp(&right.label)) + }); + nodes.dedup_by(|left, right| left.address == right.address); + Ok(nodes) +} + +fn piggyback_sort_index(label: &str) -> u32 { + label + .rsplit_once('_') + .and_then(|(_, index)| index.trim().parse::().ok()) + .unwrap_or(u32::MAX) +} + diff --git a/tauri.conf.json b/tauri.conf.json new file mode 100644 index 0000000..c4cd3b4 --- /dev/null +++ b/tauri.conf.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Contractless Wallet", + "version": "0.1.0", + "identifier": "dev.contractless.wallet", + "build": { + "frontendDist": "frontend" + }, + "bundle": { + "active": false + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "Contractless Wallet", + "width": 1180, + "height": 760, + "minWidth": 360, + "minHeight": 620 + } + ], + "security": { + "csp": null + } + } +}