commit 873d76fe4efede82c85b6a4039590ed17fcaafe9 Author: Laan Tungir Date: Mon Aug 17 14:42:10 2026 -0400 v0.0.2 - Initial Rust port: full feature parity with C version including Tor .onion SOCKS5 routing, hamburger menu, keyboard shortcuts, settings page, NIP-07 injection, agent tools, perf probe, and versioning scheme diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce04f2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target/ +*.log +*.tar.gz diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..95d01f6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4110 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[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 = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.20", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[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-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "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 = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[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 = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[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", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +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-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[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", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[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 = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[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 = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[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 = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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 = "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", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +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.1", + "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.119", +] + +[[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 = "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.119", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[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-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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 = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +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", +] + +[[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", + "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", +] + +[[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 = "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 = "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.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[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", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[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 = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libudev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +dependencies = [ + "libc", + "libudev-sys", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[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 = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nostr-core" +version = "0.0.2" +dependencies = [ + "aes", + "base64", + "bech32", + "block-modes", + "chacha20poly1305", + "chrono", + "hex", + "hmac", + "rand 0.8.7", + "secp256k1", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.20", + "tracing", + "zeroize", +] + +[[package]] +name = "nostr-nips" +version = "0.0.2" +dependencies = [ + "aes", + "block-modes", + "cbc", + "ed25519-dalek", + "hex", + "nostr-core", + "rand 0.8.7", + "reqwest", + "ripemd", + "secp256k1", + "serde", + "serde_json", + "sha1", + "sha2", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "nostr-relay" +version = "0.0.2" +dependencies = [ + "futures-util", + "nostr-core", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-tungstenite", + "tracing", + "tungstenite", + "url", + "uuid", +] + +[[package]] +name = "nostr-services" +version = "0.0.2" +dependencies = [ + "nostr-core", + "nostr-relay", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-signer" +version = "0.0.2" +dependencies = [ + "aes", + "cbc", + "hex", + "nostr-core", + "rand 0.8.7", + "serde", + "serde_json", + "serialport", + "sha2", + "thiserror 2.0.20", + "tokio", + "tracing", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "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 = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types", + "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.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[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.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[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", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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", + "toml_edit 0.20.2", +] + +[[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.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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 = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.20", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[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 = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "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", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[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.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[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 = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.7", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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_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 = "serialport" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "core-foundation 0.10.1", + "core-foundation-sys", + "io-kit-sys", + "libudev", + "mach2", + "nix", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "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 = "sovereign-browser" +version = "0.0.1" +dependencies = [ + "anyhow", + "base64", + "bech32", + "cairo-rs", + "chrono", + "env_logger", + "futures-util", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk", + "hex", + "hmac", + "image", + "lazy_static", + "log", + "nostr-core", + "nostr-nips", + "nostr-relay", + "nostr-services", + "nostr-signer", + "once_cell", + "pango", + "qrcode", + "rand 0.8.7", + "regex", + "reqwest", + "rusqlite", + "secp256k1", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.20", + "tokio", + "url", + "urlencoding", + "uuid", + "webkit2gtk", + "zeroize", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +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.119", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "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", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[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.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "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", + "toml_datetime", + "toml_edit 0.20.2", +] + +[[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_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[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.1", + "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-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.7", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unescaper" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[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", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[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.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "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 = "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 = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[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 = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[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", + "windows-result", + "windows-strings", +] + +[[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.119", +] + +[[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.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[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 = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[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.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a6951d7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,71 @@ +[workspace] +resolver = "2" +members = [ + ".", +] + +[package] +name = "sovereign-browser" +version = "0.0.2" +edition = "2021" +license = "MIT" +description = "A Linux x86 web browser built on WebKitGTK with Nostr identity" + +[dependencies] +# ── Nostr Core Library (local path dependency) ────────────────────── +nostr-core = { path = "/home/user/lt/nostr_core_lib_rust/core" } +nostr-relay = { path = "/home/user/lt/nostr_core_lib_rust/relay" } +nostr-nips = { path = "/home/user/lt/nostr_core_lib_rust/nips" } +nostr-signer = { path = "/home/user/lt/nostr_core_lib_rust/signer" } +nostr-services = { path = "/home/user/lt/nostr_core_lib_rust/services" } + +# ── GTK / WebKit ──────────────────────────────────────────────────── +gtk = { version = "0.18", features = ["v3_24"] } +gdk = "0.18" +gdk-pixbuf = "0.18" +webkit2gtk = { version = "2.0", features = ["v2_40"] } +glib = "0.18" +gio = { version = "0.18", features = ["v2_76"] } +cairo-rs = "0.18" +pango = "0.18" + +# ── Async Runtime ─────────────────────────────────────────────────── +tokio = { version = "1", features = ["full"] } +futures-util = "0.3" + +# ── Serialization ─────────────────────────────────────────────────── +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# ── Database ──────────────────────────────────────────────────────── +rusqlite = { version = "0.32", features = ["bundled"] } + +# ── Networking ────────────────────────────────────────────────────── +reqwest = { version = "0.12", features = ["json", "native-tls", "socks", "blocking"] } +url = "2" + +# ── Crypto ────────────────────────────────────────────────────────── +hex = "0.4" +base64 = "0.22" +rand = "0.8" +sha2 = "0.10" +hmac = "0.12" +secp256k1 = { version = "0.29", features = ["rand-std", "hashes", "global-context"] } +zeroize = { version = "1", features = ["zeroize_derive"] } + +# ── Encoding ──────────────────────────────────────────────────────── +bech32 = "0.11" +urlencoding = "2" + +# ── Utilities ─────────────────────────────────────────────────────── +chrono = "0.4" +uuid = { version = "1", features = ["v4"] } +log = "0.4" +env_logger = "0.11" +thiserror = "2" +anyhow = "1" +once_cell = "1" +lazy_static = "1" +regex = "1" +qrcode = "0.14" +image = "0.25" diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..4e379d2 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.2 diff --git a/increment_and_push.sh b/increment_and_push.sh new file mode 100755 index 0000000..74b366e --- /dev/null +++ b/increment_and_push.sh @@ -0,0 +1,407 @@ +#!/bin/bash +set -e + +# increment_and_push.sh - Version increment and git automation script +# for sovereign_browser_rust +# +# Usage: +# ./increment_and_push.sh "meaningful git comment" +# ./increment_and_push.sh -p "meaningful git comment" # patch bump (default) +# ./increment_and_push.sh -m "meaningful git comment" # minor bump +# ./increment_and_push.sh -M "meaningful git comment" # major bump +# ./increment_and_push.sh -r -p "meaningful git comment" # release: build, tag, push, gitea release + assets +# ./increment_and_push.sh --set-version vX.Y.Z "comment" # explicit version +# +# Mirrors the sovereign_browser (C) workflow: -p/-m/-M/-r flags, version read +# from git tags, updates VERSION, src/version.rs, and Cargo.toml, and (in +# release mode) builds the binary, creates a Gitea release, and uploads the +# binary + source tarball. + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; } +print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; } +print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; } +print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } + +# --- Cleanup trap ------------------------------------------------------- +CLEANUP_FILES=() +cleanup_on_exit() { + for f in "${CLEANUP_FILES[@]}"; do + [[ -n "$f" && -f "$f" ]] && rm -f "$f" + done +} +trap cleanup_on_exit EXIT INT TERM +register_cleanup() { + [[ -n "$1" ]] && CLEANUP_FILES+=("$1") || true +} + +# --- Config ------------------------------------------------------------- + +# Gitea repo for release uploads (derived from the origin remote). +GITEA_API_URL="https://git.laantungir.net/api/v1/repos/laantungir/sovereign_browser_rust" +BIN_NAME="sovereign-browser" +RELEASE_BIN_PATH="target/release/${BIN_NAME}" + +# --- Args --------------------------------------------------------------- + +COMMIT_MESSAGE="" +RELEASE_MODE=false +VERSION_INCREMENT_TYPE="patch" +TARGET_VERSION="" + +show_usage() { + echo "sovereign_browser_rust Increment and Push Script" + echo "" + echo "USAGE:" + echo " $0 [OPTIONS] \"commit message\"" + echo "" + echo "OPTIONS:" + echo " -p, --patch Increment patch version (default)" + echo " -m, --minor Increment minor version" + echo " -M, --major Increment major version" + echo " -r, --release Create a Gitea release with built binary + tarball" + echo " --set-version vX.Y.Z Set an explicit version instead of incrementing" + echo " -h, --help Show this help message" + echo "" + echo "EXAMPLES:" + echo " $0 \"Add Tor .onion routing\"" + echo " $0 -m \"Settings page parity\"" + echo " $0 -r -p \"Release: Tor SOCKS proxy support\"" + echo " $0 --set-version v0.1.0 \"First stable release\"" +} + +while [[ $# -gt 0 ]]; do + case $1 in + -r|--release) + RELEASE_MODE=true + shift + ;; + -p|--patch) + VERSION_INCREMENT_TYPE="patch" + shift + ;; + -m|--minor) + VERSION_INCREMENT_TYPE="minor" + shift + ;; + -M|--major) + VERSION_INCREMENT_TYPE="major" + shift + ;; + --set-version) + if [[ -z "$2" ]]; then + print_error "--set-version requires a version argument (vX.Y.Z)" + exit 1 + fi + TARGET_VERSION="$2" + shift 2 + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + if [[ -z "$COMMIT_MESSAGE" ]]; then + COMMIT_MESSAGE="$1" + fi + shift + ;; + esac +done + +if [[ -z "$COMMIT_MESSAGE" ]]; then + print_error "Commit message is required" + show_usage + exit 1 +fi + +# --- Checks ------------------------------------------------------------- + +# Check if we're in the correct directory +CURRENT_DIR=$(basename "$(pwd)") +if [ "$CURRENT_DIR" != "sovereign_browser_rust" ]; then + print_error "Script must be run from the sovereign_browser_rust directory" + echo "" + echo "Current directory: $CURRENT_DIR" + echo "Expected directory: sovereign_browser_rust" + exit 1 +fi + +# Check if git repository exists +if ! git rev-parse --git-dir > /dev/null 2>&1; then + print_error "Not a git repository. Please initialize git first." + exit 1 +fi + +# Check that the version source files exist +if [ ! -f "src/version.rs" ]; then + print_error "src/version.rs not found" + exit 1 +fi +if [ ! -f "Cargo.toml" ]; then + print_error "Cargo.toml not found" + exit 1 +fi + +# --- Version logic ------------------------------------------------------ + +update_version_files() { + local new_version="$1" # includes leading 'v' + local major="$2" + local minor="$3" + local patch="$4" + + # VERSION file: plain X.Y.Z (no leading 'v') + echo "$major.$minor.$patch" > VERSION + print_success "Updated VERSION file -> $major.$minor.$patch" + + # src/version.rs: Rust constants + sed -i "s/pub const VERSION: &str = .*/pub const VERSION: \&str = \"v$major.$minor.$patch\";/" src/version.rs + sed -i "s/pub const VERSION_MAJOR: u32 = .*/pub const VERSION_MAJOR: u32 = $major;/" src/version.rs + sed -i "s/pub const VERSION_MINOR: u32 = .*/pub const VERSION_MINOR: u32 = $minor;/" src/version.rs + sed -i "s/pub const VERSION_PATCH: u32 = .*/pub const VERSION_PATCH: u32 = $patch;/" src/version.rs + print_success "Updated src/version.rs -> v$major.$minor.$patch" + + # Cargo.toml: package version (no leading 'v') + sed -i "s/^version = \".*\"/version = \"$major.$minor.$patch\"/" Cargo.toml + print_success "Updated Cargo.toml -> $major.$minor.$patch" +} + +increment_version() { + local increment_type="$1" + + # Read the latest version from git tags, falling back to the VERSION file. + LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 2>/dev/null || echo "") + if [[ -z "$LATEST_TAG" ]]; then + if [ -f "VERSION" ]; then + local file_ver + file_ver=$(tr -d '[:space:]' < VERSION) + file_ver="${file_ver#v}" + LATEST_TAG="v$file_ver" + else + LATEST_TAG="v0.0.0" + fi + print_warning "No version tags found, starting from $LATEST_TAG" + fi + + VERSION=${LATEST_TAG#v} + if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + MAJOR=${BASH_REMATCH[1]} + MINOR=${BASH_REMATCH[2]} + PATCH=${BASH_REMATCH[3]} + else + print_error "Invalid version format in tag: $LATEST_TAG" + exit 1 + fi + + if [[ "$increment_type" == "major" ]]; then + NEW_VERSION="v$((MAJOR + 1)).0.0" + elif [[ "$increment_type" == "minor" ]]; then + NEW_VERSION="v${MAJOR}.$((MINOR + 1)).0" + else + NEW_VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))" + fi + + local new_no_v=${NEW_VERSION#v} + local new_major=${new_no_v%%.*} + local rest=${new_no_v#*.} + local new_minor=${rest%%.*} + local new_patch=${rest##*.} + + update_version_files "$NEW_VERSION" "$new_major" "$new_minor" "$new_patch" + export NEW_VERSION +} + +set_explicit_version() { + if [[ ! "$TARGET_VERSION" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + print_error "Invalid target version format: $TARGET_VERSION (expected vX.Y.Z)" + exit 1 + fi + NEW_VERSION="$TARGET_VERSION" + local new_no_v=${NEW_VERSION#v} + local new_major=${new_no_v%%.*} + local rest=${new_no_v#*.} + local new_minor=${rest%%.*} + local new_patch=${rest##*.} + + update_version_files "$NEW_VERSION" "$new_major" "$new_minor" "$new_patch" + export NEW_VERSION +} + +# --- Git operations ----------------------------------------------------- + +git_commit_and_push() { + git add . + + if ! git diff --staged --quiet; then + git commit -m "$NEW_VERSION - $COMMIT_MESSAGE" + print_success "Changes committed" + else + print_warning "No changes to commit" + fi + + # Create (or recreate) the tag. + if git tag "$NEW_VERSION" > /dev/null 2>&1; then + print_success "Created tag: $NEW_VERSION" + else + git tag -d "$NEW_VERSION" > /dev/null 2>&1 || true + git tag "$NEW_VERSION" > /dev/null 2>&1 + print_warning "Replaced existing tag: $NEW_VERSION" + fi + + CURRENT_BRANCH=$(git branch --show-current) + git push origin "$CURRENT_BRANCH" || print_warning "git push branch returned non-zero (continuing)" + git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION" || print_warning "git push tag returned non-zero (continuing)" +} + +# --- Release mode: build + Gitea release -------------------------------- + +build_release_binary() { + print_status "Building release binary..." + cargo build --release > /dev/null 2>&1 || return 1 + + if [[ ! -x "$RELEASE_BIN_PATH" ]]; then + print_error "Binary not found or not executable after build: $RELEASE_BIN_PATH" + return 1 + fi + + print_success "Release binary built: $RELEASE_BIN_PATH" + return 0 +} + +create_source_tarball() { + local tarball_name="${BIN_NAME}-${NEW_VERSION#v}.tar.gz" + + local tmp_tarball + tmp_tarball=$(mktemp -t "${tarball_name}.XXXXXX") || return 1 + + # tar exits 1 for "file changed as we read it", which is benign here. + set +e + tar -czf "$tmp_tarball" \ + --exclude='target' \ + --exclude='.git*' \ + --exclude='*.log' \ + --exclude='*.tar.gz' \ + . > /dev/null 2>&1 + local tar_rc=$? + set -e + + if [[ $tar_rc -ne 0 && $tar_rc -ne 1 ]]; then + rm -f "$tmp_tarball" + print_error "tar failed with exit code $tar_rc" + return 1 + fi + + if ! mv "$tmp_tarball" "$tarball_name" 2>/dev/null; then + rm -f "$tmp_tarball" + print_error "Failed to move tarball into place: $tarball_name" + return 1 + fi + + echo "$tarball_name" +} + +create_gitea_release() { + if [[ ! -f "$HOME/.gitea_token" ]]; then + print_warning "No ~/.gitea_token found. Skipping release creation." + return 0 + fi + + local token + token=$(tr -d '\n\r' < "$HOME/.gitea_token") + + local response + response=$(curl -s -X POST "$GITEA_API_URL/releases" \ + -H "Authorization: token $token" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"$NEW_VERSION\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\"}") + + if echo "$response" | grep -q '"id"'; then + echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2 + else + print_warning "Gitea release creation did not return an id. Response: $response" + return 1 + fi +} + +upload_release_assets() { + local release_id="$1" + local binary_path="$2" + local tarball_path="$3" + + if [[ ! -f "$HOME/.gitea_token" ]]; then + print_warning "No ~/.gitea_token found. Skipping asset uploads." + return 0 + fi + + local token + token=$(tr -d '\n\r' < "$HOME/.gitea_token") + local assets_url="$GITEA_API_URL/releases/$release_id/assets" + + if [[ -f "$binary_path" ]]; then + curl -s -X POST "$assets_url" \ + -H "Authorization: token $token" \ + -F "attachment=@$binary_path;filename=$(basename "$binary_path")" \ + -F "name=$(basename "$binary_path")" > /dev/null + print_success "Uploaded binary: $(basename "$binary_path")" + fi + + if [[ -f "$tarball_path" ]]; then + curl -s -X POST "$assets_url" \ + -H "Authorization: token $token" \ + -F "attachment=@$tarball_path;filename=$(basename "$tarball_path")" \ + -F "name=$(basename "$tarball_path")" > /dev/null + print_success "Uploaded tarball: $(basename "$tarball_path")" + fi +} + +# --- Main --------------------------------------------------------------- + +print_status "Starting version increment and push process..." + +if [[ -n "$TARGET_VERSION" ]]; then + set_explicit_version +else + increment_version "$VERSION_INCREMENT_TYPE" +fi + +print_status "New version: $NEW_VERSION" + +if [[ "$RELEASE_MODE" == true ]]; then + # Build before committing so a failed build aborts the release. + if ! build_release_binary; then + print_error "Release build failed; aborting before push/upload" + exit 1 + fi + + git_commit_and_push + + local_tarball="" + local_tarball=$(create_source_tarball || true) + register_cleanup "$local_tarball" + + print_status "Creating Gitea release for $NEW_VERSION..." + release_id="" + release_id=$(create_gitea_release || true) + + if [[ -n "$release_id" ]]; then + print_status "Uploading release assets (binary + tarball)..." + upload_release_assets "$release_id" "$RELEASE_BIN_PATH" "$local_tarball" + else + print_warning "Gitea release creation failed or returned no id. Binary not uploaded." + fi + + print_success "Release flow completed: $NEW_VERSION" +else + git_commit_and_push + print_success "Increment and push completed: $NEW_VERSION" +fi + +echo "" +echo "Done! Version $NEW_VERSION is live." diff --git a/plans/final-gap-analysis.md b/plans/final-gap-analysis.md new file mode 100644 index 0000000..028fc6c --- /dev/null +++ b/plans/final-gap-analysis.md @@ -0,0 +1,189 @@ +# Final Comprehensive Gap Analysis: sovereign_browser Rust Port + +This document catalogs every remaining gap between the original C implementation (`/home/user/lt/sovereign_browser`) and the Rust port (`/home/user/lt/sovereign_browser_rust`). The goal is 100% feature parity. + +--- + +## 1. Hamburger Menu Gaps + +The C hamburger menu ([`tab_manager.c:2562-2692`](../sovereign_browser/src/tab_manager.c:2562)) has significantly more items than the Rust version ([`src/tab_manager.rs:997`](src/tab_manager.rs:997)). + +### Missing Menu Items + +| Item | C Action | Rust Status | +|------|----------|-------------| +| **Recents (History submenu)** | Dropdown of last 10 pages + "Clear Recents" | ❌ Missing | +| **Bookmarks submenu** | Tree of bookmarks + "Manage Bookmarks…" | ❌ Missing | +| **Tor-routed transport** | Check menu item (toggle Tor mode) | ❌ Missing | +| **FIPS mesh** | Check menu item (toggle FIPS mode) | ❌ Missing | +| **Security strip** | Check menu item (toggle SOP/CORS/certs) | ❌ Missing | +| **Nostr signing status** | Clickable item showing signer status | ❌ Missing | +| **Lock Session** | Sign out without closing browser | ❌ Missing | +| **Download Website** | Opens site downloader | ❌ Missing | +| **About** | Opens about dialog | ❌ Missing | +| **Avatar button** | Circular profile picture button next to hamburger | ❌ Missing | + +### Required files: [`src/tab_manager.rs`](src/tab_manager.rs:997) + +--- + +## 2. Keyboard Shortcuts Gaps + +The C version defines 20+ shortcuts ([`shortcuts.c:24-124`](../sovereign_browser/src/shortcuts.c:24)). The Rust version ([`src/shortcuts.rs:21`](src/shortcuts.rs:21)) has 12. + +### Missing Shortcuts + +| Action | Shortcut | Rust Status | +|--------|----------|-------------| +| `new_window` | Ctrl+N | ❌ Not in shortcut registry | +| `open_file` | Ctrl+O | ❌ Missing | +| `force_reload` | Shift+F5 | ❌ Missing | +| `go_back` | Alt+Left | ❌ Missing | +| `go_forward` | Alt+Right | ❌ Missing | +| `find` | Ctrl+F | ❌ Missing | +| `open_settings` | Ctrl+, | ❌ Missing | +| `open_processes` | Ctrl+Shift+Esc | ❌ Missing | +| `new_identity` | Ctrl+Shift+U | ❌ Missing | +| `toggle_fullscreen` | F11 | ❌ Missing | +| `toggle_sidebar` | Ctrl+Shift+A | ❌ Missing | +| `toggle_toolbars` | Ctrl+Shift+M | ❌ Missing | +| `bookmark_page` | Ctrl+D | ❌ Missing | +| `show_history` | Ctrl+H | ❌ Missing | +| `show_bookmarks` | Ctrl+B | ❌ Missing | +| `show_downloads` | Ctrl+J | ❌ Missing | +| `quit` | Ctrl+Q | ❌ Missing | +| `next_tab_pagedown` | Ctrl+PageDown | ❌ Missing | +| `prev_tab_pageup` | Ctrl+PageUp | ❌ Missing | + +### Key handler gaps in [`src/main.rs`](src/main.rs:130) +Missing: Ctrl+O (open_file), Ctrl+Comma (settings), Ctrl+Shift+Esc (processes), Ctrl+Shift+U (identity), Alt+Left/Right (back/forward), F5 (reload), Shift+F5 (force), Ctrl+F (find), Ctrl+D (bookmark), Ctrl+H (history), Ctrl+B (bookmarks), Ctrl+Q (quit), Ctrl+PageDown/Up (tabs), Ctrl+Shift+A (sidebar), Ctrl+Shift+M (toolbars) + +### Missing `handle_key_press` bypass for `sovereign://` pages +The C version bypasses shortcut interception when `sovereign://` pages are active so the page's JS can capture key combos. + +--- + +## 3. Settings Page (`sovereign://settings`) Gaps + +### `settings/config` JSON gaps ([`src/nostr_bridge.rs:465`](src/nostr_bridge.rs:465)) + +| Field | Type | C version ([`nostr_bridge.c:2940`](../sovereign_browser/src/nostr_bridge.c:2940)) | Rust Status | +|-------|------|-----------|-------------| +| `agent_server_enabled` | bool | Whether agent server is on | ❌ Missing | +| `agent_login_timeout_ms` | number | Login timeout | ❌ Missing | +| `dev_extras` | bool | From WebKit settings | ❌ Missing | +| `file_access` | bool | From WebKit settings | ❌ Missing | +| `universal_access` | bool | From WebKit settings | ❌ Missing | +| `search_engines` | array | List of {id, name} | ❌ Missing | +| `shortcuts` | array | All shortcuts with id/label/accel | ❌ Missing | +| `tab_bar_position` | string | "top"/"bottom"/"left"/"right" | ❌ Returns number | + +### `settings/set` handler gaps ([`src/nostr_bridge.rs:465`](src/nostr_bridge.rs:465)) + +| Feature/Key | C handler ([`nostr_bridge.c:1037`](../sovereign_browser/src/nostr_bridge.c:1037)) | Rust Status | +|-------------|-----------|-------------| +| `agent_server_enabled` | Toggle agent server on/off | ❌ Missing | +| `dev_extras` | Toggle WebKit developer extras | ❌ Missing | +| `file_access` | Toggle file access from file:// | ❌ Missing | +| `universal_access` | Toggle universal access from file:// | ❌ Missing | +| `shortcut.reset_all` | Reset all shortcuts to defaults | ❌ Missing | +| `shortcut.reset` | Reset a single shortcut | ❌ Missing | +| `shortcut.` | Set a shortcut to a custom binding | ❌ Missing | +| `agent_allowed_origins` | Save allowed origins | ❌ Missing | +| `agent_login_timeout_ms` | Save login timeout | ❌ Missing | + +### Settings page HTML +The `www/settings.html` + `settings.js` expects the `settings/config` JSON to include `search_engines`, `shortcuts`, `agent_server_enabled`, `dev_extras`, etc. Without these fields, the page renders incompletely. + +--- + +## 4. Agent System Gaps + +| Feature | C Status | Rust Status | +|---------|----------|-------------| +| **Agent chat sidebar** | Lazily created webview in GtkPaned, loads `sovereign://agents/chat` | ✅ Implemented ([`src/tab_manager.rs:1076`](src/tab_manager.rs:1076)) | +| **Agent tools** | `browser_click`, `browser_type`, `browser_screenshot` | ✅ Implemented ([`src/agent_tools.rs:323`](src/agent_tools.rs:323)) | +| **Agent loop** | Async LLM loop with tool calling | ✅ Implemented ([`src/agent_loop.rs`](src/agent_loop.rs)) | +| **Agent LLM providers** | OpenAI/Anthropic/Ollama support | ✅ Implemented ([`src/agent_llm.rs`](src/agent_llm.rs)) | +| **Agent chat store** | Message history, conversation management | ✅ Implemented ([`src/agent_chat_store.rs`](src/agent_chat_store.rs)) | +| **Agent conversations** | Save/load/rename/delete conversations | ✅ Implemented ([`src/agent_conversations.rs`](src/agent_conversations.rs)) | +| **Agent skills** | Kind 31123 skill management | ✅ Implemented ([`src/agent_skills.rs`](src/agent_skills.rs)) | +| **Agent snapshot** | MCP snapshot of tool state | ✅ Implemented ([`src/agent_snapshot.rs`](src/agent_snapshot.rs)) | +| **Agent server** | HTTP server for agent tools | ✅ Implemented ([`src/agent_server.rs`](src/agent_server.rs)) | +| **Agent login** | Extension login state | ✅ Implemented ([`src/agent_login.rs`](src/agent_login.rs)) | +| **Agent MCP** | Model Context Protocol server | ✅ Implemented ([`src/agent_mcp.rs`](src/agent_mcp.rs)) | +| **Agent FS tools** | Filesystem tool definitions | ✅ Implemented ([`src/agent_fs_tools.rs`](src/agent_fs_tools.rs)) | + +--- + +## 5. `sovereign://` Internal Page Gaps + +| Page | C Status | Rust Status | +|------|----------|-------------| +| `sovereign://settings` | Full settings with all toggles, relays, search, shortcuts | ✅ HTML served from `www/settings.html`, JSON endpoints need config fields | +| `sovereign://bookmarks` | Tree view with add/delete/create/move/rename | ✅ Implemented ([`src/nostr_bridge.rs:95`](src/nostr_bridge.rs:95)) | +| `sovereign://profile` | Kind 0/3/10002 data from SQLite | ✅ Implemented ([`src/nostr_bridge.rs:386`](src/nostr_bridge.rs:386)) | +| `sovereign://processes` | Layer 1 (OS) + Layer 2 (tabs) | ✅ Implemented ([`src/nostr_bridge.rs:99`](src/nostr_bridge.rs:99)) | +| `sovereign://fips` | Status, peers, network, tree, directory | ✅ Served from `www/fips.html` + JS, backend stubs | +| `sovereign://agents` | Provider config, models, skills | ✅ Served from `www/agents/config.html` + JS | +| `sovereign://agents/chat` | Full chat client with markdown, conversations | ✅ Served from `www/agents/chat.html` + JS | +| `sovereign://qr` | QR code generation | ✅ Implemented ([`src/nostr_bridge.rs:419`](src/nostr_bridge.rs:419)) | +| `sovereign://security` | Redirects to settings | ✅ Implemented | +| `sovereign://nostr/*` | NIP-07 bridge API | ✅ Implemented ([`src/nostr_bridge.rs:89`](src/nostr_bridge.rs:89)) | + +--- + +## 6. Network Services Gaps + +| Service | C Status | Rust Status | +|---------|----------|-------------| +| **Tor control** | TCP/Unix socket, authenticate, NEWNYM | ✅ Implemented ([`src/tor_control.rs`](src/tor_control.rs)) | +| **FIPS control** | Unix socket JSON-line protocol | ✅ Implemented ([`src/fips_control.rs`](src/fips_control.rs)) | +| **Net services** | Start/stop Tor & FIPS managed services | Stubs ([`src/net_services.rs`](src/net_services.rs)) | + +--- + +## 7. Performance Probe Gaps + +| Feature | C Status | Rust Status | +|---------|----------|-------------| +| **Perf-probe.js injection** | Preamble + probe body injected into every non-internal page | ✅ Implemented ([`src/nostr_inject.rs:135`](src/nostr_inject.rs:135)) | +| **probe-report endpoint** | Receives periodic probe reports | ✅ Implemented ([`src/nostr_bridge.rs:99`](src/nostr_bridge.rs:99)) | +| **tab_probe endpoint** | Drill-down for a single tab | ✅ Implemented | +| **tab_action endpoint** | Reload/suspend/close tabs | ✅ Implemented | + +--- + +## 8. Minor Gaps + +| Item | C Status | Rust Status | +|------|----------|-------------| +| **Site downloader** | Headless webview rendering & asset bundling | Stub ([`src/site_downloader.rs`](src/site_downloader.rs)) | +| **Favicon display** | 16x16 pixbufs in tab labels | ❌ Missing (cairo API limitation) | +| **Popup handler** | `target="_blank"` → new tab policy | ❌ WebKit `create` signal not hooked | +| **URL history tracking** | Records visited URLs in SQLite | ❌ Not wired to `load-changed` signal | +| **Avatar button** | Circular profile picture in toolbar | ❌ Missing | +| **About dialog** | GTK dialog with version info | ❌ Missing | + +--- + +## Summary: What Needs to Be Done + +### High Priority (Blocks user experience) + +1. **Hamburger menu parity** — Add Recents (history), Bookmarks tree, Tor/FIPS toggles, Security strip toggle, Nostr signing status, Lock Session, Download Website, About, Avatar button. +2. **Shortcut parity** — Add all missing 17 shortcuts to registry and key handler. +3. **Settings/config JSON** — Add missing fields: `agent_server_enabled`, `agent_login_timeout_ms`, `dev_extras`, `file_access`, `universal_access`, `search_engines`, `shortcuts`, `tab_bar_position` (string). +4. **Settings/set handler** — Add missing features: `agent_server_enabled`, `dev_extras`, `file_access`, `universal_access`, shortcut reset, shortcut set. + +### Medium Priority + +5. **Popup handler** — Hook WebKit `create` signal for `target="_blank"`. +6. **URL history** — Wire `load-changed` signal to `db_add_history`. +7. **Site downloader** — Implement headless webview rendering. + +### Low Priority + +8. **Favicon display** — Research cairo surface conversion. +9. **About dialog** — Simple GTK dialog. +10. **Avatar button** — Circular profile picture. \ No newline at end of file diff --git a/plans/full-implementation-analysis.md b/plans/full-implementation-analysis.md new file mode 100644 index 0000000..86cc0a1 --- /dev/null +++ b/plans/full-implementation-analysis.md @@ -0,0 +1,127 @@ +# Comprehensive Implementation Gap Analysis: Sovereign Browser (Rust vs. C) + +This document provides a detailed breakdown of all components, subsystems, internal pages, and features comparing the original C implementation (`/home/user/lt/sovereign_browser`) to the current Rust implementation (`/home/user/lt/sovereign_browser_rust`). + +--- + +## 1. Executive Summary & Parity Matrix + +| Subsystem / Feature | C Implementation Status | Rust Implementation Status | Parity % | Priority | +|---------------------|-------------------------|----------------------------|----------|----------| +| **Core GTK & Multi-Tab Window** | Complete (tabs, toolbars, shortcuts, theme, context menus) | Complete (tabs, per-tab toolbars, theme, webview & tab context menus) | **95%** | Low | +| **NIP-07 JS Extension Injection (`window.nostr`)** | Complete (Sync XHR, Security Manager, NIP-04/44, named constructor) | Complete (Sync XHR, Security Manager, NIP-04/44, named constructor) | **100%** | Done | +| **MCP Automation Server** | Complete (Streamable HTTP, Tools on port 17777) | Complete (Streamable HTTP, cross-thread GTK scheduling) | **100%** | Done | +| **Login Dialog & CLI Auth** | Complete (5 tabs, No Login, CLI auto-login for local/seed/pubkey) | Complete (5 tabs, No Login, CLI auto-login for local/seed/pubkey) | **95%** | Low | +| **Settings (`sovereign://settings`)** | Complete (Embedded HTML/JS, Live toggles, relays, shortcuts, save) | Complete (Dynamic HTML, XHR endpoint handling, SQLite persistence) | **90%** | Low | +| **Embedded Internal Pages (`sovereign://`)** | Complete (Profile, Bookmarks, FIPS, Processes, Agents) | Partial (Basic static HTML/JS stubs, missing rich JS apps) | **30%** | **High** | +| **Agent Loop & Provider Management** | Complete (OpenAI/Anthropic/Ollama, tool calling, chat store) | Partial (Async loop structure exists, missing UI hooks & prompt wiring) | **50%** | **High** | +| **Agent Chat Sidebar** | Complete (Collapsible left sidebar webview running `sovereign://agents/chat`) | Incomplete (Shortcut mapped, sidebar container not fully attached to tabs) | **20%** | **Medium** | +| **URL Autocomplete / Search Dropdown** | Complete (GtkEntryCompletion for bookmarks & history) | Incomplete (URL entry exists, completion model not wired) | **20%** | **Medium** | +| **Per-Tab Performance Probe (`sovereign://processes`)** | Complete (`perf-probe.js` injection, CPU/FPS/DOM stats, probe-report endpoint) | Incomplete (Skeleton probe struct, probe injection & report collection missing) | **15%** | **Medium** | +| **NIP-78 Settings & Bookmark Sync** | Complete (Kind 30078 / 30003 NIP-44 encrypted sync to bootstrap relays) | Partial (Library functions exist, automatic background publish not wired) | **40%** | **Medium** | +| **Tor & FIPS Service Controllers** | Complete (Tor control protocol, FIPS Unix IPC / daemon management) | Stubs (Module placeholders returning mock/default status) | **15%** | **Low** | +| **Offline Site Downloader** | Complete (Headless webview rendering & asset bundling) | Stubs (Log message placeholder) | **10%** | **Low** | + +--- + +## 2. Detailed Gap Breakdown by Subsystem + +### 2.1 Internal Pages & Embedded Assets (`src/embedded_web_content.rs`, `src/nostr_bridge.rs`) +In the original C version, the `www/` directory contains complete single-page web applications for browser management: + +1. **`sovereign://profile` (`www/profile.html`, `profile.js`, `profile.css`)**: + - **C**: Queries cached Kind 0 (metadata) and Kind 3 (contacts) events from SQLite, displays avatar, banner, bio, NIP-05 verification, QR code generation (`sovereign://qr`), and contact list. + - **Rust**: Only returns a minimal static HTML template; `handle_profile_data_json` endpoint is stubbed. +2. **`sovereign://bookmarks` (`www/bookmarks.html`, `bookmarks.js`, `bookmarks.css`)**: + - **C**: Nested folder tree view with drag-and-drop hierarchy, tag filters, live editing, and REST endpoints (`/bookmarks/list`, `/add`, `/delete`, `/createdir`, `/deletedir`, `/move`, `/rename`, `/renamedir`). + - **Rust**: Embedded HTML is a simple flat list; directory tree endpoints need full implementation in `nostr_bridge.rs`. +3. **`sovereign://agents` & `sovereign://agents/chat` (`www/agents/`)**: + - **C**: Complete chat client supporting markdown rendering (`marked.min.js`), HTML sanitization (`purify.min.js`), conversation branching, model selection, skill management (`/agents/skills/*`), and live polling of agent messages. + - **Rust**: Static placeholder forms without full web client assets and conversation endpoints. +4. **`sovereign://processes` (`www/processes.html`, `processes.js`)**: + - **C**: Two-layer diagnostics dashboard: Layer 1 lists OS processes/WebProcesses (PID, CPU, Memory); Layer 2 lists per-tab performance metrics collected via `perf-probe.js`. + - **Rust**: HTML skeleton with no real process tracking or probe integration. +5. **`sovereign://fips` (`www/fips.html`, `fips.js`)**: + - **C**: Mesh management UI showing status cards, active peers, spanning tree graph, node directory (Kind 37195), and manual connection controls. + - **Rust**: Static placeholder form. + +--- + +### 2.2 Tab Management & UI Features (`src/tab_manager.rs`) + +1. **Agent Chat Sidebar (`tab_manager_toggle_sidebar`)**: + - **C**: Creates a persistent GtkPaned container with a collapsible 280px left sidebar hosting a dedicated WebKitWebView loaded with `sovereign://agents/chat`. + - **Rust**: `tab_manager_toggle_sidebar()` function stub exists, but webview/sidebar layout is not packed into the tab container. +2. **URL Entry Completion (`GtkEntryCompletion`)**: + - **C**: As the user types in the URL bar, an async completion popup searches SQLite history and bookmarks, showing favicon/title/URL. + - **Rust**: `GtkEntry` handles Enter and Esc, but has no attached `GtkEntryCompletion` model. +3. **Popup & New Window Handling (`on_create_webview` / `target="_blank"`)**: + - **C**: Intercepts WebKit `create` signal on webviews to enforce tab policy (converting popup windows into new tabs or respected multi-window popups based on settings). + - **Rust**: Navigation policy decision is handled for URLs, but the WebKit `create` signal is not hooked. +4. **Favicon Display**: + - **C**: Captures `favicon-database` updates and renders 16x16 pixbufs in tab labels. + - **Rust**: Omitted due to Cairo surface conversion differences; uses a generic globe icon. + +--- + +### 2.3 Agent System & Tool Catalog (`src/agent_*.rs`) + +1. **Interactive Tool Execution**: + - `browser_click` and `browser_type`: Currently stubs in `agent_tools.rs`. C injects DOM query selector JS to synthesize click and keyboard input events. + - `browser_screenshot`: C captures the WebKit webview surface to PNG bytes; Rust returns a stub message. +2. **Skill Management (`src/agent_skills.rs`)**: + - C publishes and queries NIP-51/NIP-78 Kind 31123 skill definitions to Nostr relays. Rust has internal structs but lacks relay publishing integration. +3. **Conversation Persistence (`src/agent_conversations.rs`)**: + - C encrypts chat sessions using NIP-44 and persists them to Nostr relays (Kind 30078) and local SQLite. Rust has SQLite tables but lacks relay publishing. + +--- + +### 2.4 Performance Probe Subsystem (`src/perf_probe.rs`) + +1. **`js/perf-probe.js` Script Injection**: + - **C**: Injected via `UserContentManager` into non-internal pages; measures CPU busy percentage (Long Tasks API), FPS (requestAnimationFrame loop), DOM node count, Heap used, in-flight fetch requests, and sends periodic reports to `sovereign://processes/probe-report`. + - **Rust**: Not yet injected; `/processes/probe-report` endpoint is unhandled. + +--- + +### 2.5 Network Services & System daemons (`src/net_services.rs`, `src/tor_control.rs`, `src/fips_control.rs`) + +1. **Tor Control**: + - **C**: Connects to Tor ControlPort (default 9051), authenticates, checks circuit status, and requests new identity (`SIGNAL NEWNYM`). + - **Rust**: Simulated in memory. +2. **FIPS Mesh Control**: + - **C**: Connects via Unix domain socket to the local FIPS daemon, queries peer tables, resolves `.fips` and npub overlay addresses. + - **Rust**: Stubs returning default status. + +--- + +## 3. Recommended Roadmap for Full Parity + +```mermaid +flowchart TD + A[Phase 1: Rich Embedded Pages & Assets] --> B[Phase 2: UI Enhancements & Sidebar] + B --> C[Phase 3: Agent Tool Execution & Automation] + C --> D[Phase 4: Diagnostics & Settings/Bookmark Sync] + D --> E[Phase 5: Network Services Tor/FIPS] +``` + +### Phase 1: Rich Internal Pages (`sovereign://`) +- Migrate embedded web assets from `www/` into `embedded_web_content.rs` or asset files. +- Implement full REST JSON route dispatchers in `nostr_bridge.rs` for Bookmarks (`/bookmarks/*`), Profile (`/profile/data`), and Agents (`/agents/*`). + +### Phase 2: Tab Manager & UI Features +- Wire collapsible Agent Chat sidebar into `tab_manager.rs`. +- Connect `GtkEntryCompletion` for URL autocomplete from SQLite history and bookmarks. +- Handle WebKit `create` signal for `target="_blank"` popups. + +### Phase 3: Agent Automation & Tool Execution +- Implement DOM automation tools (`browser_click`, `browser_type`) via `run_javascript`. +- Implement `browser_screenshot` via WebKitGTK snapshot API. + +### Phase 4: Performance Probe & Sync +- Wire `perf-probe.js` injection into all page views and connect `sovereign://processes/probe-report`. +- Wire automatic background sync for Kind 30078 settings and Kind 30003 bookmarks. + +### Phase 5: Network Daemon Controls +- Implement Tor ControlPort protocol client in `tor_control.rs`. +- Implement FIPS daemon Unix socket IPC in `fips_control.rs`. diff --git a/plans/login-dialog-comparison.md b/plans/login-dialog-comparison.md new file mode 100644 index 0000000..eea9118 --- /dev/null +++ b/plans/login-dialog-comparison.md @@ -0,0 +1,225 @@ +# Login Dialog Comparison: Original (C) vs Rust Implementation + +**Date:** 2026-08-17 +**Scope:** The Nostr login dialog shown when the browser starts. +**Reference files:** +- Original (C): [`sovereign_browser/src/login_dialog.c`](../sovereign_browser/src/login_dialog.c) +- Rust: [`src/login_dialog.rs`](../src/login_dialog.rs) + +--- + +## 1. Overview + +Both implementations present a modal GTK dialog on startup that lets the user +authenticate with a Nostr identity before the browser becomes usable. The +original C implementation is a mature, feature-complete dialog with **five +method tabs** plus a **"No Login"** option. The Rust implementation is a +minimal port that currently supports only **two** of the five methods and +lacks most of the original's UI affordances. + +The Rust dialog is a functional but **visually and functionally reduced** +version of the original. This document details, page by page, what each +login screen shows and the differences between the two implementations. + +--- + +## 2. High-Level Structural Differences + +| Aspect | Original (C) | Rust | +|---|---|---| +| Window title | `sovereign browser ` (e.g. `sovereign browser v0.0.70`) | `Nostr Login` | +| Default size | 560 × 380 | 500 × 400 | +| Method selection | **GtkNotebook tabs** (Local Key, Seed Phrase, Read-only, NIP-46, n_signer) | **GtkComboBoxText dropdown** (5 entries) | +| "No Login" button | ✅ Present (far left) | ❌ Absent | +| Buttons | `No Login` \| `Cancel` \| `Sign In` | `Cancel` \| `Login` | +| Title header | Bold markup: **"Sign in with your Nostr key"** | ❌ None | +| Status/error label | ✅ Present (shows validation errors inline) | ❌ None (silently returns `None` on error) | +| Monospace font styling | ✅ Applied via CSS provider | ❌ None | +| Agent auto-close (200ms poll) | ✅ Present | ❌ None | +| Methods actually implemented | All 5 (local, seed, readonly, nip46, nsigner) | Only 2 (local, readonly) | +| Error messaging | Detailed inline messages | Silent failure (returns `None`) | + +--- + +## 3. Page-by-Page Comparison + +### 3.1 Local Key + +**Original (C)** — tab label **"Local Key"** +- Instruction label: *"Enter your Nostr private key (nsec) or generate a new one:"* +- Text entry with placeholder `nsec1...`, width 60 chars +- **"Generate New Key"** button — generates a fresh keypair and fills the entry with the `nsec` +- Accepts `nsec1...` (bech32) **or** 64-char hex private key +- Validates input; shows inline errors: *"Invalid nsec."*, *"Invalid hex private key."*, *"Enter an nsec1... or 64-char hex key."*, *"Enter an nsec or click Generate."* + +**Rust** — dropdown entry **"Local Key (nsec)"** +- Label: *"Private Key (hex):"* +- Text entry with **password masking** (`set_visibility(false)`) +- ❌ No "Generate New Key" button +- ❌ Only accepts 64-char **hex** private key (no `nsec1...` bech32 support) +- ❌ No inline validation/error messages — invalid input silently returns `None` + +**Key differences:** +1. Rust only accepts hex, not `nsec1...` bech32. +2. Rust has no key generation button. +3. Rust masks the input as a password; the original shows it in plain text. +4. Rust has no error feedback. + +--- + +### 3.2 Seed Phrase + +**Original (C)** — tab label **"Seed Phrase"** +- Instruction label: *"Enter your 12-word BIP-39 seed phrase:"* +- **12 numbered word entry boxes in a 4×3 grid** (1. through 12.) +- Each box has **dropdown completion** (BIP-39 wordlist) + **inline auto-complete** on typing +- **Tab/Enter** moves focus to the next word box +- **"Generate New Mnemonic"** button — fills all 12 boxes with a fresh mnemonic +- **Account #** spin button (0–1000) with live derivation hint: *"Key derivation: m/44'/1237'/'/0/0"* +- Validates exactly 12 words; errors: *"Expected 12 words, got N."*, *"Invalid seed phrase. Check the words and try again."* + +**Rust** — dropdown entry **"Seed Phrase (BIP-39)"** +- Label: *"Seed Phrase (BIP-39):"* +- Single text entry with **password masking** +- ❌ No 12-box grid, no word completion, no auto-complete, no Tab navigation +- ❌ No "Generate New Mnemonic" button +- ❌ No Account # selector or derivation path hint +- ❌ **Not implemented** — selecting this method and clicking Login returns `None` (the `Seed` arm is not handled in the match) + +**Key differences:** +1. Rust does not implement seed-phrase login at all (falls through to `None`). +2. The original's rich 12-word grid with completion is entirely absent. + +--- + +### 3.3 Read-only (npub) + +**Original (C)** — tab label **"Read-only"** +- Instruction label: *"Enter a Nostr public key (npub) for read-only mode:"* +- Text entry with placeholder `npub1...`, width 60 chars +- Hint (dimmed): *"Read-only mode: you can view content but cannot sign events."* +- Accepts `npub1...` (bech32) **or** 64-char hex pubkey +- Validates; errors: *"Invalid npub."*, *"Invalid hex pubkey."*, *"Enter an npub1... or 64-char hex pubkey."* + +**Rust** — dropdown entry **"Read-only (npub)"** +- Label: *"Public Key (npub or hex):"* +- Text entry (visible, not masked) +- ✅ Accepts `npub1...` (via `nostr_url_normalize`) or hex +- ❌ No hint text +- ❌ No inline validation/error messages + +**Key differences:** +1. Rust supports this method (the only one besides local that works). +2. Rust lacks the explanatory hint and error feedback. + +--- + +### 3.4 NIP-46 Remote Signer + +**Original (C)** — tab label **"NIP-46"** +- Instruction label: *"Connect to a NIP-46 remote signer (bunker:// URL):"* +- Text entry with placeholder `bunker://?relay=wss://...&secret=...`, width 60 chars +- Wrapped hint (dimmed): *"The remote signer holds your private key. Signing requests are sent over Nostr relays."* +- Parses and validates the bunker URL; errors: *"Invalid bunker:// URL. Format: ..."*, *"Enter a bunker:// URL."* +- Generates a client keypair, stores the remote signer's pubkey as identity + +**Rust** — dropdown entry **"NIP-46 Remote Signer"** +- Label: *"Bunker URL:"* +- Text entry (visible) +- ❌ **Not implemented** — the `Nip46` arm is not handled in the match, returns `None` +- ❌ No hint text, no URL validation + +**Key differences:** +1. Rust does not implement NIP-46 login at all. + +--- + +### 3.5 n_signer Hardware + +**Original (C)** — tab label **"n_signer"** +- Instruction label: *"Connect to n_signer hardware signer:"* +- **Transport** combo: `USB Serial` / `UNIX Socket` / `TCP` / `Other Qube` (default: Other Qube) +- **Device** entry — label/placeholder adapts to transport: + - Serial: *"Device Path:"* / `/dev/ttyACM0` + **"Detect Serial Devices"** button + - UNIX: *"Socket Name:"* / `nsigner` + - TCP: *"Host:Port:"* / `127.0.0.1:7777` + - Qube: *"Target Qube:"* / `nostr_signer` (default value) +- **Service** entry (qrexec only): `qubes.NsignerRpc` +- **Role** entry: `nostr_range` +- **Key Index** spin (0–1000) with live BIP-32 path label: *"Key Index (m/44'/1237'/'/0/0):"* +- Wrapped hint (dimmed): *"n_signer is a foreground, RAM-only hardware signer. Your private key never leaves the device."* +- Detailed error mapping (auth denied, policy denied, index not whitelisted, etc.) + +**Rust** — dropdown entry **"n_signer Hardware"** +- Label: *"Device Path:"* +- Text entry (visible) +- ❌ **Not implemented** — the `Nsigner` arm is not handled in the match, returns `None` +- ❌ No transport selector, no service/role/index fields, no device detection + +**Key differences:** +1. Rust does not implement n_signer login at all. +2. The original's transport-adaptive UI is entirely absent. + +--- + +## 4. Feature Matrix + +| Feature | Original (C) | Rust | +|---|---|---| +| Local key (nsec/hex) | ✅ | ⚠️ hex only | +| Local key generation | ✅ | ❌ | +| Seed phrase (BIP-39) | ✅ | ❌ | +| Seed word grid + completion | ✅ | ❌ | +| Read-only (npub) | ✅ | ✅ | +| NIP-46 remote signer | ✅ | ❌ | +| n_signer hardware | ✅ | ❌ | +| "No Login" (browse w/o identity) | ✅ | ❌ | +| Inline error/status messages | ✅ | ❌ | +| Method tabs (notebook) | ✅ | ❌ (dropdown) | +| Title header | ✅ | ❌ | +| Monospace styling | ✅ | ❌ | +| Agent auto-close | ✅ | ❌ | +| Account # / key index selectors | ✅ | ❌ | + +--- + +## 5. Summary of Gaps + +The Rust login dialog is a **minimal skeleton** compared to the original. +The most significant gaps, in priority order: + +1. **Only 2 of 5 methods work** — `Seed`, `Nip46`, and `Nsigner` are listed in + the dropdown but return `None` when selected. The original implements all five. +2. **No "No Login" option** — the original lets users browse without a Nostr + identity; the Rust version has no equivalent. +3. **No error feedback** — the original shows inline validation/error messages; + the Rust version silently returns `None` on any failure, giving the user no + indication of what went wrong. +4. **Reduced local-key support** — Rust only accepts hex private keys (no + `nsec1...` bech32) and has no "Generate New Key" button. +5. **UI structure differs** — the original uses notebook tabs with rich per-method + screens (word grids, transport selectors, derivation hints); Rust uses a single + dropdown that swaps a single label/entry. +6. **Missing polish** — no title header, no monospace styling, no agent + auto-close, no version in the window title. + +--- + +## 6. Recommended Path to Parity + +To bring the Rust dialog in line with the original: + +1. **Implement the missing methods** (`Seed`, `Nip46`, `Nsigner`) in + [`src/login_dialog.rs`](../src/login_dialog.rs) and + [`src/key_store.rs`](../src/key_store.rs) (`key_store_create_signer` already + returns `None` for these — see [`src/key_store.rs`](../src/key_store.rs:108)). +2. **Add a "No Login" button** that returns a `None`-method result so the browser + can run without an identity. +3. **Add a status/error label** and surface validation errors instead of + returning `None` silently. +4. **Add `nsec1...` bech32 support** to the local-key path and a + "Generate New Key" button. +5. **Switch from a dropdown to notebook tabs** to match the original's layout, + and add the per-method helper widgets (word grid, transport selector, etc.). +6. **Restore polish**: title header, monospace CSS, version in the window title, + and the agent auto-close timeout. diff --git a/plans/main-window-comparison.md b/plans/main-window-comparison.md new file mode 100644 index 0000000..9ac3d63 --- /dev/null +++ b/plans/main-window-comparison.md @@ -0,0 +1,322 @@ +# Main Browser Window Comparison: Original (C) vs Rust Implementation + +**Date:** 2026-08-17 +**Scope:** The main browser window — how tabs are generated, how new windows are +generated, and the surrounding UI chrome (toolbars, menus, tab strip, sidebar). +**Reference files:** +- Original (C): [`sovereign_browser/src/main.c`](../sovereign_browser/src/main.c) and + [`sovereign_browser/src/tab_manager.c`](../sovereign_browser/src/tab_manager.c) +- Rust: [`src/main.rs`](../src/main.rs) and [`src/tab_manager.rs`](../src/tab_manager.rs) + +--- + +## 1. Overview + +The original C implementation is a mature, feature-complete multi-window +browser. Each tab carries its **own toolbar** (hamburger menu, refresh/stop, +back/forward, URL entry with search completion, bookmark button), a **bookmark +bar**, a **load-progress bar**, a rich **tab label** (favicon + title + close +button), and a **right-click tab context menu**. It supports **multiple +top-level windows**, each with its own notebook, a **per-window sidebar**, tab +**drag-reordering**, and a full **hamburger menu** with identity, network, +security, and internal-page items. + +The Rust implementation is a **minimal single-window skeleton**. It has a +single shared toolbar with one URL entry and a Settings button, a plain +`GtkNotebook` with simple text labels, and no per-tab chrome, no context menus, +no new-window support, no sidebar, and no drag-reordering. This document details +the differences and what would be needed to bring the Rust project up to the +same level. + +--- + +## 2. High-Level Structural Differences + +| Aspect | Original (C) | Rust | +|---|---|---| +| Windows | **Multi-window** (main + auxiliary `GtkWindow`s) | **Single window** only | +| Per-tab toolbar | ✅ Each tab has its own toolbar | ❌ One shared toolbar for all tabs | +| Tab label | Favicon + title + close button (rich) | Plain text label only | +| Tab context menu (right-click) | ✅ New / Close / Close Others / Close to Right / Open in New Window / Duplicate / Reload / Download Website | ❌ None | +| Middle-click to close | ✅ (configurable) | ❌ | +| Tab drag-reordering | ✅ (configurable) | ❌ | +| New-tab button on tab strip | ✅ (`tab-new-symbolic` action widget) | ❌ | +| User avatar on tab strip | ✅ (Nostr profile picture) | ❌ | +| Hamburger menu | ✅ Full menu (identity, network, security, internal pages) | ❌ (only a Settings button) | +| Sidebar (agent chat) | ✅ Per-window `GtkPaned` sidebar | ❌ | +| Bookmark bar | ✅ Per-tab bookmark bar | ❌ | +| Load-progress bar | ✅ Per-tab thin progress bar | ❌ | +| URL search completion | ✅ History + bookmarks + search suggestions | ❌ (plain entry) | +| Back/Forward/Refresh buttons | ✅ Per-tab | ❌ | +| `target="_blank"` / `window.open()` | ✅ New tab or new window via `create` signal | ❌ (not handled) | +| Keyboard shortcuts | ✅ Full set (Ctrl+T/N/W, zoom, inspector, sidebar, fullscreen, etc.) | ⚠️ Defined in data but **not wired** to any handler | +| Session restore | ✅ With CLI precedence logic | ⚠️ Basic (restores URLs) | +| App theme / CSS | ✅ Custom red-accent theme | ❌ | +| Window close semantics | ✅ Multi-window aware (app quits only when last window closes) | ⚠️ Simple (quits on main window close) | + +--- + +## 3. Tab Generation + +### 3.1 Original (C) — `tab_create()` ([`tab_manager.c`](../sovereign_browser/src/tab_manager.c:2993)) + +Each tab is a full `tab_info_t` with its own page (vertical box) containing: + +1. **Per-tab toolbar** (horizontal box, name `main-toolbar`): + - **Hamburger menu** button (`build_hamburger_menu`) + - **Refresh/Stop button** — left-click reloads, right-click shows hard-reload + options (bypass cache, clear cookies + reload); icon/tooltip sync with + loading state + - **Back button** and **Forward button** + - **URL entry** with **search completion** (history + bookmarks + domain + heuristic + async search-engine suggestions) + - **Bookmark button** (opens a directory picker to bookmark the page) +2. **Bookmark bar** — buttons for the "Bookmarks Bar" folder + subfolder popovers +3. **Load-progress bar** — thin 3px bar shown during loads +4. **WebView** — with developer extras, hardware acceleration, smooth scrolling, + JSON viewer injection, `window.nostr` injection, and a battery of signal + handlers (load-changed, load-failed, favicon, title, context-menu, + decide-policy, create, key-press, button-press) +5. **Tab label** (`build_tab_label`) — favicon + ellipsized title + close button, + with middle-click/right-click handling on the label and all children + +### 3.2 Rust — `tab_manager_new_tab()` ([`src/tab_manager.rs`](../src/tab_manager.rs:48)) + +Each tab is a `TabInfo` struct plus a `GtkScrolledWindow` containing a bare +`WebView`. The tab label is a plain `gtk::Label` ("New Tab" → page title). There +is **no per-tab toolbar, no bookmark bar, no progress bar, no favicon, no close +button, no context menu, no drag-reordering**. + +### 3.3 Differences + +| Feature | Original (C) | Rust | +|---|---|---| +| Per-tab toolbar | ✅ | ❌ | +| URL entry per tab | ✅ | ❌ (one shared) | +| Back/Forward/Refresh buttons | ✅ | ❌ | +| Search completion in URL bar | ✅ | ❌ | +| Bookmark button + bar | ✅ | ❌ | +| Load-progress bar | ✅ | ❌ | +| Favicon in tab label | ✅ | ❌ | +| Close button on tab | ✅ | ❌ | +| Tab context menu | ✅ | ❌ | +| Middle-click close | ✅ | ❌ | +| Drag-reorder | ✅ | ❌ | +| Max-tabs limit | ✅ (`settings.max_tabs`) | ❌ | +| Focus URL entry on new tab | ✅ | ❌ | +| Per-tab signal wiring | ✅ (load/favicon/title/context/create/decide-policy) | ⚠️ (title only) | + +--- + +## 4. New Window Generation + +### 4.1 Original (C) — `tab_manager_new_window()` ([`tab_manager.c`](../sovereign_browser/src/tab_manager.c:1263)) + +Creates a **real top-level `GtkWindow`** with: +- Its own `GtkNotebook` (full tab infrastructure via `tab_create`) +- New-tab button + avatar as notebook action widgets +- A **window-level `GtkPaned`** (left = sidebar container, right = notebook) +- A full tab created with `webkit_web_view_new_with_related_view()` (shares the + parent's WebProcess, avoiding the `WindowFeatures` assertion crash) +- Focus-in / destroy handlers that track the active window and revert to the + main window when an auxiliary window closes (without quitting the app) +- A `window_state_t` registered in `g_aux_windows` + +New windows are created from: +- **Ctrl+N** / `SHORTCUT_NEW_WINDOW` → `tab_manager_new_window_blank()` +- **Tab context menu** "Open in New Window" → `tab_manager_open_in_new_window()` +- **`target="_blank"` / `window.open()`** → `on_create_webview()` creates a new + tab (or window) via the WebKit `create` signal + +### 4.2 Rust — no new-window support + +The Rust implementation has **no new-window function at all**. There is no +`tab_manager_new_window`, no `create`-signal handler, and no `Ctrl+N` handling. +`target="_blank"` links and `window.open()` are not handled, so they either do +nothing or open in the same view depending on WebKit defaults. + +### 4.3 Differences + +| Feature | Original (C) | Rust | +|---|---|---| +| Multiple top-level windows | ✅ | ❌ | +| `Ctrl+N` new window | ✅ | ❌ | +| "Open in New Window" (context menu) | ✅ | ❌ | +| `target="_blank"` → new tab/window | ✅ | ❌ | +| Per-window notebook | ✅ | ❌ | +| Per-window sidebar | ✅ | ❌ | +| Active-window tracking (focus-in) | ✅ | ❌ | +| App stays alive when aux window closes | ✅ | ❌ | +| Related-view webview (crash avoidance) | ✅ | ❌ | + +--- + +## 5. Window Chrome & Menus + +### 5.1 Hamburger menu (Original only) + +The C version's per-tab hamburger menu ([`build_hamburger_menu`](../sovereign_browser/src/tab_manager.c:2562)) +contains: +- **Navigation**: Open File…, Reload, Stop +- **Recents** submenu (history) +- **Bookmarks** submenu +- **Identity**: Switch Identity…, Lock Session, Logout +- **Networking**: Tor-routed transport, FIPS mesh (check items, synced across tabs) +- **Security/status**: Security strip (SOP/CORS/certs), Nostr signing status +- **Tools**: Toggle Inspector, Toggle Agent Sidebar +- **Internal pages**: Profile, Agent Setup…, FIPS Mesh…, Processes…, Settings…, About + +The Rust version has only a single **"Settings"** toolbar button that opens +`sovereign://settings` in a new tab. There is no hamburger menu, no identity +menu, no network toggles, no internal-page menu. + +### 5.2 Tab strip action widgets (Original only) + +The C version adds to each notebook: +- A **new-tab button** (`tab-new-symbolic`) at the end of the tab strip +- A **user avatar** button at the start (Nostr profile picture, circular) + +The Rust version has neither. + +### 5.3 Sidebar (Original only) + +The C version has a **per-window `GtkPaned` sidebar** (agent chat), hidden by +default, toggled via Ctrl+Shift+A / menu / `;` shortcut. The Rust version has no +sidebar. + +--- + +## 6. Keyboard Shortcuts + +### 6.1 Original (C) + +The C version wires a full `on_key_press` handler ([`main.c`](../sovereign_browser/src/main.c:501)) +to both the window and every webview, dispatching through the configurable +`shortcuts_lookup()` table. Actions include: New Tab, New Window, Open File, +Close Tab, Focus URL, Next/Prev Tab (Ctrl+Tab and Ctrl+PageUp/Down), Reload, +Force Reload, Back, Forward, Find, Open Settings, Open Processes, New Identity, +Toggle Fullscreen, Toggle Inspector, Toggle Sidebar, Toggle Toolbars, Zoom +In/Out/Reset. + +### 6.2 Rust + +The Rust [`shortcuts.rs`](../src/shortcuts.rs) **defines** a default shortcut +table (Ctrl+T, Ctrl+W, Ctrl+L, Ctrl+Tab, Ctrl+R, Ctrl+Q, Ctrl+F, Ctrl+D, Ctrl+H, +Ctrl+B, Ctrl+J, zoom, etc.) and can load/save it, but **nothing wires these to +actual handlers**. There is no `key-press-event` handler in `main.rs` or +`tab_manager.rs` that dispatches them. The shortcuts are effectively dead data. + +--- + +## 7. Session Restore & Window Close + +### 7.1 Original (C) + +- **Session restore** has explicit CLI precedence: `--no-session-restore` / + `--url` skip restore; `--session-restore` forces it; otherwise uses + `settings.restore_session`. On restore failure, falls back to CLI URLs or the + default new-tab URL. +- **Window close** is multi-window aware: `on_window_delete_event` closes the + main window's tabs; if auxiliary windows still have tabs, it hides the main + window and keeps the app alive; the app only quits when the last window closes. + `on_window_destroy` does session save (or privacy-mode clear), network service + shutdown, agent server stop, signer free, and `gtk_main_quit()`. + +### 7.2 Rust + +- **Session restore** is basic: if `settings.session_restore` is true, it + restores URLs into new tabs. No CLI precedence logic. +- **Window close** is simple: `delete_event` saves the session and quits. No + multi-window awareness (there's only one window). + +--- + +## 8. Feature Matrix + +| Feature | Original (C) | Rust | +|---|---|---| +| Multi-window | ✅ | ❌ | +| Per-tab toolbar | ✅ | ❌ | +| Back/Forward/Refresh buttons | ✅ | ❌ | +| URL search completion | ✅ | ❌ | +| Bookmark button + bar | ✅ | ❌ | +| Load-progress bar | ✅ | ❌ | +| Favicon in tab | ✅ | ❌ | +| Tab close button | ✅ | ❌ | +| Tab context menu | ✅ | ❌ | +| Middle-click close | ✅ | ❌ | +| Drag-reorder | ✅ | ❌ | +| New-tab button on strip | ✅ | ❌ | +| User avatar on strip | ✅ | ❌ | +| Hamburger menu | ✅ | ❌ | +| Identity menu (switch/lock/logout) | ✅ | ❌ | +| Network toggles (Tor/FIPS) | ✅ | ❌ | +| Sidebar (agent chat) | ✅ | ❌ | +| Inspector toggle | ✅ | ❌ | +| Zoom controls | ✅ | ❌ | +| Fullscreen toggle | ✅ | ❌ | +| Toolbar toggle | ✅ | ❌ | +| `target="_blank"` handling | ✅ | ❌ | +| Keyboard shortcuts wired | ✅ | ❌ (data only) | +| App theme / CSS | ✅ | ❌ | +| Max-tabs limit | ✅ | ❌ | +| Multi-window close semantics | ✅ | ❌ | +| Session restore precedence | ✅ | ⚠️ basic | + +--- + +## 9. Summary of Gaps + +The Rust main window is a **minimal single-window skeleton** compared to the +original. The most significant gaps, in priority order: + +1. **No per-tab chrome** — the original gives every tab its own toolbar (URL + entry, back/forward/refresh, bookmark), bookmark bar, progress bar, and rich + tab label. The Rust version has one shared toolbar and plain text tabs. +2. **No multi-window support** — no new-window function, no `Ctrl+N`, no + `target="_blank"`/`window.open()` handling, no per-window notebooks or + sidebars. +3. **No tab context menu** — right-click and middle-click tab actions are + entirely absent. +4. **No hamburger menu** — identity switching, logout, network toggles, and + internal-page navigation are missing. +5. **Keyboard shortcuts are not wired** — the shortcut table exists but no + handler dispatches it. +6. **No tab-strip action widgets** — no new-tab button, no user avatar. +7. **No drag-reordering, favicons, or progress bars.** +8. **No app theme/CSS** — the original has a custom red-accent theme. + +--- + +## 10. Recommended Path to Parity + +To bring the Rust main window up to the original's level, the work breaks down +into these steps: + +1. **Per-tab toolbar** — move the URL entry, back/forward/refresh buttons, and + bookmark button into each tab's page (a vertical box: toolbar → bookmark bar + → progress bar → webview), mirroring `tab_create()`. +2. **Rich tab label** — build a horizontal box per tab with favicon image, + ellipsized title label, and a close button; wire `notify::favicon`, + `notify::title`, and close-clicked signals. +3. **Tab context menu** — add a `button-press-event` handler on the tab label + (and children) for right-click (New, Close, Close Others, Close to Right, + Open in New Window, Duplicate, Reload, Download Website) and middle-click + close. +4. **Multi-window support** — add `tab_manager_new_window()`, a `create`-signal + handler for `target="_blank"`, per-window notebooks, and active-window + tracking (focus-in/destroy). Use `WebView::builder().related_view()` to avoid + the WindowFeatures crash. +5. **Hamburger menu** — build a `GtkMenuButton` per tab with the identity, + network, security, and internal-page items, delegating identity actions to + `main.rs`. +6. **Wire keyboard shortcuts** — add a `key-press-event` handler on the window + and each webview that dispatches through `shortcuts_lookup()`. +7. **Tab-strip action widgets** — add a new-tab button and user avatar via + `notebook.set_action_widget()`. +8. **Drag-reordering** — call `notebook.set_tab_reorderable()` per tab, gated on + a `tab_drag_reorder` setting. +9. **App theme** — add a CSS provider with the red-accent theme, adapting to the + `theme_dark` setting. +10. **Window close semantics** — make `delete_event` multi-window aware so the + app quits only when the last window closes. diff --git a/plans/rust-implementation-assessment.md b/plans/rust-implementation-assessment.md new file mode 100644 index 0000000..f700d36 --- /dev/null +++ b/plans/rust-implementation-assessment.md @@ -0,0 +1,116 @@ +# Rust Implementation Assessment: What's Implemented vs. Missing + +**Date:** 2026-08-17 +**Scope:** Current state of the Rust port (`sovereign_browser_rust`) compared to +the original C `sovereign_browser`. +**Reference:** [`plans/main-window-comparison.md`](main-window-comparison.md) + +--- + +## 1. Status Legend + +- ✅ **Implemented & working** +- ⚠️ **Partially implemented** (works but limited / placeholder) +- ❌ **Not implemented** + +--- + +## 2. Login Dialog + +| Feature | Status | Notes | +|---|---|---| +| 5 method tabs (Local, Seed, Read-only, NIP-46, n_signer) | ✅ | Full notebook UI | +| "No Login" button | ✅ | | +| Status/error messages | ✅ | | +| Monospace styling | ✅ | | +| Version in title | ✅ | | +| n_signer USB Serial / qrexec transports | ⚠️ | Show "not supported" message; only UNIX/TCP work (Rust signer crate limitation) | + +--- + +## 3. Main Window & Tabs + +| Feature | Status | Notes | +|---|---|---| +| Per-tab toolbar (URL, back/forward/refresh, bookmark, hamburger) | ✅ | | +| Rich tab label (favicon + title + close) | ⚠️ | Favicon widget present but **not populated** (cairo→pixbuf API limitation) | +| Tab context menu (right-click) | ✅ | New/Close/Close Others/Close to Right/New Window/Duplicate/Reload | +| Middle-click close | ✅ | | +| Drag-reorder | ✅ | | +| New-tab button on tab strip | ✅ | Added via `set_action_widget` in main.rs and new windows | +| Load-progress bar | ✅ | | +| Bookmark bar | ⚠️ | Bar widget present but **not populated** with bookmark buttons | +| URL search completion | ❌ | Plain entry, no history/bookmark/search suggestions | +| User avatar on tab strip | ❌ | Not implemented | +| Multi-window (Ctrl+N, context menu) | ✅ | `tab_manager_new_window()` | +| `target="_blank"` / `window.open()` | ❌ | No `create`-signal handler wired | +| Active-window tracking (focus-in) | ⚠️ | New windows set active notebook, but main window doesn't track focus | +| App stays alive when aux window closes | ⚠️ | Aux window destroy doesn't quit, but main-window close always quits | + +--- + +## 4. Menus & Chrome + +| Feature | Status | Notes | +|---|---|---| +| Hamburger menu | ✅ | Open File, Reload, Stop, Switch Identity, Logout, Inspector, Sidebar, Profile, Agent, Processes, Settings, About | +| Identity menu (switch/lock/logout) | ⚠️ | Switch + Logout wired; **Lock Session** defined but not in menu | +| Network toggles (Tor/FIPS) | ❌ | Not in menu | +| Security strip toggle | ❌ | Not in menu | +| Nostr signing status | ❌ | Not in menu | +| Sidebar (agent chat) | ❌ | Placeholder only (`tab_manager_toggle_sidebar` is a no-op) | +| App theme CSS | ✅ | Red accent, adapts to `theme_dark` | + +--- + +## 5. Keyboard Shortcuts + +| Feature | Status | Notes | +|---|---|---| +| Ctrl+T new tab | ✅ | | +| Ctrl+N new window | ✅ | | +| Ctrl+W close tab | ✅ | | +| Ctrl+L focus URL | ✅ | | +| Ctrl+R reload | ✅ | | +| Ctrl+Tab / Ctrl+Shift+Tab | ✅ | | +| Zoom in/out/reset | ✅ | | +| Ctrl+Shift+I inspector | ✅ | | +| Ctrl+Shift+A sidebar | ⚠️ | Wired but sidebar is a no-op | +| Ctrl+Shift+M toolbars | ✅ | | +| F11 fullscreen | ⚠️ | Toggles but no state tracking (gtk-rs has no `is_fullscreen` getter) | +| Configurable shortcuts (settings page) | ❌ | `shortcuts.rs` defines defaults but they're not wired to the settings UI | + +--- + +## 6. Session & Window Lifecycle + +| Feature | Status | Notes | +|---|---|---| +| Session save/restore | ✅ | Basic (restores URLs) | +| Session restore CLI precedence | ❌ | No `--no-session-restore` / `--url` precedence logic | +| Multi-window close semantics | ⚠️ | App quits when main window closes even if aux windows remain | +| Privacy mode (clear session/history on shutdown) | ❌ | Not implemented | + +--- + +## 7. Summary of Remaining Gaps (Priority Order) + +1. **`target="_blank"` / `window.open()` handling** — no `create`-signal handler, so popups/new-window links don't work. This is the most impactful missing feature. +2. **Agent sidebar** — `tab_manager_toggle_sidebar` is a no-op; the original has a full per-window `GtkPaned` sidebar with an agent chat webview. +3. **URL search completion** — the original shows history + bookmarks + search suggestions in the URL bar; the Rust version has a plain entry. +4. **User avatar on the tab strip** — the original shows the Nostr profile picture; not implemented. +5. **Bookmark bar population** — the bar widget exists but isn't filled with bookmark buttons. +6. **Favicon population** — the favicon widget exists but isn't populated (cairo→pixbuf conversion not exposed in gdk-rs 0.18). +7. **Hamburger menu completeness** — missing Tor/FIPS toggles, security strip, Nostr signing status, and Lock Session. +8. **Multi-window close semantics** — app should stay alive when only auxiliary windows remain. +9. **Session restore CLI precedence** and **privacy mode** on shutdown. +10. **Configurable keyboard shortcuts** wired to the settings page. + +--- + +## 8. Notes on Recent Fixes + +- **Removed the redundant shared top-level toolbar** (the "weird entry box with Settings") — each tab now has its own toolbar. +- **Fixed URL loading** — added `normalize_url()` so bare domains like `google.com` get `https://` prepended. +- **Fixed the main notebook/window storage** — the main notebook and window are now stored in `glib::thread_guard::ThreadGuard` globals, which fixes all features that depend on `tab_manager_get_main_notebook()` (keyboard shortcuts, menu identity actions, new-window, etc.). Previously these returned `None` and were silently broken. +- **New-tab button** — present via `set_action_widget` in both the main window and new windows; added an explicit `show()` to ensure visibility. diff --git a/plans/webkit-assessment.md b/plans/webkit-assessment.md new file mode 100644 index 0000000..8957140 --- /dev/null +++ b/plans/webkit-assessment.md @@ -0,0 +1,97 @@ +# WebKitGTK Rust Binding Assessment + +## Current Situation + +The C project (`sovereign_browser`) uses **WebKitGTK 4.1** (`webkit2gtk-4.1`), which is the GTK3-based WebKit2 API. This is specified in the Makefile: + +``` +CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1 libsoup-3.0 ...) +``` + +The Rust port currently uses the `webkit2gtk` crate v2.0.2, which is the **Tauri fork** of the gtk-rs WebKitGTK bindings. This crate wraps **WebKitGTK 2.x** (the GTK3 API), NOT WebKitGTK 4.1. + +## The Version Confusion + +WebKitGTK has two independent versioning schemes: + +| pkg-config name | GTK version | WebKit API version | Soup version | Status | +|---|---|---|---|---| +| `webkit2gtk-4.0` | GTK3 | WebKit2 | libsoup2 | Legacy | +| `webkit2gtk-4.1` | GTK3 | WebKit2 | libsoup3 | **Current stable (GTK3)** | +| `webkit6gtk` | GTK4 | WebKitWebProcess | libsoup3 | Future (GTK4) | + +The C project uses `webkit2gtk-4.1` — the GTK3 + libsoup3 variant. This is the current stable API for GTK3-based browsers. + +## Available Rust Crates + +### 1. `webkit2gtk` v2.0.2 (current — Tauri fork) +- **Wraps**: WebKitGTK 2.x (GTK3, libsoup2) +- **GTK version**: GTK3 (gtk 0.18) +- **Soup version**: libsoup3 (soup3 crate) +- **Status**: Maintained by Tauri team, stable +- **API mismatch**: Wraps an older WebKitGTK API than the C project uses +- **Feature flags**: `v2_2` through `v2_40` control which API versions are available + +### 2. `tauri-webkit2gtk` v0.14.0 +- **Wraps**: Same as above, different package name +- **Status**: Tauri's own fork, same underlying bindings +- **Not useful**: Same API as `webkit2gtk` 2.0 + +### 3. `webkit2gtk-webextension` v0.16.0 +- **Wraps**: WebKit web extension API only +- **Not useful**: For browser extensions, not the main browser + +### 4. GTK4 + `webkit6gtk` (does not exist yet as a Rust crate) +- **Wraps**: WebKitGTK for GTK4 +- **Status**: No stable Rust crate exists +- **Not viable**: Would require GTK4 migration and no bindings available + +## Assessment + +### The `webkit2gtk` 2.0 crate IS the right choice + +Despite the version number confusion, the `webkit2gtk` 2.0.2 crate is the **correct and only viable option** for a Rust WebKitGTK browser: + +1. **It's the only maintained Rust binding** for WebKitGTK on GTK3 +2. **It uses GTK3** (gtk 0.18), matching the C project's GTK3 dependency +3. **It uses libsoup3** (soup3 crate), matching the C project's `libsoup-3.0` dependency +4. **The feature flags** (`v2_40`) enable the latest WebKitGTK API features +5. **The API differences** from the C project are minor — method names and signatures differ slightly but the functionality is the same + +### Why not switch to GTK4? + +- No stable Rust WebKitGTK binding exists for GTK4 +- GTK4 has a completely different widget API (no `GtkNotebook`, no `GtkToolbar`, etc.) +- Would require a complete UI rewrite +- The C project is GTK3, so staying on GTK3 maintains parity + +### The compilation errors are NOT a version problem + +The ~25 remaining compilation errors are **normal Rust binding API differences**, not version incompatibilities: + +| Error | Cause | Fix | +|---|---|---| +| `no method set_allow_popups` | Method doesn't exist in this binding | Remove the call | +| `no method read_to_end` | Needs `InputStreamExtManual` trait | Add `use gio::prelude::InputStreamExtManual` | +| `static mut Send/Sync` | Raw pointers aren't Send/Sync | Use `Mutex>` instead of `static mut` | +| `Notebook methods not found` | Need `gtk::prelude::*` | Add the prelude import | +| `RelayPool::query_sync` return type | Type inference failure | Add explicit type annotation | +| `UserScript::new` args | Takes 5 args not 3 | Already fixed — pass `&[]` for allow/block lists | +| `WebView::web_context` | Builder method not setter | Already fixed — use builder pattern | + +## Recommendation + +**Stay with `webkit2gtk` 2.0.2.** It is the correct choice. The compilation errors are straightforward Rust API fixes, not architectural problems. Switching to a different WebKitGTK version would not help — there is no better Rust binding available. + +## Next Steps + +1. Fix the remaining ~25 compilation errors (all are minor API differences) +2. Build and run the browser +3. Test the Nostr bridge, login dialog, and tab management + +The errors can be fixed by: +- Adding `use gtk::prelude::*` where needed +- Replacing `static mut` with `Mutex>` +- Removing non-existent method calls (`set_allow_popups`) +- Adding explicit type annotations where inference fails +- Using the correct trait imports (`InputStreamExtManual`) diff --git a/src/agent_chat.rs b/src/agent_chat.rs new file mode 100644 index 0000000..eccb5e8 --- /dev/null +++ b/src/agent_chat.rs @@ -0,0 +1,20 @@ +//! Agent chat management +//! +//! Port of `agent_chat.c` / `agent_chat.h` from the C project. + +use crate::agent_chat_store; + +/// Initialize the agent chat module. +pub fn agent_chat_init() { + agent_chat_store::agent_chat_store_init(); +} + +/// Handle a chat request from the web UI. +pub fn agent_chat_handle_request(_request: &str) -> String { + // TODO: implement chat request handling + serde_json::json!({ + "status": "ok", + "message": "Chat handler not yet fully implemented" + }) + .to_string() +} diff --git a/src/agent_chat_store.rs b/src/agent_chat_store.rs new file mode 100644 index 0000000..f210e08 --- /dev/null +++ b/src/agent_chat_store.rs @@ -0,0 +1,100 @@ +//! Agent chat message store +//! +//! Port of `agent_chat_store.c` / `agent_chat_store.h` from the C project. + +use std::sync::Mutex; +use once_cell::sync::Lazy; + +/// A single chat message. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + +/// A tool call within a message. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub call_type: String, + pub function: ToolCallFunction, +} + +/// A tool call function. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ToolCallFunction { + pub name: String, + pub arguments: String, +} + +/// The chat session state. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ChatSession { + pub messages: Vec, + pub system_prompt: String, +} + +impl Default for ChatSession { + fn default() -> Self { + ChatSession { + messages: Vec::new(), + system_prompt: String::new(), + } + } +} + +/// Global chat store. +static G_CHAT_STORE: Lazy> = Lazy::new(|| Mutex::new(ChatSession::default())); + +/// Initialize the chat store. +pub fn agent_chat_store_init() { + let mut store = G_CHAT_STORE.lock().unwrap(); + *store = ChatSession::default(); +} + +/// Add a message to the chat store. +pub fn agent_chat_store_add_message(msg: ChatMessage) { + let mut store = G_CHAT_STORE.lock().unwrap(); + store.messages.push(msg); +} + +/// Get all messages. +pub fn agent_chat_store_get_messages() -> Vec { + let store = G_CHAT_STORE.lock().unwrap(); + store.messages.clone() +} + +/// Clear all messages. +pub fn agent_chat_store_clear() { + let mut store = G_CHAT_STORE.lock().unwrap(); + store.messages.clear(); +} + +/// Set the system prompt. +pub fn agent_chat_store_set_system_prompt(prompt: &str) { + let mut store = G_CHAT_STORE.lock().unwrap(); + store.system_prompt = prompt.to_string(); +} + +/// Get the system prompt. +pub fn agent_chat_store_get_system_prompt() -> String { + let store = G_CHAT_STORE.lock().unwrap(); + store.system_prompt.clone() +} + +/// Get the full session. +pub fn agent_chat_store_get_session() -> ChatSession { + let store = G_CHAT_STORE.lock().unwrap(); + store.clone() +} + +/// Load a session. +pub fn agent_chat_store_load_session(session: ChatSession) { + let mut store = G_CHAT_STORE.lock().unwrap(); + *store = session; +} diff --git a/src/agent_conversations.rs b/src/agent_conversations.rs new file mode 100644 index 0000000..c3270f2 --- /dev/null +++ b/src/agent_conversations.rs @@ -0,0 +1,164 @@ +//! Nostr conversation persistence for agent chat +//! +//! Port of `agent_conversations.c` / `agent_conversations.h` from the C project. +//! Uses `nostr_signer` from `rust_core_lib`. + +use nostr_signer::traits::NostrSigner; +use std::sync::Arc; +use std::sync::Mutex; +use once_cell::sync::Lazy; + +use crate::db; +use crate::agent_chat_store; + +/// The Nostr kind for arbitrary custom app data (NIP-78). +const AGENT_CONV_KIND: u64 = 30078; + +/// The t-tag that identifies conversation events. +const AGENT_CONV_T_TAG: &str = "client-ai-chat-v1"; + +/// Global state for conversation persistence. +static G_CONV_STATE: Lazy> = Lazy::new(|| Mutex::new(ConvState::default())); + +struct ConvState { + signer: Option>, + pubkey_hex: String, + have_signer: bool, +} + +impl Default for ConvState { + fn default() -> Self { + ConvState { + signer: None, + pubkey_hex: String::new(), + have_signer: false, + } + } +} + +/// Initialize the conversation persistence module. +pub fn agent_conversations_init(signer: Option>, pubkey_hex: &str) { + let mut state = G_CONV_STATE.lock().unwrap(); + state.signer = signer; + state.pubkey_hex = pubkey_hex.to_string(); + state.have_signer = state.signer.is_some() && !state.pubkey_hex.is_empty(); +} + +/// Update the signer reference. +pub fn agent_conversations_set_signer(signer: Option>, pubkey_hex: &str) { + agent_conversations_init(signer, pubkey_hex); +} + +/// List all saved conversations from the local SQLite cache. +pub fn agent_conversations_list() -> Vec { + match db::db_query_events(&[AGENT_CONV_KIND], None, None, None, None) { + Ok(events) => { + let mut result = Vec::new(); + for event in events { + let tags = event["tags"].as_array(); + let has_conv_tag = tags.map_or(false, |t| { + t.iter().any(|tag| { + tag.as_array() + .map_or(false, |a| a.len() >= 2 && a[0] == "t" && a[1] == AGENT_CONV_T_TAG) + }) + }); + + if has_conv_tag { + let conv_id = tags.and_then(|t| { + t.iter().find_map(|tag| { + tag.as_array().and_then(|a| { + if a.len() >= 2 && a[0] == "d" { + Some(a[1].as_str().unwrap_or("").to_string()) + } else { + None + } + }) + }) + }).unwrap_or_default(); + + result.push(serde_json::json!({ + "id": conv_id, + "title": event["content"].as_str().unwrap_or("Untitled"), + "updated_at": event["created_at"], + })); + } + } + result + } + Err(_) => Vec::new(), + } +} + +/// Save the current agent chat session to Nostr as a kind 30078 event. +pub fn agent_conversations_save( + conversation_id: Option<&str>, + title: Option<&str>, +) -> Option { + let state = G_CONV_STATE.lock().unwrap(); + if !state.have_signer { + return None; + } + + let conv_id = conversation_id + .map(|s| s.to_string()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + let session = agent_chat_store::agent_chat_store_get_session(); + let _title = title + .map(|s| s.to_string()) + .unwrap_or_else(|| { + session.messages.first() + .map(|m| { + let truncated: String = m.content.chars().take(60).collect(); + truncated + }) + .unwrap_or_else(|| "Untitled".to_string()) + }); + + println!( + "[conversations] Save requested: id={} (NIP-44 encryption not yet implemented)", + conv_id + ); + + Some(conv_id) +} + +/// Load a conversation from the local SQLite cache. +pub fn agent_conversations_load(conversation_id: &str) -> Result<(), Box> { + let events = db::db_query_events( + &[AGENT_CONV_KIND], + None, + None, + None, + Some(1), + )?; + + for event in events { + let tags = event["tags"].as_array(); + let has_id = tags.map_or(false, |t| { + t.iter().any(|tag| { + tag.as_array() + .map_or(false, |a| a.len() >= 2 && a[0] == "d" && a[1] == conversation_id) + }) + }); + + if has_id { + if let Some(content_str) = event["content"].as_str() { + if let Ok(data) = serde_json::from_str::(content_str) { + if let Some(messages) = data["messages"].as_array() { + let msgs: Vec = + serde_json::from_value(serde_json::Value::Array(messages.clone()))?; + let session = agent_chat_store::ChatSession { + messages: msgs, + system_prompt: String::new(), + }; + agent_chat_store::agent_chat_store_load_session(session); + return Ok(()); + } + } + } + } + } + + Err("Conversation not found".into()) +} diff --git a/src/agent_fs_tools.rs b/src/agent_fs_tools.rs new file mode 100644 index 0000000..76f2054 --- /dev/null +++ b/src/agent_fs_tools.rs @@ -0,0 +1,24 @@ +//! Filesystem tools for the agent +//! +//! Port of `agent_fs_tools.c` / `agent_fs_tools.h` from the C project. + +/// Read a file and return its contents. +pub fn agent_fs_read_file(path: &str) -> Result { + std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e)) +} + +/// Write content to a file. +pub fn agent_fs_write_file(path: &str, content: &str) -> Result<(), String> { + std::fs::write(path, content).map_err(|e| format!("Failed to write file: {}", e)) +} + +/// List files in a directory. +pub fn agent_fs_list_dir(path: &str) -> Result, String> { + let entries = std::fs::read_dir(path).map_err(|e| format!("Failed to list directory: {}", e))?; + let mut files = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + files.push(entry.file_name().to_string_lossy().to_string()); + } + Ok(files) +} diff --git a/src/agent_llm.rs b/src/agent_llm.rs new file mode 100644 index 0000000..d6cc48d --- /dev/null +++ b/src/agent_llm.rs @@ -0,0 +1,82 @@ +//! LLM client for agent chat +//! +//! Port of `agent_llm.c` / `agent_llm.h` from the C project. + +use std::sync::Mutex; +use once_cell::sync::Lazy; + +/// LLM provider configuration. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LlmProvider { + pub name: String, + pub base_url: String, + pub api_key: String, + pub model: String, +} + +/// Global LLM state. +static G_LLM_STATE: Lazy> = Lazy::new(|| Mutex::new(LlmState::default())); + +struct LlmState { + provider: Option, +} + +impl Default for LlmState { + fn default() -> Self { + LlmState { provider: None } + } +} + +/// Initialize the LLM module. +pub fn agent_llm_init() { + let mut state = G_LLM_STATE.lock().unwrap(); + if let Ok(Some(config)) = crate::db::db_kv_get("agent.llm_provider") { + if let Ok(provider) = serde_json::from_str::(&config) { + state.provider = Some(provider); + } + } +} + +/// Set the LLM provider. +pub fn agent_llm_set_provider(provider: LlmProvider) { + let mut state = G_LLM_STATE.lock().unwrap(); + state.provider = Some(provider.clone()); + if let Ok(config) = serde_json::to_string(&provider) { + let _ = crate::db::db_kv_set("agent.llm_provider", &config); + } +} + +/// Get the current LLM provider. +pub fn agent_llm_get_provider() -> Option { + let state = G_LLM_STATE.lock().unwrap(); + state.provider.clone() +} + +/// Send a chat completion request to the LLM. +pub async fn agent_llm_chat_completion( + messages: &[serde_json::Value], + tools: Option<&[serde_json::Value]>, +) -> Result> { + let state = G_LLM_STATE.lock().unwrap(); + let provider = state.provider.as_ref().ok_or("No LLM provider configured")?; + + let client = reqwest::Client::new(); + let mut body = serde_json::json!({ + "model": provider.model, + "messages": messages, + }); + + if let Some(tools) = tools { + body["tools"] = serde_json::Value::Array(tools.to_vec()); + } + + let response = client + .post(format!("{}/v1/chat/completions", provider.base_url)) + .header("Authorization", format!("Bearer {}", provider.api_key)) + .json(&body) + .send() + .await?; + + let result: serde_json::Value = response.json().await?; + Ok(result) +} diff --git a/src/agent_login.rs b/src/agent_login.rs new file mode 100644 index 0000000..ce37c0a --- /dev/null +++ b/src/agent_login.rs @@ -0,0 +1,53 @@ +//! Agent login management +//! +//! Port of `agent_login.c` / `agent_login.h` from the C project. + +use nostr_signer::traits::NostrSigner; +use std::sync::Arc; +use std::sync::Mutex; +use once_cell::sync::Lazy; + +/// Agent login state. +static G_AGENT_LOGIN: Lazy> = Lazy::new(|| Mutex::new(AgentLoginState::default())); + +struct AgentLoginState { + signer: Option>, + pubkey_hex: String, + logged_in: bool, +} + +impl Default for AgentLoginState { + fn default() -> Self { + AgentLoginState { + signer: None, + pubkey_hex: String::new(), + logged_in: false, + } + } +} + +/// Initialize agent login. +pub fn agent_login_init() { + let mut state = G_AGENT_LOGIN.lock().unwrap(); + state.logged_in = false; +} + +/// Set the agent signer. +pub fn agent_login_set_signer(signer: Option>, pubkey_hex: &str) { + let mut state = G_AGENT_LOGIN.lock().unwrap(); + state.signer = signer; + state.pubkey_hex = pubkey_hex.to_string(); + state.logged_in = !pubkey_hex.is_empty(); +} + +/// Check if the agent is logged in. +pub fn agent_login_is_logged_in() -> bool { + let state = G_AGENT_LOGIN.lock().unwrap(); + state.logged_in +} + +/// Get the agent's pubkey. +pub fn agent_login_get_pubkey() -> String { + let state = G_AGENT_LOGIN.lock().unwrap(); + state.pubkey_hex.clone() +} diff --git a/src/agent_loop.rs b/src/agent_loop.rs new file mode 100644 index 0000000..4a9d147 --- /dev/null +++ b/src/agent_loop.rs @@ -0,0 +1,124 @@ +//! Agent main loop — orchestrates LLM calls and tool execution +//! +//! Port of `agent_loop.c` / `agent_loop.h` from the C project. + +use crate::agent_chat_store; +use crate::agent_llm; +use crate::agent_tools; + +/// Run the agent loop for a single user message. +pub async fn agent_loop_run(user_message: &str) -> Result> { + // Add user message to store + agent_chat_store::agent_chat_store_add_message( + agent_chat_store::ChatMessage { + role: "user".to_string(), + content: user_message.to_string(), + tool_calls: None, + tool_call_id: None, + }, + ); + + // Get conversation history + let session = agent_chat_store::agent_chat_store_get_session(); + let mut messages: Vec = Vec::new(); + + // Add system prompt + if !session.system_prompt.is_empty() { + messages.push(serde_json::json!({ + "role": "system", + "content": session.system_prompt, + })); + } + + // Add conversation history + for msg in &session.messages { + messages.push(serde_json::json!({ + "role": msg.role, + "content": msg.content, + })); + } + + // Get available tools + let tools: Vec = agent_tools::agent_tools_get_all() + .iter() + .map(|t| { + serde_json::json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.input_schema, + } + }) + }) + .collect(); + + // Call LLM + let response = agent_llm::agent_llm_chat_completion(&messages, Some(&tools)).await?; + + // Extract assistant message + if let Some(choice) = response["choices"][0]["message"].as_object() { + let content = choice.get("content").and_then(|c| c.as_str()).unwrap_or(""); + let tool_calls = choice.get("tool_calls"); + + // Add assistant message to store + let mut assistant_msg = agent_chat_store::ChatMessage { + role: "assistant".to_string(), + content: content.to_string(), + tool_calls: None, + tool_call_id: None, + }; + + // Handle tool calls + if let Some(tc) = tool_calls { + if let Some(calls) = tc.as_array() { + let mut tool_call_list = Vec::new(); + for call in calls { + let name = call["function"]["name"].as_str().unwrap_or(""); + let args_str = call["function"]["arguments"].as_str().unwrap_or("{}"); + let args: serde_json::Value = serde_json::from_str(args_str).unwrap_or_default(); + + // Execute the tool + match agent_tools::agent_tools_execute(name, &args) { + Ok(result) => { + tool_call_list.push(agent_chat_store::ToolCall { + id: call["id"].as_str().unwrap_or("").to_string(), + call_type: "function".to_string(), + function: agent_chat_store::ToolCallFunction { + name: name.to_string(), + arguments: args_str.to_string(), + }, + }); + + // Add tool result message + agent_chat_store::agent_chat_store_add_message( + agent_chat_store::ChatMessage { + role: "tool".to_string(), + content: serde_json::to_string(&result).unwrap_or_default(), + tool_calls: None, + tool_call_id: Some(call["id"].as_str().unwrap_or("").to_string()), + }, + ); + } + Err(e) => { + agent_chat_store::agent_chat_store_add_message( + agent_chat_store::ChatMessage { + role: "tool".to_string(), + content: format!("Error: {}", e), + tool_calls: None, + tool_call_id: Some(call["id"].as_str().unwrap_or("").to_string()), + }, + ); + } + } + } + assistant_msg.tool_calls = Some(tool_call_list); + } + } + + agent_chat_store::agent_chat_store_add_message(assistant_msg); + return Ok(content.to_string()); + } + + Ok(String::new()) +} diff --git a/src/agent_mcp.rs b/src/agent_mcp.rs new file mode 100644 index 0000000..856e015 --- /dev/null +++ b/src/agent_mcp.rs @@ -0,0 +1,309 @@ +//! MCP (Model Context Protocol) server for agent tools +//! +//! Port of `agent_mcp.c` / `agent_mcp.h` from the C project. +//! +//! Implements a Streamable HTTP server on port 17777 that accepts +//! JSON-RPC 2.0 requests (initialize, tools/list, tools/call) and +//! dispatches them to the agent_tools module. + +use std::collections::HashMap; +use std::io::{Read, Write, BufRead, BufReader}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Mutex; +use std::thread; +use once_cell::sync::Lazy; + +use crate::agent_tools; + +/// MCP tool definition. +#[derive(Debug, Clone, serde::Serialize)] +pub struct McpTool { + pub name: String, + pub description: String, + pub input_schema: serde_json::Value, +} + +/// MCP server state. +static G_MCP_STATE: Lazy> = Lazy::new(|| Mutex::new(McpState::default())); + +struct McpState { + running: bool, + port: u16, +} + +impl Default for McpState { + fn default() -> Self { + McpState { + running: false, + port: 17777, + } + } +} + +/// Initialize the MCP server. +pub fn agent_mcp_init() { + let mut state = G_MCP_STATE.lock().unwrap(); + state.port = 17777; +} + +/// Start the MCP server (spawns a background thread). +pub fn agent_mcp_start() -> Result<(), Box> { + let mut state = G_MCP_STATE.lock().unwrap(); + if state.running { + return Ok(()); + } + + state.running = true; + let port = state.port; + drop(state); + + println!("[mcp] MCP server starting on port {}", port); + + thread::spawn(move || { + run_server(port); + }); + + Ok(()) +} + +/// Stop the MCP server. +pub fn agent_mcp_stop() { + let mut state = G_MCP_STATE.lock().unwrap(); + state.running = false; + println!("[mcp] MCP server stopped"); +} + +/// Check if the MCP server is running. +pub fn agent_mcp_is_running() -> bool { + let state = G_MCP_STATE.lock().unwrap(); + state.running +} + +/// Get the MCP server port. +pub fn agent_mcp_get_port() -> u16 { + let state = G_MCP_STATE.lock().unwrap(); + state.port +} + +/// Main server loop: accept connections and handle HTTP requests. +fn run_server(port: u16) { + let listener = match TcpListener::bind(("127.0.0.1", port)) { + Ok(l) => l, + Err(e) => { + eprintln!("[mcp] Failed to bind port {}: {}", port, e); + // Try fallback ports + for p in (port + 1)..(port + 100) { + if let Ok(l) = TcpListener::bind(("127.0.0.1", p)) { + println!("[mcp] MCP server bound to fallback port {}", p); + let mut state = G_MCP_STATE.lock().unwrap(); + state.port = p; + drop(state); + handle_connections(l); + return; + } + } + eprintln!("[mcp] Failed to bind any port in range {}-{}", port, port + 100); + return; + } + }; + println!("[mcp] MCP server listening on http://127.0.0.1:{}/mcp", port); + handle_connections(listener); +} + +/// Accept and handle connections. +fn handle_connections(listener: TcpListener) { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + thread::spawn(move || { + let _ = handle_connection(stream); + }); + } + Err(e) => { + eprintln!("[mcp] Accept error: {}", e); + } + } + } +} + +/// Handle a single HTTP connection. +fn handle_connection(mut stream: TcpStream) -> std::io::Result<()> { + let mut reader = BufReader::new(stream.try_clone()?); + let mut line = String::new(); + + // Read the request line (e.g. "POST /mcp HTTP/1.1"). + if reader.read_line(&mut line)? == 0 { + return Ok(()); + } + + let is_mcp = line.contains("/mcp") || line.contains("/"); + + // Read headers. + let mut headers: HashMap = HashMap::new(); + let mut content_length: usize = 0; + + loop { + line.clear(); + if reader.read_line(&mut line)? == 0 { + break; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + break; // End of headers + } + if let Some(pos) = trimmed.find(':') { + let key = trimmed[..pos].trim().to_lowercase(); + let val = trimmed[pos + 1..].trim().to_string(); + if key == "content-length" { + content_length = val.parse().unwrap_or(0); + } + headers.insert(key, val); + } + } + + // Read body if present. + let mut body = String::new(); + if content_length > 0 { + let mut buf = vec![0u8; content_length]; + reader.read_exact(&mut buf)?; + body = String::from_utf8_lossy(&buf).to_string(); + } + + // Handle the request. + let (status, response_body) = if is_mcp && !body.is_empty() { + match agent_mcp_handle_request(&body) { + Ok(resp) => ("200 OK", resp), + Err(e) => ( + "500 Internal Server Error", + serde_json::json!({ + "jsonrpc": "2.0", + "error": { "code": -32603, "message": e.to_string() } + }) + .to_string(), + ), + } + } else if is_mcp && body.is_empty() { + // GET request to /mcp — return server info. + ( + "200 OK", + serde_json::json!({ + "server": "sovereign-browser-mcp", + "version": crate::version::VERSION, + "capabilities": { + "tools": {} + } + }) + .to_string(), + ) + } else { + ("404 Not Found", "Not Found".to_string()) + }; + + // Send HTTP response. + let response = format!( + "HTTP/1.1 {}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Access-Control-Allow-Methods: POST, GET, OPTIONS\r\n\ + Access-Control-Allow-Headers: Content-Type\r\n\ + \r\n\ + {}", + status, + response_body.len(), + response_body + ); + + stream.write_all(response.as_bytes())?; + stream.flush()?; + + Ok(()) +} + +/// Handle an MCP JSON-RPC request. +pub fn agent_mcp_handle_request(body: &str) -> Result> { + let request: serde_json::Value = serde_json::from_str(body)?; + + let method = request["method"] + .as_str() + .ok_or("Missing method")?; + + match method { + "initialize" => { + Ok(serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "sovereign-browser", + "version": crate::version::VERSION + } + } + }) + .to_string()) + } + "tools/list" => { + let tools = agent_tools::agent_tools_get_all(); + let mcp_tools: Vec = tools + .into_iter() + .map(|t| McpTool { + name: t.name, + description: t.description, + input_schema: t.input_schema, + }) + .collect(); + + Ok(serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { "tools": mcp_tools } + }) + .to_string()) + } + "tools/call" => { + let name = request["params"]["name"] + .as_str() + .ok_or("Missing tool name")?; + let args = &request["params"]["arguments"]; + + match agent_tools::agent_tools_execute(name, args) { + Ok(result) => { + Ok(serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { "content": [{ "type": "text", "text": serde_json::to_string(&result)? }] } + }) + .to_string()) + } + Err(e) => { + Ok(serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": { "code": -32000, "message": e } + }) + .to_string()) + } + } + } + "ping" => { + Ok(serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {} + }) + .to_string()) + } + _ => { + Ok(serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": { "code": -32601, "message": format!("Method not found: {}", method) } + }) + .to_string()) + } + } +} diff --git a/src/agent_server.rs b/src/agent_server.rs new file mode 100644 index 0000000..2bf392e --- /dev/null +++ b/src/agent_server.rs @@ -0,0 +1,67 @@ +//! Agent HTTP server for browser automation +//! +//! Port of `agent_server.c` / `agent_server.h` from the C project. + +use std::sync::Mutex; +use once_cell::sync::Lazy; + +use crate::agent_mcp; + +/// Agent server state. +static G_SERVER_STATE: Lazy> = Lazy::new(|| Mutex::new(ServerState::default())); + +struct ServerState { + running: bool, + port: u16, +} + +impl Default for ServerState { + fn default() -> Self { + ServerState { + running: false, + port: 17777, + } + } +} + +/// Initialize the agent server. +pub fn agent_server_init() { + let mut state = G_SERVER_STATE.lock().unwrap(); + state.port = 17777; +} + +/// Start the agent server. +pub fn agent_server_start() -> Result<(), Box> { + let mut state = G_SERVER_STATE.lock().unwrap(); + if state.running { + return Ok(()); + } + + state.running = true; + println!("[server] Agent server starting on port {}", state.port); + + // Start MCP server + agent_mcp::agent_mcp_start()?; + + Ok(()) +} + +/// Stop the agent server. +pub fn agent_server_stop() { + let mut state = G_SERVER_STATE.lock().unwrap(); + state.running = false; + agent_mcp::agent_mcp_stop(); + println!("[server] Agent server stopped"); +} + +/// Check if the agent server is running. +pub fn agent_server_is_running() -> bool { + let state = G_SERVER_STATE.lock().unwrap(); + state.running +} + +/// Get the agent server port. +pub fn agent_server_get_port() -> u16 { + let state = G_SERVER_STATE.lock().unwrap(); + state.port +} diff --git a/src/agent_skills.rs b/src/agent_skills.rs new file mode 100644 index 0000000..3cffe3c --- /dev/null +++ b/src/agent_skills.rs @@ -0,0 +1,166 @@ +//! Nostr kind 31123 skill management for sovereign_browser +//! +//! Skills are PUBLIC Nostr events (kind 31123) that define system prompt +//! templates, LLM parameters, and tool requirements. +//! +//! Port of `agent_skills.c` / `agent_skills.h` from the C project. +//! Uses `nostr_signer` from `rust_core_lib`. + +use nostr_signer::traits::NostrSigner; +use std::sync::Arc; +use std::sync::Mutex; +use once_cell::sync::Lazy; + +use crate::db; + +/// The Nostr kind for skills. +const AGENT_SKILL_KIND: u64 = 31123; + +/// db_kv key for the selected skill d-tags. +const AGENT_SKILLS_SELECTED_KEY: &str = "agent.selected_skills"; + +/// Maximum number of requires_tool tags per skill. +const AGENT_SKILL_MAX_TOOLS: usize = 32; + +/// A skill definition. +#[derive(Debug, Clone)] +pub struct AgentSkill { + pub d_tag: String, + pub name: String, + pub description: String, + pub pubkey: String, + pub content: String, + pub requires_tools: Vec, +} + +/// Global state for skills. +static G_SKILL_STATE: Lazy> = Lazy::new(|| Mutex::new(SkillState::default())); + +struct SkillState { + signer: Option>, + pubkey_hex: String, + have_signer: bool, +} + +impl Default for SkillState { + fn default() -> Self { + SkillState { + signer: None, + pubkey_hex: String::new(), + have_signer: false, + } + } +} + +/// Initialize the skills module. +pub fn agent_skills_init(signer: Option>, pubkey_hex: &str) { + let mut state = G_SKILL_STATE.lock().unwrap(); + state.signer = signer; + state.pubkey_hex = pubkey_hex.to_string(); + state.have_signer = state.signer.is_some() && !state.pubkey_hex.is_empty(); +} + +/// Update the signer reference. +pub fn agent_skills_set_signer(signer: Option>, pubkey_hex: &str) { + agent_skills_init(signer, pubkey_hex); +} + +/// Fetch kind 31123 skill events from the local SQLite cache. +pub fn agent_skills_fetch() -> Vec { + match db::db_query_events(&[AGENT_SKILL_KIND], None, None, None, None) { + Ok(events) => { + let mut result = Vec::new(); + for event in events { + let tags = event["tags"].as_array(); + let d_tag = tags.and_then(|t| { + t.iter().find_map(|tag| { + tag.as_array().and_then(|a| { + if a.len() >= 2 && a[0] == "d" { + Some(a[1].as_str().unwrap_or("").to_string()) + } else { + None + } + }) + }) + }).unwrap_or_default(); + + let name = tags.and_then(|t| { + t.iter().find_map(|tag| { + tag.as_array().and_then(|a| { + if a.len() >= 2 && a[0] == "name" { + Some(a[1].as_str().unwrap_or("").to_string()) + } else { + None + } + }) + }) + }).unwrap_or_default(); + + let description = tags.and_then(|t| { + t.iter().find_map(|tag| { + tag.as_array().and_then(|a| { + if a.len() >= 2 && a[0] == "description" { + Some(a[1].as_str().unwrap_or("").to_string()) + } else { + None + } + }) + }) + }).unwrap_or_default(); + + let requires_tools: Vec = tags.map_or(vec![], |t| { + t.iter().filter_map(|tag| { + tag.as_array().and_then(|a| { + if a.len() >= 2 && a[0] == "requires_tool" { + Some(a[1].as_str().unwrap_or("").to_string()) + } else { + None + } + }) + }).collect() + }); + + result.push(serde_json::json!({ + "d": d_tag, + "name": name, + "description": description, + "requires_tools": requires_tools, + "content": event["content"].as_str().unwrap_or(""), + "pubkey": event["pubkey"].as_str().unwrap_or(""), + })); + } + result + } + Err(_) => Vec::new(), + } +} + +/// Get the default Sovereign Browser Skill. +pub fn agent_skills_get_default() -> serde_json::Value { + serde_json::json!({ + "d": "", + "name": "Sovereign Browser Skill", + "description": "Default skill for browser automation", + "content": "You are a helpful assistant with access to browser tools.", + "requires_tools": ["browser_navigate", "browser_click", "browser_type", "browser_screenshot"], + "unsaved": true, + "pubkey": "", + }) +} + +/// Build the combined system prompt from selected skills. +pub fn agent_skills_build_system_prompt(selected_skills: &[String]) -> String { + let skills = agent_skills_fetch(); + let mut parts = Vec::new(); + + for skill in &skills { + let d_tag = skill["d"].as_str().unwrap_or(""); + if selected_skills.contains(&d_tag.to_string()) { + if let Some(content) = skill["content"].as_str() { + parts.push(content.to_string()); + } + } + } + + parts.join("\n\n") +} diff --git a/src/agent_snapshot.rs b/src/agent_snapshot.rs new file mode 100644 index 0000000..d472da0 --- /dev/null +++ b/src/agent_snapshot.rs @@ -0,0 +1,16 @@ +//! Agent state snapshot for persistence +//! +//! Port of `agent_snapshot.c` / `agent_snapshot.h` from the C project. + +use crate::db; + +/// Save the agent state snapshot. +pub fn agent_snapshot_save(state_json: &str) -> Result<(), Box> { + db::db_kv_set("agent.snapshot", state_json)?; + Ok(()) +} + +/// Load the agent state snapshot. +pub fn agent_snapshot_load() -> Result, Box> { + db::db_kv_get("agent.snapshot") +} diff --git a/src/agent_tools.rs b/src/agent_tools.rs new file mode 100644 index 0000000..6c064f6 --- /dev/null +++ b/src/agent_tools.rs @@ -0,0 +1,470 @@ +//! Agent tool catalog and execution +//! +//! Port of `agent_tools.c` / `agent_tools.h` from the C project. + +use std::collections::HashMap; +use std::sync::Mutex; +use once_cell::sync::Lazy; +use webkit2gtk::WebViewExt; + +/// A tool definition. +#[derive(Debug, Clone)] +pub struct AgentTool { + pub name: String, + pub description: String, + pub input_schema: serde_json::Value, +} + +/// Global tool registry. +static G_TOOL_REGISTRY: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); + +/// Initialize the tool registry with built-in tools. +pub fn agent_tools_init() { + let mut registry = G_TOOL_REGISTRY.lock().unwrap(); + registry.clear(); + + // Browser navigation tools + registry.insert( + "browser_navigate".to_string(), + AgentTool { + name: "browser_navigate".to_string(), + description: "Navigate to a URL in the current tab".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "url": {"type": "string", "description": "The URL to navigate to"} + }, + "required": ["url"] + }), + }, + ); + + registry.insert( + "browser_click".to_string(), + AgentTool { + name: "browser_click".to_string(), + description: "Click an element on the page".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "selector": {"type": "string", "description": "CSS selector for the element"} + }, + "required": ["selector"] + }), + }, + ); + + registry.insert( + "browser_type".to_string(), + AgentTool { + name: "browser_type".to_string(), + description: "Type text into an input field".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "selector": {"type": "string", "description": "CSS selector for the input"}, + "text": {"type": "string", "description": "Text to type"} + }, + "required": ["selector", "text"] + }), + }, + ); + + registry.insert( + "browser_screenshot".to_string(), + AgentTool { + name: "browser_screenshot".to_string(), + description: "Take a screenshot of the current page".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + ); + + // Filesystem tools + registry.insert( + "fs_read_file".to_string(), + AgentTool { + name: "fs_read_file".to_string(), + description: "Read a file from the filesystem".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path to the file"} + }, + "required": ["path"] + }), + }, + ); + + registry.insert( + "fs_write_file".to_string(), + AgentTool { + name: "fs_write_file".to_string(), + description: "Write content to a file".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path to the file"}, + "content": {"type": "string", "description": "Content to write"} + }, + "required": ["path", "content"] + }), + }, + ); + + registry.insert( + "fs_list_dir".to_string(), + AgentTool { + name: "fs_list_dir".to_string(), + description: "List files in a directory".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path to the directory"} + }, + "required": ["path"] + }), + }, + ); + + // Tab management tools + registry.insert( + "tab_list".to_string(), + AgentTool { + name: "tab_list".to_string(), + description: "List all open tabs".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + ); + + registry.insert( + "new_tab".to_string(), + AgentTool { + name: "new_tab".to_string(), + description: "Open a new browser tab".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "url": {"type": "string", "description": "URL to open (optional)"} + }, + "required": [] + }), + }, + ); + + registry.insert( + "tab_count".to_string(), + AgentTool { + name: "tab_count".to_string(), + description: "Get the number of open tabs".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + ); + + registry.insert( + "toggle_inspector".to_string(), + AgentTool { + name: "toggle_inspector".to_string(), + description: "Toggle the WebKit web inspector on the active tab".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + ); + + registry.insert( + "eval_js".to_string(), + AgentTool { + name: "eval_js".to_string(), + description: "Execute JavaScript in the active tab's page. Console.log output appears in browser stderr.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "js": {"type": "string", "description": "JavaScript code to execute"} + }, + "required": ["js"] + }), + }, + ); + + // Shell tool + registry.insert( + "shell_exec".to_string(), + AgentTool { + name: "shell_exec".to_string(), + description: "Execute a shell command".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "command": {"type": "string", "description": "Command to execute"} + }, + "required": ["command"] + }), + }, + ); +} + +/// Get all registered tools. +pub fn agent_tools_get_all() -> Vec { + let registry = G_TOOL_REGISTRY.lock().unwrap(); + registry.values().cloned().collect() +} + +/// Get a tool by name. +pub fn agent_tools_get(name: &str) -> Option { + let registry = G_TOOL_REGISTRY.lock().unwrap(); + registry.get(name).cloned() +} + +/// Execute a tool by name with the given arguments. +pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result { + match name { + "tab_list" => { + let tabs = crate::tab_manager::tab_manager_get_tabs(); + Ok(serde_json::json!({ + "status": "ok", + "tabs": tabs.iter().map(|t| serde_json::json!({ + "id": t.id, + "title": t.title, + "url": t.url, + })).collect::>() + })) + } + "tab_count" => { + let count = crate::tab_manager::tab_manager_get_count(); + Ok(serde_json::json!({ + "status": "ok", + "count": count + })) + } + "new_tab" => { + let url = args["url"].as_str(); + // Schedule tab creation on the GTK main thread. + // `idle_add_once` requires `Send` (callable from any thread, unlike + // `idle_add_local` which panics off-main). Our closure only captures + // `Option` (Send); gtk widgets are created on the main thread + // when the closure actually runs. + let url_owned = url.map(|s| s.to_string()); + glib::idle_add_once(move || { + if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { + if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { + if let Some(ctx) = wv.web_context() { + crate::tab_manager::tab_manager_new_tab(&nb, &ctx, url_owned.as_deref()); + } + } + } + }); + Ok(serde_json::json!({ + "status": "ok", + "message": "New tab scheduled" + })) + } + "toggle_inspector" => { + // Schedule the inspector toggle on the GTK main thread. + glib::idle_add_once(|| { + crate::tab_manager::tab_manager_toggle_inspector(); + }); + Ok(serde_json::json!({ + "status": "ok", + "message": "Inspector toggle scheduled" + })) + } + "browser_navigate" => { + let url = args["url"].as_str().ok_or("Missing url argument")?.to_string(); + let url_for_msg = url.clone(); + // Schedule navigation on the GTK main thread. + glib::idle_add_once(move || { + if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { + if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { + let normalized = crate::search::normalize_url(&url); + wv.load_uri(&normalized); + } + } + }); + Ok(serde_json::json!({ + "status": "ok", + "message": format!("Navigating to {}", url_for_msg) + })) + } + "eval_js" => { + let js = args["js"].as_str().ok_or("Missing js argument")?.to_string(); + // We can't easily get the return value of async JS from webkit2gtk + // in a synchronous MCP response. Instead, we run the JS and return + // a status. The JS can write results to console.log which appears + // in the browser's stderr. + glib::idle_add_once(move || { + if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { + if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { + // run_javascript is deprecated in webkit2gtk 2.0.2 but + // there is no replacement in the Rust bindings. Suppress. + #[allow(deprecated)] + wv.run_javascript(&js, None::<&gio::Cancellable>, |_| {}); + } + } + }); + Ok(serde_json::json!({ + "status": "ok", + "message": "JS execution scheduled (check browser stderr for console.log output)" + })) + } + "browser_click" => { + let selector = args["selector"].as_str().ok_or("Missing selector argument")?.to_string(); + // Build JS to click the element matching the selector. + let js = format!( + r#"(function() {{ + var el = document.querySelector({:?}); + if (el) {{ el.click(); return "clicked"; }} + return "not found"; + }})()"#, + selector + ); + let (tx, rx) = std::sync::mpsc::channel::(); + glib::idle_add_once(move || { + if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { + if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { + #[allow(deprecated)] + wv.run_javascript(&js, None::<&gio::Cancellable>, move |result| { + let msg = match result { + Ok(_) => "clicked", + Err(e) => &format!("error: {}", e), + }; + let _ = tx.send(msg.to_string()); + }); + } else { let _ = tx.send("no webview".to_string()); } + } else { let _ = tx.send("no notebook".to_string()); } + }); + let result = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap_or_else(|_| "timeout".to_string()); + Ok(serde_json::json!({"status": "ok", "result": result})) + } + "browser_type" => { + let selector = args["selector"].as_str().ok_or("Missing selector argument")?.to_string(); + let text = args["text"].as_str().ok_or("Missing text argument")?.to_string(); + // Build JS to set the value of the element matching the selector. + let js = format!( + r#"(function() {{ + var el = document.querySelector({:?}); + if (el) {{ + el.value = {}; + el.dispatchEvent(new Event('input', {{bubbles: true}})); + el.dispatchEvent(new Event('change', {{bubbles: true}})); + return "typed"; + }} + return "not found"; + }})()"#, + selector, + serde_json::to_string(&text).unwrap_or_default() + ); + let (tx, rx) = std::sync::mpsc::channel::(); + glib::idle_add_once(move || { + if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { + if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { + #[allow(deprecated)] + wv.run_javascript(&js, None::<&gio::Cancellable>, move |result| { + let msg = match result { + Ok(_) => "typed", + Err(e) => &format!("error: {}", e), + }; + let _ = tx.send(msg.to_string()); + }); + } else { let _ = tx.send("no webview".to_string()); } + } else { let _ = tx.send("no notebook".to_string()); } + }); + let result = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap_or_else(|_| "timeout".to_string()); + Ok(serde_json::json!({"status": "ok", "result": result})) + } + "browser_screenshot" => { + // Capture a snapshot of the active webview. + // The cairo::Surface from webkit2gtk's snapshot() is captured + // but cairo-rs 0.18 doesn't expose write_to_png. We capture the + // surface dimensions and return a status. A future improvement + // would convert the surface to PNG via the cairo C FFI. + let (tx, rx) = std::sync::mpsc::channel::>(); + glib::idle_add_once(move || { + if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { + if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { + wv.snapshot( + webkit2gtk::SnapshotRegion::Visible, + webkit2gtk::SnapshotOptions::NONE, + None::<&gio::Cancellable>, + move |result: Result| { + match result { + Ok(_surface) => { + let _ = tx.send(Ok("snapshot captured".to_string())); + } + Err(e) => { let _ = tx.send(Err(e.to_string())); } + } + }, + ); + } else { let _ = tx.send(Err("no webview".to_string())); } + } else { let _ = tx.send(Err("no notebook".to_string())); } + }); + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(Ok(msg)) => Ok(serde_json::json!({"status": "ok", "message": msg})), + Ok(Err(e)) => Err(format!("Screenshot failed: {}", e)), + Err(_) => Err("Screenshot timed out".to_string()), + } + } + "fs_read_file" => { + let path = args["path"].as_str().ok_or("Missing path argument")?; + match std::fs::read_to_string(path) { + Ok(content) => Ok(serde_json::json!({"status": "ok", "content": content})), + Err(e) => Err(format!("Failed to read file: {}", e)), + } + } + "fs_write_file" => { + let path = args["path"].as_str().ok_or("Missing path argument")?; + let content = args["content"].as_str().ok_or("Missing content argument")?; + match std::fs::write(path, content) { + Ok(_) => Ok(serde_json::json!({"status": "ok", "message": "File written"})), + Err(e) => Err(format!("Failed to write file: {}", e)), + } + } + "fs_list_dir" => { + let path = args["path"].as_str().ok_or("Missing path argument")?; + match std::fs::read_dir(path) { + Ok(entries) => { + let files: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + Ok(serde_json::json!({"status": "ok", "files": files})) + } + Err(e) => Err(format!("Failed to list directory: {}", e)), + } + } + "shell_exec" => { + let command = args["command"].as_str().ok_or("Missing command argument")?; + match std::process::Command::new("sh") + .arg("-c") + .arg(command) + .output() + { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + Ok(serde_json::json!({ + "status": if output.status.success() { "ok" } else { "error" }, + "stdout": stdout, + "stderr": stderr, + "exit_code": output.status.code().unwrap_or(-1), + })) + } + Err(e) => Err(format!("Failed to execute command: {}", e)), + } + } + _ => Err(format!("Unknown tool: {}", name)), + } +} diff --git a/src/bookmarks.rs b/src/bookmarks.rs new file mode 100644 index 0000000..1519f53 --- /dev/null +++ b/src/bookmarks.rs @@ -0,0 +1,39 @@ +//! Bookmark management for sovereign_browser +//! +//! Port of `bookmarks.c` / `bookmarks.h` from the C project. + +use crate::db; + +/// Add a bookmark. +pub fn bookmarks_add(title: &str, url: &str, folder: &str) -> Result> { + let id = db::db_add_bookmark(title, url, folder)?; + // Trigger bookmark sync (Kind 30003) — stub for now, actual relay + // publishing requires NIP-44 encryption + relay connection management. + bookmarks_publish(); + Ok(id) +} + +/// Get all bookmarks. +pub fn bookmarks_get_all() -> Result, Box> { + db::db_get_bookmarks() +} + +/// Delete a bookmark. +pub fn bookmarks_delete(id: i64) -> Result<(), Box> { + db::db_delete_bookmark(id)?; + bookmarks_publish(); + Ok(()) +} + +/// Publish bookmarks to Nostr relays as kind 30003 bookmark sets. +/// The actual relay publishing requires NIP-44 encryption and relay +/// connection management. For now, we serialize and store the bookmark +/// set locally. The infrastructure is in place for future relay publishing. +pub fn bookmarks_publish() { + if let Ok(bookmarks) = db::db_get_bookmarks() { + if let Ok(json) = serde_json::to_string(&bookmarks) { + let _ = db::db_kv_set("bookmarks_sync.last_payload", &json); + } + } + // TODO: Build and sign a kind 30003 event, publish to bootstrap relays. +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..cd24aad --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,121 @@ +//! Command-line interface for sovereign_browser +//! +//! Port of `cli.c` / `cli.h` from the C project. + +use crate::key_store::KeyStoreMethod; +use crate::version; + +/// Parsed CLI arguments. +#[derive(Debug, Default)] +pub struct CliArgs { + pub urls: Vec, + pub login_method: Option, + pub privkey: Option, + pub pubkey: Option, + pub mnemonic: Option, + pub bunker_url: Option, + pub nsigner_transport: Option, + pub nsigner_device: Option, + pub nsigner_index: Option, + pub profile: Option, + pub no_agent: bool, + pub no_session: bool, + pub no_login: bool, + pub help: bool, + pub version: bool, +} + +/// Parse command-line arguments. +pub fn cli_parse() -> CliArgs { + let args: Vec = std::env::args().collect(); + let mut cli = CliArgs::default(); + let mut i = 1; + + while i < args.len() { + match args[i].as_str() { + "--help" | "-h" => cli.help = true, + "--version" | "-v" => cli.version = true, + "--local" | "-l" => { + cli.login_method = Some(KeyStoreMethod::Local); + if i + 1 < args.len() && !args[i + 1].starts_with('-') { + i += 1; + cli.privkey = Some(args[i].clone()); + } + } + "--pubkey" | "-p" => { + cli.login_method = Some(KeyStoreMethod::Readonly); + if i + 1 < args.len() && !args[i + 1].starts_with('-') { + i += 1; + cli.pubkey = Some(args[i].clone()); + } + } + "--seed" | "-s" => { + cli.login_method = Some(KeyStoreMethod::Seed); + if i + 1 < args.len() && !args[i + 1].starts_with('-') { + i += 1; + cli.mnemonic = Some(args[i].clone()); + } + } + "--bunker" | "-b" => { + cli.login_method = Some(KeyStoreMethod::Nip46); + if i + 1 < args.len() && !args[i + 1].starts_with('-') { + i += 1; + cli.bunker_url = Some(args[i].clone()); + } + } + "--nsigner" => { + cli.login_method = Some(KeyStoreMethod::Nsigner); + if i + 1 < args.len() && !args[i + 1].starts_with('-') { + i += 1; + cli.nsigner_device = Some(args[i].clone()); + } + if i + 1 < args.len() && !args[i + 1].starts_with('-') { + i += 1; + cli.nsigner_index = Some(args[i].parse().unwrap_or(0)); + } + } + "--profile" => { + if i + 1 < args.len() { + i += 1; + cli.profile = Some(args[i].clone()); + } + } + "--no-agent" => cli.no_agent = true, + "--no-session" => cli.no_session = true, + "--no-login" => cli.no_login = true, + _ => { + if !args[i].starts_with('-') { + cli.urls.push(args[i].clone()); + } + } + } + i += 1; + } + + cli +} + +/// Print help message. +pub fn cli_print_help() { + println!("sovereign_browser — WebKitGTK + Rust"); + println!(); + println!("USAGE:"); + println!(" sovereign_browser [OPTIONS] [URLS...]"); + println!(); + println!("OPTIONS:"); + println!(" -h, --help Show this help message"); + println!(" -v, --version Show version"); + println!(" -l, --local [KEY] Login with local nsec key"); + println!(" -p, --pubkey [KEY] Read-only mode with npub"); + println!(" -s, --seed [PHRASE] Login with BIP-39 seed phrase"); + println!(" -b, --bunker [URL] Login with NIP-46 bunker URL"); + println!(" --nsigner [DEVICE] [IDX] Login with n_signer hardware"); + println!(" --profile [PUBKEY] Use specific profile"); + println!(" --no-agent Disable agent server"); + println!(" --no-session Disable session restore"); +} + +/// Print version. +pub fn cli_print_version() { + println!("sovereign_browser {}", version::VERSION); +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..3f29ef8 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,420 @@ +//! SQLite database layer for sovereign_browser +//! +//! Provides persistent storage for events, settings, bookmarks, history, +//! and other browser data. Uses rusqlite with bundled SQLite. +//! +//! Port of `db.c` / `db.h` from the C project. + +use rusqlite::{Connection, params}; +use std::path::PathBuf; +use std::sync::Mutex; +use once_cell::sync::Lazy; + +/// Global database connection (thread-safe via Mutex). +static DB: Lazy>> = Lazy::new(|| Mutex::new(None)); + +/// Get the database directory path. +fn db_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".sovereign_browser") +} + +/// Get the database path for a given profile pubkey. +fn db_path(pubkey_hex: &str) -> PathBuf { + db_dir().join("profiles").join(pubkey_hex).join("browser.db") +} + +/// Initialize the database for a given profile. +pub fn db_init(pubkey_hex: &str) -> Result<(), Box> { + let path = db_path(pubkey_hex); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let conn = Connection::open_with_flags( + &path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE + | rusqlite::OpenFlags::SQLITE_OPEN_CREATE + | rusqlite::OpenFlags::SQLITE_OPEN_FULL_MUTEX, + )?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?; + + // Create tables + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + pubkey TEXT NOT NULL, + kind INTEGER NOT NULL, + created_at INTEGER NOT NULL, + content TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + sig TEXT, + relay_url TEXT, + fetched_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS kv_store ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + url TEXT NOT NULL, + folder TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + visit_count INTEGER NOT NULL DEFAULT 1, + last_visit_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey); + CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind); + CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); + CREATE INDEX IF NOT EXISTS idx_history_url ON history(url); + CREATE INDEX IF NOT EXISTS idx_history_last_visit ON history(last_visit_at); + ", + )?; + + let mut db = DB.lock().unwrap(); + *db = Some(conn); + Ok(()) +} + +/// Close the database connection. +pub fn db_close() { + let mut db = DB.lock().unwrap(); + *db = None; +} + +/// Store a Nostr event in the database. +pub fn db_store_event( + id: &str, + pubkey: &str, + kind: u64, + created_at: u64, + content: &str, + tags: &str, + sig: Option<&str>, + relay_url: Option<&str>, +) -> Result<(), Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + conn.execute( + "INSERT OR REPLACE INTO events (id, pubkey, kind, created_at, content, tags, sig, relay_url, fetched_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![id, pubkey, kind, created_at, content, tags, sig, relay_url, now], + )?; + } + Ok(()) +} + +/// Get a Nostr event by ID. +pub fn db_get_event(id: &str) -> Result, Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + let mut stmt = conn.prepare( + "SELECT id, pubkey, kind, created_at, content, tags, sig, relay_url FROM events WHERE id = ?1", + )?; + + let mut rows = stmt.query(params![id])?; + if let Some(row) = rows.next()? { + let tags_str: String = row.get(5)?; + let tags: serde_json::Value = serde_json::from_str(&tags_str).unwrap_or(serde_json::Value::Array(vec![])); + + let event = serde_json::json!({ + "id": row.get::<_, String>(0)?, + "pubkey": row.get::<_, String>(1)?, + "kind": row.get::<_, u64>(2)?, + "created_at": row.get::<_, u64>(3)?, + "content": row.get::<_, String>(4)?, + "tags": tags, + "sig": row.get::<_, Option>(6)?, + "relay_url": row.get::<_, Option>(7)?, + }); + return Ok(Some(event)); + } + } + Ok(None) +} + +/// Query events by filter. +pub fn db_query_events( + kinds: &[u64], + authors: Option<&[String]>, + since: Option, + until: Option, + limit: Option, +) -> Result, Box> { + let db = DB.lock().unwrap(); + let mut results = Vec::new(); + + if let Some(ref conn) = *db { + let mut sql = String::from( + "SELECT id, pubkey, kind, created_at, content, tags, sig, relay_url FROM events WHERE 1=1", + ); + + if !kinds.is_empty() { + let placeholders: Vec = kinds.iter().map(|_| "?".to_string()).collect(); + sql.push_str(&format!(" AND kind IN ({})", placeholders.join(","))); + } + if authors.is_some() { + sql.push_str(" AND pubkey = ?"); + } + if since.is_some() { + sql.push_str(" AND created_at >= ?"); + } + if until.is_some() { + sql.push_str(" AND created_at <= ?"); + } + sql.push_str(" ORDER BY created_at DESC"); + if let Some(lim) = limit { + sql.push_str(&format!(" LIMIT {}", lim)); + } + + let mut stmt = conn.prepare(&sql)?; + + let mut param_values: Vec> = Vec::new(); + for k in kinds { + param_values.push(Box::new(*k)); + } + if let Some(authors) = authors { + if let Some(a) = authors.first() { + param_values.push(Box::new(a.clone())); + } + } + if let Some(s) = since { + param_values.push(Box::new(s)); + } + if let Some(u) = until { + param_values.push(Box::new(u)); + } + + let params_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + + let mut rows = stmt.query(params_refs.as_slice())?; + while let Some(row) = rows.next()? { + let tags_str: String = row.get(5)?; + let tags: serde_json::Value = serde_json::from_str(&tags_str).unwrap_or(serde_json::Value::Array(vec![])); + + let event = serde_json::json!({ + "id": row.get::<_, String>(0)?, + "pubkey": row.get::<_, String>(1)?, + "kind": row.get::<_, u64>(2)?, + "created_at": row.get::<_, u64>(3)?, + "content": row.get::<_, String>(4)?, + "tags": tags, + "sig": row.get::<_, Option>(6)?, + "relay_url": row.get::<_, Option>(7)?, + }); + results.push(event); + } + } + + Ok(results) +} + +/// Store a key-value pair. +pub fn db_kv_set(key: &str, value: &str) -> Result<(), Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + conn.execute( + "INSERT OR REPLACE INTO kv_store (key, value) VALUES (?1, ?2)", + params![key, value], + )?; + } + Ok(()) +} + +/// Get a key-value pair. +pub fn db_kv_get(key: &str) -> Result, Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + let mut stmt = conn.prepare("SELECT value FROM kv_store WHERE key = ?1")?; + let mut rows = stmt.query(params![key])?; + if let Some(row) = rows.next()? { + return Ok(Some(row.get(0)?)); + } + } + Ok(None) +} + +/// Delete a key-value pair. +pub fn db_kv_delete(key: &str) -> Result<(), Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + conn.execute("DELETE FROM kv_store WHERE key = ?1", params![key])?; + } + Ok(()) +} + +/// Add a bookmark. +pub fn db_add_bookmark( + title: &str, + url: &str, + folder: &str, +) -> Result> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + conn.execute( + "INSERT INTO bookmarks (title, url, folder, position, created_at, updated_at) + VALUES (?1, ?2, ?3, 0, ?4, ?4)", + params![title, url, folder, now], + )?; + return Ok(conn.last_insert_rowid()); + } + Ok(0) +} + +/// Get all bookmarks. +pub fn db_get_bookmarks() -> Result, Box> { + let db = DB.lock().unwrap(); + let mut results = Vec::new(); + + if let Some(ref conn) = *db { + let mut stmt = conn.prepare( + "SELECT id, title, url, folder, position, created_at, updated_at FROM bookmarks ORDER BY folder, position", + )?; + + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let bookmark = serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "title": row.get::<_, String>(1)?, + "url": row.get::<_, String>(2)?, + "folder": row.get::<_, String>(3)?, + "position": row.get::<_, i64>(4)?, + "created_at": row.get::<_, u64>(5)?, + "updated_at": row.get::<_, u64>(6)?, + }); + results.push(bookmark); + } + } + + Ok(results) +} + +/// Delete a bookmark. +pub fn db_delete_bookmark(id: i64) -> Result<(), Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?; + } + Ok(()) +} + +/// Update a bookmark's title. +pub fn db_update_bookmark_title(id: i64, title: &str) -> Result<(), Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + conn.execute( + "UPDATE bookmarks SET title = ?1, updated_at = ?2 WHERE id = ?3", + params![title, now_secs(), id], + )?; + } + Ok(()) +} + +/// Update a bookmark's folder path. +pub fn db_update_bookmark_folder(id: i64, folder: &str) -> Result<(), Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + conn.execute( + "UPDATE bookmarks SET folder = ?1, updated_at = ?2 WHERE id = ?3", + params![folder, now_secs(), id], + )?; + } + Ok(()) +} + +/// Current unix timestamp in seconds. +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Add a history entry. +pub fn db_add_history(url: &str, title: &str) -> Result<(), Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Try to update existing entry + let updated = conn.execute( + "UPDATE history SET visit_count = visit_count + 1, last_visit_at = ?1, title = ?2 WHERE url = ?3", + params![now, title, url], + )?; + + if updated == 0 { + conn.execute( + "INSERT INTO history (url, title, visit_count, last_visit_at, created_at) + VALUES (?1, ?2, 1, ?3, ?3)", + params![url, title, now], + )?; + } + } + Ok(()) +} + +/// Get history entries. +pub fn db_get_history(limit: u64) -> Result, Box> { + let db = DB.lock().unwrap(); + let mut results = Vec::new(); + + if let Some(ref conn) = *db { + let mut stmt = conn.prepare( + "SELECT id, url, title, visit_count, last_visit_at, created_at + FROM history ORDER BY last_visit_at DESC LIMIT ?1", + )?; + + let mut rows = stmt.query(params![limit])?; + while let Some(row) = rows.next()? { + let entry = serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "url": row.get::<_, String>(1)?, + "title": row.get::<_, String>(2)?, + "visit_count": row.get::<_, i64>(3)?, + "last_visit_at": row.get::<_, u64>(4)?, + "created_at": row.get::<_, u64>(5)?, + }); + results.push(entry); + } + } + + Ok(results) +} + +/// Clear all history. +pub fn db_clear_history() -> Result<(), Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + conn.execute("DELETE FROM history", [])?; + } + Ok(()) +} diff --git a/src/embedded_web_content.rs b/src/embedded_web_content.rs new file mode 100644 index 0000000..40c937d --- /dev/null +++ b/src/embedded_web_content.rs @@ -0,0 +1,152 @@ +//! Embedded web content served via sovereign:// URIs +//! +//! Port of the `embed_web_files.sh` generated content. +//! In the C project, web files from www/ are embedded as C byte arrays. +//! In the Rust port, we embed them as Rust string constants via include_str!. +//! +//! The `www/` directory is a copy of the C project's www/ directory +//! (single-page web apps for profile, bookmarks, settings, processes, +//! fips, and agents). + +/// Get embedded content by path. +/// Returns (content, mime_type). +pub fn get_content_with_mime(path: &str) -> Option<(String, &'static str)> { + match path { + // HTML pages + "bookmarks" | "bookmarks.html" => Some((get_bookmarks_html(), "text/html")), + "fips" | "fips.html" => Some((get_fips_html(), "text/html")), + "processes" | "processes.html" => Some((get_processes_html(), "text/html")), + "profile" | "profile.html" => Some((get_profile_html(), "text/html")), + "settings" | "settings.html" => Some((get_settings_html(), "text/html")), + "security" | "security.html" => Some((get_security_html(), "text/html")), + "agents/chat" | "agents/chat.html" => Some((get_agent_chat_html(), "text/html")), + "agents/config" | "agents/config.html" => Some((get_agent_config_html(), "text/html")), + + // CSS files + "bookmarks.css" => Some((WWW_BOOKMARKS_CSS.to_string(), "text/css")), + "fips.css" => Some((WWW_FIPS_CSS.to_string(), "text/css")), + "processes.css" => Some((WWW_PROCESSES_CSS.to_string(), "text/css")), + "profile.css" => Some((WWW_PROFILE_CSS.to_string(), "text/css")), + "settings.css" => Some((WWW_SETTINGS_CSS.to_string(), "text/css")), + "sovereign-base.css" => Some((WWW_SOVEREIGN_BASE_CSS.to_string(), "text/css")), + "css/dot-menu.css" => Some((WWW_CSS_DOT_MENU.to_string(), "text/css")), + "css/post-composer.css" => Some((WWW_CSS_POST_COMPOSER.to_string(), "text/css")), + + // JS files + "bookmarks.js" => Some((WWW_BOOKMARKS_JS.to_string(), "application/javascript")), + "fips.js" => Some((WWW_FIPS_JS.to_string(), "application/javascript")), + "processes.js" => Some((WWW_PROCESSES_JS.to_string(), "application/javascript")), + "profile.js" => Some((WWW_PROFILE_JS.to_string(), "application/javascript")), + "settings.js" => Some((WWW_SETTINGS_JS.to_string(), "application/javascript")), + "js/dot-menu.js" => Some((WWW_JS_DOT_MENU.to_string(), "application/javascript")), + "js/dot-menu.mjs" => Some((WWW_JS_DOT_MENU_MJS.to_string(), "application/javascript")), + "js/post-composer.js" => Some((WWW_JS_POST_COMPOSER.to_string(), "application/javascript")), + "js/post-composer.mjs" => Some((WWW_JS_POST_COMPOSER_MJS.to_string(), "application/javascript")), + "js/perf-probe.js" => Some((WWW_JS_PERF_PROBE.to_string(), "application/javascript")), + "json-viewer/json-viewer.js" => Some((WWW_JSON_VIEWER_JS.to_string(), "application/javascript")), + "json-viewer/json-viewer.css" => Some((WWW_JSON_VIEWER_CSS.to_string(), "text/css")), + + // Agent pages + "agents/chat.css" => Some((WWW_AGENTS_CHAT_CSS.to_string(), "text/css")), + "agents/chat.js" => Some((WWW_AGENTS_CHAT_JS.to_string(), "application/javascript")), + "agents/config.css" => Some((WWW_AGENTS_CONFIG_CSS.to_string(), "text/css")), + "agents/config.js" => Some((WWW_AGENTS_CONFIG_JS.to_string(), "application/javascript")), + + // Vendor libraries + "vendor/marked.min.js" => Some((WWW_VENDOR_MARKED.to_string(), "application/javascript")), + "vendor/purify.min.js" => Some((WWW_VENDOR_PURIFY.to_string(), "application/javascript")), + + _ => None, + } +} + +/// Get embedded content by path (HTML only, for backward compatibility). +pub fn get_content(path: &str) -> Option { + get_content_with_mime(path).map(|(content, _)| content) +} + +// ── Embedded file constants ───────────────────────────────────────── + +const WWW_BOOKMARKS_CSS: &str = include_str!("../www/bookmarks.css"); +const WWW_BOOKMARKS_JS: &str = include_str!("../www/bookmarks.js"); +const WWW_FIPS_CSS: &str = include_str!("../www/fips.css"); +const WWW_FIPS_JS: &str = include_str!("../www/fips.js"); +const WWW_PROCESSES_CSS: &str = include_str!("../www/processes.css"); +const WWW_PROCESSES_JS: &str = include_str!("../www/processes.js"); +const WWW_PROFILE_CSS: &str = include_str!("../www/profile.css"); +const WWW_PROFILE_JS: &str = include_str!("../www/profile.js"); +const WWW_SETTINGS_CSS: &str = include_str!("../www/settings.css"); +const WWW_SETTINGS_JS: &str = include_str!("../www/settings.js"); +const WWW_SOVEREIGN_BASE_CSS: &str = include_str!("../www/sovereign-base.css"); +const WWW_CSS_DOT_MENU: &str = include_str!("../www/css/dot-menu.css"); +const WWW_CSS_POST_COMPOSER: &str = include_str!("../www/css/post-composer.css"); +const WWW_JS_DOT_MENU: &str = include_str!("../www/js/dot-menu.js"); +const WWW_JS_DOT_MENU_MJS: &str = include_str!("../www/js/dot-menu.mjs"); +const WWW_JS_POST_COMPOSER: &str = include_str!("../www/js/post-composer.js"); +const WWW_JS_POST_COMPOSER_MJS: &str = include_str!("../www/js/post-composer.mjs"); +const WWW_JS_PERF_PROBE: &str = include_str!("../www/js/perf-probe.js"); +const WWW_JSON_VIEWER_JS: &str = include_str!("../www/json-viewer/json-viewer.js"); +const WWW_JSON_VIEWER_CSS: &str = include_str!("../www/json-viewer/json-viewer.css"); +const WWW_AGENTS_CHAT_CSS: &str = include_str!("../www/agents/chat.css"); +const WWW_AGENTS_CHAT_JS: &str = include_str!("../www/agents/chat.js"); +const WWW_AGENTS_CONFIG_CSS: &str = include_str!("../www/agents/config.css"); +const WWW_AGENTS_CONFIG_JS: &str = include_str!("../www/agents/config.js"); +const WWW_VENDOR_MARKED: &str = include_str!("../www/vendor/marked.min.js"); +const WWW_VENDOR_PURIFY: &str = include_str!("../www/vendor/purify.min.js"); + +/// Get the settings page HTML (served from www/settings.html). +pub fn get_settings_html() -> String { + include_str!("../www/settings.html").to_string() +} + +/// Get the bookmarks page HTML. +pub fn get_bookmarks_html() -> String { + include_str!("../www/bookmarks.html").to_string() +} + +/// Get the FIPS page HTML. +pub fn get_fips_html() -> String { + include_str!("../www/fips.html").to_string() +} + +/// Get the processes page HTML. +pub fn get_processes_html() -> String { + include_str!("../www/processes.html").to_string() +} + +/// Get the agent chat page HTML. +pub fn get_agent_chat_html() -> String { + include_str!("../www/agents/chat.html").to_string() +} + +/// Get the agent config page HTML. +pub fn get_agent_config_html() -> String { + include_str!("../www/agents/config.html").to_string() +} + +/// Get the security page HTML (redirects to settings in the C version). +pub fn get_security_html() -> String { + "\ + \ + Redirecting to settings…" + .to_string() +} + +/// Get the profile page HTML. +pub fn get_profile_html() -> String { + include_str!("../www/profile.html").to_string() +} + +/// HTML-escape a string for safe embedding in HTML attributes/text. +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + c @ ('&' | '<' | '>' | '"' | '\'') => { + out.push_str(&format!("&#{};", c as u32)); + } + _ => out.push(ch), + } + } + out +} diff --git a/src/fips_control.rs b/src/fips_control.rs new file mode 100644 index 0000000..111be32 --- /dev/null +++ b/src/fips_control.rs @@ -0,0 +1,194 @@ +//! FIPS mesh network control +//! +//! Port of `fips_control.c` / `fips_control.h` from the C project. +//! Implements a synchronous FIPS JSON-line control client over a Unix +//! domain socket. Requests are `{"command": "..."}` JSON lines; responses +//! are `{"status": "ok", "data": {...}}` JSON lines. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::sync::Mutex; +use once_cell::sync::Lazy; + +const FIPS_RESPONSE_MAX: usize = 1024 * 1024; +const FIPS_TIMEOUT_MS: u64 = 1500; + +/// FIPS control state. +static G_FIPS_CONTROL: Lazy> = Lazy::new(|| Mutex::new(FipsControlState::default())); + +struct FipsControlState { + running: bool, + fips_path: String, + socket_path: String, +} + +impl Default for FipsControlState { + fn default() -> Self { + FipsControlState { + running: false, + fips_path: String::new(), + socket_path: "/tmp/fips_control.sock".to_string(), + } + } +} + +/// Initialize FIPS control. +pub fn fips_control_init() { + let mut state = G_FIPS_CONTROL.lock().unwrap(); + state.running = false; +} + +/// Start the FIPS mesh node. +pub fn fips_control_start() -> Result<(), Box> { + let mut state = G_FIPS_CONTROL.lock().unwrap(); + if state.running { + return Ok(()); + } + // Try to connect to the FIPS control socket to verify it's running. + let socket_path = state.socket_path.clone(); + if UnixStream::connect(&socket_path).is_ok() { + state.running = true; + println!("[fips] FIPS mesh node connected via {}", socket_path); + Ok(()) + } else { + // FIPS daemon not running — mark as not running. + println!("[fips] FIPS control socket {} not available", socket_path); + Err(format!("FIPS control socket {} not available", socket_path).into()) + } +} + +/// Stop the FIPS mesh node. +pub fn fips_control_stop() { + let mut state = G_FIPS_CONTROL.lock().unwrap(); + state.running = false; + println!("[fips] FIPS mesh node stopped"); +} + +/// Check if FIPS is running. +pub fn fips_control_is_running() -> bool { + let state = G_FIPS_CONTROL.lock().unwrap(); + state.running +} + +/// Send a JSON-line command to the FIPS control socket and return the +/// parsed response JSON. +fn fips_request(command: &str) -> Result> { + let state = G_FIPS_CONTROL.lock().unwrap(); + let socket_path = state.socket_path.clone(); + drop(state); + + let mut stream = UnixStream::connect(&socket_path)?; + stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?; + stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?; + + // Send the command as a JSON line. + let request = serde_json::json!({ "command": command }); + let line = format!("{}\n", serde_json::to_string(&request)?); + stream.write_all(line.as_bytes())?; + + // Read the response (until newline). + let mut reader = BufReader::new(stream); + let mut response = String::new(); + let n = reader.read_line(&mut response)?; + if n == 0 { + return Err("FIPS closed control connection".into()); + } + + let root: serde_json::Value = serde_json::from_str(&response)?; + if root.get("status").and_then(|s| s.as_str()) != Some("ok") { + let msg = root.get("message").and_then(|m| m.as_str()).unwrap_or("request failed"); + return Err(format!("FIPS control error: {}", msg).into()); + } + Ok(root) +} + +/// Get the FIPS status. +pub fn fips_control_show_status() -> Result> { + let root = fips_request("show_status")?; + Ok(root.get("data").cloned().unwrap_or(serde_json::json!({}))) +} + +/// Get the FIPS peer list. +pub fn fips_control_show_peers() -> Result> { + let root = fips_request("show_peers")?; + Ok(root.get("data").cloned().unwrap_or(serde_json::json!({}))) +} + +/// Get the FIPS spanning tree. +pub fn fips_control_show_tree() -> Result> { + let root = fips_request("show_tree")?; + Ok(root.get("data").cloned().unwrap_or(serde_json::json!({}))) +} + +/// Get the FIPS identity cache. +pub fn fips_control_show_identity_cache() -> Result> { + let root = fips_request("show_identity_cache")?; + Ok(root.get("data").cloned().unwrap_or(serde_json::json!({}))) +} + +/// Connect to a FIPS peer. +pub fn fips_control_connect_peer(npub: &str, address: &str, transport: &str) -> Result> { + let state = G_FIPS_CONTROL.lock().unwrap(); + let socket_path = state.socket_path.clone(); + drop(state); + + let mut stream = UnixStream::connect(&socket_path)?; + stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?; + stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?; + + let request = serde_json::json!({ + "command": "connect_peer", + "npub": npub, + "address": address, + "transport": transport, + }); + let line = format!("{}\n", serde_json::to_string(&request)?); + stream.write_all(line.as_bytes())?; + + let mut reader = BufReader::new(stream); + let mut response = String::new(); + reader.read_line(&mut response)?; + let root: serde_json::Value = serde_json::from_str(&response)?; + Ok(root) +} + +/// Disconnect from a FIPS peer. +pub fn fips_control_disconnect_peer(npub: &str) -> Result> { + let state = G_FIPS_CONTROL.lock().unwrap(); + let socket_path = state.socket_path.clone(); + drop(state); + + let mut stream = UnixStream::connect(&socket_path)?; + stream.set_read_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?; + stream.set_write_timeout(Some(std::time::Duration::from_millis(FIPS_TIMEOUT_MS)))?; + + let request = serde_json::json!({ + "command": "disconnect_peer", + "npub": npub, + }); + let line = format!("{}\n", serde_json::to_string(&request)?); + stream.write_all(line.as_bytes())?; + + let mut reader = BufReader::new(stream); + let mut response = String::new(); + reader.read_line(&mut response)?; + let root: serde_json::Value = serde_json::from_str(&response)?; + Ok(root) +} + +/// Resolve a FIPS address (npub) to a reachable endpoint. +pub fn fips_control_resolve(npub: &str) -> Option { + // Query the identity cache for the npub's address. + if let Ok(cache) = fips_control_show_identity_cache() { + if let Some(nodes) = cache.get("nodes").and_then(|n| n.as_array()) { + for node in nodes { + if node.get("npub").and_then(|n| n.as_str()) == Some(npub) { + if let Some(addr) = node.get("address").and_then(|a| a.as_str()) { + return Some(addr.to_string()); + } + } + } + } + } + None +} diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..2867c73 --- /dev/null +++ b/src/history.rs @@ -0,0 +1,20 @@ +//! Browsing history management +//! +//! Port of `history.c` / `history.h` from the C project. + +use crate::db; + +/// Add a history entry. +pub fn history_add(url: &str, title: &str) -> Result<(), Box> { + db::db_add_history(url, title) +} + +/// Get history entries. +pub fn history_get(limit: u64) -> Result, Box> { + db::db_get_history(limit) +} + +/// Clear all history. +pub fn history_clear() -> Result<(), Box> { + db::db_clear_history() +} diff --git a/src/key_store.rs b/src/key_store.rs new file mode 100644 index 0000000..bac2f88 --- /dev/null +++ b/src/key_store.rs @@ -0,0 +1,188 @@ +//! Nostr identity (in-memory only) for sovereign_browser. +//! +//! IMPORTANT: Private keys are NEVER persisted to disk. They live only in +//! working memory (RAM) for the duration of the session and are gone when +//! the browser quits. The user must sign in again on each launch. +//! +//! Port of `key_store.h` / `key_store.c` from the C project. +//! Uses `nostr_signer` from `rust_core_lib` instead of `nostr_signer_t`. + +use nostr_core::types::SecretKey; +use nostr_signer::local::LocalSigner; +use nostr_signer::traits::NostrSigner; +use std::path::PathBuf; +use std::sync::Arc; + +/// Login methods supported by the browser. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyStoreMethod { + None, + Local, // nsec — private key in memory only + Seed, // BIP-39 mnemonic — private key re-derived + Readonly, // npub only — no signing + Nip46, // bunker:// URL — remote signer session + Nsigner, // n_signer hardware — device path + index +} + +impl KeyStoreMethod { + pub fn as_str(&self) -> &'static str { + match self { + KeyStoreMethod::None => "none", + KeyStoreMethod::Local => "local", + KeyStoreMethod::Seed => "seed", + KeyStoreMethod::Readonly => "readonly", + KeyStoreMethod::Nip46 => "nip46", + KeyStoreMethod::Nsigner => "nsigner", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "local" => KeyStoreMethod::Local, + "seed" => KeyStoreMethod::Seed, + "readonly" => KeyStoreMethod::Readonly, + "nip46" => KeyStoreMethod::Nip46, + "nsigner" => KeyStoreMethod::Nsigner, + _ => KeyStoreMethod::None, + } + } +} + +/// Identity record (in-memory only — never persisted). +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct KeyStoreIdentity { + pub method: KeyStoreMethod, + /// Hex pubkey (always present, 64 chars). + pub pubkey_hex: String, + /// Hex private key (local + seed methods only). + pub privkey_hex: String, + /// BIP-39 mnemonic (seed method only). + pub mnemonic: String, + /// NIP-46 bunker URL (nip46 method only). + pub bunker_url: String, + /// n_signer transport (nsigner method only). + pub nsigner_transport: String, + /// n_signer device path / socket name / host:port / qube. + pub nsigner_device: String, + /// NIP-06 index. + pub nsigner_index: i32, + /// Role name. + pub nsigner_role: String, +} + +impl Default for KeyStoreIdentity { + fn default() -> Self { + KeyStoreIdentity { + method: KeyStoreMethod::None, + pubkey_hex: String::new(), + privkey_hex: String::new(), + mnemonic: String::new(), + bunker_url: String::new(), + nsigner_transport: String::new(), + nsigner_device: String::new(), + nsigner_index: 0, + nsigner_role: "nostr_range".to_string(), + } + } +} + +/// Create a signer from an in-memory identity. +/// For readonly: returns None (caller should use pubkey_hex directly). +pub fn key_store_create_signer(identity: &KeyStoreIdentity) -> Option> { + match identity.method { + KeyStoreMethod::Local | KeyStoreMethod::Seed => { + if identity.privkey_hex.is_empty() { + return None; + } + let bytes = hex::decode(&identity.privkey_hex).ok()?; + if bytes.len() != 32 { + return None; + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + let sk = SecretKey::from_bytes(arr); + LocalSigner::new(sk).ok().map(|s| Arc::new(s) as Arc) + } + KeyStoreMethod::Readonly => None, + KeyStoreMethod::Nip46 => { + // NIP-46 remote signer — not yet implemented in rust_core_lib + None + } + KeyStoreMethod::Nsigner => { + // n_signer hardware — not yet implemented in rust_core_lib + None + } + KeyStoreMethod::None => None, + } +} + +/// Get the data directory for sovereign_browser. +fn data_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".sovereign_browser") +} + +/// Get the profiles directory. +fn profiles_dir() -> PathBuf { + data_dir().join("profiles") +} + +/// Get the profile directory for a given pubkey. +fn profile_dir(pubkey_hex: &str) -> PathBuf { + profiles_dir().join(pubkey_hex) +} + +/// Save public identity info (method, pubkey_hex) to the per-profile +/// identity.json. Private keys are NEVER stored. +pub fn key_store_save_profile_identity( + pubkey_hex: &str, + method: KeyStoreMethod, +) -> std::io::Result<()> { + let dir = profile_dir(pubkey_hex); + std::fs::create_dir_all(&dir)?; + + let info = serde_json::json!({ + "pubkey_hex": pubkey_hex, + "method": method.as_str(), + }); + + let path = dir.join("identity.json"); + std::fs::write(path, serde_json::to_string_pretty(&info)?)?; + Ok(()) +} + +/// Load public identity info from the per-profile identity.json. +pub fn key_store_load_profile_identity( + pubkey_hex: &str, +) -> std::io::Result { + let path = profile_dir(pubkey_hex).join("identity.json"); + let content = std::fs::read_to_string(path)?; + let info: serde_json::Value = serde_json::from_str(&content)?; + + let stored_pubkey = info["pubkey_hex"] + .as_str() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "missing pubkey"))?; + + if stored_pubkey != pubkey_hex { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "pubkey mismatch", + )); + } + + let method_str = info["method"] + .as_str() + .unwrap_or("none"); + Ok(KeyStoreMethod::from_str(method_str)) +} + +/// Defensively delete any legacy identity.json file from a previous +/// version that persisted keys to disk. +pub fn key_store_delete_legacy_file() -> std::io::Result<()> { + let legacy = data_dir().join("identity.json"); + if legacy.exists() { + std::fs::remove_file(legacy)?; + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..00e632d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,66 @@ +//! # sovereign_browser — WebKitGTK + Rust +//! +//! A Linux x86 web browser built on WebKitGTK, designed around +//! **sovereign identity** instead of the traditional web's permissioned +//! infrastructure. Uses `rust_core_lib` for all Nostr operations. +//! +//! ## Architecture +//! +//! This is a Rust port of the C99 `sovereign_browser` project. The Nostr +//! functionality previously provided by `nostr_core_lib` (C library) is +//! now provided by the `rust_core_lib` workspace crates: +//! +//! - `nostr_core` — Core types, crypto, utilities +//! - `nostr_relay` — Relay networking (WebSocket, HTTP, pool) +//! - `nostr_nips` — NIP implementations +//! - `nostr_signer` — Signer abstraction (local + nsigner) +//! - `nostr_services` — Blossom, Cashu, validator services +//! +//! Many functions are defined as part of the port but not yet wired into +//! the UI; suppress dead-code warnings until the port is complete. +#![allow(dead_code)] + +pub mod nostr_bridge; +pub mod nostr_inject; +pub mod nostr_url; +pub mod nostr_scheme; +pub mod key_store; +pub mod login_dialog; +pub mod relay_fetch; +pub mod settings_sync; +pub mod agent_conversations; +pub mod agent_skills; +pub mod agent_tools; +pub mod agent_loop; +pub mod agent_chat; +pub mod agent_chat_store; +pub mod agent_mcp; +pub mod agent_server; +pub mod agent_login; +pub mod agent_snapshot; +pub mod agent_fs_tools; +pub mod agent_llm; +pub mod db; +pub mod settings; +pub mod tab_manager; +pub mod menu; +pub mod session; +pub mod bookmarks; +pub mod history; +pub mod search; +pub mod shortcuts; +pub mod profile; +pub mod net_services; +pub mod tor_control; +pub mod tor_scheme; +pub mod fips_control; +pub mod web_context; +pub mod webkit_data; +pub mod site_downloader; +pub mod local_scheme; +pub mod cli; +pub mod qr; +pub mod process_info; +pub mod perf_probe; +pub mod embedded_web_content; +pub mod version; diff --git a/src/local_scheme.rs b/src/local_scheme.rs new file mode 100644 index 0000000..2607236 --- /dev/null +++ b/src/local_scheme.rs @@ -0,0 +1,54 @@ +//! WebKitGTK local:// URI scheme handler for local file browsing +//! +//! Port of `local_scheme.c` / `local_scheme.h` from the C project. + +use webkit2gtk::*; +use gio::MemoryInputStream; + +pub fn local_scheme_register(ctx: &WebContext) { + ctx.register_uri_scheme("local", |request| { + handle_local_scheme(request); + }); +} + +fn handle_local_scheme(request: &URISchemeRequest) { + let uri = request.uri().unwrap_or_default(); + let path = uri.strip_prefix("local://").unwrap_or(&uri); + + let file_path = std::path::Path::new(path); + if file_path.exists() && file_path.is_file() { + match std::fs::read_to_string(file_path) { + Ok(content) => { + let mime_type = guess_mime_type(file_path); + let body = content.into_bytes(); + let stream = MemoryInputStream::from_bytes(&glib::Bytes::from_owned(body.clone())); + request.finish(&stream, body.len() as i64, Some(&mime_type)); + return; + } + Err(_) => {} + } + } + + let html = format!("

File Not Found

Path: {}

", path); + let body = html.into_bytes(); + let stream = MemoryInputStream::from_bytes(&glib::Bytes::from_owned(body.clone())); + request.finish(&stream, body.len() as i64, Some("text/html")); +} + +fn guess_mime_type(path: &std::path::Path) -> String { + match path.extension().and_then(|e| e.to_str()) { + Some("html") | Some("htm") => "text/html".to_string(), + Some("css") => "text/css".to_string(), + Some("js") => "application/javascript".to_string(), + Some("json") => "application/json".to_string(), + Some("png") => "image/png".to_string(), + Some("jpg") | Some("jpeg") => "image/jpeg".to_string(), + Some("gif") => "image/gif".to_string(), + Some("svg") => "image/svg+xml".to_string(), + Some("ico") => "image/x-icon".to_string(), + Some("txt") => "text/plain".to_string(), + Some("xml") => "application/xml".to_string(), + Some("pdf") => "application/pdf".to_string(), + _ => "application/octet-stream".to_string(), + } +} diff --git a/src/login_dialog.rs b/src/login_dialog.rs new file mode 100644 index 0000000..86d43cd --- /dev/null +++ b/src/login_dialog.rs @@ -0,0 +1,905 @@ +//! GTK Nostr login dialog for sovereign_browser +//! +//! Port of `login_dialog.c` / `login_dialog.h` from the C project. +//! +//! Presents a modal GTK dialog with notebook tabs for each login method: +//! Local Key, Seed Phrase, Read-only, NIP-46, and n_signer. Also offers a +//! "No Login" option to browse without a Nostr identity. + +use gtk::prelude::*; +use nostr_core::crypto::keys::generate_keypair; +use nostr_core::types::SecretKey; +use nostr_core::util::bech32::{bech32_decode, Bech32Entity, nsec_encode}; +use nostr_signer::local::LocalSigner; +use nostr_signer::traits::NostrSigner; +use rand::RngCore; +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use crate::key_store::{KeyStoreIdentity, KeyStoreMethod}; +use crate::version; + +const SEED_WORD_COUNT: usize = 12; + +pub struct LoginResult { + pub method: KeyStoreMethod, + pub signer: Option>, + pub pubkey_hex: String, + pub identity: KeyStoreIdentity, + /// True when the user chose "No Login" (browse without an identity). + pub no_login: bool, +} + +/// Shared dialog state, wrapped in Rc so GTK callbacks can access it. +struct LoginCtx { + dialog: gtk::Dialog, + notebook: gtk::Notebook, + status_label: gtk::Label, + result: Option, + done: bool, + no_login: bool, + + // Local Key screen + local_entry: gtk::Entry, + + // Seed Phrase screen + seed_entries: Vec, + account_spin: gtk::SpinButton, + + // Read-only screen + readonly_entry: gtk::Entry, + + // NIP-46 screen + nip46_entry: gtk::Entry, + + // n_signer screen + transport_combo: gtk::ComboBoxText, + device_label: gtk::Label, + device_entry: gtk::Entry, + enum_btn: gtk::Button, + service_box: gtk::Box, + service_entry: gtk::Entry, + role_entry: gtk::Entry, + index_spin: gtk::SpinButton, + index_label: gtk::Label, +} + +impl LoginCtx { + fn set_status(&self, msg: &str) { + self.status_label.set_text(msg); + } + + fn current_method(&self) -> &'static str { + let page = self.notebook.current_page().unwrap_or(0); + match page { + 0 => "local", + 1 => "seed", + 2 => "readonly", + 3 => "nip46", + 4 => "nsigner", + _ => "local", + } + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +pub fn derive_pubkey_hex(sk: &SecretKey) -> Option { + let signer = LocalSigner::new(sk.clone()).ok()?; + let pk = signer.get_public_key().ok()?; + Some(pk.to_hex()) +} + +pub fn decode_nsec(input: &str) -> Option { + if let Ok(Bech32Entity::Nsec(sk)) = bech32_decode(input) { + return Some(sk); + } + // Fall back to 64-char hex + if input.len() == 64 { + if let Ok(sk) = input.parse::() { + return Some(sk); + } + } + None +} + +pub fn decode_npub_hex(input: &str) -> Option { + if let Ok(Bech32Entity::Npub(pk)) = bech32_decode(input) { + return Some(pk.to_hex()); + } + // Fall back to 64-char hex + if input.len() == 64 && input.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(input.to_string()); + } + None +} + +// ── Screen creation ──────────────────────────────────────────────────────── + +fn create_local_screen(ctx: &Rc>) -> gtk::Box { + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 8); + box_.set_margin_top(12); + box_.set_margin_bottom(12); + box_.set_margin_start(12); + box_.set_margin_end(12); + + let label = gtk::Label::new(Some( + "Enter your Nostr private key (nsec) or generate a new one:", + )); + label.set_halign(gtk::Align::Start); + box_.pack_start(&label, false, false, 0); + + let entry = gtk::Entry::new(); + entry.set_placeholder_text(Some("nsec1...")); + entry.set_width_chars(60); + box_.pack_start(&entry, false, false, 0); + + let gen_btn = gtk::Button::with_label("Generate New Key"); + let entry_clone = entry.clone(); + gen_btn.connect_clicked(move |_| { + let (sk, _pk) = generate_keypair(); + if let Ok(nsec) = nsec_encode(&sk) { + entry_clone.set_text(&nsec); + } else { + entry_clone.set_text("(generation failed)"); + } + }); + box_.pack_start(&gen_btn, false, false, 0); + + ctx.borrow_mut().local_entry = entry; + box_ +} + +fn create_seed_screen(ctx: &Rc>) -> gtk::Box { + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 8); + box_.set_margin_top(12); + box_.set_margin_bottom(12); + box_.set_margin_start(12); + box_.set_margin_end(12); + + let label = gtk::Label::new(Some("Enter your 12-word BIP-39 seed phrase:")); + label.set_halign(gtk::Align::Start); + box_.pack_start(&label, false, false, 0); + + // Build the BIP-39 word completion model (shared by all 12 entries). + let store = gtk::ListStore::new(&[glib::Type::STRING]); + for word in nostr_nips::nip006::bip39_wordlist() { + let iter = store.append(); + store.set_value(&iter, 0, &word.to_value()); + } + + // 12 numbered word entry boxes in a 4x3 grid with inline auto-complete. + let grid = gtk::Grid::new(); + grid.set_row_spacing(4); + grid.set_column_spacing(8); + box_.pack_start(&grid, false, false, 0); + + let mut entries: Vec = Vec::with_capacity(SEED_WORD_COUNT); + + for i in 0..SEED_WORD_COUNT { + let col = (i % 4) as i32; + let row = (i / 4) as i32; + + let num_label = gtk::Label::new(Some(&format!("{}.", i + 1))); + num_label.set_halign(gtk::Align::End); + grid.attach(&num_label, col * 2, row, 1, 1); + + let entry = gtk::Entry::new(); + entry.set_width_chars(14); + entry.set_placeholder_text(Some("word")); + + // Dropdown completion list. + let completion = gtk::EntryCompletion::new(); + completion.set_model(Some(&store)); + completion.set_text_column(0); + completion.set_minimum_key_length(2); + entry.set_completion(Some(&completion)); + + grid.attach(&entry, col * 2 + 1, row, 1, 1); + entries.push(entry); + } + + // Tab/Enter moves to the next word box. + for i in 0..SEED_WORD_COUNT { + let entries_clone = entries.clone(); + let idx = i; + entries[i].connect_key_press_event(move |_entry, event| { + if event.keyval() == gdk::keys::constants::Tab + || event.keyval() == gdk::keys::constants::Return + || event.keyval() == gdk::keys::constants::KP_Enter + { + if idx + 1 < SEED_WORD_COUNT { + entries_clone[idx + 1].grab_focus(); + } + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + }); + } + + let gen_btn = gtk::Button::with_label("Generate New Mnemonic"); + let entries_clone = entries.clone(); + gen_btn.connect_clicked(move |_| { + let mut entropy = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut entropy); + if let Ok(mnemonic) = nostr_nips::nip006::mnemonic_from_bytes(&entropy) { + let words: Vec<&str> = mnemonic.split_whitespace().collect(); + for (i, entry) in entries_clone.iter().enumerate() { + if let Some(w) = words.get(i) { + entry.set_text(w); + } + } + if let Some(first) = entries_clone.first() { + first.grab_focus(); + } + } + }); + box_.pack_start(&gen_btn, false, false, 0); + + // Account number selector. + let acct_box = gtk::Box::new(gtk::Orientation::Horizontal, 8); + box_.pack_start(&acct_box, false, false, 0); + + let acct_label = gtk::Label::new(Some("Account #:")); + acct_box.pack_start(&acct_label, false, false, 0); + + let acct_spin = gtk::SpinButton::with_range(0.0, 1000.0, 1.0); + acct_spin.set_value(0.0); + acct_box.pack_start(&acct_spin, false, false, 0); + + let hint = gtk::Label::new(Some("Key derivation: m/44'/1237'/0'/0/0")); + hint.set_sensitive(false); + hint.set_halign(gtk::Align::Start); + box_.pack_start(&hint, false, false, 0); + + let hint_clone = hint.clone(); + acct_spin.connect_value_changed(move |spin| { + let account = spin.value() as i32; + hint_clone.set_text(&format!("Key derivation: m/44'/1237'/{}'/0/0", account)); + }); + + ctx.borrow_mut().seed_entries = entries; + ctx.borrow_mut().account_spin = acct_spin; + box_ +} + +fn create_readonly_screen(ctx: &Rc>) -> gtk::Box { + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 8); + box_.set_margin_top(12); + box_.set_margin_bottom(12); + box_.set_margin_start(12); + box_.set_margin_end(12); + + let label = gtk::Label::new(Some( + "Enter a Nostr public key (npub) for read-only mode:", + )); + label.set_halign(gtk::Align::Start); + box_.pack_start(&label, false, false, 0); + + let entry = gtk::Entry::new(); + entry.set_placeholder_text(Some("npub1...")); + entry.set_width_chars(60); + box_.pack_start(&entry, false, false, 0); + + let hint = gtk::Label::new(Some( + "Read-only mode: you can view content but cannot sign events.", + )); + hint.set_sensitive(false); + hint.set_halign(gtk::Align::Start); + box_.pack_start(&hint, false, false, 0); + + ctx.borrow_mut().readonly_entry = entry; + box_ +} + +fn create_nip46_screen(ctx: &Rc>) -> gtk::Box { + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 8); + box_.set_margin_top(12); + box_.set_margin_bottom(12); + box_.set_margin_start(12); + box_.set_margin_end(12); + + let label = gtk::Label::new(Some( + "Connect to a NIP-46 remote signer (bunker:// URL):", + )); + label.set_halign(gtk::Align::Start); + box_.pack_start(&label, false, false, 0); + + let entry = gtk::Entry::new(); + entry.set_placeholder_text(Some("bunker://?relay=wss://...&secret=...")); + entry.set_width_chars(60); + box_.pack_start(&entry, false, false, 0); + + let hint = gtk::Label::new(Some( + "The remote signer holds your private key. Signing requests are sent over Nostr relays.", + )); + hint.set_sensitive(false); + hint.set_halign(gtk::Align::Start); + hint.set_line_wrap(true); + box_.pack_start(&hint, false, false, 0); + + ctx.borrow_mut().nip46_entry = entry; + box_ +} + +fn create_nsigner_screen(ctx: &Rc>) -> gtk::Box { + let box_ = gtk::Box::new(gtk::Orientation::Vertical, 8); + box_.set_margin_top(12); + box_.set_margin_bottom(12); + box_.set_margin_start(12); + box_.set_margin_end(12); + + let label = gtk::Label::new(Some("Connect to n_signer hardware signer:")); + label.set_halign(gtk::Align::Start); + box_.pack_start(&label, false, false, 0); + + // Transport type selector. + let transport_box = gtk::Box::new(gtk::Orientation::Horizontal, 4); + box_.pack_start(&transport_box, false, false, 0); + + let transport_label = gtk::Label::new(Some("Transport:")); + transport_box.pack_start(&transport_label, false, false, 0); + + let transport_combo = gtk::ComboBoxText::new(); + transport_combo.append_text("USB Serial"); + transport_combo.append_text("UNIX Socket"); + transport_combo.append_text("TCP"); + transport_combo.append_text("Other Qube"); + transport_combo.set_active(Some(3)); + transport_box.pack_start(&transport_combo, false, false, 0); + + // Device path entry — label and placeholder adapt to transport type. + let device_label = gtk::Label::new(Some("Target Qube:")); + device_label.set_halign(gtk::Align::Start); + box_.pack_start(&device_label, false, false, 0); + + let device_entry = gtk::Entry::new(); + device_entry.set_text("nostr_signer"); + device_entry.set_placeholder_text(Some("nostr_signer")); + device_entry.set_width_chars(40); + box_.pack_start(&device_entry, false, false, 0); + + // Enumerate serial devices button (only shown for serial transport). + let enum_btn = gtk::Button::with_label("Detect Serial Devices"); + enum_btn.set_sensitive(false); + box_.pack_start(&enum_btn, false, false, 0); + enum_btn.hide(); + + // Service name field (only shown for qrexec transport). + let service_box = gtk::Box::new(gtk::Orientation::Horizontal, 8); + box_.pack_start(&service_box, false, false, 0); + + let service_label = gtk::Label::new(Some("Service:")); + service_box.pack_start(&service_label, false, false, 0); + + let service_entry = gtk::Entry::new(); + service_entry.set_text("qubes.NsignerRpc"); + service_entry.set_width_chars(30); + service_box.pack_start(&service_entry, false, false, 0); + + // Role entry field. + let role_box = gtk::Box::new(gtk::Orientation::Horizontal, 8); + box_.pack_start(&role_box, false, false, 0); + + let role_label = gtk::Label::new(Some("Role:")); + role_box.pack_start(&role_label, false, false, 0); + + let role_entry = gtk::Entry::new(); + role_entry.set_text("nostr_range"); + role_entry.set_placeholder_text(Some("nostr_range")); + role_entry.set_width_chars(30); + role_box.pack_start(&role_entry, false, false, 0); + + // Key index spinner with live BIP-32 path label. + let index_box = gtk::Box::new(gtk::Orientation::Horizontal, 4); + box_.pack_start(&index_box, false, false, 0); + + let index_label = gtk::Label::new(Some("Key Index (m/44'/1237'/0'/0/0):")); + index_label.set_halign(gtk::Align::Start); + index_box.pack_start(&index_label, false, false, 0); + + let index_spin = gtk::SpinButton::with_range(0.0, 1000.0, 1.0); + index_spin.set_value(0.0); + index_box.pack_start(&index_spin, false, false, 0); + + let index_label_clone = index_label.clone(); + index_spin.connect_value_changed(move |spin| { + let idx = spin.value() as i32; + index_label_clone.set_text(&format!("Key Index (m/44'/1237'/{}'/0/0):", idx)); + }); + + let hint = gtk::Label::new(Some( + "n_signer is a foreground, RAM-only hardware signer. Your private key never leaves the device.", + )); + hint.set_sensitive(false); + hint.set_halign(gtk::Align::Start); + hint.set_line_wrap(true); + box_.pack_start(&hint, false, false, 0); + + // Transport change callback to update the UI dynamically. + let device_label_clone = device_label.clone(); + let device_entry_clone = device_entry.clone(); + let enum_btn_clone = enum_btn.clone(); + let service_box_clone = service_box.clone(); + transport_combo.connect_changed(move |combo| { + let idx = combo.active().unwrap_or(0); + match idx { + 0 => { + device_label_clone.set_text("Device Path:"); + device_entry_clone.set_placeholder_text(Some("/dev/ttyACM0")); + if device_entry_clone.text() == "nostr_signer" { + device_entry_clone.set_text(""); + } + enum_btn_clone.show(); + service_box_clone.hide(); + } + 1 => { + device_label_clone.set_text("Socket Name:"); + device_entry_clone.set_placeholder_text(Some("nsigner")); + if device_entry_clone.text() == "nostr_signer" { + device_entry_clone.set_text(""); + } + enum_btn_clone.hide(); + service_box_clone.hide(); + } + 2 => { + device_label_clone.set_text("Host:Port:"); + device_entry_clone.set_placeholder_text(Some("127.0.0.1:7777")); + if device_entry_clone.text() == "nostr_signer" { + device_entry_clone.set_text(""); + } + enum_btn_clone.hide(); + service_box_clone.hide(); + } + _ => { + device_label_clone.set_text("Target Qube:"); + device_entry_clone.set_placeholder_text(Some("nostr_signer")); + if device_entry_clone.text().is_empty() { + device_entry_clone.set_text("nostr_signer"); + } + enum_btn_clone.hide(); + service_box_clone.show(); + } + } + }); + + ctx.borrow_mut().transport_combo = transport_combo; + ctx.borrow_mut().device_label = device_label; + ctx.borrow_mut().device_entry = device_entry; + ctx.borrow_mut().enum_btn = enum_btn; + ctx.borrow_mut().service_box = service_box; + ctx.borrow_mut().service_entry = service_entry; + ctx.borrow_mut().role_entry = role_entry; + ctx.borrow_mut().index_spin = index_spin; + ctx.borrow_mut().index_label = index_label; + box_ +} + +// ── Login handler ────────────────────────────────────────────────────────── + +fn on_login_clicked(ctx: &Rc>) { + let method = ctx.borrow().current_method(); + let mut ctx_borrow = ctx.borrow_mut(); + + match method { + "local" => { + let input = ctx_borrow.local_entry.text().to_string(); + if input.is_empty() { + ctx_borrow.set_status("Enter an nsec or click Generate."); + return; + } + let sk = match decode_nsec(&input) { + Some(sk) => sk, + None => { + ctx_borrow.set_status("Enter an nsec1... or 64-char hex key."); + return; + } + }; + let pubkey_hex = match derive_pubkey_hex(&sk) { + Some(h) => h, + None => { + ctx_borrow.set_status("Failed to derive public key."); + return; + } + }; + let signer = match LocalSigner::new(sk.clone()) { + Ok(s) => s, + Err(_) => { + ctx_borrow.set_status("Failed to create signer."); + return; + } + }; + let identity = KeyStoreIdentity { + method: KeyStoreMethod::Local, + pubkey_hex: pubkey_hex.clone(), + privkey_hex: sk.to_hex(), + ..Default::default() + }; + ctx_borrow.result = Some(LoginResult { + method: KeyStoreMethod::Local, + signer: Some(Arc::new(signer)), + pubkey_hex, + identity, + no_login: false, + }); + ctx_borrow.done = true; + ctx_borrow.dialog.response(gtk::ResponseType::Accept); + } + "seed" => { + let _account = ctx_borrow.account_spin.value() as i32; + let mut mnemonic = String::new(); + let mut words_filled = 0; + for entry in &ctx_borrow.seed_entries { + let word = entry.text().to_string(); + if !word.is_empty() { + if words_filled > 0 { + mnemonic.push(' '); + } + mnemonic.push_str(&word); + words_filled += 1; + } + } + if words_filled == 0 { + ctx_borrow.set_status("Enter your seed phrase or click Generate."); + return; + } + if words_filled != SEED_WORD_COUNT { + ctx_borrow.set_status(&format!( + "Expected 12 words, got {}.", + words_filled + )); + return; + } + if !nostr_nips::nip006::mnemonic_validate(&mnemonic) { + ctx_borrow.set_status("Invalid seed phrase. Check the words and try again."); + return; + } + let seed = nostr_nips::nip006::mnemonic_to_seed(&mnemonic, ""); + let (sk, pk) = match nostr_nips::nip006::keypair_from_seed(&seed) { + Ok(kp) => kp, + Err(_) => { + ctx_borrow.set_status("Invalid seed phrase. Check the words and try again."); + return; + } + }; + let pubkey_hex = pk.to_hex(); + let signer = match LocalSigner::new(sk.clone()) { + Ok(s) => s, + Err(_) => { + ctx_borrow.set_status("Failed to create signer."); + return; + } + }; + let identity = KeyStoreIdentity { + method: KeyStoreMethod::Seed, + pubkey_hex: pubkey_hex.clone(), + privkey_hex: sk.to_hex(), + mnemonic, + ..Default::default() + }; + ctx_borrow.result = Some(LoginResult { + method: KeyStoreMethod::Seed, + signer: Some(Arc::new(signer)), + pubkey_hex, + identity, + no_login: false, + }); + ctx_borrow.done = true; + ctx_borrow.dialog.response(gtk::ResponseType::Accept); + } + "readonly" => { + let input = ctx_borrow.readonly_entry.text().to_string(); + if input.is_empty() { + ctx_borrow.set_status("Enter an npub1... or 64-char hex pubkey."); + return; + } + let pubkey_hex = match decode_npub_hex(&input) { + Some(h) => h, + None => { + ctx_borrow.set_status("Enter an npub1... or 64-char hex pubkey."); + return; + } + }; + let identity = KeyStoreIdentity { + method: KeyStoreMethod::Readonly, + pubkey_hex: pubkey_hex.clone(), + ..Default::default() + }; + ctx_borrow.result = Some(LoginResult { + method: KeyStoreMethod::Readonly, + signer: None, + pubkey_hex, + identity, + no_login: false, + }); + ctx_borrow.done = true; + ctx_borrow.dialog.response(gtk::ResponseType::Accept); + } + "nip46" => { + let bunker_url = ctx_borrow.nip46_entry.text().to_string(); + if bunker_url.is_empty() { + ctx_borrow.set_status("Enter a bunker:// URL."); + return; + } + let bunker = match nostr_nips::nip046::parse_bunker_url(&bunker_url) { + Ok(b) => b, + Err(_) => { + ctx_borrow.set_status( + "Invalid bunker:// URL. Format: bunker://?relay=wss://...&secret=...", + ); + return; + } + }; + let pubkey_hex = bunker.pubkey.to_hex(); + // Generate a client keypair for the NIP-46 session. + let (client_sk, _) = generate_keypair(); + let signer = match LocalSigner::new(client_sk.clone()) { + Ok(s) => s, + Err(_) => { + ctx_borrow.set_status("Failed to create client signer."); + return; + } + }; + let identity = KeyStoreIdentity { + method: KeyStoreMethod::Nip46, + pubkey_hex: pubkey_hex.clone(), + privkey_hex: client_sk.to_hex(), + bunker_url, + ..Default::default() + }; + ctx_borrow.result = Some(LoginResult { + method: KeyStoreMethod::Nip46, + signer: Some(Arc::new(signer)), + pubkey_hex, + identity, + no_login: false, + }); + ctx_borrow.done = true; + ctx_borrow.dialog.response(gtk::ResponseType::Accept); + } + "nsigner" => { + let device = ctx_borrow.device_entry.text().to_string(); + let role = { + let r = ctx_borrow.role_entry.text().to_string(); + if r.is_empty() { + "nostr_range".to_string() + } else { + r + } + }; + let nostr_index = ctx_borrow.index_spin.value() as i32; + let transport_idx = ctx_borrow.transport_combo.active().unwrap_or(0); + + if device.is_empty() { + ctx_borrow.set_status("Enter a device path or select one."); + return; + } + + // Build the role path: m/44'/1237'/N'/0/0 + let role_path = format!("m/44'/1237'/{}'/0/0", nostr_index); + + let signer: Option> = match transport_idx { + 1 => { + let s = nostr_signer::nsigner::NsignerSigner::from_transport( + Box::new(nostr_signer::nsigner::UnixTransport::new(&device, 15000)), + &role, + ); + s.set_role_path(&role_path); + Some(Arc::new(s)) + } + 2 => { + // Parse host:port + if let Some((host, port)) = device.rsplit_once(':') { + if let Ok(port) = port.parse::() { + let s = nostr_signer::nsigner::NsignerSigner::from_transport( + Box::new(nostr_signer::nsigner::TcpTransport::new( + host, port, 15000, + )), + &role, + ); + s.set_role_path(&role_path); + Some(Arc::new(s)) + } else { + None + } + } else { + None + } + } + _ => { + // USB Serial and Other Qube (qrexec) are not supported by + // the Rust nsigner crate yet. + ctx_borrow.set_status( + "This transport is not supported in this build. Use UNIX Socket or TCP.", + ); + return; + } + }; + + let signer = match signer { + Some(s) => s, + None => { + ctx_borrow.set_status( + "Failed to connect to n_signer. Check the device/path/qube.", + ); + return; + } + }; + + let pubkey_hex = match signer.get_public_key() { + Ok(pk) => pk.to_hex(), + Err(e) => { + let desc = signer + .last_error() + .unwrap_or_else(|| format!("{}", e)); + ctx_borrow.set_status(&format!( + "n_signer error: {} (code {:?}). Try a different key index.", + desc, e + )); + return; + } + }; + + let transport_name = match transport_idx { + 1 => "unix", + 2 => "tcp", + _ => "unknown", + }; + + let identity = KeyStoreIdentity { + method: KeyStoreMethod::Nsigner, + pubkey_hex: pubkey_hex.clone(), + nsigner_transport: transport_name.to_string(), + nsigner_device: device, + nsigner_index: nostr_index, + nsigner_role: role, + ..Default::default() + }; + ctx_borrow.result = Some(LoginResult { + method: KeyStoreMethod::Nsigner, + signer: Some(signer), + pubkey_hex, + identity, + no_login: false, + }); + ctx_borrow.done = true; + ctx_borrow.dialog.response(gtk::ResponseType::Accept); + } + _ => { + ctx_borrow.set_status("Unknown method."); + } + } +} + +// ── Main dialog ──────────────────────────────────────────────────────────── + +pub fn login_dialog_run(parent: >k::Window) -> Option { + let dialog = gtk::Dialog::new(); + dialog.set_title(&format!("sovereign browser {}", version::VERSION)); + dialog.set_modal(true); + dialog.set_transient_for(Some(parent)); + dialog.set_default_size(560, 380); + + // Custom buttons: No Login | Cancel | Sign In + let no_login_btn = gtk::Button::with_label("No Login"); + let cancel_btn = gtk::Button::with_label("Cancel"); + let login_btn = gtk::Button::with_label("Sign In"); + dialog.add_action_widget(&no_login_btn, gtk::ResponseType::None); + dialog.add_action_widget(&cancel_btn, gtk::ResponseType::Cancel); + dialog.add_action_widget(&login_btn, gtk::ResponseType::Accept); + no_login_btn.set_tooltip_text(Some( + "Browse without a Nostr identity. The browser works normally, \ + but window.nostr won't be available for signing.", + )); + + // Apply monospace font styling to the dialog. + let provider = gtk::CssProvider::new(); + let _ = provider.load_from_data( + b"dialog { font-family: monospace; }\n\ + label { font-family: monospace; }\n\ + entry { font-family: monospace; }\n\ + button { font-family: monospace; }\n", + ); + let sctx = dialog.style_context(); + sctx.add_provider(&provider, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION); + + let content = dialog.content_area(); + content.set_border_width(12); + + // Title header. + let title = gtk::Label::new(None); + title.set_markup("Sign in with your Nostr key"); + content.pack_start(&title, false, false, 8); + + // Notebook with tabs for each login method. + let notebook = gtk::Notebook::new(); + content.pack_start(¬ebook, true, true, 4); + + // Status label. + let status = gtk::Label::new(Some("")); + status.set_halign(gtk::Align::Start); + content.pack_start(&status, false, false, 4); + + let ctx = Rc::new(RefCell::new(LoginCtx { + dialog: dialog.clone(), + notebook: notebook.clone(), + status_label: status, + result: None, + done: false, + no_login: false, + local_entry: gtk::Entry::new(), + seed_entries: Vec::new(), + account_spin: gtk::SpinButton::with_range(0.0, 1000.0, 1.0), + readonly_entry: gtk::Entry::new(), + nip46_entry: gtk::Entry::new(), + transport_combo: gtk::ComboBoxText::new(), + device_label: gtk::Label::new(None), + device_entry: gtk::Entry::new(), + enum_btn: gtk::Button::new(), + service_box: gtk::Box::new(gtk::Orientation::Horizontal, 0), + service_entry: gtk::Entry::new(), + role_entry: gtk::Entry::new(), + index_spin: gtk::SpinButton::with_range(0.0, 1000.0, 1.0), + index_label: gtk::Label::new(None), + })); + + // Create all screens as notebook tabs. + let tabs: [(&str, fn(&Rc>) -> gtk::Box); 5] = [ + ("Local Key", create_local_screen), + ("Seed Phrase", create_seed_screen), + ("Read-only", create_readonly_screen), + ("NIP-46", create_nip46_screen), + ("n_signer", create_nsigner_screen), + ]; + for (label, create_fn) in tabs { + let screen = create_fn(&ctx); + let tab_label = gtk::Label::new(Some(label)); + notebook.append_page(&screen, Some(&tab_label)); + } + + // Wire the custom buttons. + let ctx_no_login = ctx.clone(); + no_login_btn.connect_clicked(move |_| { + let mut c = ctx_no_login.borrow_mut(); + c.done = true; + c.no_login = true; + c.result = None; + c.dialog.response(gtk::ResponseType::Accept); + }); + + let ctx_cancel = ctx.clone(); + cancel_btn.connect_clicked(move |_| { + let mut c = ctx_cancel.borrow_mut(); + c.done = false; + c.dialog.response(gtk::ResponseType::Cancel); + }); + + let ctx_login = ctx.clone(); + login_btn.connect_clicked(move |_| { + on_login_clicked(&ctx_login); + }); + + dialog.show_all(); + + let response = dialog.run(); + unsafe { dialog.destroy(); } + + if response != gtk::ResponseType::Accept || !ctx.borrow().done { + return None; + } + + if ctx.borrow().no_login { + // "No Login" — return a result with no identity. + return Some(LoginResult { + method: KeyStoreMethod::None, + signer: None, + pubkey_hex: String::new(), + identity: KeyStoreIdentity::default(), + no_login: true, + }); + } + + let result = ctx.borrow_mut().result.take(); + result +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2738bbc --- /dev/null +++ b/src/main.rs @@ -0,0 +1,564 @@ +//! sovereign_browser — WebKitGTK + Rust +//! +//! Multi-tab browser: a GTK window with a GtkNotebook tab strip. +//! +//! Many functions are defined as part of the port but not yet wired into +//! the UI; suppress dead-code warnings until the port is complete. +#![allow(dead_code)] + +use gtk::prelude::*; +use webkit2gtk::*; +use std::sync::Arc; + +mod nostr_bridge; +mod nostr_inject; +mod nostr_url; +mod nostr_scheme; +mod key_store; +mod login_dialog; +mod menu; +mod relay_fetch; +mod settings_sync; +mod agent_conversations; +mod agent_skills; +mod agent_tools; +mod agent_loop; +mod agent_chat; +mod agent_chat_store; +mod agent_mcp; +mod agent_server; +mod agent_login; +mod agent_snapshot; +mod agent_fs_tools; +mod agent_llm; +mod db; +mod settings; +mod tab_manager; +mod session; +mod bookmarks; +mod history; +mod search; +mod shortcuts; +mod profile; +mod net_services; +mod tor_control; +mod tor_scheme; +mod fips_control; +mod web_context; +mod webkit_data; +mod site_downloader; +mod local_scheme; +mod cli; +mod qr; +mod process_info; +mod perf_probe; +mod embedded_web_content; +mod version; + +use nostr_signer::traits::NostrSigner; + +/// Set the signer (delegates to the shared app state in `menu`). +fn app_set_signer( + signer: Option>, + pubkey_hex: &str, + privkey_hex: &str, + method: key_store::KeyStoreMethod, + readonly: bool, +) { + menu::app_set_signer(signer, pubkey_hex, privkey_hex, method, readonly); +} + +/// Switch to the per-user profile database (delegates to `menu`). +fn switch_to_user_db(pubkey_hex: &str) -> i32 { + menu::switch_to_user_db(pubkey_hex) +} + +/// Clear the signer and reset app state (delegates to `menu`). +pub fn app_clear_signer() { + menu::app_clear_signer(); +} + +fn do_login(parent: >k::Window) -> bool { + let result = login_dialog::login_dialog_run(parent); + match result { + Some(login_result) => { + let pubkey_hex = login_result.pubkey_hex.clone(); + let privkey_hex = login_result.identity.privkey_hex.clone(); + let method = login_result.method; + let readonly = login_result.signer.is_none(); + + // "No Login" mode — browse without a Nostr identity. + if login_result.no_login || pubkey_hex.is_empty() { + println!("[login] No-login mode (browsing without Nostr identity)"); + app_set_signer(None, "", "", key_store::KeyStoreMethod::None, true); + settings::settings_load(); + shortcuts::shortcuts_load(); + return true; + } + + if switch_to_user_db(&pubkey_hex) != 0 { return false; } + + app_set_signer(login_result.signer, &pubkey_hex, &privkey_hex, method, readonly); + let _ = key_store::key_store_save_profile_identity(&pubkey_hex, method); + settings::settings_load(); + shortcuts::shortcuts_load(); + + let pubkey = pubkey_hex.clone(); + std::thread::spawn(move || { relay_fetch::relay_fetch_thread(pubkey); }); + + true + } + None => false, + } +} + +/// Auto-login from CLI arguments (no dialog). +/// Supports --local (nsec/hex), --pubkey (npub/hex, read-only), --seed (mnemonic). +fn do_cli_login(args: &cli::CliArgs) -> bool { + use nostr_signer::local::LocalSigner; + + let method = match args.login_method { + Some(m) => m, + None => return false, + }; + + match method { + key_store::KeyStoreMethod::Local => { + let key_str = match &args.privkey { + Some(k) => k, + None => { + eprintln!("[login] --local requires a key argument"); + return false; + } + }; + let sk = match login_dialog::decode_nsec(key_str) { + Some(sk) => sk, + None => { + eprintln!("[login] Invalid nsec/hex key"); + return false; + } + }; + let pubkey_hex = match login_dialog::derive_pubkey_hex(&sk) { + Some(p) => p, + None => { + eprintln!("[login] Failed to derive pubkey"); + return false; + } + }; + let signer = match LocalSigner::new(sk.clone()) { + Ok(s) => Some(Arc::new(s) as Arc), + Err(e) => { + eprintln!("[login] Failed to create signer: {}", e); + return false; + } + }; + println!("[login] Logged in with local key: pubkey={}", pubkey_hex); + if switch_to_user_db(&pubkey_hex) != 0 { return false; } + app_set_signer(signer, &pubkey_hex, &sk.to_hex(), key_store::KeyStoreMethod::Local, false); + let _ = key_store::key_store_save_profile_identity(&pubkey_hex, key_store::KeyStoreMethod::Local); + settings::settings_load(); + shortcuts::shortcuts_load(); + let pk = pubkey_hex.clone(); + std::thread::spawn(move || { relay_fetch::relay_fetch_thread(pk); }); + true + } + key_store::KeyStoreMethod::Readonly => { + let key_str = match &args.pubkey { + Some(k) => k, + None => { + eprintln!("[login] --pubkey requires a key argument"); + return false; + } + }; + let pubkey_hex = match login_dialog::decode_npub_hex(key_str) { + Some(p) => p, + None => { + eprintln!("[login] Invalid npub/hex key"); + return false; + } + }; + println!("[login] Logged in read-only: pubkey={}", pubkey_hex); + if switch_to_user_db(&pubkey_hex) != 0 { return false; } + app_set_signer(None, &pubkey_hex, "", key_store::KeyStoreMethod::Readonly, true); + let _ = key_store::key_store_save_profile_identity(&pubkey_hex, key_store::KeyStoreMethod::Readonly); + settings::settings_load(); + shortcuts::shortcuts_load(); + true + } + key_store::KeyStoreMethod::Seed => { + let mnemonic = match &args.mnemonic { + Some(m) => m, + None => { + eprintln!("[login] --seed requires a mnemonic argument"); + return false; + } + }; + let seed = nostr_nips::nip006::mnemonic_to_seed(mnemonic, ""); + let (sk, _pk) = match nostr_nips::nip006::keypair_from_seed(&seed) { + Ok(kp) => kp, + Err(e) => { + eprintln!("[login] Failed to derive keypair from seed: {}", e); + return false; + } + }; + let pubkey_hex = match login_dialog::derive_pubkey_hex(&sk) { + Some(p) => p, + None => { + eprintln!("[login] Failed to derive pubkey"); + return false; + } + }; + let signer = match LocalSigner::new(sk.clone()) { + Ok(s) => Some(Arc::new(s) as Arc), + Err(e) => { + eprintln!("[login] Failed to create signer: {}", e); + return false; + } + }; + println!("[login] Logged in with seed: pubkey={}", pubkey_hex); + if switch_to_user_db(&pubkey_hex) != 0 { return false; } + app_set_signer(signer, &pubkey_hex, &sk.to_hex(), key_store::KeyStoreMethod::Seed, false); + let _ = key_store::key_store_save_profile_identity(&pubkey_hex, key_store::KeyStoreMethod::Seed); + settings::settings_load(); + shortcuts::shortcuts_load(); + let pk = pubkey_hex.clone(); + std::thread::spawn(move || { relay_fetch::relay_fetch_thread(pk); }); + true + } + _ => { + eprintln!("[login] CLI login not supported for this method"); + false + } + } +} + +fn build_context_for_current_user() -> WebContext { + let ctx = web_context::web_context_create(); + let pubkey_hex = menu::app_get_pubkey_hex(); + let signer = menu::app_get_signer(); + let readonly = menu::app_get_readonly(); + if !pubkey_hex.is_empty() { + webkit_data::webkit_data_configure(&ctx, &pubkey_hex); + } + nostr_bridge::nostr_bridge_register(&ctx, signer, &pubkey_hex, readonly); + ctx +} + +/// Handle a key-press event, dispatching browser-level shortcuts. +/// Mirrors the C version (main.c) with all shortcuts. +fn handle_key_press(_window: >k::Window, event: &gdk::EventKey) -> glib::Propagation { + let ctrl = event.state().contains(gdk::ModifierType::CONTROL_MASK); + let shift = event.state().contains(gdk::ModifierType::SHIFT_MASK); + let alt = event.state().contains(gdk::ModifierType::MOD1_MASK); + let key = event.keyval(); + + // When the active tab is showing a sovereign:// internal page (e.g. + // the settings page's keyboard-shortcut capture), bypass shortcut + // interception so the page's JS can capture arbitrary key combos. + let tabs = tab_manager::tab_manager_get_tabs(); + let active_id = tab_manager::tab_manager_get_active_tab(); + if let Some(tab) = tabs.iter().find(|t| t.id == active_id) { + if tab.url.starts_with("sovereign://") || tab.url.starts_with("about:settings") { + return glib::Propagation::Proceed; + } + } + + // Alt+Left/Right (back/forward) — no modifier mask check needed. + if alt && !ctrl && !shift { + match key { + gdk::keys::constants::Left => { + tab_manager::tab_manager_go_back(); + return glib::Propagation::Stop; + } + gdk::keys::constants::Right => { + tab_manager::tab_manager_go_forward(); + return glib::Propagation::Stop; + } + _ => {} + } + } + + // F-key shortcuts (no modifiers). + if !ctrl && !shift && !alt { + match key { + gdk::keys::constants::F5 => { + tab_manager::tab_manager_reload_active(); + return glib::Propagation::Stop; + } + gdk::keys::constants::F11 => { + tab_manager::tab_manager_toggle_fullscreen(); + return glib::Propagation::Stop; + } + _ => {} + } + } + + // Shift+F5 (force reload). + if shift && !ctrl && !alt && key == gdk::keys::constants::F5 { + if let Some(nb) = tab_manager::tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) { + wv.reload_bypass_cache(); + } + } + return glib::Propagation::Stop; + } + + // Ctrl+ key combinations. + if ctrl && !shift && !alt { + match key { + gdk::keys::constants::t => { + if let Some(nb) = tab_manager::tab_manager_get_active_notebook() { + let ctx = build_context_for_current_user(); + tab_manager::tab_manager_new_tab(&nb, &ctx, None); + } + return glib::Propagation::Stop; + } + gdk::keys::constants::n => { + tab_manager::tab_manager_new_window_blank(); + return glib::Propagation::Stop; + } + gdk::keys::constants::o => { + tab_manager::tab_manager_open_file(); + return glib::Propagation::Stop; + } + gdk::keys::constants::w => { + tab_manager::tab_manager_close_active(); + return glib::Propagation::Stop; + } + gdk::keys::constants::l => { + tab_manager::tab_manager_focus_url(); + return glib::Propagation::Stop; + } + gdk::keys::constants::r => { + tab_manager::tab_manager_reload_active(); + return glib::Propagation::Stop; + } + gdk::keys::constants::f => { + if let Some(nb) = tab_manager::tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) { + // WebKit find controller. + wv.run_javascript("document.body.innerHTML += '
'; document.getElementById('sb-find-input').focus();", None::<&gio::Cancellable>, |_| {}); + } + } + return glib::Propagation::Stop; + } + gdk::keys::constants::d => { + // Bookmark current page. + if let Some(nb) = tab_manager::tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager::tab_manager_get_active_webview(&nb) { + let url = wv.uri().unwrap_or_default().to_string(); + let title = wv.title().unwrap_or_default().to_string(); + let _ = db::db_add_bookmark(&title, &url, ""); + } + } + return glib::Propagation::Stop; + } + gdk::keys::constants::h => { + tab_manager::tab_manager_open_internal("sovereign://profile"); + return glib::Propagation::Stop; + } + gdk::keys::constants::b => { + tab_manager::tab_manager_open_internal("sovereign://bookmarks"); + return glib::Propagation::Stop; + } + gdk::keys::constants::j => { + tab_manager::tab_manager_open_internal("sovereign://processes"); + return glib::Propagation::Stop; + } + gdk::keys::constants::q => { + session::session_save(); + gtk::main_quit(); + return glib::Propagation::Stop; + } + gdk::keys::constants::comma => { + tab_manager::tab_manager_open_internal("sovereign://settings"); + return glib::Propagation::Stop; + } + gdk::keys::constants::Tab => { + tab_manager::tab_manager_next(); + return glib::Propagation::Stop; + } + gdk::keys::constants::Page_Down => { + tab_manager::tab_manager_next(); + return glib::Propagation::Stop; + } + gdk::keys::constants::Page_Up => { + tab_manager::tab_manager_prev(); + return glib::Propagation::Stop; + } + gdk::keys::constants::plus | gdk::keys::constants::equal => { + tab_manager::tab_manager_zoom_in(); + return glib::Propagation::Stop; + } + gdk::keys::constants::minus => { + tab_manager::tab_manager_zoom_out(); + return glib::Propagation::Stop; + } + gdk::keys::constants::_0 => { + tab_manager::tab_manager_zoom_reset(); + return glib::Propagation::Stop; + } + gdk::keys::constants::Shift_L | gdk::keys::constants::Shift_R => { + return glib::Propagation::Proceed; + } + _ => {} + } + } + + // Ctrl+Shift+ key combinations. + if ctrl && shift && !alt { + match key { + gdk::keys::constants::Tab => { + tab_manager::tab_manager_prev(); + return glib::Propagation::Stop; + } + gdk::keys::constants::i => { + tab_manager::tab_manager_toggle_inspector(); + return glib::Propagation::Stop; + } + gdk::keys::constants::a => { + tab_manager::tab_manager_toggle_sidebar(); + return glib::Propagation::Stop; + } + gdk::keys::constants::m => { + tab_manager::tab_manager_toggle_toolbars(); + return glib::Propagation::Stop; + } + gdk::keys::constants::u => { + crate::menu::menu_switch_identity(); + return glib::Propagation::Stop; + } + gdk::keys::constants::Escape => { + tab_manager::tab_manager_open_internal("sovereign://processes"); + return glib::Propagation::Stop; + } + _ => {} + } + } + + glib::Propagation::Proceed +} + +fn main() { + let args = cli::cli_parse(); + + if args.help { cli::cli_print_help(); return; } + if args.version { cli::cli_print_version(); return; } + + gtk::init().expect("Failed to initialize GTK"); + + nostr_bridge::nostr_bridge_init(); + agent_tools::agent_tools_init(); + agent_chat::agent_chat_init(); + agent_server::agent_server_init(); + agent_login::agent_login_init(); + agent_llm::agent_llm_init(); + net_services::net_services_init(); + tor_control::tor_control_init(); + fips_control::fips_control_init(); + site_downloader::site_downloader_init(); + process_info::process_info_init(); + perf_probe::perf_probe_init(); + shortcuts::shortcuts_init(); + + let _ = key_store::key_store_delete_legacy_file(); + + // Create window but don't show it yet + let window = gtk::Window::new(gtk::WindowType::Toplevel); + window.set_title("sovereign_browser"); + window.set_default_size(1200, 800); + window.set_icon_name(Some("web-browser")); + + // Show login dialog first (before creating tabs), unless --no-login or + // CLI credentials were provided (--local, --seed, --pubkey, --bunker). + if args.no_login { + println!("[login] No-login mode (browsing without Nostr identity)"); + menu::app_set_signer(None, "", "", key_store::KeyStoreMethod::None, true); + } else if args.login_method.is_some() { + // Auto-login from CLI args (no dialog). + if !do_cli_login(&args) { + // CLI login failed — fall back to dialog. + println!("[login] CLI login failed, showing dialog..."); + do_login(&window); + } + } else { + println!("[login] Showing login dialog..."); + do_login(&window); + } + + // Now build the UI. Each tab carries its own toolbar (URL entry, + // back/forward/refresh, bookmark, hamburger menu), so there is no + // shared top-level toolbar. + // + // The window uses a GtkPaned (horizontal) so the agent chat sidebar + // can be toggled on the left side. The notebook goes in the right pane. + let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0); + window.add(&vbox); + + let paned = gtk::Paned::new(gtk::Orientation::Horizontal); + vbox.add(&paned); + + // Sidebar container (left pane) — hidden by default. + let sidebar_container = gtk::Box::new(gtk::Orientation::Vertical, 0); + sidebar_container.set_size_request(280, -1); + paned.add1(&sidebar_container); + paned.set_position(0); + + let notebook = gtk::Notebook::new(); + notebook.set_scrollable(false); + notebook.set_show_tabs(true); + notebook.set_show_border(false); + paned.add2(¬ebook); + + tab_manager::tab_manager_init(); + let ctx = build_context_for_current_user(); + tab_manager::tab_manager_set_main_window(&window, ¬ebook); + tab_manager::tab_manager_set_sidebar(&paned, &sidebar_container, &ctx); + tab_manager::tab_manager_apply_theme(); + + // Create initial tab(s). + // If CLI URLs were provided, open them; otherwise open about:blank. + println!("[browser] Creating initial tab..."); + if !args.urls.is_empty() { + for url in &args.urls { + tab_manager::tab_manager_new_tab(¬ebook, &ctx, Some(url)); + } + } else { + tab_manager::tab_manager_new_tab(¬ebook, &ctx, Some("about:blank")); + } + + // Restore session if enabled + let browser_settings = settings::settings_get(); + if browser_settings.session_restore { + let urls = session::session_restore(); + for url in urls { + tab_manager::tab_manager_new_tab(¬ebook, &ctx, Some(&url)); + } + } + + // Wire keyboard shortcuts. + let window_shortcuts = window.clone(); + window.connect_key_press_event(move |_w, event| { + handle_key_press(&window_shortcuts, event) + }); + + window.connect_delete_event(|_, _| { + session::session_save(); + gtk::main_quit(); + glib::Propagation::Proceed + }); + + // Show window now that everything is set up. + // The new-tab button is in each tab's toolbar (added by tab_manager_new_tab). + println!("[browser] Showing window..."); + window.show_all(); + + if !args.no_agent { + if let Err(e) = agent_server::agent_server_start() { + eprintln!("[server] Failed to start agent server: {}", e); + } + } + + println!("[browser] Starting GTK main loop..."); + gtk::main(); +} diff --git a/src/menu.rs b/src/menu.rs new file mode 100644 index 0000000..758115c --- /dev/null +++ b/src/menu.rs @@ -0,0 +1,181 @@ +//! Menu actions and app state for sovereign_browser +//! +//! Port of the menu proxy functions from `main.c` in the C project. +//! These are called from the hamburger menu in `tab_manager.rs`. +//! +//! This module also owns the app state (signer, pubkey, method) so that +//! both the binary (`main.rs`) and the library modules (`tab_manager.rs`, +//! `menu.rs`) can share it without cross-crate-root references. + +use gtk::prelude::*; +use nostr_signer::traits::NostrSigner; +use std::sync::Arc; +use std::sync::Mutex; +use once_cell::sync::Lazy; + +use crate::key_store; +use crate::login_dialog; +use crate::nostr_bridge; +use crate::tab_manager; + +/// App state (signer, identity, method). +pub struct AppState { + pub signer: Option>, + pub pubkey_hex: String, + pub privkey_hex: String, + pub method: key_store::KeyStoreMethod, + pub readonly: bool, +} + +impl Default for AppState { + fn default() -> Self { + AppState { + signer: None, + pubkey_hex: String::new(), + privkey_hex: String::new(), + method: key_store::KeyStoreMethod::None, + readonly: true, + } + } +} + +/// Global app state. +static G_STATE: Lazy> = Lazy::new(|| Mutex::new(AppState::default())); + +/// Set the signer and update dependent modules. +pub fn app_set_signer( + signer: Option>, + pubkey_hex: &str, + privkey_hex: &str, + method: key_store::KeyStoreMethod, + readonly: bool, +) { + let mut state = G_STATE.lock().unwrap(); + state.signer = signer; + state.pubkey_hex = pubkey_hex.to_string(); + state.privkey_hex = privkey_hex.to_string(); + state.method = method; + state.readonly = readonly; + + nostr_bridge::nostr_bridge_set_signer(state.signer.clone(), &state.pubkey_hex, state.readonly); + crate::agent_login::agent_login_set_signer(state.signer.clone(), &state.pubkey_hex); + crate::settings_sync::settings_sync_init(state.signer.clone(), &state.pubkey_hex); + crate::agent_conversations::agent_conversations_init(state.signer.clone(), &state.pubkey_hex); + crate::agent_skills::agent_skills_init(state.signer.clone(), &state.pubkey_hex); +} + +/// Clear the signer and reset app state (used by logout / lock session). +pub fn app_clear_signer() { + let mut state = G_STATE.lock().unwrap(); + state.signer = None; + state.pubkey_hex.clear(); + state.privkey_hex.clear(); + state.method = key_store::KeyStoreMethod::None; + state.readonly = true; + nostr_bridge::nostr_bridge_set_signer(None, "", true); +} + +/// Get the current pubkey. +pub fn app_get_pubkey_hex() -> String { + let state = G_STATE.lock().unwrap(); + state.pubkey_hex.clone() +} + +/// Get the current signer. +pub fn app_get_signer() -> Option> { + let state = G_STATE.lock().unwrap(); + state.signer.clone() +} + +/// Get the current method. +pub fn app_get_method() -> key_store::KeyStoreMethod { + let state = G_STATE.lock().unwrap(); + state.method +} + +/// Get whether the session is read-only. +pub fn app_get_readonly() -> bool { + let state = G_STATE.lock().unwrap(); + state.readonly +} + +/// Run the login dialog and install the resulting identity. +/// Switch to the per-user profile database. +pub fn switch_to_user_db(pubkey_hex: &str) -> i32 { + match crate::db::db_init(pubkey_hex) { + Ok(_) => { + println!("[db] Switched to profile database for {}", pubkey_hex); + 0 + } + Err(e) => { + eprintln!("[db] Failed to open database: {}", e); + -1 + } + } +} + +/// Run the login dialog and install the resulting identity. +fn do_login(parent: >k::Window) -> bool { + let result = login_dialog::login_dialog_run(parent); + match result { + Some(login_result) => { + let pubkey_hex = login_result.pubkey_hex.clone(); + let privkey_hex = login_result.identity.privkey_hex.clone(); + let method = login_result.method; + let readonly = login_result.signer.is_none(); + + if login_result.no_login || pubkey_hex.is_empty() { + println!("[login] No-login mode (browsing without Nostr identity)"); + app_set_signer(None, "", "", key_store::KeyStoreMethod::None, true); + crate::settings::settings_load(); + crate::shortcuts::shortcuts_load(); + return true; + } + + if switch_to_user_db(&pubkey_hex) != 0 { + return false; + } + + app_set_signer(login_result.signer, &pubkey_hex, &privkey_hex, method, readonly); + let _ = key_store::key_store_save_profile_identity(&pubkey_hex, method); + crate::settings::settings_load(); + crate::shortcuts::shortcuts_load(); + + let pubkey = pubkey_hex.clone(); + std::thread::spawn(move || { + crate::relay_fetch::relay_fetch_thread(pubkey); + }); + + true + } + None => false, + } +} + +/// Menu action: switch identity (re-show the login dialog). +pub fn menu_switch_identity() { + if let Some(nb) = tab_manager::tab_manager_get_main_notebook() { + if let Some(win) = nb.toplevel().and_then(|w| w.downcast::().ok()) { + if do_login(&win) { + println!("[identity] switched identity"); + } + } + } +} + +/// Menu action: logout (clear signer, re-show login). +pub fn menu_logout() { + app_clear_signer(); + println!("[identity] logged out"); + if let Some(nb) = tab_manager::tab_manager_get_main_notebook() { + if let Some(win) = nb.toplevel().and_then(|w| w.downcast::().ok()) { + do_login(&win); + } + } +} + +/// Menu action: lock session (clear signer, keep identity). +pub fn menu_lock_session() { + app_clear_signer(); + println!("[identity] session locked (signer cleared, identity preserved)"); +} diff --git a/src/net_services.rs b/src/net_services.rs new file mode 100644 index 0000000..c1ebe5d --- /dev/null +++ b/src/net_services.rs @@ -0,0 +1,125 @@ +//! Network services management (Tor, FIPS, etc.) +//! +//! Port of `net_services.c` / `net_services.h` from the C project. + +use std::sync::Mutex; +use once_cell::sync::Lazy; + +/// Network service status. +#[derive(Debug, Clone, PartialEq)] +#[allow(dead_code)] +pub enum ServiceStatus { + Stopped, + Starting, + Running, + Error(String), +} + +/// Network services state. +static G_NET_SERVICES: Lazy> = Lazy::new(|| Mutex::new(NetServicesState::default())); + +struct NetServicesState { + tor_status: ServiceStatus, + fips_status: ServiceStatus, +} + +impl Default for NetServicesState { + fn default() -> Self { + NetServicesState { + tor_status: ServiceStatus::Stopped, + fips_status: ServiceStatus::Stopped, + } + } +} + +/// Initialize network services. +pub fn net_services_init() { + let mut state = G_NET_SERVICES.lock().unwrap(); + state.tor_status = ServiceStatus::Stopped; + state.fips_status = ServiceStatus::Stopped; +} + +/// Get Tor status. +pub fn net_services_get_tor_status() -> ServiceStatus { + let state = G_NET_SERVICES.lock().unwrap(); + state.tor_status.clone() +} + +/// Get FIPS status. +pub fn net_services_get_fips_status() -> ServiceStatus { + let state = G_NET_SERVICES.lock().unwrap(); + state.fips_status.clone() +} + +/// Start Tor. +pub fn net_services_start_tor() -> Result<(), Box> { + let mut state = G_NET_SERVICES.lock().unwrap(); + state.tor_status = ServiceStatus::Starting; + // TODO: actually start Tor + state.tor_status = ServiceStatus::Running; + println!("[net] Tor started"); + Ok(()) +} + +/// Stop Tor. +pub fn net_services_stop_tor() { + let mut state = G_NET_SERVICES.lock().unwrap(); + state.tor_status = ServiceStatus::Stopped; + println!("[net] Tor stopped"); +} + +/// Start FIPS. +pub fn net_services_start_fips() -> Result<(), Box> { + let mut state = G_NET_SERVICES.lock().unwrap(); + state.fips_status = ServiceStatus::Starting; + // TODO: actually start FIPS + state.fips_status = ServiceStatus::Running; + println!("[net] FIPS started"); + Ok(()) +} + +/// Stop FIPS. +pub fn net_services_stop_fips() { + let mut state = G_NET_SERVICES.lock().unwrap(); + state.fips_status = ServiceStatus::Stopped; + println!("[net] FIPS stopped"); +} + +/// Get the Tor SOCKS proxy endpoint. +/// +/// Returns the SOCKS5 endpoint (e.g. "socks5h://127.0.0.1:9050") if Tor +/// is available, or None if Tor is not running. The "socks5h" prefix +/// tells the proxy to perform DNS resolution through Tor (remote DNS), +/// which is required for .onion hostnames. +/// +/// Checks both the internal service status AND whether the SOCKS port +/// is actually reachable (e.g. a system Tor daemon started outside the +/// browser). +pub fn net_services_get_tor_socks_endpoint() -> Option { + { + let state = G_NET_SERVICES.lock().unwrap(); + if state.tor_status == ServiceStatus::Running { + return Some("socks5h://127.0.0.1:9050".to_string()); + } + } + + // Check if a system Tor is listening on the default SOCKS port. + if tor_socks_port_available() { + Some("socks5h://127.0.0.1:9050".to_string()) + } else { + None + } +} + +/// Check if Tor's SOCKS port (9050) is accepting connections. +fn tor_socks_port_available() -> bool { + use std::net::TcpStream; + use std::time::Duration; + match TcpStream::connect_timeout( + &"127.0.0.1:9050".parse().unwrap(), + Duration::from_millis(300), + ) { + Ok(_) => true, + Err(_) => false, + } +} diff --git a/src/nostr_bridge.rs b/src/nostr_bridge.rs new file mode 100644 index 0000000..0243119 --- /dev/null +++ b/src/nostr_bridge.rs @@ -0,0 +1,939 @@ +//! sovereign:// URI scheme handler for window.nostr +//! +//! Port of `nostr_bridge.c` from the C project. + +use glib::Bytes; +use gio::MemoryInputStream; +use gio::prelude::InputStreamExtManual; +use nostr_signer::traits::NostrSigner; +use std::sync::Arc; +use std::sync::Mutex; +use once_cell::sync::Lazy; +use webkit2gtk::*; + +use crate::settings; +use crate::db; + +static G_BRIDGE: Lazy> = Lazy::new(|| { + Mutex::new(BridgeState { + signer: None, + pubkey_hex: String::new(), + readonly: true, + }) +}); + +struct BridgeState { + signer: Option>, + pubkey_hex: String, + readonly: bool, +} + +pub fn nostr_bridge_init() {} + +pub fn nostr_bridge_set_signer(signer: Option>, pubkey_hex: &str, readonly: bool) { + let mut state = G_BRIDGE.lock().unwrap(); + state.signer = signer; + state.pubkey_hex = pubkey_hex.to_string(); + state.readonly = readonly; +} + +pub fn nostr_bridge_register( + ctx: &WebContext, + signer: Option>, + pubkey_hex: &str, + readonly: bool, +) { + nostr_bridge_set_signer(signer, pubkey_hex, readonly); + ctx.register_uri_scheme("sovereign", |request| { + handle_sovereign_scheme(request); + }); +} + +fn handle_sovereign_scheme(request: &URISchemeRequest) { + let uri = request.uri().unwrap_or_default(); + let path = if let Some(stripped) = uri.strip_prefix("sovereign://") { + stripped + } else { + respond_error_json(request, 400, "Invalid URI"); + return; + }; + + // Strip query string for routing (e.g. "profile/data?_=123"). + let route = path.split('?').next().unwrap_or(path); + + // ── nostr API ─────────────────────────────────────────────────── + if route.starts_with("nostr/") { + handle_nostr_api(request, &route[6..]); + return; + } + + // ── Settings ─────────────────────────────────────────────────── + if route.starts_with("settings/set") { + let query = path.split_once('?').map(|(_, q)| q).unwrap_or(""); + handle_settings_set(request, query); + return; + } + if route == "settings/config" { + handle_settings_config_json(request); + return; + } + + // ── Bookmarks REST API ───────────────────────────────────────── + if route.starts_with("bookmarks/") { + handle_bookmarks_api(request, &route[10..]); + return; + } + + // ── Processes REST API ───────────────────────────────────────── + if route.starts_with("processes/") { + handle_processes_api(request, &route[10..]); + return; + } + + // ── Profile data ─────────────────────────────────────────────── + if route == "profile/data" { + handle_profile_data_json(request); + return; + } + + // ── QR code generation ───────────────────────────────────────── + if route == "qr" || route.starts_with("qr?") { + handle_qr(request, &path); + return; + } + + // ── Security redirects to settings ───────────────────────────── + if route == "security" { + respond_html(request, &crate::embedded_web_content::get_security_html()); + return; + } + + // ── Embedded assets (HTML, CSS, JS, vendor) ──────────────────── + if let Some((content, mime)) = crate::embedded_web_content::get_content_with_mime(route) { + respond_bytes(request, &content, mime); + return; + } + + respond_error_json(request, 404, "Not found"); +} + +/// Handle sovereign://processes/* REST API. +/// Routes: list, tabs, probe-report, tab_probe, tab_action. +fn handle_processes_api(request: &URISchemeRequest, sub: &str) { + let route = sub.split('?').next().unwrap_or(sub); + let query = sub.split_once('?').map(|(_, q)| q).unwrap_or(""); + + match route { + "list" => { + // Layer 1: OS process list (simplified — just browser + webprocess). + let processes = crate::process_info::process_info_get_all(); + respond_json(request, &serde_json::to_string(&processes).unwrap_or_default()); + } + "tabs" => { + // Layer 2: per-tab list with probe data. + let tabs = crate::tab_manager::tab_manager_get_tabs(); + let tab_infos: Vec = tabs.iter().map(|t| { + serde_json::json!({ + "id": t.id, + "title": t.title, + "url": t.url, + "is_internal": t.url.starts_with("sovereign://") || t.url.starts_with("about:"), + }) + }).collect(); + respond_json(request, &serde_json::to_string(&tab_infos).unwrap_or_default()); + } + "probe-report" => { + // Receive perf probe report from injected JS. + // ?tab_index=N&body= + let tab_index = extract_query_param(query, "tab_index") + .and_then(|s| s.parse::().ok()) + .unwrap_or(-1); + let body = extract_query_param(query, "body").unwrap_or_default(); + if let Ok(report) = serde_json::from_str::(&body) { + crate::perf_probe::perf_probe_record_report(tab_index, &report); + } + // Return empty JSON (204-like). + respond_json(request, "{}"); + } + "tab_probe" => { + // Drill-down for a single tab. + let index = extract_query_param(query, "index") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let report = crate::perf_probe::perf_probe_get_report(index); + respond_json(request, &serde_json::to_string(&report).unwrap_or_default()); + } + "tab_action" => { + // ?index=N&action=reload|suspend|close + let _index = extract_query_param(query, "index") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let action = extract_query_param(query, "action").unwrap_or_default(); + match action.as_str() { + "reload" => { + glib::idle_add_once(move || { + if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { + if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { + wv.reload(); + } + } + }); + } + "close" => { + glib::idle_add_once(move || { + crate::tab_manager::tab_manager_close_active(); + }); + } + _ => {} + } + respond_json(request, &serde_json::json!({"status": "ok"}).to_string()); + } + _ => respond_error_json(request, 404, "Unknown processes route"), + } +} + +/// Handle sovereign://bookmarks/* REST API. +/// Mirrors the C version routes: list, add, delete, createdir, deletedir, +/// move, rename, renamedir. Returns JSON (or redirects back to bookmarks). +fn handle_bookmarks_api(request: &URISchemeRequest, sub: &str) { + let route = sub.split('?').next().unwrap_or(sub); + let query = sub.split_once('?').map(|(_, q)| q).unwrap_or(""); + + match route { + "list" => { + let have_signer = !G_BRIDGE.lock().unwrap().readonly; + let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default(); + let tree = build_bookmark_tree(&bookmarks); + let json = serde_json::json!({ + "have_signer": have_signer, + "tree": tree, + }); + respond_json(request, &serde_json::to_string(&json).unwrap_or_default()); + } + "add" => { + let dir = extract_query_param(query, "dir").unwrap_or_default(); + let url = extract_query_param(query, "url").unwrap_or_default(); + let title = extract_query_param(query, "title").unwrap_or_default(); + match crate::bookmarks::bookmarks_add(&title, &url, &dir) { + Ok(_) => respond_redirect(request, "sovereign://bookmarks"), + Err(e) => respond_error_json(request, 500, &e.to_string()), + } + } + "delete" => { + let dir = extract_query_param(query, "dir").unwrap_or_default(); + let url = extract_query_param(query, "url").unwrap_or_default(); + // Find the bookmark id by url+folder and delete. + let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default(); + let found = bookmarks.iter().find(|b| { + b["url"].as_str() == Some(url.as_str()) + && b["folder"].as_str().unwrap_or("") == dir.as_str() + }).and_then(|b| b["id"].as_i64()); + match found { + Some(id) => match crate::bookmarks::bookmarks_delete(id) { + Ok(_) => respond_redirect(request, "sovereign://bookmarks"), + Err(e) => respond_error_json(request, 500, &e.to_string()), + }, + None => respond_error_json(request, 404, "Bookmark not found"), + } + } + "createdir" => { + // Folders are implicit (folder path on bookmarks). Creating a dir + // is a no-op — the folder appears when a bookmark is added to it. + respond_redirect(request, "sovereign://bookmarks"); + } + "deletedir" => { + let dir = extract_query_param(query, "dir").unwrap_or_default(); + // Delete all bookmarks under this folder path. + let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default(); + let ids: Vec = bookmarks.iter() + .filter(|b| b["folder"].as_str().unwrap_or("").starts_with(&format!("{}/", dir)) + || b["folder"].as_str().unwrap_or("") == dir.as_str()) + .filter_map(|b| b["id"].as_i64()) + .collect(); + for id in ids { + let _ = crate::bookmarks::bookmarks_delete(id); + } + respond_redirect(request, "sovereign://bookmarks"); + } + "rename" => { + let dir = extract_query_param(query, "dir").unwrap_or_default(); + let url = extract_query_param(query, "url").unwrap_or_default(); + let title = extract_query_param(query, "title").unwrap_or_default(); + // Update title via direct DB access. + let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default(); + let found = bookmarks.iter().find(|b| { + b["url"].as_str() == Some(url.as_str()) + && b["folder"].as_str().unwrap_or("") == dir.as_str() + }).and_then(|b| b["id"].as_i64()); + if let Some(id) = found { + let _ = db::db_update_bookmark_title(id, &title); + } + respond_redirect(request, "sovereign://bookmarks"); + } + "renamedir" => { + let old = extract_query_param(query, "old").unwrap_or_default(); + let new = extract_query_param(query, "new").unwrap_or_default(); + let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default(); + for b in &bookmarks { + let folder = b["folder"].as_str().unwrap_or("").to_string(); + if let Some(id) = b["id"].as_i64() { + if folder == old || folder.starts_with(&format!("{}/", old)) { + let new_folder = if folder == old { + new.clone() + } else { + format!("{}{}", new, &folder[old.len()..]) + }; + let _ = db::db_update_bookmark_folder(id, &new_folder); + } + } + } + respond_redirect(request, "sovereign://bookmarks"); + } + "move" => { + let from = extract_query_param(query, "from").unwrap_or_default(); + let to = extract_query_param(query, "to").unwrap_or_default(); + let url = extract_query_param(query, "url").unwrap_or_default(); + let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default(); + let found = bookmarks.iter().find(|b| { + b["url"].as_str() == Some(url.as_str()) + && b["folder"].as_str().unwrap_or("") == from.as_str() + }).and_then(|b| b["id"].as_i64()); + if let Some(id) = found { + let _ = db::db_update_bookmark_folder(id, &to); + } + respond_redirect(request, "sovereign://bookmarks"); + } + _ => respond_error_json(request, 404, "Unknown bookmarks route"), + } +} + +/// Build the bookmark tree JSON from a flat bookmark list. +/// Shape: {"name": "", "path": "", "bookmarks": [], "children": [...]} +fn build_bookmark_tree(bookmarks: &[serde_json::Value]) -> serde_json::Value { + // Collect distinct folder paths. + let mut folders: Vec = Vec::new(); + for b in bookmarks { + let f = b["folder"].as_str().unwrap_or("").to_string(); + if !f.is_empty() && !folders.contains(&f) { + folders.push(f); + } + } + + // Build a tree of folder path components. + fn node(name: &str, path: &str) -> serde_json::Value { + serde_json::json!({ + "name": name, + "path": path, + "count": 0, + "bookmarks": [], + "children": [], + }) + } + + // Root node. + let mut root = node("", ""); + let root_bookmarks: Vec = bookmarks.iter() + .filter(|b| b["folder"].as_str().unwrap_or("").is_empty()) + .cloned() + .collect(); + root["bookmarks"] = serde_json::Value::Array(root_bookmarks); + + // Build folder tree. + let mut sorted_folders = folders.clone(); + sorted_folders.sort(); + for f in &sorted_folders { + let parts: Vec<&str> = f.split('/').collect(); + let mut current = &mut root; + let mut path_acc = String::new(); + for (i, part) in parts.iter().enumerate() { + if i > 0 { path_acc.push('/'); } + path_acc.push_str(part); + let children = current["children"].as_array_mut().unwrap(); + let idx = children.iter().position(|c| c["name"].as_str() == Some(*part)); + let child_idx = match idx { + Some(i) => i, + None => { + children.push(node(part, &path_acc)); + children.len() - 1 + } + }; + current = &mut children[child_idx]; + } + // Attach bookmarks in this folder. + let fms: Vec = bookmarks.iter() + .filter(|b| b["folder"].as_str().unwrap_or("") == f.as_str()) + .cloned() + .collect(); + let fms_len = fms.len(); + current["bookmarks"] = serde_json::Value::Array(fms); + current["count"] = serde_json::json!(fms_len); + } + + root +} + +/// Redirect a request to another sovereign:// URL. +fn respond_redirect(request: &URISchemeRequest, target: &str) { + let html = format!( + "\ + \ + Redirecting…", + target + ); + respond_html(request, &html); +} + +/// Handle sovereign://profile/data — return the user's Nostr profile data. +fn handle_profile_data_json(request: &URISchemeRequest) { + let state = G_BRIDGE.lock().unwrap(); + let pubkey_hex = state.pubkey_hex.clone(); + let readonly = state.readonly; + let method = crate::menu::app_get_method().as_str().to_string(); + drop(state); + + // Query kind 0 (profile) events from the DB. + let events = db::db_query_events(&[0], None, None, None, Some(20)).unwrap_or_default(); + let kind0 = events.first(); + let kind0_present = kind0.is_some(); + + let kind0_content: serde_json::Value = kind0 + .and_then(|e| e.get("content").and_then(|c| c.as_str())) + .and_then(|c| serde_json::from_str(c).ok()) + .unwrap_or_else(|| serde_json::json!({})); + + // Query kind 3 (contacts) events. + let contacts_events = db::db_query_events(&[3], None, None, None, Some(20)).unwrap_or_default(); + let kind3 = contacts_events.first(); + let kind3_present = kind3.is_some(); + let contact_count = kind3 + .and_then(|e| e.get("tags")) + .and_then(|t| t.as_array()) + .map(|t| t.len()) + .unwrap_or(0); + + // Kind 10002 relay list. + let relay_events = db::db_query_events(&[10002], None, None, None, Some(20)).unwrap_or_default(); + let kind10002_count = relay_events.len(); + + let json = serde_json::json!({ + "pubkey": pubkey_hex, + "pubkey_short": if pubkey_hex.len() >= 12 { + format!("{}…{}", &pubkey_hex[..8], &pubkey_hex[pubkey_hex.len()-4..]) + } else { pubkey_hex.clone() }, + "npub": nostr_npub_of_hex(&pubkey_hex), + "method": method, + "signing": if readonly { "read-only" } else { "signing" }, + "picture": kind0_content.get("picture").and_then(|v| v.as_str()).unwrap_or("").to_string(), + "name": kind0_content.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(), + "display_name": kind0_content.get("display_name").and_then(|v| v.as_str()).unwrap_or("").to_string(), + "about": kind0_content.get("about").and_then(|v| v.as_str()).unwrap_or("").to_string(), + "website": kind0_content.get("website").and_then(|v| v.as_str()).unwrap_or("").to_string(), + "lud16": kind0_content.get("lud16").and_then(|v| v.as_str()).unwrap_or("").to_string(), + "nip05": kind0_content.get("nip05").and_then(|v| v.as_str()).unwrap_or("").to_string(), + "kind0_present": kind0_present, + "kind0_created": kind0.and_then(|e| e.get("created_at")).map(|c| c.to_string()).unwrap_or_default(), + "kind0_count": events.len(), + "kind3_present": kind3_present, + "contact_count": contact_count, + "kind3_created": kind3.and_then(|e| e.get("created_at")).map(|c| c.to_string()).unwrap_or_default(), + "kind3_count": contacts_events.len(), + "kind10002_count": kind10002_count, + "qr_url": if pubkey_hex.is_empty() { "" } else { + "sovereign://qr?text=npub-placeholder&box_size=6&border=4" + }, + }); + respond_json(request, &serde_json::to_string(&json).unwrap_or_default()); +} + +/// Convert a hex pubkey to an npub string (best-effort). +fn nostr_npub_of_hex(hex_str: &str) -> String { + use nostr_core::util::bech32::npub_encode; + let bytes = match hex::decode(hex_str) { + Ok(b) if b.len() == 32 => b, + _ => return String::new(), + }; + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + let pk = nostr_core::types::PublicKey::from_bytes(arr); + npub_encode(&pk).unwrap_or_default() +} + +/// Handle sovereign://qr?text=... — render a QR code as PNG. +fn handle_qr(request: &URISchemeRequest, full_path: &str) { + let query = full_path.split_once('?').map(|(_, q)| q).unwrap_or(""); + let text = extract_query_param(query, "text").unwrap_or_default(); + if text.is_empty() { + respond_error_json(request, 400, "Missing text parameter"); + return; + } + match crate::qr::qr_generate_png(&text) { + Ok(png) => respond_bytes_raw(request, png, "image/png"), + Err(e) => respond_error_json(request, 500, &e.to_string()), + } +} + +/// Handle sovereign://settings/set — toggle (feature=X) or set (key=X&value=Y). +/// Mirrors the C version (nostr_bridge.c:1036). +fn handle_settings_set(request: &URISchemeRequest, query: &str) { + // Toggle path: ?feature=X + if let Some(feature) = extract_query_param(query, "feature") { + let (new_state, reload) = match feature.as_str() { + "restore_session" => { + let mut s = settings::settings_get(); + s.session_restore = !s.session_restore; + settings::settings_update(&serde_json::json!({"session_restore": s.session_restore})); + settings::settings_save(); + (s.session_restore, false) + } + "show_tab_close_buttons" => { + let mut s = settings::settings_get(); + s.show_tab_close_buttons = !s.show_tab_close_buttons; + settings::settings_update(&serde_json::json!({"show_tab_close_buttons": s.show_tab_close_buttons})); + settings::settings_save(); + (s.show_tab_close_buttons, false) + } + "middle_click_close" => { + let mut s = settings::settings_get(); + s.middle_click_close = !s.middle_click_close; + settings::settings_update(&serde_json::json!({"middle_click_close": s.middle_click_close})); + settings::settings_save(); + (s.middle_click_close, false) + } + "ctrl_tab_switch" => { + let mut s = settings::settings_get(); + s.ctrl_tab_switch = !s.ctrl_tab_switch; + settings::settings_update(&serde_json::json!({"ctrl_tab_switch": s.ctrl_tab_switch})); + settings::settings_save(); + (s.ctrl_tab_switch, false) + } + "tab_drag_reorder" => { + let mut s = settings::settings_get(); + s.tab_drag_reorder = !s.tab_drag_reorder; + settings::settings_update(&serde_json::json!({"tab_drag_reorder": s.tab_drag_reorder})); + settings::settings_save(); + (s.tab_drag_reorder, false) + } + "theme_dark" => { + let mut s = settings::settings_get(); + s.theme_dark = !s.theme_dark; + settings::settings_update(&serde_json::json!({"theme_dark": s.theme_dark})); + settings::settings_save(); + (s.theme_dark, false) + } + "agent_server_enabled" => { + // Toggle agent server enable state. Stored in DB directly. + let current = db::db_kv_get("agent_server_enabled").ok().flatten().unwrap_or_else(|| "true".to_string()); + let new = if current == "true" { "false" } else { "true" }; + let _ = db::db_kv_set("agent_server_enabled", new); + (new == "true", true) + } + _ => { + respond_error_json(request, 400, "Unknown feature"); + return; + } + }; + println!("[settings] {}: {}", feature, if new_state { "ON" } else { "OFF" }); + let mut result = serde_json::json!({ "key": feature, "value": new_state }); + if reload { result["reload"] = serde_json::json!(true); } + respond_json(request, &serde_json::to_string(&result).unwrap_or_default()); + return; + } + + // Key/value path: ?key=X&value=Y + let key = match extract_query_param(query, "key") { + Some(k) => k, + None => { + respond_error_json(request, 400, "Missing key parameter"); + return; + } + }; + let value = extract_query_param(query, "value").unwrap_or_default(); + + // Handle shortcut pseudo-keys. + if key == "shortcut.reset_all" { + crate::shortcuts::shortcuts_init(); + crate::shortcuts::shortcuts_save(); + println!("[settings] shortcut.reset_all"); + respond_json(request, &serde_json::json!({"key": "shortcut.reset_all", "value": "defaults"}).to_string()); + return; + } + if key == "shortcut.reset" { + // Reset a single shortcut by action. + if let Some(action) = extract_query_param(query, "action") { + crate::shortcuts::shortcuts_init(); + crate::shortcuts::shortcuts_save(); + println!("[settings] shortcut.reset {}", action); + respond_json(request, &serde_json::json!({"key": "shortcut.reset", "value": &action}).to_string()); + return; + } + } + if key.starts_with("shortcut.") { + // Set a shortcut: key="shortcut.new_tab", value="t" + // Our Shortcut model uses modifiers+key. For now, reinitialize. + crate::shortcuts::shortcuts_init(); + crate::shortcuts::shortcuts_save(); + println!("[settings] shortcut set: {} = {}", key, value); + respond_json(request, &serde_json::json!({"key": key, "value": value}).to_string()); + return; + } + + let reload = match key.as_str() { + "new_tab_url" => { + settings::settings_update(&serde_json::json!({ "new_tab_url": value })); + settings::settings_save(); + false + } + "tab_bar_position" => { + let pos = match value.as_str() { + "top" => 0, + "bottom" => 1, + "left" => 2, + "right" => 3, + _ => { respond_error_json(request, 400, "Invalid position value"); return; } + }; + settings::settings_update(&serde_json::json!({ "tab_bar_position": pos })); + settings::settings_save(); + false + } + "max_tabs" => { + let v: i32 = value.parse().unwrap_or(1).max(1); + settings::settings_update(&serde_json::json!({ "max_tabs": v })); + settings::settings_save(); + false + } + "agent_server_port" => { + let v: i32 = value.parse().unwrap_or(17777); + if v < 1 || v > 65535 { respond_error_json(request, 400, "Port out of range (1-65535)"); return; } + settings::settings_update(&serde_json::json!({ "agent_port": v })); + settings::settings_save(); + true + } + "agent_allowed_origins" => { + let _ = db::db_kv_set("agent_allowed_origins", &value); + false + } + "agent_login_timeout_ms" => { + let v: i64 = value.parse().unwrap_or(0).max(0); + let _ = db::db_kv_set("agent_login_timeout_ms", &v.to_string()); + false + } + "bootstrap_relays" => { + settings::settings_update(&serde_json::json!({ "bootstrap_relays": value })); + settings::settings_save(); + false + } + "search_engine" => { + let valid = matches!(value.as_str(), "duckduckgo" | "google" | "brave"); + if !valid { respond_error_json(request, 400, "Unknown search engine"); return; } + settings::settings_update(&serde_json::json!({ "search_engine": value })); + settings::settings_save(); + false + } + _ => { respond_error_json(request, 400, "Unknown key"); return; } + }; + + let mut result = serde_json::json!({ "key": key, "value": value }); + if reload { result["reload"] = serde_json::json!(true); } + respond_json(request, &serde_json::to_string(&result).unwrap_or_default()); +} + +/// Handle sovereign://settings/config — return all settings as JSON. +/// Mirrors the C version (nostr_bridge.c:2940). +fn handle_settings_config_json(request: &URISchemeRequest) { + let s = settings::settings_get(); + + let pos_str = match s.tab_bar_position { + 0 => "top", + 1 => "bottom", + 2 => "left", + 3 => "right", + _ => "top", + }; + + // Search engine list. + let search_engines = serde_json::json!([ + {"id": "duckduckgo", "name": "DuckDuckGo"}, + {"id": "google", "name": "Google"}, + {"id": "brave", "name": "Brave"}, + ]); + + // Keyboard shortcuts. + let all_shortcuts = crate::shortcuts::shortcuts_get_all(); + let shortcuts: Vec = all_shortcuts.iter().map(|sc| { + let accel = format!("<{}>{}", sc.modifiers, sc.key); + serde_json::json!({ + "id": sc.action, + "label": sc.action, + "accel": accel, + }) + }).collect(); + + let json = serde_json::json!({ + "theme_dark": s.theme_dark, + "restore_session": s.session_restore, + "new_tab_url": s.new_tab_url, + "tab_bar_position": pos_str, + "show_tab_close_buttons": s.show_tab_close_buttons, + "middle_click_close": s.middle_click_close, + "ctrl_tab_switch": s.ctrl_tab_switch, + "tab_drag_reorder": s.tab_drag_reorder, + "max_tabs": s.max_tabs, + "agent_server_enabled": true, + "agent_server_port": s.agent_port, + "agent_allowed_origins": "", + "agent_login_timeout_ms": 30000, + "bootstrap_relays": s.bootstrap_relays, + "search_engine": s.search_engine, + "dev_extras": true, + "file_access": true, + "universal_access": true, + "search_engines": search_engines, + "shortcuts": shortcuts, + }); + respond_json(request, &serde_json::to_string(&json).unwrap_or_default()); +} + +fn handle_nostr_api(request: &URISchemeRequest, method: &str) { + // The method may have a query string (e.g. "signEvent?body=..."). + // Split it off. + let (method_name, query) = method.split_once('?').unwrap_or((method, "")); + let body_param = extract_query_param(query, "body"); + + match method_name { + "getPublicKey" => { + let state = G_BRIDGE.lock().unwrap(); + let json = serde_json::json!({ "pubkey": state.pubkey_hex }); + respond_json(request, &serde_json::to_string(&json).unwrap_or_default()); + } + "signEvent" => { + let event_json = match body_param { + Some(b) => b, + None => match read_request_body(request) { + Ok(b) => b, + Err(_) => { + respond_error_json(request, 400, "Invalid request body"); + return; + } + }, + }; + let state = G_BRIDGE.lock().unwrap(); + if state.readonly { + respond_error_json(request, 403, "Read-only mode"); + return; + } + if let Some(ref signer) = state.signer { + match serde_json::from_str::(&event_json) { + Ok(event) => { + match signer.sign_event(&event) { + Ok(signed) => { + let result = serde_json::to_string(&signed).unwrap_or_default(); + respond_json(request, &result); + } + Err(e) => respond_error_json(request, 500, &e.to_string()), + } + } + Err(e) => respond_error_json(request, 400, &e.to_string()), + } + } else { + respond_error_json(request, 500, "No signer available"); + } + } + "getRelays" => { + let relays = settings::settings_get_bootstrap_relays(); + let json = serde_json::json!({ "relays": relays }); + respond_json(request, &serde_json::to_string(&json).unwrap_or_default()); + } + "nip04Encrypt" | "nip04Decrypt" | "nip44Encrypt" | "nip44Decrypt" => { + let body_json = match &body_param { + Some(b) => b.clone(), + None => match read_request_body(request) { + Ok(b) => b, + Err(_) => { + respond_error_json(request, 400, "Invalid request body"); + return; + } + }, + }; + let params: serde_json::Value = match serde_json::from_str(&body_json) { + Ok(v) => v, + Err(e) => { + respond_error_json(request, 400, &format!("Invalid JSON: {}", e)); + return; + } + }; + let peer_pubkey_hex = match params.get("peer_pubkey").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => { + respond_error_json(request, 400, "Missing peer_pubkey"); + return; + } + }; + + let state = G_BRIDGE.lock().unwrap(); + if state.readonly { + respond_error_json(request, 403, "Read-only mode"); + return; + } + let signer = match &state.signer { + Some(s) => s.clone(), + None => { + respond_error_json(request, 500, "No signer available"); + return; + } + }; + drop(state); + + // Parse the peer pubkey. + let peer_pk = match parse_pubkey_hex(&peer_pubkey_hex) { + Some(pk) => pk, + None => { + respond_error_json(request, 400, "Invalid peer_pubkey"); + return; + } + }; + + match method_name { + "nip04Encrypt" => { + let plaintext = params.get("plaintext").and_then(|v| v.as_str()).unwrap_or(""); + match signer.nip04_encrypt(&peer_pk, plaintext) { + Ok(ciphertext) => { + let json = serde_json::json!({ "result": ciphertext }); + respond_json(request, &serde_json::to_string(&json).unwrap_or_default()); + } + Err(e) => respond_error_json(request, 500, &e.to_string()), + } + } + "nip04Decrypt" => { + let ciphertext = params.get("ciphertext").and_then(|v| v.as_str()).unwrap_or(""); + match signer.nip04_decrypt(&peer_pk, ciphertext) { + Ok(plaintext) => { + let json = serde_json::json!({ "result": plaintext }); + respond_json(request, &serde_json::to_string(&json).unwrap_or_default()); + } + Err(e) => respond_error_json(request, 500, &e.to_string()), + } + } + "nip44Encrypt" => { + let plaintext = params.get("plaintext").and_then(|v| v.as_str()).unwrap_or(""); + match signer.nip44_encrypt(&peer_pk, plaintext) { + Ok(ciphertext) => { + let json = serde_json::json!({ "result": ciphertext }); + respond_json(request, &serde_json::to_string(&json).unwrap_or_default()); + } + Err(e) => respond_error_json(request, 500, &e.to_string()), + } + } + "nip44Decrypt" => { + let ciphertext_bytes: Vec = params.get("ciphertext").map(|v| { + if let Some(s) = v.as_str() { + s.as_bytes().to_vec() + } else if let Some(arr) = v.as_array() { + arr.iter().filter_map(|b| b.as_u64().map(|n| n as u8)).collect() + } else { + Vec::new() + } + }).unwrap_or_default(); + match signer.nip44_decrypt(&peer_pk, &ciphertext_bytes) { + Ok(plaintext) => { + let json = serde_json::json!({ "result": plaintext }); + respond_json(request, &serde_json::to_string(&json).unwrap_or_default()); + } + Err(e) => respond_error_json(request, 500, &e.to_string()), + } + } + _ => unreachable!(), + } + } + _ => respond_error_json(request, 404, "Unknown method"), + } +} + +/// Extract a query parameter value from a query string (e.g. "body=...&foo=bar"). +fn extract_query_param(query: &str, key: &str) -> Option { + for pair in query.split('&') { + if let Some((k, v)) = pair.split_once('=') { + if k == key { + // URL-decode the value. + return Some(url_decode(v)); + } + } + } + None +} + +/// Simple URL percent-decoding. +fn url_decode(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '%' { + let h1 = chars.next(); + let h2 = chars.next(); + if let (Some(a), Some(b)) = (h1, h2) { + if let Ok(byte) = u8::from_str_radix(&format!("{}{}", a, b), 16) { + result.push(byte as char); + continue; + } + } + result.push(c); + } else if c == '+' { + result.push(' '); + } else { + result.push(c); + } + } + result +} + +/// Parse a hex pubkey string into a PublicKey. +fn parse_pubkey_hex(hex_str: &str) -> Option { + let bytes = hex::decode(hex_str).ok()?; + if bytes.len() != 32 { + return None; + } + let arr: [u8; 32] = bytes.try_into().ok()?; + Some(nostr_core::types::PublicKey::from_bytes(arr)) +} + +fn respond_json(request: &URISchemeRequest, json: &str) { + let body = json.to_string().into_bytes(); + let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone())); + request.finish(&stream, body.len() as i64, Some("application/json")); +} + +fn respond_bytes(request: &URISchemeRequest, content: &str, mime: &str) { + let body = content.as_bytes().to_vec(); + let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone())); + request.finish(&stream, body.len() as i64, Some(mime)); +} + +fn respond_bytes_raw(request: &URISchemeRequest, body: Vec, mime: &str) { + let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone())); + request.finish(&stream, body.len() as i64, Some(mime)); +} + +fn respond_html(request: &URISchemeRequest, html: &str) { + let body = html.to_string().into_bytes(); + let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone())); + request.finish(&stream, body.len() as i64, Some("text/html")); +} + +fn respond_error_json(request: &URISchemeRequest, _code: i32, message: &str) { + let json = serde_json::json!({ "error": message }); + let body = serde_json::to_string(&json).unwrap_or_default().into_bytes(); + let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone())); + request.finish(&stream, body.len() as i64, Some("application/json")); +} + +fn read_request_body(request: &URISchemeRequest) -> Result { + if let Some(body_stream) = request.http_body() { + let mut buf = vec![0u8; 65536]; + let cancellable: Option<&gio::Cancellable> = None; + if body_stream.read_all(&mut buf, cancellable).is_ok() { + let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + buf.truncate(len); + return String::from_utf8(buf).map_err(|_| ()); + } + } + Err(()) +} diff --git a/src/nostr_inject.rs b/src/nostr_inject.rs new file mode 100644 index 0000000..55e8664 --- /dev/null +++ b/src/nostr_inject.rs @@ -0,0 +1,211 @@ +//! window.nostr JS injection for sovereign_browser +//! +//! Port of `nostr_inject.c` / `nostr_inject.h` from the C project. + +use webkit2gtk::*; + +/// The NIP-07 JavaScript shim that bridges window.nostr calls to the +/// sovereign:// URI scheme. +/// +/// Mirrors the C version (nostr_inject.c). Key design decisions: +/// +/// 1. **Synchronous XMLHttpRequest** — WebKitGTK's `fetch()` and async XHR +/// enforce CORS on custom schemes even when registered as secure. +/// Synchronous XHR to a secure custom scheme works without CORS headers. +/// The bridge is local (sovereign:// URI scheme handler in Rust), so the +/// call is effectively instant. +/// +/// 2. **Body as query parameter** — WebKitGTK's custom scheme handler doesn't +/// easily expose POST bodies, so we URL-encode the body as `?body=`. +/// +/// 3. **Object.defineProperty with a named constructor** — Libraries like +/// nostr-lite.js check `window.nostr.constructor.name` to detect "real +/// extensions". A plain object has constructor name "Object" which fails +/// the check. We create a named constructor function so the constructor +/// name is "NostrExtension", making it look like a real extension. +const NOSTR_SHIM_JS: &str = r#" +(function() { + 'use strict'; + + var BRIDGE_BASE = 'sovereign://nostr/'; + + function bridgeCall(method, bodyObj) { + var url = BRIDGE_BASE + method; + + if (bodyObj !== undefined && bodyObj !== null) { + /* Encode the body as a query parameter since WebKitGTK's custom + * scheme handler doesn't easily expose POST bodies. */ + var bodyJson = JSON.stringify(bodyObj); + url += '?body=' + encodeURIComponent(bodyJson); + } + + /* Use synchronous XMLHttpRequest — WebKitGTK's fetch() and async XHR + * enforce CORS on custom schemes even when registered as secure, but + * synchronous XHR to a secure custom scheme works without CORS headers. + * The bridge is local (sovereign:// URI scheme handler in Rust), so the + * call is effectively instant. */ + return new Promise(function(resolve, reject) { + try { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); /* synchronous */ + xhr.send(); + var text = xhr.responseText; + if (!text) { + reject(new Error('Bridge returned empty response')); + return; + } + var data; + try { + data = JSON.parse(text); + } catch(e) { + reject(new Error('Bridge returned invalid JSON: ' + text)); + return; + } + if (data.error !== undefined) { + var msg = data.message || ('Error code ' + data.error); + reject(new Error(msg)); + return; + } + resolve(data); + } catch(e) { + reject(new Error('Bridge request failed: ' + e.message)); + } + }); + } + + /* NIP-07 window.nostr API. + * + * We use a named constructor function so that window.nostr.constructor.name + * returns "NostrExtension" instead of "Object". Some libraries (e.g. + * nostr-lite.js) check the constructor name to detect "real extensions" + * vs plain object shims. */ + function NostrExtension() { + this.getPublicKey = function() { + return bridgeCall('getPublicKey').then(function(d) { return d.pubkey; }); + }; + this.signEvent = function(event) { + return bridgeCall('signEvent', event).then(function(d) { return d; }); + }; + this.getRelays = function() { + return bridgeCall('getRelays').then(function(d) { return d.relays; }); + }; + this.nip04 = { + encrypt: function(peerPubkey, plaintext) { + return bridgeCall('nip04Encrypt', { + peer_pubkey: peerPubkey, + plaintext: plaintext + }).then(function(d) { return d.result; }); + }, + decrypt: function(peerPubkey, ciphertext) { + return bridgeCall('nip04Decrypt', { + peer_pubkey: peerPubkey, + ciphertext: ciphertext + }).then(function(d) { return d.result; }); + } + }; + this.nip44 = { + encrypt: function(peerPubkey, plaintext) { + return bridgeCall('nip44Encrypt', { + peer_pubkey: peerPubkey, + plaintext: plaintext + }).then(function(d) { return d.result; }); + }, + decrypt: function(peerPubkey, ciphertext) { + return bridgeCall('nip44Decrypt', { + peer_pubkey: peerPubkey, + ciphertext: ciphertext + }).then(function(d) { return d.result; }); + } + }; + } + + /* Only install if no real extension is present. */ + if (window.nostr === undefined || window.nostr === null) { + var nostr = new NostrExtension(); + Object.defineProperty(window, 'nostr', { + value: nostr, + writable: false, + configurable: false, + enumerable: true + }); + } +})(); +"#; + +/// Perf probe preamble — skips sovereign:// and about: pages, sets tab index. +/// The tab index is baked in at injection time as a literal. +fn perf_probe_preamble(tab_index: i32) -> String { + format!( + r#"(function(){{ + 'use strict'; + try {{ + var u = location.href || ''; + if (u.indexOf('sovereign://') === 0 || u.indexOf('about:') === 0) {{ + window.__sbPerfProbeSkip = true; + }} else {{ + window.__sbPerfProbeSkip = false; + window.__sbPerfTabIndex = {}; + }} + }} catch(e) {{}} +}})(); +"#, + tab_index + ) +} + +/// The perf-probe.js script (embedded from www/js/perf-probe.js). +const PERF_PROBE_JS: &str = include_str!("../www/js/perf-probe.js"); + +/// Create a UserContentManager with the NIP-07 shim and perf probe injected. +/// The perf probe is injected with a preamble that skips sovereign:// pages. +/// The tab_index is used by the probe to identify which tab is reporting. +pub fn nostr_inject_create_content_manager() -> UserContentManager { + let manager = UserContentManager::new(); + let script = UserScript::new( + NOSTR_SHIM_JS, + UserContentInjectedFrames::AllFrames, + UserScriptInjectionTime::Start, + &[], // allow_list + &[], // block_list + ); + manager.add_script(&script); + manager +} + +/// Create a UserContentManager with the NIP-07 shim and perf probe injected, +/// with the given tab index baked into the preamble. +pub fn nostr_inject_create_content_manager_with_probe(tab_index: i32) -> UserContentManager { + let manager = UserContentManager::new(); + + // NIP-07 shim. + let script = UserScript::new( + NOSTR_SHIM_JS, + UserContentInjectedFrames::AllFrames, + UserScriptInjectionTime::Start, + &[], // allow_list + &[], // block_list + ); + manager.add_script(&script); + + // Perf probe preamble (skips sovereign:// pages, sets tab index). + let preamble = UserScript::new( + &perf_probe_preamble(tab_index), + UserContentInjectedFrames::AllFrames, + UserScriptInjectionTime::Start, + &[], + &[], + ); + manager.add_script(&preamble); + + // Perf probe body. + let probe = UserScript::new( + PERF_PROBE_JS, + UserContentInjectedFrames::AllFrames, + UserScriptInjectionTime::Start, + &[], + &[], + ); + manager.add_script(&probe); + + manager +} diff --git a/src/nostr_scheme.rs b/src/nostr_scheme.rs new file mode 100644 index 0000000..c668178 --- /dev/null +++ b/src/nostr_scheme.rs @@ -0,0 +1,43 @@ +//! WebKitGTK nostr:// URI scheme handler +//! +//! Port of `nostr_scheme.c` / `nostr_scheme.h` from the C project. + +use webkit2gtk::*; +use gio::MemoryInputStream; + +use crate::nostr_url; + +pub fn nostr_scheme_register(ctx: &WebContext) { + ctx.register_uri_scheme("nostr", |request| { + handle_nostr_scheme(request); + }); +} + +fn handle_nostr_scheme(request: &URISchemeRequest) { + let uri = request.uri().unwrap_or_default(); + let entity_str = nostr_url::nostr_url_entity(&uri).unwrap_or(&uri); + + match nostr_url::nostr_url_decode(entity_str) { + Ok(entity) => { + let html = format!( + r#"Nostr Entity +

Nostr Entity

Type: {}

{}{}{}{}{}"#, + entity.entity_type, + entity.pubkey.map(|pk| format!("

Pubkey: {}

", hex::encode(pk))).unwrap_or_default(), + entity.event_id.map(|eid| format!("

Event ID: {}

", hex::encode(eid))).unwrap_or_default(), + entity.kind.map(|k| format!("

Kind: {}

", k)).unwrap_or_default(), + entity.identifier.as_ref().map(|id| format!("

Identifier: {}

", id)).unwrap_or_default(), + entity.relay_url.as_ref().map(|url| format!("

Relay: {}

", url)).unwrap_or_default(), + ); + let body = html.into_bytes(); + let stream = MemoryInputStream::from_bytes(&glib::Bytes::from_owned(body.clone())); + request.finish(&stream, body.len() as i64, Some("text/html")); + } + Err(_) => { + let html = format!("

Nostr URI

Raw: {}

", entity_str); + let body = html.into_bytes(); + let stream = MemoryInputStream::from_bytes(&glib::Bytes::from_owned(body.clone())); + request.finish(&stream, body.len() as i64, Some("text/html")); + } + } +} diff --git a/src/nostr_url.rs b/src/nostr_url.rs new file mode 100644 index 0000000..610c881 --- /dev/null +++ b/src/nostr_url.rs @@ -0,0 +1,152 @@ +//! NIP-19 decoding and Nostr URI normalization. +//! +//! Port of `nostr_url.h` / `nostr_url.c` from the C project. + +use nostr_core::NostrResult; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NostrEntityType { + None, + Npub, + Nsec, + Note, + Nevent, + Naddr, + Nprofile, + Nrelay, +} + +impl fmt::Display for NostrEntityType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NostrEntityType::None => write!(f, "none"), + NostrEntityType::Npub => write!(f, "npub"), + NostrEntityType::Nsec => write!(f, "nsec"), + NostrEntityType::Note => write!(f, "note"), + NostrEntityType::Nevent => write!(f, "nevent"), + NostrEntityType::Naddr => write!(f, "naddr"), + NostrEntityType::Nprofile => write!(f, "nprofile"), + NostrEntityType::Nrelay => write!(f, "nrelay"), + } + } +} + +#[derive(Debug, Clone)] +pub struct NostrDecodedEntity { + pub entity_type: NostrEntityType, + pub pubkey: Option<[u8; 32]>, + pub event_id: Option<[u8; 32]>, + pub kind: Option, + pub identifier: Option, + pub relay_url: Option, + pub relay_hints: Vec, +} + +impl Default for NostrDecodedEntity { + fn default() -> Self { + NostrDecodedEntity { + entity_type: NostrEntityType::None, + pubkey: None, + event_id: None, + kind: None, + identifier: None, + relay_url: None, + relay_hints: Vec::new(), + } + } +} + +pub fn nostr_url_detect(input: &str) -> NostrEntityType { + if input.starts_with("npub") { NostrEntityType::Npub } + else if input.starts_with("nsec") { NostrEntityType::Nsec } + else if input.starts_with("note") { NostrEntityType::Note } + else if input.starts_with("nevent") { NostrEntityType::Nevent } + else if input.starts_with("naddr") { NostrEntityType::Naddr } + else if input.starts_with("nprofile") { NostrEntityType::Nprofile } + else if input.starts_with("nrelay") { NostrEntityType::Nrelay } + else { NostrEntityType::None } +} + +pub fn nostr_url_decode(input: &str) -> NostrResult { + let entity_type = nostr_url_detect(input); + if entity_type == NostrEntityType::None { + return Err(nostr_core::NostrError::InvalidInput); + } + + // bech32 0.11.1 decode returns (Hrp, Vec) + let (_hrp, bytes): (bech32::Hrp, Vec) = bech32::decode(input) + .map_err(|_| nostr_core::NostrError::InvalidInput)?; + + let mut entity = NostrDecodedEntity { + entity_type, + ..Default::default() + }; + + match entity_type { + NostrEntityType::Npub | NostrEntityType::Nsec => { + if bytes.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + entity.pubkey = Some(arr); + } + } + NostrEntityType::Note => { + if bytes.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + entity.event_id = Some(arr); + } + } + NostrEntityType::Nevent | NostrEntityType::Nprofile | NostrEntityType::Naddr => { + let mut i = 0; + while i < bytes.len() { + if i + 1 >= bytes.len() { break; } + let tlv_type = bytes[i]; + let tlv_len = bytes[i + 1] as usize; + i += 2; + if i + tlv_len > bytes.len() { break; } + let tlv_value = &bytes[i..i + tlv_len]; + i += tlv_len; + match tlv_type { + 0 => { + if tlv_value.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(tlv_value); + if entity_type == NostrEntityType::Nevent { + entity.event_id = Some(arr); + } else { + entity.pubkey = Some(arr); + } + } + } + 1 => { if let Ok(s) = String::from_utf8(tlv_value.to_vec()) { entity.relay_hints.push(s); } } + 2 => { if entity_type == NostrEntityType::Naddr { if let Ok(s) = String::from_utf8(tlv_value.to_vec()) { entity.identifier = Some(s); } } } + 3 => { if tlv_value.len() == 4 { let mut arr = [0u8; 4]; arr.copy_from_slice(tlv_value); entity.kind = Some(u32::from_be_bytes(arr)); } } + _ => {} + } + } + } + NostrEntityType::Nrelay => { + if let Ok(s) = String::from_utf8(bytes) { entity.relay_url = Some(s); } + } + NostrEntityType::None => {} + } + + Ok(entity) +} + +pub fn nostr_url_normalize(input: &str) -> String { + if let Ok(entity) = nostr_url_decode(input) { + if let Some(pk) = entity.pubkey { return hex::encode(pk); } + } + input.to_string() +} + +pub fn nostr_url_entity(input: &str) -> Option<&str> { + if let Some(stripped) = input.strip_prefix("nostr:") { Some(stripped) } else { Some(input) } +} + +pub fn nostr_hex_encode32(data: &[u8; 32]) -> String { + hex::encode(data) +} diff --git a/src/perf_probe.rs b/src/perf_probe.rs new file mode 100644 index 0000000..aba7039 --- /dev/null +++ b/src/perf_probe.rs @@ -0,0 +1,106 @@ +//! Performance probing and monitoring +//! +//! Port of `perf_probe.c` / `perf_probe.h` from the C project. + +use std::sync::Mutex; +use once_cell::sync::Lazy; +use std::time::Instant; + +/// Performance metrics. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PerfMetrics { + pub page_load_times: Vec, + pub memory_usage_mb: f64, + pub cpu_usage_percent: f64, + pub tab_count: usize, +} + +/// Performance probe state. +static G_PERF_PROBE: Lazy> = Lazy::new(|| Mutex::new(PerfProbeState::default())); + +struct PerfProbeState { + page_load_start: Option, + page_load_times: Vec, +} + +impl Default for PerfProbeState { + fn default() -> Self { + PerfProbeState { + page_load_start: None, + page_load_times: Vec::new(), + } + } +} + +/// Initialize performance probing. +pub fn perf_probe_init() { + let mut state = G_PERF_PROBE.lock().unwrap(); + state.page_load_start = None; + state.page_load_times.clear(); +} + +/// Mark the start of a page load. +pub fn perf_probe_page_load_start() { + let mut state = G_PERF_PROBE.lock().unwrap(); + state.page_load_start = Some(Instant::now()); +} + +/// Mark the end of a page load and record the time. +pub fn perf_probe_page_load_end() { + let mut state = G_PERF_PROBE.lock().unwrap(); + if let Some(start) = state.page_load_start { + let elapsed = start.elapsed().as_secs_f64(); + state.page_load_times.push(elapsed); + if state.page_load_times.len() > 100 { + state.page_load_times.remove(0); + } + } + state.page_load_start = None; +} + +/// Per-tab probe report received from the injected perf-probe.js. +static G_PROBE_REPORTS: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); + +/// A probe report from a single tab. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ProbeReport { + pub tab_index: i32, + pub cpu_busy_percent: f64, + pub fps: f64, + pub timer_count: u32, + pub net_inflight: u32, + pub heap_used: f64, + pub event_listener_count: u32, + pub worker_count: u32, + pub dom_node_count: u32, +} + +/// Record a probe report from a tab. +pub fn perf_probe_record_report(tab_index: i32, report: &serde_json::Value) { + let mut reports = G_PROBE_REPORTS.lock().unwrap(); + // Update or insert the report for this tab index. + if let Some(existing) = reports.iter_mut().find(|r| r.tab_index == tab_index) { + if let Ok(parsed) = serde_json::from_value::(report.clone()) { + *existing = parsed; + } + } else if let Ok(parsed) = serde_json::from_value::(report.clone()) { + reports.push(parsed); + } +} + +/// Get the probe report for a specific tab index. +pub fn perf_probe_get_report(index: usize) -> Option { + let reports = G_PROBE_REPORTS.lock().unwrap(); + reports.iter().find(|r| r.tab_index as usize == index).cloned() +} + +/// Get current performance metrics. +pub fn perf_probe_get_metrics() -> PerfMetrics { + let state = G_PERF_PROBE.lock().unwrap(); + PerfMetrics { + page_load_times: state.page_load_times.clone(), + memory_usage_mb: 0.0, // TODO: get actual memory usage + cpu_usage_percent: 0.0, // TODO: get actual CPU usage + tab_count: crate::tab_manager::tab_manager_get_count(), + } +} diff --git a/src/process_info.rs b/src/process_info.rs new file mode 100644 index 0000000..e2ff6a5 --- /dev/null +++ b/src/process_info.rs @@ -0,0 +1,61 @@ +//! Process information for the processes page +//! +//! Port of `process_info.c` / `process_info.h` from the C project. + +use std::sync::Mutex; +use once_cell::sync::Lazy; + +/// Process info state. +static G_PROCESS_INFO: Lazy> = Lazy::new(|| Mutex::new(ProcessInfoState::default())); + +struct ProcessInfoState { + web_processes: Vec, +} + +/// Information about a web process. +#[derive(Debug, Clone, serde::Serialize)] +pub struct WebProcessInfo { + pub pid: u32, + pub url: String, + pub title: String, + pub memory_mb: f64, + pub cpu_percent: f64, +} + +impl Default for ProcessInfoState { + fn default() -> Self { + ProcessInfoState { + web_processes: Vec::new(), + } + } +} + +/// Initialize process info. +pub fn process_info_init() { + let mut state = G_PROCESS_INFO.lock().unwrap(); + state.web_processes.clear(); +} + +/// Get all web processes. +pub fn process_info_get_all() -> Vec { + let state = G_PROCESS_INFO.lock().unwrap(); + state.web_processes.clone() +} + +/// Add a web process. +pub fn process_info_add(pid: u32, url: &str, title: &str) { + let mut state = G_PROCESS_INFO.lock().unwrap(); + state.web_processes.push(WebProcessInfo { + pid, + url: url.to_string(), + title: title.to_string(), + memory_mb: 0.0, + cpu_percent: 0.0, + }); +} + +/// Remove a web process. +pub fn process_info_remove(pid: u32) { + let mut state = G_PROCESS_INFO.lock().unwrap(); + state.web_processes.retain(|p| p.pid != pid); +} diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..1b315b8 --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,47 @@ +//! Per-user profile management +//! +//! Port of `profile.c` / `profile.h` from the C project. + +use std::path::PathBuf; + +/// Get the profiles directory. +pub fn profile_get_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".sovereign_browser").join("profiles") +} + +/// Get the profile directory for a given pubkey. +pub fn profile_get_profile_dir(pubkey_hex: &str) -> PathBuf { + profile_get_dir().join(pubkey_hex) +} + +/// List all profiles. +pub fn profile_list() -> Vec { + let dir = profile_get_dir(); + if !dir.exists() { + return Vec::new(); + } + + let mut profiles = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + if let Some(name) = entry.file_name().to_str() { + profiles.push(name.to_string()); + } + } + } + } + profiles +} + +/// Check if a profile exists. +pub fn profile_exists(pubkey_hex: &str) -> bool { + profile_get_profile_dir(pubkey_hex).exists() +} + +/// Create a profile directory. +pub fn profile_create(pubkey_hex: &str) -> std::io::Result<()> { + let dir = profile_get_profile_dir(pubkey_hex); + std::fs::create_dir_all(dir) +} diff --git a/src/qr.rs b/src/qr.rs new file mode 100644 index 0000000..11053ef --- /dev/null +++ b/src/qr.rs @@ -0,0 +1,29 @@ +//! QR code generation for sovereign_browser +//! +//! Port of `qr.c` / `qr.h` from the C project. + +use qrcode::QrCode; +use qrcode::render::svg; + +/// Generate an SVG QR code from a string. +pub fn qr_generate_svg(data: &str) -> Result> { + let code = QrCode::new(data)?; + let svg = code.render() + .min_dimensions(200, 200) + .dark_color(svg::Color("#000000")) + .light_color(svg::Color("#ffffff")) + .build(); + Ok(svg) +} + +/// Generate a PNG QR code as bytes. +pub fn qr_generate_png(data: &str) -> Result, Box> { + let code = QrCode::new(data)?; + let image = code.render::>() + .min_dimensions(200, 200) + .build(); + + let mut buf = std::io::Cursor::new(Vec::new()); + image.write_to(&mut buf, image::ImageFormat::Png)?; + Ok(buf.into_inner()) +} diff --git a/src/relay_fetch.rs b/src/relay_fetch.rs new file mode 100644 index 0000000..bfc1c5f --- /dev/null +++ b/src/relay_fetch.rs @@ -0,0 +1,134 @@ +//! Post-login bootstrap relay fetch for sovereign_browser +//! +//! Port of `relay_fetch.c` / `relay_fetch.h` from the C project. +//! Uses `nostr_relay` from `rust_core_lib`. + +use nostr_core::types::Filter; +use nostr_relay::pool::ReconnectConfig; +use tokio::runtime::Runtime; + +use crate::db; +use crate::settings; + +/// Maximum number of relays to parse from the bootstrap_relays setting. +const MAX_RELAYS: usize = 32; + +/// Relay query timeout in seconds. +const RELAY_TIMEOUT_MS: u64 = 15000; + +/// Fetch the user's kind 0, 3, and 10002 events from the bootstrap relays. +/// +/// Filters out .onion relay URLs when Tor is not running, since +/// tokio_tungstenite cannot resolve .onion addresses through clearnet DNS. +pub fn relay_fetch_bootstrap( + pubkey_hex: &str, + relay_urls: &[String], +) -> Result> { + if pubkey_hex.is_empty() { + eprintln!("[relay] No pubkey, skipping bootstrap fetch"); + return Ok(0); + } + if relay_urls.is_empty() { + eprintln!("[relay] No bootstrap relays configured"); + return Ok(0); + } + + // Filter out .onion relays when Tor is not available. + // Tor may be running as a system daemon (not managed by the browser), + // so check the SOCKS port availability rather than just internal status. + let tor_available = crate::net_services::net_services_get_tor_socks_endpoint().is_some(); + let filtered: Vec = relay_urls.iter() + .filter(|url| { + if url.contains(".onion") && !tor_available { + println!("[relay] Skipping .onion relay (Tor not available): {}", url); + false + } else { + true + } + }) + .cloned() + .collect(); + + if filtered.is_empty() { + eprintln!("[relay] All relays are .onion and Tor is not running"); + return Ok(0); + } + + println!( + "[relay] Fetching kind 0/3/10002/30003/30078/31123 for {} from {} relay(s)...", + pubkey_hex, + filtered.len() + ); + + // Build the filter + let filter = Filter::new() + .authors(vec![pubkey_hex.to_string()]) + .kinds(vec![0, 3, 10002, 30003, 30078, 31123]); + + // Use tokio runtime for async relay operations + let rt = Runtime::new()?; + let result = rt.block_on(async { + use nostr_relay::pool::RelayPool; + + let pool = RelayPool::new(Some(ReconnectConfig::default())); + + // Add relays + for url in filtered.iter().take(MAX_RELAYS) { + let _ = pool.add_relay(url.as_str()).await; + } + + // Connect to relays + pool.connect_all().await; + + // Query with timeout + let events_result = tokio::time::timeout( + std::time::Duration::from_millis(RELAY_TIMEOUT_MS), + pool.query_sync(&filtered, filter, RELAY_TIMEOUT_MS), + ) + .await; + + match events_result { + Ok(Ok(event_list)) => { + let count = event_list.len(); + println!("[relay] Fetched {} events", count); + + // Store events in database + for event in &event_list { + let tags_json = serde_json::to_string(&event.tags).unwrap_or_default(); + let _ = db::db_store_event( + &event.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(), + &event.pubkey.to_hex(), + event.kind.as_u64(), + event.created_at, + &event.content, + &tags_json, + event.sig.as_ref().map(|s| s.to_hex()).as_deref(), + None, + ); + } + + // Disconnect + pool.disconnect_all().await; + Ok(count as i32) + } + _ => { + eprintln!("[relay] Query timed out or failed"); + pool.disconnect_all().await; + Ok(0) + } + } + }); + + result +} + +/// Background thread entry point for relay_fetch_bootstrap. +pub fn relay_fetch_thread(pubkey_hex: String) { + let relays = settings::settings_get_bootstrap_relays(); + let relay_refs: Vec = relays.iter().map(|s| s.clone()).collect(); + + match relay_fetch_bootstrap(&pubkey_hex, &relay_refs) { + Ok(count) => println!("[relay] Bootstrap fetch complete: {} events stored", count), + Err(e) => eprintln!("[relay] Bootstrap fetch error: {}", e), + } +} diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000..4a76ca8 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,47 @@ +//! Search engine integration +//! +//! Port of `search.c` / `search.h` from the C project. + +use crate::settings; + +/// Perform a web search using the configured search engine. +pub fn search_query(query: &str) -> String { + let settings = settings::settings_get(); + let encoded_query = urlencoding::encode(query); + + match settings.search_engine.as_str() { + "duckduckgo" => format!("https://duckduckgo.com/?q={}", encoded_query), + "google" => format!("https://www.google.com/search?q={}", encoded_query), + "bing" => format!("https://www.bing.com/search?q={}", encoded_query), + "brave" => format!("https://search.brave.com/search?q={}", encoded_query), + _ => format!("https://duckduckgo.com/?q={}", encoded_query), + } +} + +/// Check if a string looks like a URL (vs a search query). +pub fn search_is_url(input: &str) -> bool { + input.starts_with("http://") + || input.starts_with("https://") + || input.starts_with("file://") + || input.starts_with("about:") + || input.starts_with("sovereign://") + || input.starts_with("nostr://") + || input.starts_with("fips://") + || input.starts_with("tor://") + || input.contains('.') +} + +/// Normalize a URL string: prepend `https://` if it has no scheme. +/// Returns the normalized URL. +pub fn normalize_url(input: &str) -> String { + let trimmed = input.trim(); + if trimmed.is_empty() { + return trimmed.to_string(); + } + // If it already has a scheme, return as-is. + if trimmed.contains("://") || trimmed.starts_with("about:") { + return trimmed.to_string(); + } + // Otherwise treat as a bare domain and prepend https:// + format!("https://{}", trimmed) +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..55ebba8 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,43 @@ +//! Session save/restore for sovereign_browser +//! +//! Port of `session.c` / `session.h` from the C project. + +use crate::db; +use crate::tab_manager; + +/// Save the current session (open tabs) to the database. +pub fn session_save() { + let tabs = tab_manager::tab_manager_get_tabs(); + let tab_data: Vec = tabs + .iter() + .map(|t| { + serde_json::json!({ + "id": t.id, + "title": t.title, + "url": t.url, + }) + }) + .collect(); + + if let Ok(json) = serde_json::to_string(&tab_data) { + let _ = db::db_kv_set("session.tabs", &json); + } +} + +/// Restore the previous session. +pub fn session_restore() -> Vec { + if let Ok(Some(json)) = db::db_kv_get("session.tabs") { + if let Ok(tabs) = serde_json::from_str::>(&json) { + return tabs + .iter() + .filter_map(|t| t["url"].as_str().map(|s| s.to_string())) + .collect(); + } + } + Vec::new() +} + +/// Clear the saved session. +pub fn session_clear() { + let _ = db::db_kv_delete("session.tabs"); +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..2fa57a3 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,171 @@ +//! Browser settings management +//! +//! Port of `settings.c` / `settings.h` from the C project. + +use std::sync::Mutex; +use once_cell::sync::Lazy; + +use crate::db; + +/// Browser settings structure. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct BrowserSettings { + pub new_tab_url: String, + pub tab_bar_position: i32, + pub show_tab_close_buttons: bool, + pub middle_click_close: bool, + pub ctrl_tab_switch: bool, + pub tab_drag_reorder: bool, + pub max_tabs: i32, + pub bootstrap_relays: String, + pub search_engine: String, + pub agent_port: i32, + pub agent_allowed_origins: String, + pub session_restore: bool, + pub theme_dark: bool, + pub enable_webgl: bool, + pub enable_webrtc: bool, + pub enable_javascript: bool, + pub enable_plugins: bool, + pub allow_modal_dialogs: bool, + pub allow_popups: bool, + pub enable_developer_extras: bool, + pub enable_private_browsing: bool, + pub enable_fullscreen: bool, + pub enable_media_stream: bool, + pub enable_media_source: bool, + pub enable_smooth_scrolling: bool, + pub enable_accelerated_2d_canvas: bool, + pub enable_write_console_messages_to_stdout: bool, +} + +impl Default for BrowserSettings { + fn default() -> Self { + BrowserSettings { + new_tab_url: "about:blank".to_string(), + tab_bar_position: 0, + show_tab_close_buttons: true, + middle_click_close: true, + ctrl_tab_switch: true, + tab_drag_reorder: true, + max_tabs: 50, + bootstrap_relays: "wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol,wss://relay.nostr.band".to_string(), + search_engine: "duckduckgo".to_string(), + agent_port: 17777, + agent_allowed_origins: "http://localhost:*".to_string(), + session_restore: true, + theme_dark: false, + enable_webgl: true, + enable_webrtc: true, + enable_javascript: true, + enable_plugins: false, + allow_modal_dialogs: true, + allow_popups: false, + enable_developer_extras: false, + enable_private_browsing: false, + enable_fullscreen: true, + enable_media_stream: true, + enable_media_source: true, + enable_smooth_scrolling: true, + enable_accelerated_2d_canvas: true, + enable_write_console_messages_to_stdout: false, + } + } +} + +/// Global settings singleton. +static G_SETTINGS: Lazy> = Lazy::new(|| Mutex::new(BrowserSettings::default())); + +/// Get a reference to the current settings. +pub fn settings_get() -> BrowserSettings { + G_SETTINGS.lock().unwrap().clone() +} + +/// Update settings from a JSON value. +pub fn settings_update(json: &serde_json::Value) { + let mut settings = G_SETTINGS.lock().unwrap(); + + if let Some(v) = json.get("new_tab_url").and_then(|v| v.as_str()) { + settings.new_tab_url = v.to_string(); + } + if let Some(v) = json.get("tab_bar_position").and_then(|v| v.as_i64()) { + settings.tab_bar_position = v as i32; + } + if let Some(v) = json.get("show_tab_close_buttons").and_then(|v| v.as_bool()) { + settings.show_tab_close_buttons = v; + } + if let Some(v) = json.get("middle_click_close").and_then(|v| v.as_bool()) { + settings.middle_click_close = v; + } + if let Some(v) = json.get("ctrl_tab_switch").and_then(|v| v.as_bool()) { + settings.ctrl_tab_switch = v; + } + if let Some(v) = json.get("tab_drag_reorder").and_then(|v| v.as_bool()) { + settings.tab_drag_reorder = v; + } + if let Some(v) = json.get("max_tabs").and_then(|v| v.as_i64()) { + settings.max_tabs = v as i32; + } + if let Some(v) = json.get("bootstrap_relays").and_then(|v| v.as_str()) { + settings.bootstrap_relays = v.to_string(); + } + if let Some(v) = json.get("search_engine").and_then(|v| v.as_str()) { + settings.search_engine = v.to_string(); + } + if let Some(v) = json.get("agent_port").and_then(|v| v.as_i64()) { + settings.agent_port = v as i32; + } + if let Some(v) = json.get("session_restore").and_then(|v| v.as_bool()) { + settings.session_restore = v; + } + if let Some(v) = json.get("theme_dark").and_then(|v| v.as_bool()) { + settings.theme_dark = v; + } +} + +/// Load settings from the database. +pub fn settings_load() { + if let Ok(Some(json_str)) = db::db_kv_get("browser_settings") { + if let Ok(json) = serde_json::from_str::(&json_str) { + settings_update(&json); + } + } +} + +/// Save settings to the database. +pub fn settings_save() { + let settings = G_SETTINGS.lock().unwrap(); + let json = serde_json::json!({ + "new_tab_url": settings.new_tab_url, + "tab_bar_position": settings.tab_bar_position, + "show_tab_close_buttons": settings.show_tab_close_buttons, + "middle_click_close": settings.middle_click_close, + "ctrl_tab_switch": settings.ctrl_tab_switch, + "tab_drag_reorder": settings.tab_drag_reorder, + "max_tabs": settings.max_tabs, + "bootstrap_relays": settings.bootstrap_relays, + "search_engine": settings.search_engine, + "agent_port": settings.agent_port, + "session_restore": settings.session_restore, + "theme_dark": settings.theme_dark, + }); + + if let Ok(json_str) = serde_json::to_string(&json) { + let _ = db::db_kv_set("browser_settings", &json_str); + } + + // Trigger NIP-78 settings sync (debounced in the C version; immediate here). + // The sync module handles the case where no signer is available. + crate::settings_sync::settings_sync_publish(); +} + +/// Get bootstrap relays as a vector. +pub fn settings_get_bootstrap_relays() -> Vec { + let settings = G_SETTINGS.lock().unwrap(); + settings.bootstrap_relays + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} diff --git a/src/settings_sync.rs b/src/settings_sync.rs new file mode 100644 index 0000000..f7d63fb --- /dev/null +++ b/src/settings_sync.rs @@ -0,0 +1,102 @@ +//! NIP-78 (kind 30078) settings sync for sovereign_browser +//! +//! Port of `settings_sync.c` / `settings_sync.h` from the C project. +//! Uses `nostr_signer` and `nostr_relay` from `rust_core_lib`. + +use nostr_signer::traits::NostrSigner; +use std::sync::Arc; +use std::sync::Mutex; +use once_cell::sync::Lazy; + +use crate::db; +use crate::settings; + +/// The d-tag identifier used for the shared kind 30078 user-settings event. +const SETTINGS_SYNC_D_TAG: &str = "user-settings"; + +/// The Nostr kind for arbitrary custom app data (NIP-78). +const _SETTINGS_SYNC_KIND: u64 = 30078; + +/// Global signer state for settings sync. +static G_SYNC_STATE: Lazy> = Lazy::new(|| Mutex::new(SyncState::default())); + +struct SyncState { + signer: Option>, + pubkey_hex: String, + have_signer: bool, +} + +impl Default for SyncState { + fn default() -> Self { + SyncState { + signer: None, + pubkey_hex: String::new(), + have_signer: false, + } + } +} + +/// Initialize the settings sync module. +pub fn settings_sync_init(signer: Option>, pubkey_hex: &str) { + let mut state = G_SYNC_STATE.lock().unwrap(); + state.signer = signer; + state.pubkey_hex = pubkey_hex.to_string(); + state.have_signer = state.signer.is_some() && !state.pubkey_hex.is_empty(); +} + +/// Update the signer reference. +pub fn settings_sync_set_signer(signer: Option>, pubkey_hex: &str) { + settings_sync_init(signer, pubkey_hex); +} + +/// Serialize syncable settings, build and sign a kind 30078 event. +pub fn settings_sync_publish() { + let state = G_SYNC_STATE.lock().unwrap(); + if !state.have_signer { + return; + } + + let browser_settings = settings::settings_get(); + + let payload = serde_json::json!({ + "v": 2, + "updatedAt": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + "sovereign_browser": { + "new_tab_url": browser_settings.new_tab_url, + "tab_bar_position": browser_settings.tab_bar_position, + "show_tab_close_buttons": browser_settings.show_tab_close_buttons, + "middle_click_close": browser_settings.middle_click_close, + "ctrl_tab_switch": browser_settings.ctrl_tab_switch, + "tab_drag_reorder": browser_settings.tab_drag_reorder, + "max_tabs": browser_settings.max_tabs, + "bootstrap_relays": browser_settings.bootstrap_relays, + "search_engine": browser_settings.search_engine, + } + }); + + if let Ok(payload_str) = serde_json::to_string(&payload) { + let _ = db::db_kv_set("settings_sync.last_payload", &payload_str); + } + + println!("[settings_sync] Publish requested (NIP-44 encryption not yet implemented)"); +} + +/// Merge settings from a fetched kind 30078 event. +pub fn settings_sync_merge_from_nostr(event_json: &serde_json::Value) -> Result<(), Box> { + let content = event_json["content"] + .as_str() + .ok_or("Missing content")?; + + if let Ok(payload) = serde_json::from_str::(content) { + if let Some(browser) = payload.get("sovereign_browser") { + settings::settings_update(browser); + settings::settings_save(); + println!("[settings_sync] Merged settings from Nostr event"); + } + } + + Ok(()) +} diff --git a/src/shortcuts.rs b/src/shortcuts.rs new file mode 100644 index 0000000..d7d35ce --- /dev/null +++ b/src/shortcuts.rs @@ -0,0 +1,99 @@ +//! Keyboard shortcuts for sovereign_browser +//! +//! Port of `shortcuts.c` / `shortcuts.h` from the C project. + +use std::collections::HashMap; +use std::sync::Mutex; +use once_cell::sync::Lazy; + +/// A keyboard shortcut binding. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Shortcut { + pub key: String, + pub modifiers: String, // "Ctrl", "Ctrl+Shift", etc. + pub action: String, +} + +/// Global shortcuts registry. +static G_SHORTCUTS: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); + +/// Initialize default shortcuts. +/// Mirrors the C version (shortcuts.c:24) with all 20+ shortcuts. +pub fn shortcuts_init() { + let mut shortcuts = G_SHORTCUTS.lock().unwrap(); + shortcuts.clear(); + + let defaults = vec![ + Shortcut { key: "t".to_string(), modifiers: "Ctrl".to_string(), action: "new_tab".to_string() }, + Shortcut { key: "n".to_string(), modifiers: "Ctrl".to_string(), action: "new_window".to_string() }, + Shortcut { key: "o".to_string(), modifiers: "Ctrl".to_string(), action: "open_file".to_string() }, + Shortcut { key: "w".to_string(), modifiers: "Ctrl".to_string(), action: "close_tab".to_string() }, + Shortcut { key: "l".to_string(), modifiers: "Ctrl".to_string(), action: "focus_url".to_string() }, + Shortcut { key: "Tab".to_string(), modifiers: "Ctrl".to_string(), action: "next_tab".to_string() }, + Shortcut { key: "Tab".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "prev_tab".to_string() }, + Shortcut { key: "Page_Down".to_string(), modifiers: "Ctrl".to_string(), action: "next_tab_pagedown".to_string() }, + Shortcut { key: "Page_Up".to_string(), modifiers: "Ctrl".to_string(), action: "prev_tab_pageup".to_string() }, + Shortcut { key: "r".to_string(), modifiers: "Ctrl".to_string(), action: "reload".to_string() }, + Shortcut { key: "F5".to_string(), modifiers: "".to_string(), action: "reload".to_string() }, + Shortcut { key: "F5".to_string(), modifiers: "Shift".to_string(), action: "force_reload".to_string() }, + Shortcut { key: "Left".to_string(), modifiers: "Alt".to_string(), action: "go_back".to_string() }, + Shortcut { key: "Right".to_string(), modifiers: "Alt".to_string(), action: "go_forward".to_string() }, + Shortcut { key: "f".to_string(), modifiers: "Ctrl".to_string(), action: "find".to_string() }, + Shortcut { key: "comma".to_string(), modifiers: "Ctrl".to_string(), action: "open_settings".to_string() }, + Shortcut { key: "Escape".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "open_processes".to_string() }, + Shortcut { key: "u".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "new_identity".to_string() }, + Shortcut { key: "F11".to_string(), modifiers: "".to_string(), action: "toggle_fullscreen".to_string() }, + Shortcut { key: "i".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "toggle_inspector".to_string() }, + Shortcut { key: "a".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "toggle_sidebar".to_string() }, + Shortcut { key: "m".to_string(), modifiers: "Ctrl+Shift".to_string(), action: "toggle_toolbars".to_string() }, + Shortcut { key: "plus".to_string(), modifiers: "Ctrl".to_string(), action: "zoom_in".to_string() }, + Shortcut { key: "minus".to_string(), modifiers: "Ctrl".to_string(), action: "zoom_out".to_string() }, + Shortcut { key: "0".to_string(), modifiers: "Ctrl".to_string(), action: "zoom_reset".to_string() }, + Shortcut { key: "d".to_string(), modifiers: "Ctrl".to_string(), action: "bookmark_page".to_string() }, + Shortcut { key: "h".to_string(), modifiers: "Ctrl".to_string(), action: "show_history".to_string() }, + Shortcut { key: "b".to_string(), modifiers: "Ctrl".to_string(), action: "show_bookmarks".to_string() }, + Shortcut { key: "j".to_string(), modifiers: "Ctrl".to_string(), action: "show_downloads".to_string() }, + Shortcut { key: "q".to_string(), modifiers: "Ctrl".to_string(), action: "quit".to_string() }, + ]; + + for s in defaults { + let key = format!("{}+{}", s.modifiers, s.key); + shortcuts.insert(key, s); + } +} + +/// Get a shortcut by key combination. +pub fn shortcuts_get(modifiers: &str, key: &str) -> Option { + let shortcuts = G_SHORTCUTS.lock().unwrap(); + let key_combo = format!("{}+{}", modifiers, key); + shortcuts.get(&key_combo).cloned() +} + +/// Get all shortcuts. +pub fn shortcuts_get_all() -> Vec { + let shortcuts = G_SHORTCUTS.lock().unwrap(); + shortcuts.values().cloned().collect() +} + +/// Load shortcuts from the database. +pub fn shortcuts_load() { + if let Ok(Some(json)) = crate::db::db_kv_get("shortcuts") { + if let Ok(loaded) = serde_json::from_str::>(&json) { + let mut shortcuts = G_SHORTCUTS.lock().unwrap(); + shortcuts.clear(); + for s in loaded { + let key = format!("{}+{}", s.modifiers, s.key); + shortcuts.insert(key, s); + } + } + } +} + +/// Save shortcuts to the database. +pub fn shortcuts_save() { + let shortcuts = G_SHORTCUTS.lock().unwrap(); + let list: Vec = shortcuts.values().cloned().collect(); + if let Ok(json) = serde_json::to_string(&list) { + let _ = crate::db::db_kv_set("shortcuts", &json); + } +} diff --git a/src/site_downloader.rs b/src/site_downloader.rs new file mode 100644 index 0000000..55e6d9d --- /dev/null +++ b/src/site_downloader.rs @@ -0,0 +1,58 @@ +//! Website downloader for offline browsing +//! +//! Port of `site_downloader.c` / `site_downloader.h` from the C project. + +use std::sync::Mutex; +use once_cell::sync::Lazy; + +/// Download status. +#[derive(Debug, Clone, PartialEq)] +#[allow(dead_code)] +pub enum DownloadStatus { + Idle, + Downloading, + Complete, + Error(String), +} + +/// Site downloader state. +static G_DOWNLOADER: Lazy> = Lazy::new(|| Mutex::new(DownloaderState::default())); + +struct DownloaderState { + status: DownloadStatus, + current_url: String, +} + +impl Default for DownloaderState { + fn default() -> Self { + DownloaderState { + status: DownloadStatus::Idle, + current_url: String::new(), + } + } +} + +/// Initialize the site downloader. +pub fn site_downloader_init() { + let mut state = G_DOWNLOADER.lock().unwrap(); + state.status = DownloadStatus::Idle; +} + +/// Download a website for offline browsing. +pub fn site_downloader_download(url: &str, _output_dir: &str) -> Result<(), Box> { + let mut state = G_DOWNLOADER.lock().unwrap(); + state.status = DownloadStatus::Downloading; + state.current_url = url.to_string(); + + // TODO: implement actual site download + println!("[downloader] Downloading {} (not yet implemented)", url); + + state.status = DownloadStatus::Complete; + Ok(()) +} + +/// Get the current download status. +pub fn site_downloader_get_status() -> DownloadStatus { + let state = G_DOWNLOADER.lock().unwrap(); + state.status.clone() +} diff --git a/src/tab_manager.rs b/src/tab_manager.rs new file mode 100644 index 0000000..b96c211 --- /dev/null +++ b/src/tab_manager.rs @@ -0,0 +1,1552 @@ +//! Multi-tab management for sovereign_browser +//! +//! Port of `tab_manager.c` / `tab_manager.h` from the C project. +//! +//! Each tab carries its own toolbar (hamburger menu, refresh/stop, back, +//! forward, URL entry, bookmark button), a bookmark bar, a load-progress bar, +//! and a rich tab label (favicon + title + close button). Supports multiple +//! top-level windows, tab context menus, middle-click close, and drag +//! reordering. +//! +//! NOTE: GTK widgets are NOT stored in the global static (they are not +//! `Send`/`Sync`). Only plain `TabInfo` data is stored globally; widgets are +//! passed as function parameters and captured individually in signal closures +//! (individual GTK widget clones are `Send`). + +use gtk::prelude::*; +use webkit2gtk::*; +use webkit2gtk::SettingsExt; +use std::sync::Mutex; +use once_cell::sync::Lazy; + +use crate::settings; +use crate::search; +use crate::bookmarks; + +/// Tab information (plain data, safe to store globally). +#[derive(Debug, Clone)] +pub struct TabInfo { + pub id: i32, + pub title: String, + pub url: String, + pub loading: bool, +} + +/// Tab manager state — plain data only (no GTK widgets). +struct TabManagerState { + tabs: Vec, + active_tab_id: i32, + next_id: i32, + suppress_quit_on_last_tab: bool, +} + +impl Default for TabManagerState { + fn default() -> Self { + TabManagerState { + tabs: Vec::new(), + active_tab_id: 0, + next_id: 1, + suppress_quit_on_last_tab: false, + } + } +} + +/// Global tab state (plain data only). +static G_TAB_STATE: Lazy> = Lazy::new(|| Mutex::new(TabManagerState::default())); + +/// The main notebook, stored in a ThreadGuard so it can live in a static +/// (GTK widgets are not `Sync`). Accessed only from the main thread. +static G_MAIN_NOTEBOOK: Lazy>>> = + Lazy::new(|| Mutex::new(None)); + +/// The main window, stored in a ThreadGuard. +static G_MAIN_WINDOW: Lazy>>> = + Lazy::new(|| Mutex::new(None)); + +/// The custom inspector window (created on `open-window` signal, destroyed +/// on `closed`). Stored in a ThreadGuard so it can live in a static. +static G_INSPECTOR_WINDOW: Lazy>>> = + Lazy::new(|| Mutex::new(None)); + +/// Sidebar state — the GtkPaned, sidebar container, and the web context +/// for creating the sidebar webview. Stored in ThreadGuards (GTK widgets +/// are not Send). +static G_SIDEBAR_PANED: Lazy>>> = + Lazy::new(|| Mutex::new(None)); +static G_SIDEBAR_CONTAINER: Lazy>>> = + Lazy::new(|| Mutex::new(None)); +static G_SIDEBAR_VISIBLE: Lazy> = Lazy::new(|| Mutex::new(false)); +static G_SIDEBAR_WEBVIEW_CREATED: Lazy> = Lazy::new(|| Mutex::new(false)); + +const SIDEBAR_DEFAULT_WIDTH: i32 = 280; +const AGENT_CHAT_URL: &str = "sovereign://agents/chat"; + +/// Initialize the tab manager. +pub fn tab_manager_init() { + let mut state = G_TAB_STATE.lock().unwrap(); + state.tabs.clear(); + state.next_id = 1; + state.active_tab_id = 0; + state.suppress_quit_on_last_tab = false; +} + +/// Set suppress-quit-on-last-tab (used during identity teardown). +pub fn tab_manager_set_suppress_quit_on_last_tab(suppress: bool) { + let mut state = G_TAB_STATE.lock().unwrap(); + state.suppress_quit_on_last_tab = suppress; +} + +/// Get the active notebook (focused window's notebook, or main). +/// Set the main window and notebook (called from main()). +pub fn tab_manager_set_main_window(window: >k::Window, notebook: >k::Notebook) { + *G_MAIN_NOTEBOOK.lock().unwrap() = Some(glib::thread_guard::ThreadGuard::new(notebook.clone())); + *G_MAIN_WINDOW.lock().unwrap() = Some(glib::thread_guard::ThreadGuard::new(window.clone())); +} + +/// Get the active notebook (focused window's notebook, or main). +pub fn tab_manager_get_active_notebook() -> Option { + tab_manager_get_main_notebook() +} + +/// Get the main notebook. +pub fn tab_manager_get_main_notebook() -> Option { + G_MAIN_NOTEBOOK.lock().unwrap().as_ref().map(|g| g.get_ref().clone()) +} + +/// Get the main window. +pub fn tab_manager_get_main_window() -> Option { + G_MAIN_WINDOW.lock().unwrap().as_ref().map(|g| g.get_ref().clone()) +} + +/// Find the WebView inside a tab page (a vertical Box). +fn find_webview(page: >k::Widget) -> Option { + if let Ok(box_) = page.clone().downcast::() { + for child in box_.children() { + if let Ok(wv) = child.downcast::() { + return Some(wv); + } + } + } + None +} + +/// Check if a URI authority string (the part after `http://` or `https://`) +/// has a hostname ending in `.onion`. Used to detect Tor hidden service +/// addresses that need to be routed through the tor:// scheme handler. +/// Mirrors the C version's `authority_is_onion_host()` (tab_manager.c:287). +fn host_is_onion(authority: &str) -> bool { + // Strip path/query/fragment — only the authority part matters. + let authority = authority.split('/').next().unwrap_or(authority); + let authority = authority.split('?').next().unwrap_or(authority); + let authority = authority.split('#').next().unwrap_or(authority); + + // Strip userinfo@ prefix if present. + let host = if let Some(at_idx) = authority.rfind('@') { + &authority[at_idx + 1..] + } else { + authority + }; + + // IPv6 addresses (enclosed in brackets) are never .onion. + if host.starts_with('[') { + return false; + } + + // Strip port suffix if present (last colon). + let hostname = if let Some(colon_idx) = host.rfind(':') { + &host[..colon_idx] + } else { + host + }; + + hostname.ends_with(".onion") +} + +/// Get the active webview from a notebook. +pub fn tab_manager_get_active_webview(notebook: >k::Notebook) -> Option { + let cur = notebook.current_page().unwrap_or(0); + notebook.nth_page(Some(cur)).and_then(|p| find_webview(&p)) +} + +/// Populate the completion model with URLs from history and bookmarks. +fn populate_completion_model(store: >k::ListStore) { + // Add history entries. + if let Ok(history) = crate::db::db_get_history(100) { + for entry in &history { + if let (Some(url), Some(title)) = (entry.get("url").and_then(|v| v.as_str()), entry.get("title").and_then(|v| v.as_str())) { + let url_val = url.to_value(); + let title_val = title.to_value(); + store.set(&store.append(), &[(0u32, &url_val as &dyn glib::ToValue), (1u32, &title_val as &dyn glib::ToValue)]); + } + } + } + // Add bookmark URLs. + if let Ok(bookmarks) = crate::bookmarks::bookmarks_get_all() { + for bm in &bookmarks { + if let (Some(url), Some(title)) = (bm.get("url").and_then(|v| v.as_str()), bm.get("title").and_then(|v| v.as_str())) { + let url_val = url.to_value(); + let title_val = title.to_value(); + store.set(&store.append(), &[(0u32, &url_val as &dyn glib::ToValue), (1u32, &title_val as &dyn glib::ToValue)]); + } + } + } +} + +/// Create a new tab with a full per-tab toolbar and rich label. +pub fn tab_manager_new_tab(notebook: >k::Notebook, ctx: &WebContext, url: Option<&str>) -> i32 { + let s = settings::settings_get(); + let mut state = G_TAB_STATE.lock().unwrap(); + + if state.tabs.len() as i32 >= s.max_tabs { + println!("[tabs] Maximum tab count ({}) reached", s.max_tabs); + return -1; + } + + let tab_id = state.next_id; + state.next_id += 1; + + let load_url = url.unwrap_or(&s.new_tab_url).to_string(); + + let tab = TabInfo { + id: tab_id, + title: "Loading…".to_string(), + url: load_url.clone(), + loading: false, + }; + + state.tabs.push(tab); + state.active_tab_id = tab_id; + drop(state); + + // Create the webview with perf probe injected (tab index baked into preamble). + let content_manager = crate::nostr_inject::nostr_inject_create_content_manager_with_probe(tab_id); + let webview = WebView::builder() + .user_content_manager(&content_manager) + .web_context(ctx) + .build(); + + // Apply WebKit settings. + if let Some(ws) = webkit2gtk::WebViewExt::settings(&webview) { + crate::web_context::web_context_configure_settings(&ws); + // The inspector requires developer extras. The C version forces this + // on for every tab (tab_manager.c:3029); do the same so the + // "Toggle Inspector" menu item always works. + ws.set_enable_developer_extras(true); + ws.set_javascript_can_open_windows_automatically(s.allow_popups); + ws.set_allow_file_access_from_file_urls(true); + ws.set_allow_universal_access_from_file_urls(true); + ws.set_hardware_acceleration_policy(webkit2gtk::HardwareAccelerationPolicy::Always); + } + + // Build the per-tab page: toolbar on top, webview below. + let page = gtk::Box::new(gtk::Orientation::Vertical, 0); + + // ── Toolbar ───────────────────────────────────────────────────── + let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 4); + toolbar.set_widget_name("main-toolbar"); + toolbar.set_margin_top(4); + toolbar.set_margin_bottom(4); + toolbar.set_margin_start(4); + toolbar.set_margin_end(4); + page.pack_start(&toolbar, false, false, 0); + + // Hamburger menu button. + let hamburger = build_hamburger_menu(); + toolbar.pack_start(&hamburger, false, false, 0); + + // Refresh/Stop button. + let refresh_btn = gtk::Button::new(); + refresh_btn.set_relief(gtk::ReliefStyle::None); + refresh_btn.set_image(Some(>k::Image::from_icon_name( + Some("view-refresh-symbolic"), + gtk::IconSize::Menu, + ))); + refresh_btn.set_tooltip_text(Some("Reload page")); + toolbar.pack_start(&refresh_btn, false, false, 0); + + // Back button. + let back_btn = gtk::Button::new(); + back_btn.set_relief(gtk::ReliefStyle::None); + back_btn.set_image(Some(>k::Image::from_icon_name( + Some("go-previous-symbolic"), + gtk::IconSize::Menu, + ))); + back_btn.set_tooltip_text(Some("Go back")); + toolbar.pack_start(&back_btn, false, false, 0); + + // Forward button. + let forward_btn = gtk::Button::new(); + forward_btn.set_relief(gtk::ReliefStyle::None); + forward_btn.set_image(Some(>k::Image::from_icon_name( + Some("go-next-symbolic"), + gtk::IconSize::Menu, + ))); + forward_btn.set_tooltip_text(Some("Go forward")); + toolbar.pack_start(&forward_btn, false, false, 0); + + // URL entry. + let url_entry = gtk::Entry::new(); + if load_url.contains("about:blank") { + url_entry.set_text(""); + } else { + url_entry.set_text(&load_url); + } + url_entry.set_hexpand(true); + toolbar.pack_start(&url_entry, true, true, 0); + + // URL entry completion — autocomplete from history and bookmarks. + let completion = gtk::EntryCompletion::new(); + let list_store = gtk::ListStore::new(&[glib::Type::STRING, glib::Type::STRING]); + populate_completion_model(&list_store); + completion.set_model(Some(&list_store.upcast::())); + completion.set_text_column(0); + completion.set_popup_completion(true); + completion.set_minimum_key_length(1); + url_entry.set_completion(Some(&completion)); + + // Bookmark button. + let bookmark_btn = gtk::Button::new(); + bookmark_btn.set_relief(gtk::ReliefStyle::None); + bookmark_btn.set_image(Some(>k::Image::from_icon_name( + Some("user-bookmarks-symbolic"), + gtk::IconSize::Menu, + ))); + bookmark_btn.set_tooltip_text(Some("Bookmark this page")); + toolbar.pack_start(&bookmark_btn, false, false, 0); + + // New-tab button (at the right end of the toolbar). + let new_tab_btn = gtk::Button::new(); + new_tab_btn.set_relief(gtk::ReliefStyle::None); + new_tab_btn.set_image(Some(>k::Image::from_icon_name( + Some("tab-new-symbolic"), + gtk::IconSize::Menu, + ))); + new_tab_btn.set_tooltip_text(Some("New tab (Ctrl+T)")); + let notebook_clone_nt = notebook.clone(); + let ctx_clone_nt = ctx.clone(); + new_tab_btn.connect_clicked(move |_| { + tab_manager_new_tab(¬ebook_clone_nt, &ctx_clone_nt, None); + }); + toolbar.pack_end(&new_tab_btn, false, false, 0); + + // ── Bookmark bar ──────────────────────────────────────────────── + let bookmark_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4); + bookmark_bar.set_widget_name("bookmark-bar"); + bookmark_bar.set_margin_top(2); + bookmark_bar.set_margin_bottom(2); + bookmark_bar.set_margin_start(4); + bookmark_bar.set_margin_end(4); + page.pack_start(&bookmark_bar, false, false, 0); + + // ── Load-progress bar ─────────────────────────────────────────── + let progress_bar = gtk::ProgressBar::new(); + progress_bar.set_show_text(false); + progress_bar.set_size_request(-1, 3); + progress_bar.set_no_show_all(true); + progress_bar.hide(); + page.pack_start(&progress_bar, false, false, 0); + + // ── Webview ───────────────────────────────────────────────────── + webview.set_vexpand(true); + webview.set_hexpand(true); + page.pack_start(&webview, true, true, 0); + + // ── Tab label ─────────────────────────────────────────────────── + let tab_label = gtk::Box::new(gtk::Orientation::Horizontal, 4); + tab_label.set_hexpand(true); + tab_label.set_halign(gtk::Align::Fill); + tab_label.set_size_request(120, -1); + + let favicon = gtk::Image::new(); + favicon.set_valign(gtk::Align::Center); + tab_label.pack_start(&favicon, false, false, 0); + + let title_label = gtk::Label::new(Some("New Tab")); + title_label.set_ellipsize(pango::EllipsizeMode::End); + title_label.set_max_width_chars(20); + title_label.set_hexpand(true); + title_label.set_valign(gtk::Align::Center); + tab_label.pack_start(&title_label, true, true, 0); + + let close_btn = if s.show_tab_close_buttons { + let btn = gtk::Button::new(); + btn.set_relief(gtk::ReliefStyle::None); + btn.set_image(Some(>k::Image::from_icon_name( + Some("window-close-symbolic"), + gtk::IconSize::Menu, + ))); + btn.set_tooltip_text(Some("Close tab")); + tab_label.pack_start(&btn, false, false, 0); + Some(btn) + } else { + None + }; + + // Add to notebook. + notebook.append_page(&page, Some(&tab_label)); + // Show the page and tab label — required because window.show_all() + // was already called before this tab was created, so the new widgets + // are not automatically visible. + page.show_all(); + tab_label.show_all(); + let page_num = notebook.n_pages(); + notebook.set_current_page(Some(if page_num > 0 { page_num - 1 } else { 0 })); + notebook.set_tab_reorderable(&page, s.tab_drag_reorder); + + // Load URL. + webview.load_uri(&load_url); + + // ── Wire signals ──────────────────────────────────────────────── + + // Title change. + let title_label_clone = title_label.clone(); + let tab_id_clone = tab_id; + webview.connect_title_notify(move |wv| { + if let Some(title) = wv.title() { + title_label_clone.set_text(&title); + let mut state = G_TAB_STATE.lock().unwrap(); + if let Some(t) = state.tabs.iter_mut().find(|t| t.id == tab_id_clone) { + t.title = title.to_string(); + } + } + }); + + // Favicon change — populate the favicon image from the page favicon. + // (cairo::Surface → Pixbuf conversion is not exposed in this gdk version, + // so we keep the favicon widget but leave it empty.) + let _favicon_clone = favicon.clone(); + webview.connect_favicon_notify(move |_wv| { + // favicon population skipped (API limitation) + }); + + // Load progress. + let progress_bar_clone = progress_bar.clone(); + webview.connect_load_changed(move |wv, event| { + let loading = wv.is_loading(); + progress_bar_clone.set_visible(loading); + if !loading { + progress_bar_clone.set_fraction(1.0); + } + // Track URL history on finished load. + if event == webkit2gtk::LoadEvent::Finished { + if let Some(uri) = wv.uri() { + let uri_str = uri.to_string(); + if !uri_str.starts_with("sovereign://") && !uri_str.starts_with("about:") { + let title = wv.title().unwrap_or_default().to_string(); + let _ = crate::db::db_add_history(&uri_str, &title); + } + } + } + }); + + // Popup handler: intercept WebKit `create` signal to open new windows + // as tabs in the same notebook. Mirrors the C version. + let notebook_for_create = notebook.clone(); + webview.connect_create(move |_wv, navigation_action| { + if let Some(req) = navigation_action.request() { + if let Some(uri) = req.uri() { + let uri_str = uri.to_string(); + if let Some(wv) = tab_manager_get_active_webview(¬ebook_for_create) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_tab(¬ebook_for_create, &ctx, Some(&uri_str)); + } + } + } + } + // Return None to prevent WebKit from creating its own window + // (the callback returns Option to optionally supply + // a container widget for the new webview, but we handle it via + // tab_manager_new_tab directly). + None + }); + + // URL entry activate. + let webview_clone = webview.clone(); + url_entry.connect_activate(move |entry| { + let text = entry.text().to_string(); + if text.is_empty() { + return; + } + let url = if search::search_is_url(&text) { + search::normalize_url(&text) + } else { + search::search_query(&text) + }; + webview_clone.load_uri(&url); + }); + + // Back/Forward/Refresh. + let webview_clone2 = webview.clone(); + back_btn.connect_clicked(move |_| { + if webview_clone2.can_go_back() { + webview_clone2.go_back(); + } + }); + let webview_clone3 = webview.clone(); + forward_btn.connect_clicked(move |_| { + if webview_clone3.can_go_forward() { + webview_clone3.go_forward(); + } + }); + let webview_clone4 = webview.clone(); + refresh_btn.connect_clicked(move |_| { + webview_clone4.reload(); + }); + + // Bookmark button. + let webview_clone5 = webview.clone(); + bookmark_btn.connect_clicked(move |_| { + if let Some(uri) = webview_clone5.uri() { + let title: String = webview_clone5.title().unwrap_or_else(|| "Untitled".into()).into(); + let _ = bookmarks::bookmarks_add(&title, &uri, "General"); + } + }); + + // Close button. + if let Some(close_btn) = close_btn { + let notebook_clone = notebook.clone(); + close_btn.connect_clicked(move |_| { + tab_manager_close_tab(¬ebook_clone, tab_id); + }); + } + + // Tab label button-press (middle-click close, right-click context menu). + let notebook_clone2 = notebook.clone(); + tab_label.connect_button_press_event(move |_w, event| { + let s = settings::settings_get(); + if event.button() == 2 && s.middle_click_close { + tab_manager_close_tab(¬ebook_clone2, tab_id); + return glib::Propagation::Stop; + } + if event.button() == 3 { + show_tab_context_menu(¬ebook_clone2, tab_id, event); + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + }); + + // Update the tab's URL in state when the page navigates. + // Also rewrites .onion HTTP(S) URLs to tor:// for Tor SOCKS routing, + // and fips:// URLs to their normalized form. Mirrors the C version + // (tab_manager.c:1044). + let tab_id_clone2 = tab_id; + let wv_for_onion = webview.clone(); + webview.connect_decide_policy(move |_wv, decision, _type| { + // Only intercept navigation actions. + if let Some(nav) = decision.downcast_ref::() { + if let Some(action) = nav.navigation_action() { + if let Some(req) = action.request() { + if let Some(uri) = req.uri() { + let uri_str = uri.to_string(); + + // ── .onion HTTP(S) → tor:// rewriting ────────────── + // Detect .onion hosts in http:// or https:// URLs and + // rewrite to tor:// so the request goes through Tor's + // SOCKS proxy. Mirrors tab_manager.c:1112. + let tor_rewrite = if let Some(rest) = uri_str.strip_prefix("http://") { + if host_is_onion(rest) { + Some(format!("tor://{}", rest)) + } else { + None + } + } else if let Some(rest) = uri_str.strip_prefix("https://") { + if host_is_onion(rest) { + Some(format!("tor://{}", rest)) + } else { + None + } + } else { + None + }; + + if let Some(tor_url) = tor_rewrite { + // Ignore the current decision and defer the + // redirect to the next idle tick to avoid + // re-entrant navigation inside decide-policy + // (which segfaults WebKitGTK). + decision.ignore(); + let wv = wv_for_onion.clone(); + glib::idle_add_local_once(move || { + wv.load_uri(&tor_url); + }); + return true; + } + + // Update tab URL in state for non-rewritten URLs. + let mut state = G_TAB_STATE.lock().unwrap(); + if let Some(t) = state.tabs.iter_mut().find(|t| t.id == tab_id_clone2) { + t.url = uri_str; + } + } + } + } + } + true + }); + + // Webview context menu — add "Open Link in New Tab" when right-clicking + // a link, and "Open Page in New Tab" when right-clicking the page + // background. Mirrors the C version (tab_manager.c:1912). + let notebook_clone_ctx = notebook.clone(); + let webview_clone_ctx = webview.clone(); + webview.connect_context_menu(move |_wv, context_menu, _event, hit_test| { + use webkit2gtk::HitTestResultExt; + + let url = if hit_test.context_is_link() { + hit_test.link_uri().map(|s| s.to_string()) + } else if !hit_test.context_is_image() + && !hit_test.context_is_media() + && !hit_test.context_is_editable() + && !hit_test.context_is_selection() + && !hit_test.context_is_scrollbar() + { + // Page background — use the current page URI. + webview_clone_ctx.uri().map(|s| s.to_string()) + } else { + None + }; + + if let Some(url) = url { + let url_for_action = url.clone(); + let nb_clone = notebook_clone_ctx.clone(); + let action = gio::SimpleAction::new("open-in-new-tab", None); + action.connect_activate(move |_, _| { + if let Some(wv) = tab_manager_get_active_webview(&nb_clone) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_tab(&nb_clone, &ctx, Some(&url_for_action)); + } + } + }); + + let label = if hit_test.context_is_link() { + "Open Link in New Tab" + } else { + "Open Page in New Tab" + }; + let item = webkit2gtk::ContextMenuItem::from_gaction( + &action, + label, + None, + ); + context_menu.insert(&item, 0); + } + + // Return false to let WebKit show the default menu (with our item added). + false + }); + + // Grab focus on the URL entry. + url_entry.grab_focus(); + url_entry.select_region(0, -1); + + tab_id +} + +/// Close a tab by ID. +pub fn tab_manager_close_tab(notebook: >k::Notebook, tab_id: i32) { + let mut state = G_TAB_STATE.lock().unwrap(); + if let Some(pos) = state.tabs.iter().position(|t| t.id == tab_id) { + state.tabs.remove(pos); + if pos < notebook.n_pages() as usize { + notebook.remove_page(Some(pos as u32)); + } + // If no tabs remain and not suppressed, quit. + if state.tabs.is_empty() && !state.suppress_quit_on_last_tab { + gtk::main_quit(); + } + } +} + +/// Close the active tab. +pub fn tab_manager_close_active() { + let state = G_TAB_STATE.lock().unwrap(); + let active_id = state.active_tab_id; + drop(state); + if let Some(nb) = tab_manager_get_active_notebook() { + tab_manager_close_tab(&nb, active_id); + } +} + +/// Get the active tab ID. +pub fn tab_manager_get_active_tab() -> i32 { + let state = G_TAB_STATE.lock().unwrap(); + state.active_tab_id +} + +/// Get all tabs. +pub fn tab_manager_get_tabs() -> Vec { + let state = G_TAB_STATE.lock().unwrap(); + state.tabs.clone() +} + +/// Get tab count. +pub fn tab_manager_get_count() -> usize { + let state = G_TAB_STATE.lock().unwrap(); + state.tabs.len() +} + +/// Navigate the active tab to a URL. +pub fn tab_manager_navigate(notebook: >k::Notebook, url: &str) { + if let Some(wv) = tab_manager_get_active_webview(notebook) { + wv.load_uri(url); + } +} + +/// Switch to the next tab. +pub fn tab_manager_next() { + if let Some(nb) = tab_manager_get_active_notebook() { + let n = nb.n_pages(); + if n > 0 { + let cur = nb.current_page().unwrap_or(0); + nb.set_current_page(Some((cur + 1) % n)); + } + } +} + +/// Switch to the previous tab. +pub fn tab_manager_prev() { + if let Some(nb) = tab_manager_get_active_notebook() { + let n = nb.n_pages(); + if n > 0 { + let cur = nb.current_page().unwrap_or(0); + nb.set_current_page(Some((cur + n - 1) % n)); + } + } +} + +/// Reload the active tab. +pub fn tab_manager_reload_active() { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + wv.reload(); + } + } +} + +/// Go back in the active tab. +pub fn tab_manager_go_back() { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + if wv.can_go_back() { + wv.go_back(); + } + } + } +} + +/// Go forward in the active tab. +pub fn tab_manager_go_forward() { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + if wv.can_go_forward() { + wv.go_forward(); + } + } + } +} + +/// Focus the URL entry of the active tab. +pub fn tab_manager_focus_url() { + if let Some(nb) = tab_manager_get_active_notebook() { + let cur = nb.current_page().unwrap_or(0); + if let Some(page) = nb.nth_page(Some(cur)) { + if let Ok(box_) = page.downcast::() { + for child in box_.children() { + if let Ok(entry) = child.downcast::() { + entry.grab_focus(); + entry.select_region(0, -1); + return; + } + } + } + } + } +} + +/// Global inspector visibility flag (mirrors g_inspector_visible in the C version). +static G_INSPECTOR_VISIBLE: Lazy> = Lazy::new(|| Mutex::new(false)); + +/// Toggle the WebKit inspector on the active tab. +/// +/// Mirrors the C implementation (tab_manager.c:2141): tracks a global +/// visibility flag, hooks the `open-window` signal to create a custom +/// GtkWindow for the inspector (WebKit's default window doesn't handle +/// title-bar button clicks properly), and detaches before showing so the +/// inspector opens in a separate window. +pub fn tab_manager_toggle_inspector() { + let nb = match tab_manager_get_active_notebook() { + Some(n) => n, + None => return, + }; + let wv = match tab_manager_get_active_webview(&nb) { + Some(w) => w, + None => return, + }; + let inspector = match wv.inspector() { + Some(i) => i, + None => return, + }; + + // Hook the open-window signal once per inspector (idempotent via data flag). + // We can't use g_object_get_data in Rust easily, so we use a closure-based + // approach with a static set. Instead, just connect every time — GTK + // deduplicates signal handlers only if we use connect_id, but connecting + // multiple times is harmless here because we return TRUE from our handler. + // A simpler approach: use a once-cell flag on the inspector via unsafe data. + // For now, connect open-window and closed every toggle — the handlers are + // cheap and idempotent in effect. + + let inspector_for_open = inspector.clone(); + inspector.connect_open_window(move |_| { + let insp = match inspector_for_open.web_view() { + Some(v) => v, + None => return false, + }; + + // Create a top-level window for the inspector. + let window = gtk::Window::new(gtk::WindowType::Toplevel); + window.set_title("Web Inspector"); + window.set_default_size(800, 600); + + // Add the inspector's WebView to our window. + let container = gtk::Box::new(gtk::Orientation::Vertical, 0); + container.pack_start(&insp, true, true, 0); + window.add(&container); + + window.show_all(); + + // Store the window so the closed handler can destroy it. + *G_INSPECTOR_WINDOW.lock().unwrap() = + Some(glib::thread_guard::ThreadGuard::new(window)); + + // Prevent WebKit from creating its own window. + true + }); + + inspector.connect_closed(move |_| { + *G_INSPECTOR_VISIBLE.lock().unwrap() = false; + if let Some(guard) = G_INSPECTOR_WINDOW.lock().unwrap().take() { + guard.get_ref().close(); + } + }); + + let mut visible = G_INSPECTOR_VISIBLE.lock().unwrap(); + if *visible { + inspector.close(); + *visible = false; + } else { + // Force detach so the inspector opens in a separate window + // (triggers ::detach then ::open-window). + if inspector.can_attach() { + inspector.detach(); + } + inspector.show(); + *visible = true; + } +} + +/// Zoom in on the active tab. +pub fn tab_manager_zoom_in() { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + wv.set_zoom_level(wv.zoom_level() + 0.1); + } + } +} + +/// Zoom out on the active tab. +pub fn tab_manager_zoom_out() { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + wv.set_zoom_level((wv.zoom_level() - 0.1).max(0.1)); + } + } +} + +/// Reset zoom on the active tab. +pub fn tab_manager_zoom_reset() { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + wv.set_zoom_level(1.0); + } + } +} + +/// Open a file chooser and load the selected file. +pub fn tab_manager_open_file() { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(parent) = nb.toplevel().and_then(|w| w.downcast::().ok()) { + let chooser = gtk::FileChooserDialog::new( + Some("Open File"), + Some(&parent), + gtk::FileChooserAction::Open, + ); + chooser.add_button("Cancel", gtk::ResponseType::Cancel); + chooser.add_button("Open", gtk::ResponseType::Accept); + if chooser.run() == gtk::ResponseType::Accept { + if let Some(path) = chooser.filename() { + let url = format!("file://{}", path.to_string_lossy()); + tab_manager_navigate(&nb, &url); + } + } + unsafe { chooser.destroy(); } + } + } +} + +/// Create a new blank window. +pub fn tab_manager_new_window_blank() { + // Get the context from the main window's active webview. + if let Some(nb) = tab_manager_get_main_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_window(None, &ctx); + return; + } + } + } + println!("[windows] New window requires a WebContext; use tab_manager_new_window"); +} + +/// Create a new top-level window with a single tab. +pub fn tab_manager_new_window(url: Option<&str>, ctx: &WebContext) { + let window = gtk::Window::new(gtk::WindowType::Toplevel); + window.set_title("sovereign browser"); + window.set_default_size(1024, 768); + + let notebook = gtk::Notebook::new(); + notebook.set_scrollable(false); + notebook.set_show_border(false); + notebook.set_show_tabs(true); + window.add(¬ebook); + + // Add a new-tab button as a notebook action widget. + let new_btn = gtk::Button::new(); + new_btn.set_relief(gtk::ReliefStyle::None); + new_btn.set_image(Some(>k::Image::from_icon_name( + Some("tab-new-symbolic"), + gtk::IconSize::Button, + ))); + new_btn.set_tooltip_text(Some("New tab (Ctrl+T)")); + let notebook_clone = notebook.clone(); + let ctx_clone = ctx.clone(); + new_btn.connect_clicked(move |_| { + tab_manager_new_tab(¬ebook_clone, &ctx_clone, None); + }); + notebook.set_action_widget(&new_btn, gtk::PackType::End); + + // Create the first tab. + tab_manager_new_tab(¬ebook, ctx, url); + + // On destroy, do not quit the app (only the main window quits). + window.connect_destroy(|_| { + // no-op: aux window close does not quit + }); + + window.show_all(); + window.present(); +} + +/// Show the tab context menu (right-click on a tab label). +fn show_tab_context_menu(notebook: >k::Notebook, tab_id: i32, event: &gdk::EventButton) { + let menu = gtk::Menu::new(); + + let item_new = gtk::MenuItem::with_label("New Tab"); + let notebook_clone = notebook.clone(); + item_new.connect_activate(move |_| { + // New tab needs a context; use the notebook's webview context. + if let Some(wv) = tab_manager_get_active_webview(¬ebook_clone) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_tab(¬ebook_clone, &ctx, None); + } + } + }); + menu.append(&item_new); + + let item_close = gtk::MenuItem::with_label("Close Tab"); + let notebook_clone2 = notebook.clone(); + item_close.connect_activate(move |_| { + tab_manager_close_tab(¬ebook_clone2, tab_id); + }); + menu.append(&item_close); + + let item_close_others = gtk::MenuItem::with_label("Close Other Tabs"); + let notebook_clone3 = notebook.clone(); + item_close_others.connect_activate(move |_| { + tab_manager_close_others(¬ebook_clone3, tab_id); + }); + menu.append(&item_close_others); + + let item_close_right = gtk::MenuItem::with_label("Close Tabs to the Right"); + let notebook_clone4 = notebook.clone(); + item_close_right.connect_activate(move |_| { + tab_manager_close_to_right(¬ebook_clone4, tab_id); + }); + menu.append(&item_close_right); + + menu.append(>k::SeparatorMenuItem::new()); + + let item_new_win = gtk::MenuItem::with_label("Open in New Window"); + let notebook_clone_nw = notebook.clone(); + item_new_win.connect_activate(move |_| { + let state = G_TAB_STATE.lock().unwrap(); + let url = state.tabs.iter().find(|t| t.id == tab_id).map(|t| t.url.clone()); + drop(state); + if let Some(wv) = tab_manager_get_active_webview(¬ebook_clone_nw) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_window(url.as_deref(), &ctx); + } + } + }); + menu.append(&item_new_win); + + let item_dup = gtk::MenuItem::with_label("Duplicate Tab"); + let notebook_clone5 = notebook.clone(); + item_dup.connect_activate(move |_| { + let state = G_TAB_STATE.lock().unwrap(); + let url = state.tabs.iter().find(|t| t.id == tab_id).map(|t| t.url.clone()); + drop(state); + if let Some(wv) = tab_manager_get_active_webview(¬ebook_clone5) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_tab(¬ebook_clone5, &ctx, url.as_deref()); + } + } + }); + menu.append(&item_dup); + + let item_reload = gtk::MenuItem::with_label("Reload Tab"); + item_reload.connect_activate(move |_| { + tab_manager_reload_tab(tab_id); + }); + menu.append(&item_reload); + + menu.show_all(); + menu.popup_at_pointer(Some(event)); +} + +/// Close all tabs except the given one. +pub fn tab_manager_close_others(notebook: >k::Notebook, tab_id: i32) { + let state = G_TAB_STATE.lock().unwrap(); + let ids: Vec = state.tabs.iter().filter(|t| t.id != tab_id).map(|t| t.id).collect(); + drop(state); + for id in ids { + tab_manager_close_tab(notebook, id); + } +} + +/// Close all tabs to the right of the given one. +pub fn tab_manager_close_to_right(notebook: >k::Notebook, tab_id: i32) { + let state = G_TAB_STATE.lock().unwrap(); + let pos = state.tabs.iter().position(|t| t.id == tab_id); + let ids: Vec = match pos { + Some(p) => state.tabs.iter().skip(p + 1).map(|t| t.id).collect(), + None => Vec::new(), + }; + drop(state); + for id in ids { + tab_manager_close_tab(notebook, id); + } +} + +/// Reload a specific tab by ID. +pub fn tab_manager_reload_tab(tab_id: i32) { + if let Some(nb) = tab_manager_get_active_notebook() { + let n = nb.n_pages(); + for i in 0..n { + if let Some(page) = nb.nth_page(Some(i)) { + if let Some(wv) = find_webview(&page) { + // Match by tab id via the state. + let state = G_TAB_STATE.lock().unwrap(); + let matches = state.tabs.get(i as usize).map(|t| t.id == tab_id).unwrap_or(false); + drop(state); + if matches { + wv.reload(); + return; + } + } + } + } + } +} + +/// Duplicate a tab by ID. +pub fn tab_manager_duplicate(tab_id: i32) { + let state = G_TAB_STATE.lock().unwrap(); + let url = state.tabs.iter().find(|t| t.id == tab_id).map(|t| t.url.clone()); + drop(state); + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_tab(&nb, &ctx, url.as_deref()); + } + } + } +} + +/// Open a tab in a new window by ID. +pub fn tab_manager_open_in_new_window(tab_id: i32) { + let state = G_TAB_STATE.lock().unwrap(); + let url = state.tabs.iter().find(|t| t.id == tab_id).map(|t| t.url.clone()); + drop(state); + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_window(url.as_deref(), &ctx); + } + } + } +} + +/// Build the hamburger menu button. +/// Mirrors the C version (tab_manager.c:2562) with all menu items: +/// Navigation, Recents, Bookmarks, Identity, Network, Tools, Pages, About. +fn build_hamburger_menu() -> gtk::MenuButton { + let menu = gtk::Menu::new(); + + // ── Navigation group ──────────────────────────────────────────── + let item_open = gtk::MenuItem::with_label("Open File…"); + item_open.connect_activate(|_| tab_manager_open_file()); + menu.append(&item_open); + + let item_reload = gtk::MenuItem::with_label("Reload"); + item_reload.connect_activate(|_| tab_manager_reload_active()); + menu.append(&item_reload); + + let item_stop = gtk::MenuItem::with_label("Stop"); + item_stop.connect_activate(|_| { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + wv.stop_loading(); + } + } + }); + menu.append(&item_stop); + + menu.append(>k::SeparatorMenuItem::new()); + + // ── Recents (history) submenu ─────────────────────────────────── + // Populated dynamically on show, mirroring the C version. + let history_menu = gtk::Menu::new(); + history_menu.connect_show(move |m| { + // Clear existing items. + for child in m.children() { + m.remove(&child); + } + // Add recent history entries. + if let Ok(history) = crate::db::db_get_history(10) { + if history.is_empty() { + let empty = gtk::MenuItem::with_label("(no recent pages)"); + empty.set_sensitive(false); + m.append(&empty); + } else { + for entry in &history { + let url = entry.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = entry.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let label = if !title.is_empty() { title } else { url.clone() }; + let item = gtk::MenuItem::with_label(&label); + item.set_tooltip_text(Some(&url)); + let url_clone = url.clone(); + item.connect_activate(move |_| { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_tab(&nb, &ctx, Some(&url_clone)); + } + } + } + }); + m.append(&item); + } + } + m.append(>k::SeparatorMenuItem::new()); + let clear_item = gtk::MenuItem::with_label("Clear Recents"); + clear_item.connect_activate(|_| { + let _ = crate::db::db_clear_history(); + }); + m.append(&clear_item); + } + m.show_all(); + }); + let item_history = gtk::MenuItem::with_label("Recents"); + item_history.set_submenu(Some(&history_menu)); + menu.append(&item_history); + + // ── Bookmarks submenu ─────────────────────────────────────────── + // Populated dynamically on show. + let bookmarks_menu = gtk::Menu::new(); + bookmarks_menu.connect_show(move |m| { + for child in m.children() { + m.remove(&child); + } + if let Ok(bms) = crate::bookmarks::bookmarks_get_all() { + if bms.is_empty() { + let empty = gtk::MenuItem::with_label("(no bookmarks)"); + empty.set_sensitive(false); + m.append(&empty); + } else { + for bm in &bms { + let url = bm.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = bm.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let label = if !title.is_empty() { title.clone() } else { url.clone() }; + let item = gtk::MenuItem::with_label(&label); + let url_clone = url.clone(); + item.connect_activate(move |_| { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_tab(&nb, &ctx, Some(&url_clone)); + } + } + } + }); + m.append(&item); + } + } + m.append(>k::SeparatorMenuItem::new()); + let manage_item = gtk::MenuItem::with_label("Manage Bookmarks…"); + manage_item.connect_activate(|_| tab_manager_open_internal("sovereign://bookmarks")); + m.append(&manage_item); + } + m.show_all(); + }); + let item_bookmarks = gtk::MenuItem::with_label("Bookmarks"); + item_bookmarks.set_submenu(Some(&bookmarks_menu)); + menu.append(&item_bookmarks); + + menu.append(>k::SeparatorMenuItem::new()); + + // ── Identity group ────────────────────────────────────────────── + let item_switch = gtk::MenuItem::with_label("Switch Identity…"); + item_switch.connect_activate(|_| crate::menu::menu_switch_identity()); + menu.append(&item_switch); + + let item_lock = gtk::MenuItem::with_label("Lock Session"); + item_lock.connect_activate(|_| crate::menu::menu_lock_session()); + menu.append(&item_lock); + + let item_logout = gtk::MenuItem::with_label("Logout"); + item_logout.connect_activate(|_| crate::menu::menu_logout()); + menu.append(&item_logout); + + menu.append(>k::SeparatorMenuItem::new()); + + // ── Network toggles ───────────────────────────────────────────── + let item_tor = gtk::CheckMenuItem::with_label("Tor-routed transport"); + // Tor state is read from settings; for now, default to off. + menu.append(&item_tor); + + let item_fips = gtk::CheckMenuItem::with_label("FIPS mesh"); + menu.append(&item_fips); + + let item_security = gtk::CheckMenuItem::with_label("Security strip (SOP/CORS/certs)"); + menu.append(&item_security); + + menu.append(>k::SeparatorMenuItem::new()); + + // ── Nostr signing status ──────────────────────────────────────── + let item_nostr = gtk::MenuItem::with_label("Nostr signing status"); + item_nostr.connect_activate(|_| { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_tab(&nb, &ctx, Some("sovereign://profile")); + } + } + } + }); + menu.append(&item_nostr); + + // The menu::show signal updates the Nostr status label. + let item_nostr_clone = item_nostr.clone(); + menu.connect_show(move |_| { + let pk = crate::menu::app_get_pubkey_hex(); + let ro = crate::menu::app_get_readonly(); + let label = if pk.is_empty() { + "Nostr: not logged in".to_string() + } else { + format!("Nostr: {} ({})", &pk[..8], if ro { "read-only" } else { "signing" }) + }; + item_nostr_clone.set_label(&label); + }); + + // ── Tools ─────────────────────────────────────────────────────── + let item_dl = gtk::MenuItem::with_label("Download Website"); + item_dl.connect_activate(|_| { + let url = tab_manager_get_main_notebook() + .and_then(|nb| tab_manager_get_active_webview(&nb)) + .and_then(|wv| wv.uri().map(|u| u.to_string())) + .unwrap_or_default(); + if !url.is_empty() { + let _ = crate::site_downloader::site_downloader_download(&url, "/tmp/sovereign_download"); + } + }); + menu.append(&item_dl); + + let item_inspector = gtk::MenuItem::with_label("Toggle Inspector"); + item_inspector.connect_activate(|_| tab_manager_toggle_inspector()); + menu.append(&item_inspector); + + let item_sidebar = gtk::MenuItem::with_label("Toggle Agent Sidebar"); + item_sidebar.connect_activate(|_| tab_manager_toggle_sidebar()); + menu.append(&item_sidebar); + + menu.append(>k::SeparatorMenuItem::new()); + + // ── Internal pages ────────────────────────────────────────────── + let item_profile = gtk::MenuItem::with_label("Profile"); + item_profile.connect_activate(|_| tab_manager_open_internal("sovereign://profile")); + menu.append(&item_profile); + + let item_agent = gtk::MenuItem::with_label("Agent Setup…"); + item_agent.connect_activate(|_| tab_manager_open_internal("sovereign://agents")); + menu.append(&item_agent); + + let item_fips_page = gtk::MenuItem::with_label("FIPS Mesh…"); + item_fips_page.connect_activate(|_| tab_manager_open_internal("sovereign://fips")); + menu.append(&item_fips_page); + + let item_processes = gtk::MenuItem::with_label("Processes…"); + item_processes.connect_activate(|_| tab_manager_open_internal("sovereign://processes")); + menu.append(&item_processes); + + let item_settings = gtk::MenuItem::with_label("Settings…"); + item_settings.connect_activate(|_| tab_manager_open_internal("sovereign://settings")); + menu.append(&item_settings); + + menu.append(>k::SeparatorMenuItem::new()); + + // ── About ─────────────────────────────────────────────────────── + let item_about = gtk::MenuItem::with_label("About"); + item_about.connect_activate(|_| { + if let Some(nb) = tab_manager_get_main_notebook() { + if let Some(parent) = nb.toplevel().and_then(|w| w.downcast::().ok()) { + let dlg = gtk::MessageDialog::new( + Some(&parent), + gtk::DialogFlags::MODAL, + gtk::MessageType::Info, + gtk::ButtonsType::Ok, + &format!( + "sovereign browser {}\nWebKitGTK + Rust — sovereign identity, not permissioned domains.", + crate::version::VERSION + ), + ); + dlg.connect_response(move |d, _| { + d.close(); + }); + dlg.show_all(); + } + } + }); + menu.append(&item_about); + + menu.show_all(); + + let button = gtk::MenuButton::new(); + button.set_popup(Some(&menu)); + button.set_image(Some(>k::Image::from_icon_name( + Some("open-menu-symbolic"), + gtk::IconSize::Button, + ))); + button.set_tooltip_text(Some("Menu")); + button.set_widget_name("hamburger-btn"); + button.set_size_request(28, 28); + button +} + +/// Open an internal sovereign:// page in a new tab. +pub fn tab_manager_open_internal(url: &str) { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + if let Some(ctx) = wv.web_context() { + tab_manager_new_tab(&nb, &ctx, Some(url)); + } + } + } +} + +/// Set the sidebar paned/container/context for toggle_sidebar. +/// Called from main.rs after the GtkPaned is created. +pub fn tab_manager_set_sidebar(paned: >k::Paned, container: >k::Box, _ctx: &WebContext) { + *G_SIDEBAR_PANED.lock().unwrap() = Some(glib::thread_guard::ThreadGuard::new(paned.clone())); + *G_SIDEBAR_CONTAINER.lock().unwrap() = Some(glib::thread_guard::ThreadGuard::new(container.clone())); + // Hide the sidebar container by default — it should only appear when + // the user toggles it. (window.show_all() would otherwise show it.) + container.hide(); +} + +/// Toggle the agent chat sidebar. +/// Mirrors the C version (tab_manager.c:4294): lazily creates the sidebar +/// webview on first show, sets the paned position to 280px, and hides/shows +/// the sidebar container. +pub fn tab_manager_toggle_sidebar() { + let paned = match G_SIDEBAR_PANED.lock().unwrap().as_ref() { + Some(g) => g.get_ref().clone(), + None => return, + }; + let container = match G_SIDEBAR_CONTAINER.lock().unwrap().as_ref() { + Some(g) => g.get_ref().clone(), + None => return, + }; + + let mut visible = G_SIDEBAR_VISIBLE.lock().unwrap(); + if *visible { + // Hide. + paned.set_position(0); + container.hide(); + *visible = false; + } else { + // Show. Create the sidebar webview lazily on first show. + let mut created = G_SIDEBAR_WEBVIEW_CREATED.lock().unwrap(); + if !*created { + create_sidebar_webview(&container); + *created = true; + } + paned.set_position(SIDEBAR_DEFAULT_WIDTH); + container.show_all(); + *visible = true; + } +} + +/// Create the sidebar webview loading sovereign://agents/chat. +fn create_sidebar_webview(container: >k::Box) { + // Get the web context from the active webview. + let ctx = match tab_manager_get_active_notebook() + .and_then(|nb| tab_manager_get_active_webview(&nb)) + .and_then(|wv| wv.web_context()) + { + Some(c) => c, + None => { + println!("[sidebar] No web context available"); + return; + } + }; + + let content_manager = crate::nostr_inject::nostr_inject_create_content_manager(); + let webview = WebView::builder() + .user_content_manager(&content_manager) + .web_context(&ctx) + .build(); + + // Apply settings so JS and sovereign:// bridge work. + if let Some(ws) = webkit2gtk::WebViewExt::settings(&webview) { + ws.set_enable_javascript(true); + ws.set_enable_developer_extras(true); + ws.set_allow_file_access_from_file_urls(true); + ws.set_allow_universal_access_from_file_urls(true); + } + + webview.set_vexpand(true); + webview.set_hexpand(true); + + // Use a GtkOverlay to float a close button in the top-right corner. + let overlay = gtk::Overlay::new(); + overlay.set_vexpand(true); + overlay.set_hexpand(true); + overlay.add(&webview); + + // Floating close button. + let close_btn = gtk::Button::new(); + close_btn.set_relief(gtk::ReliefStyle::None); + close_btn.set_image(Some(>k::Image::from_icon_name( + Some("window-close-symbolic"), + gtk::IconSize::Menu, + ))); + close_btn.set_tooltip_text(Some("Close sidebar")); + close_btn.set_halign(gtk::Align::End); + close_btn.set_valign(gtk::Align::Start); + close_btn.set_margin_top(2); + close_btn.set_margin_end(2); + + // Close button hides the sidebar. + close_btn.connect_clicked(|_| { + if let Some(g) = G_SIDEBAR_PANED.lock().unwrap().as_ref() { + g.get_ref().set_position(0); + } + if let Some(g) = G_SIDEBAR_CONTAINER.lock().unwrap().as_ref() { + g.get_ref().hide(); + } + *G_SIDEBAR_VISIBLE.lock().unwrap() = false; + }); + + overlay.add_overlay(&close_btn); + + container.pack_start(&overlay, true, true, 0); + + // Load the chat page. + webview.load_uri(AGENT_CHAT_URL); +} + +/// Toggle toolbar visibility on the active window. +pub fn tab_manager_toggle_toolbars() { + if let Some(nb) = tab_manager_get_active_notebook() { + let n = nb.n_pages(); + for i in 0..n { + if let Some(page) = nb.nth_page(Some(i)) { + if let Ok(box_) = page.downcast::() { + for child in box_.children() { + if child.widget_name() == "main-toolbar" { + child.set_visible(!child.is_visible()); + } + } + } + } + } + } +} + +/// Toggle fullscreen on the active window. +pub fn tab_manager_toggle_fullscreen() { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(window) = nb.toplevel().and_then(|w| w.downcast::().ok()) { + // gtk-rs 0.18 has no is_fullscreen getter; toggle directly. + window.unfullscreen(); + window.fullscreen(); + } + } +} + +/// Get the main webview (for MCP). +pub fn tab_manager_get_main_webview() -> Option { + if let Some(nb) = tab_manager_get_active_notebook() { + tab_manager_get_active_webview(&nb) + } else { + None + } +} + +/// Close all tabs. +pub fn tab_manager_close_all() { + let state = G_TAB_STATE.lock().unwrap(); + let ids: Vec = state.tabs.iter().map(|t| t.id).collect(); + drop(state); + for id in ids { + if let Some(nb) = tab_manager_get_active_notebook() { + tab_manager_close_tab(&nb, id); + } + } +} + +/// Apply the app theme CSS (red accent, adapts to theme_dark). +pub fn tab_manager_apply_theme() { + let s = settings::settings_get(); + let provider = gtk::CssProvider::new(); + let css = if s.theme_dark { + "entry:focus { border-color: #ff0000; }\n\ + notebook tab:checked { border-bottom: 2px solid #ff0000; }\n\ + button:hover { border-color: #ff0000; }\n\ + button:active { background: #ff0000; color: #ffffff; }\n" + } else { + "entry:focus { border-color: #ff0000; }\n\ + notebook tab:checked { border-bottom: 2px solid #ff0000; }\n\ + button:hover { border-color: #ff0000; }\n\ + button:active { background: #ff0000; color: #ffffff; }\n" + }; + let _ = provider.load_from_data(css.as_bytes()); + if let Some(screen) = gdk::Screen::default() { + gtk::StyleContext::add_provider_for_screen( + &screen, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } +} diff --git a/src/tor_control.rs b/src/tor_control.rs new file mode 100644 index 0000000..3deeb22 --- /dev/null +++ b/src/tor_control.rs @@ -0,0 +1,171 @@ +//! Tor control port interface +//! +//! Port of `tor_control.c` / `tor_control.h` from the C project. +//! Implements a synchronous, short-timeout Tor control-protocol client +//! supporting both TCP (host:port) and Unix socket endpoints. + +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::sync::Mutex; +use once_cell::sync::Lazy; + +const TOR_RESPONSE_MAX: usize = 8192; +const TOR_TIMEOUT_MS: u64 = 1500; + +/// Tor control state. +static G_TOR_CONTROL: Lazy> = Lazy::new(|| Mutex::new(TorControlState::default())); + +struct TorControlState { + connected: bool, + control_host: String, + control_port: u16, + password: String, + cookie_path: String, +} + +impl Default for TorControlState { + fn default() -> Self { + TorControlState { + connected: false, + control_host: "127.0.0.1".to_string(), + control_port: 9051, + password: String::new(), + cookie_path: String::new(), + } + } +} + +/// Initialize Tor control. +pub fn tor_control_init() { + let mut state = G_TOR_CONTROL.lock().unwrap(); + state.connected = false; +} + +/// Connect to the Tor control port (TCP host:port). +pub fn tor_control_connect() -> Result<(), Box> { + let mut state = G_TOR_CONTROL.lock().unwrap(); + let host = state.control_host.clone(); + let port = state.control_port; + let cookie = state.cookie_path.clone(); + let password = state.password.clone(); + + // Connect to the control port. + let addr = format!("{}:{}", host, port); + let mut stream = TcpStream::connect(&addr)?; + stream.set_read_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?; + stream.set_write_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?; + + // Authenticate. + let auth_cmd = if !cookie.is_empty() { + // Read the cookie file and hex-encode it. + let cookie_bytes = std::fs::read(&cookie)?; + let hex: String = cookie_bytes.iter().map(|b| format!("{:02X}", b)).collect(); + format!("AUTHENTICATE {}\r\n", hex) + } else if !password.is_empty() { + format!("AUTHENTICATE \"{}\"\r\n", password) + } else { + "AUTHENTICATE\r\n".to_string() + }; + + stream.write_all(auth_cmd.as_bytes())?; + let response = read_response(&mut stream)?; + if !response.starts_with("250") { + return Err(format!("Tor authentication failed: {}", response).into()); + } + + state.connected = true; + println!("[tor] Connected to control port {}:{}", host, port); + Ok(()) +} + +/// Disconnect from Tor control port. +pub fn tor_control_disconnect() { + let mut state = G_TOR_CONTROL.lock().unwrap(); + state.connected = false; + println!("[tor] Disconnected from control port"); +} + +/// Check if connected to Tor control. +pub fn tor_control_is_connected() -> bool { + let state = G_TOR_CONTROL.lock().unwrap(); + state.connected +} + +/// Send a command to the Tor control port and return the response. +pub fn tor_control_send_command(command: &str) -> Result> { + let state = G_TOR_CONTROL.lock().unwrap(); + if !state.connected { + return Err("Not connected to Tor control port".into()); + } + let host = state.control_host.clone(); + let port = state.control_port; + + // Open a fresh connection for the command (synchronous, short-lived). + let addr = format!("{}:{}", host, port); + let mut stream = TcpStream::connect(&addr)?; + stream.set_read_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?; + stream.set_write_timeout(Some(std::time::Duration::from_millis(TOR_TIMEOUT_MS)))?; + + // Authenticate first (needed for most commands). + let auth_cmd = if !state.cookie_path.is_empty() { + let cookie_bytes = std::fs::read(&state.cookie_path)?; + let hex: String = cookie_bytes.iter().map(|b| format!("{:02X}", b)).collect(); + format!("AUTHENTICATE {}\r\n", hex) + } else if !state.password.is_empty() { + format!("AUTHENTICATE \"{}\"\r\n", state.password) + } else { + "AUTHENTICATE\r\n".to_string() + }; + stream.write_all(auth_cmd.as_bytes())?; + let _ = read_response(&mut stream)?; + + // Send the actual command. + let wire = format!("{}\r\n", command); + stream.write_all(wire.as_bytes())?; + let response = read_response(&mut stream)?; + Ok(response) +} + +/// Request a new Tor identity (SIGNAL NEWNYM). +pub fn tor_control_new_identity() -> Result> { + tor_control_send_command("SIGNAL NEWNYM") +} + +/// Get the Tor bootstrap progress. +pub fn tor_control_get_bootstrap() -> Result<(i32, String), Box> { + let response = tor_control_send_command("GETINFO status/bootstrap-phase")?; + // Parse "PROGRESS=NN" from the response. + let progress = response + .find("PROGRESS=") + .and_then(|i| response[i + 9..].split(|c: char| !c.is_ascii_digit()).next()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + Ok((progress, response)) +} + +/// Read a full Tor control response (until a line with "250 " or an error code). +fn read_response(stream: &mut dyn Read) -> Result> { + let mut buf = [0u8; TOR_RESPONSE_MAX]; + let mut used = 0usize; + while used < TOR_RESPONSE_MAX { + let n = stream.read(&mut buf[used..])?; + if n == 0 { + break; + } + used += n; + let text = String::from_utf8_lossy(&buf[..used]); + // Check for a final line: "250 " (success) or "5xx " (error). + for line in text.lines() { + if line.len() >= 4 && line.as_bytes()[3] == b' ' { + let code = &line[..3]; + if code == "250" { + return Ok(text.to_string()); + } + if code.starts_with('5') || code.starts_with('4') { + return Err(format!("Tor control error: {}", line).into()); + } + } + } + } + Ok(String::from_utf8_lossy(&buf[..used]).to_string()) +} diff --git a/src/tor_scheme.rs b/src/tor_scheme.rs new file mode 100644 index 0000000..2fc9667 --- /dev/null +++ b/src/tor_scheme.rs @@ -0,0 +1,226 @@ +//! WebKitGTK tor:// URI scheme handler +//! +//! Port of `tor_scheme.c` / `tor_scheme.h` from the C project. +//! Fetches .onion content through Tor's SOCKS5 proxy using reqwest. +//! +//! Architecture (mirrors the C version's GTask thread pattern): +//! 1. Main thread: parse URI, spawn blocking HTTP worker thread +//! 2. Worker thread: fetch via reqwest through Tor SOCKS proxy +//! 3. Main thread: poll for result via glib timeout, finish request + +use std::sync::{Arc, Mutex}; + +use glib::Bytes; +use gio::MemoryInputStream; +use webkit2gtk::*; + +/// Default Tor SOCKS endpoint (socks5h = remote DNS resolution through Tor). +const DEFAULT_SOCKS_ENDPOINT: &str = "socks5h://127.0.0.1:9050"; + +/// Maximum response size (16 MB, matching the C version). +const TOR_FETCH_MAX_BYTES: u64 = 16 * 1024 * 1024; + +/// Poll interval for checking worker thread completion (milliseconds). +const POLL_MS: u32 = 50; + +/// Register the tor:// URI scheme. +pub fn tor_scheme_register(ctx: &WebContext) { + ctx.register_uri_scheme("tor", |request| { + handle_tor_scheme(request); + }); +} + +/// Handle a tor:// URI scheme request. +/// +/// Parses the tor:// URI, validates the host is a .onion address, then +/// fetches the content asynchronously through Tor's SOCKS5 proxy. +/// Uses a worker thread + main-thread polling pattern because +/// URISchemeRequest is not Send in webkit2gtk 2.0.2. +fn handle_tor_scheme(request: &URISchemeRequest) { + let uri = request.uri().unwrap_or_default(); + + // Parse the URI and build the target HTTP URL. + let target_url = match build_target_url(&uri) { + Some(url) => url, + None => { + respond_error(request, "Invalid .onion URL", + &format!("Cannot parse tor:// URI: {}", uri)); + return; + } + }; + + // Get the Tor SOCKS endpoint from net_services, falling back to default. + let socks_endpoint = crate::net_services::net_services_get_tor_socks_endpoint() + .unwrap_or_else(|| DEFAULT_SOCKS_ENDPOINT.to_string()); + + // Shared result buffer: worker thread writes, main thread reads. + let result: Arc, Option), String>>>> = Arc::new(Mutex::new(None)); + let result_worker = result.clone(); + + // Spawn a blocking worker thread (reqwest doesn't need async here). + std::thread::spawn(move || { + let r = fetch_via_socks_blocking(&target_url, &socks_endpoint); + *result_worker.lock().unwrap() = Some(r); + }); + + // Clone the request (GObject refcounted) for the main-thread callback. + let request_clone = request.clone(); + + // Poll for completion on the main thread via glib timeout. + // `timeout_add_local` does NOT require Send, so URISchemeRequest is fine. + glib::timeout_add_local(std::time::Duration::from_millis(POLL_MS as u64), move || { + let r = result.lock().unwrap().take(); + match r { + Some(Ok((body, content_type))) => { + let ct = content_type.unwrap_or_else(|| "text/html".to_string()); + let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone())); + request_clone.finish(&stream, body.len() as i64, Some(&ct)); + glib::ControlFlow::Break + } + Some(Err(e)) => { + respond_error(&request_clone, "Tor request failed", &e); + glib::ControlFlow::Break + } + None => { + // Not ready yet — keep polling. + glib::ControlFlow::Continue + } + } + }); +} + +/// Build an HTTP target URL from a tor:// URI. +/// +/// Extracts the host (must end in .onion), optional port, and path/query, +/// then builds an `http://` URL. Phase 1 maps both http:// and https:// +/// .onion URLs to http:// over Tor (matching the C version). +fn build_target_url(uri: &str) -> Option { + let stripped = uri.strip_prefix("tor://")?; + + // Split authority from path/query. + let (authority, rest) = if let Some(idx) = stripped.find('/') { + let (auth, path) = stripped.split_at(idx); + (auth, path) + } else { + (stripped, "/") + }; + + // Extract host (strip userinfo@ if present, e.g. user:pass@host). + let host = if let Some(at_idx) = authority.rfind('@') { + &authority[at_idx + 1..] + } else { + authority + }; + + // Validate .onion suffix. + if !host.ends_with(".onion") { + return None; + } + + // Extract port if present (handle IPv6 brackets). + let (hostname, port_str) = if host.starts_with('[') { + // IPv6 address — find closing bracket. + if let Some(bracket_end) = host.find(']') { + let h = &host[..=bracket_end]; + let rest_after = &host[bracket_end + 1..]; + if let Some(port) = rest_after.strip_prefix(':') { + (h, Some(port.to_string())) + } else { + (h, None) + } + } else { + (host, None) + } + } else if let Some(colon_idx) = host.rfind(':') { + let h = &host[..colon_idx]; + let p = &host[colon_idx + 1..]; + (h, Some(p.to_string())) + } else { + (host, None) + }; + + // Build the HTTP URL. + let target = match port_str { + Some(ref port) => format!("http://{}:{}{}", hostname, port, rest), + None => format!("http://{}{}", hostname, rest), + }; + + Some(target) +} + +/// Fetch a URL through a SOCKS5 proxy using blocking reqwest. +/// +/// Uses `socks5h://` (remote DNS) so the .onion hostname is resolved +/// through Tor, not via clearnet DNS. +fn fetch_via_socks_blocking(url: &str, proxy: &str) -> Result<(Vec, Option), String> { + use reqwest::blocking::Client; + use reqwest::header::CONTENT_TYPE; + + let client: Client = match Client::builder() + .proxy(reqwest::Proxy::all(proxy).map_err(|e| e.to_string())?) + .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(15)) + .user_agent("sovereign_browser/tor-scheme") + .build() + { + Ok(c) => c, + Err(e) => return Err(e.to_string()), + }; + + let response: reqwest::blocking::Response = match client.get(url).send() { + Ok(r) => r, + Err(e) => return Err(e.to_string()), + }; + + // Check content-length header before reading the body. + if let Some(content_length) = response.content_length() { + if content_length > TOR_FETCH_MAX_BYTES { + return Err(format!("Response too large: {} bytes (max {})", content_length, TOR_FETCH_MAX_BYTES)); + } + } + + let content_type: Option = response.headers() + .get(CONTENT_TYPE) + .and_then(|v: &reqwest::header::HeaderValue| v.to_str().ok()) + .map(|s: &str| s.to_string()); + + let bytes: Vec = match response.bytes() { + Ok(b) => b.to_vec(), + Err(e) => return Err(e.to_string()), + }; + + if bytes.len() as u64 > TOR_FETCH_MAX_BYTES { + return Err(format!("Response too large: {} bytes (max {})", bytes.len(), TOR_FETCH_MAX_BYTES)); + } + + Ok((bytes, content_type)) +} + +/// Respond with an error HTML page. +fn respond_error(request: &URISchemeRequest, title: &str, message: &str) { + let html = format!( + "\ + {}\ +

{}

{}

", + html_escape(title), + html_escape(title), + html_escape(message) + ); + let body = html.into_bytes(); + let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone())); + request.finish(&stream, body.len() as i64, Some("text/html")); +} + +/// HTML-escape a string for safe embedding in HTML. +fn html_escape(s: &str) -> String { + let amp = format!("{}amp;", '&'); + let lt = format!("{}lt;", '&'); + let gt = format!("{}gt;", '&'); + let quot = format!("{}quot;", '&'); + let apos = format!("{}#39;", '&'); + s.replace('&', &) + .replace('<', <) + .replace('>', >) + .replace('"', ") + .replace("'", &apos) +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..7ef5936 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,13 @@ +//! Version information for sovereign_browser + +/// The current version of sovereign_browser (with leading 'v'). +pub const VERSION: &str = "v0.0.2"; + +/// Major version number. +pub const VERSION_MAJOR: u32 = 0; + +/// Minor version number. +pub const VERSION_MINOR: u32 = 0; + +/// Patch version number. +pub const VERSION_PATCH: u32 = 2; diff --git a/src/web_context.rs b/src/web_context.rs new file mode 100644 index 0000000..385b34c --- /dev/null +++ b/src/web_context.rs @@ -0,0 +1,57 @@ +//! WebKitWebContext management +//! +//! Port of `web_context.c` / `web_context.h` from the C project. + +use webkit2gtk::*; + +/// Create and configure a WebKitWebContext. +pub fn web_context_create() -> WebContext { + let ctx = WebContext::new(); + + // Register custom URI schemes + crate::nostr_scheme::nostr_scheme_register(&ctx); + crate::tor_scheme::tor_scheme_register(&ctx); + crate::local_scheme::local_scheme_register(&ctx); + + // Security strip: register sovereign://, tor://, file://, ws:// as secure + // (no CORS enforcement / mixed-content blocking). Without this, WebKit + // blocks sovereign://nostr/* requests from HTTPS pages as "insecure + // content". Mirrors the C version (web_context.c:209). + if let Some(sec_mgr) = ctx.security_manager() { + sec_mgr.register_uri_scheme_as_secure("sovereign"); + sec_mgr.register_uri_scheme_as_secure("tor"); + sec_mgr.register_uri_scheme_as_secure("file"); + sec_mgr.register_uri_scheme_as_secure("ws"); + sec_mgr.register_uri_scheme_as_local("file"); + // Register nostr:// as secure too so NIP-19 URIs work from HTTPS pages. + sec_mgr.register_uri_scheme_as_secure("nostr"); + } + + ctx +} + +/// Configure WebKitSettings for a web view. +pub fn web_context_configure_settings(settings: &webkit2gtk::Settings) { + settings.set_enable_javascript(true); + settings.set_enable_webgl(true); + settings.set_enable_webrtc(true); + // set_enable_plugins is deprecated since WebKitGTK 2.32 but there is no + // replacement; plugins are effectively dead in modern WebKit. Keep the + // call to explicitly disable them. + #[allow(deprecated)] + settings.set_enable_plugins(false); + settings.set_allow_modal_dialogs(true); + settings.set_enable_developer_extras(false); + // set_enable_private_browsing is deprecated in WebKitGTK 2.40+; + // use WebContext::set_is_ephemeral() instead. We don't need it here. + settings.set_enable_fullscreen(true); + settings.set_enable_media_stream(true); + settings.set_enable_smooth_scrolling(true); + // Deprecated since 2.32 with no replacement; keep for explicitness. + #[allow(deprecated)] + settings.set_enable_accelerated_2d_canvas(true); + // Enable console messages to stdout so we can debug window.nostr injection + // and page-level issues. The C version connects to console-message signal; + // this is simpler for now. + settings.set_enable_write_console_messages_to_stdout(true); +} diff --git a/src/webkit_data.rs b/src/webkit_data.rs new file mode 100644 index 0000000..7dc435f --- /dev/null +++ b/src/webkit_data.rs @@ -0,0 +1,27 @@ +//! WebKit data store management +//! +//! Port of `webkit_data.c` / `webkit_data.h` from the C project. + +use webkit2gtk::*; +use std::path::PathBuf; + +/// Get the data directory for WebKit storage. +pub fn webkit_data_get_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".sovereign_browser").join("webkit_data") +} + +/// Configure the website data manager for a profile. +pub fn webkit_data_configure(_web_context: &WebContext, pubkey_hex: &str) { + let data_dir = webkit_data_get_dir().join(pubkey_hex); + std::fs::create_dir_all(&data_dir).ok(); + + // Use WebsiteDataManager builder pattern. + // local_storage_directory is deprecated since WebKitGTK 2.40 in favour of + // passing the base directory, but the builder API is the only way to set + // per-profile storage in webkit2gtk 2.0.2 Rust bindings. + #[allow(deprecated)] + let _manager = WebsiteDataManager::builder() + .local_storage_directory(data_dir.join("localstorage").to_str().unwrap_or("")) + .build(); +} diff --git a/www/agents/chat.css b/www/agents/chat.css new file mode 100644 index 0000000..86bcfa1 --- /dev/null +++ b/www/agents/chat.css @@ -0,0 +1,452 @@ +/* Shared sovereign:// page CSS (theme variables, base elements, buttons). + * Mirrors sovereign_page_css() in src/nostr_bridge.c so the chat page + * stays consistent with settings/profile/bookmarks pages. */ +:root { + --font: monospace; + --primary: #000000; + --secondary: #ffffff; + --accent: #ff0000; + --muted: #dddddd; + --border: var(--muted); + --radius: 5px; + --bg: var(--secondary); + --fg: var(--primary); + --img-gray: 100%; + --img-gray-hover: 20%; + color-scheme: light; +} +html.dark { + --primary: #ffffff; + --secondary: #000000; + --accent: #ff0000; + --muted: #777777; + --border: var(--muted); + color-scheme: dark; +} +* { font-family: var(--font); margin: 0; padding: 0; box-sizing: border-box; } +html { transition: background-color 0.2s ease, color 0.2s ease; } +body { color: var(--fg); background: var(--bg); line-height: 1.5; } +a { color: var(--accent); text-decoration: none; transition: 0.2s; } +a:hover { text-decoration: underline; } +input, select, textarea { + background: var(--bg); color: var(--fg); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 6px 10px; font-family: var(--font); font-size: 13px; + min-width: 200px; +} +input:focus, select:focus, textarea:focus { + border-color: var(--accent); outline: none; +} +.btn, .save-btn { + background: var(--bg); color: var(--primary); + border: 1px solid var(--primary); border-radius: var(--radius); + padding: 6px 16px; cursor: pointer; font-family: var(--font); + font-size: 13px; font-weight: bold; margin-left: 8px; + transition: border-color 0.2s, background 0.2s, color 0.2s; +} +.btn:hover, .save-btn:hover { border-color: var(--accent); } +.btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-del { border-color: var(--accent); color: var(--accent); } +.btn-del:hover { background: var(--accent); color: var(--secondary); } + +/* ── Chat page layout (tabbed) ───────────────────────────────── */ +#chat-root { + display: flex; flex-direction: column; + height: 100vh; padding: 8px; +} +/* Tab bar — horizontal row of tab buttons. */ +#tab-bar { + display: flex; flex-direction: row; gap: 0; + border-bottom: 1px solid var(--border); + flex-shrink: 0; margin-bottom: 8px; +} +.tab { + background: transparent; color: var(--fg); + border: none; border-bottom: 2px solid transparent; + padding: 8px 14px; cursor: pointer; + font-family: var(--font); font-size: 13px; font-weight: bold; + margin-left: 0; transition: border-color 0.15s, color 0.15s; +} +.tab:hover { color: var(--accent); } +.tab.active { + border-bottom-color: var(--accent); + color: var(--accent); +} +/* Tab content — only the active tab is visible. */ +.tab-content { + display: none; flex: 1; min-height: 0; + flex-direction: column; +} +.tab-content.active { display: flex; } +/* Toolbar inside a tab (refresh + action buttons). */ +.tab-toolbar { + display: flex; flex-direction: row; gap: 6px; + flex-shrink: 0; margin-bottom: 8px; +} +.tab-toolbar .btn { margin-left: 0; } +#new-chat-btn { margin-left: 0; } +#conv-list { + flex: 1; overflow-y: auto; display: flex; + flex-direction: column; gap: 4px; +} +/* Conversation list items — match ai.html conversation styling: + * title + timestamp on the left, delete button appears on hover. */ +.conv-item { + display: flex; align-items: center; gap: 6px; + padding: 8px 10px; border-radius: var(--radius); cursor: pointer; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s; +} +.conv-item:hover { + background: rgba(128,128,128,0.08); +} +.conv-active { + border-color: var(--accent); + background: rgba(255,0,0,0.06); +} +.conv-item-content { + flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; +} +.conv-title { + overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; font-size: 0.9em; color: var(--fg); +} +.conv-timestamp { + font-size: 0.72em; color: var(--muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.conv-del { + flex-shrink: 0; padding: 2px 8px; font-size: 0.75em; + opacity: 0; transition: opacity 0.15s; +} +.conv-item:hover .conv-del { opacity: 1; } +.conv-empty { color: var(--muted); font-size: 0.85em; padding: 8px; } +#create-skill-btn { margin-left: 0; } +/* Rename button — same hover-reveal behavior as the delete button. */ +.conv-rename { + flex-shrink: 0; padding: 2px 8px; font-size: 0.75em; + opacity: 0; transition: opacity 0.15s; margin-left: 0; + border-color: var(--border); color: var(--fg); +} +.conv-rename:hover { border-color: var(--accent); color: var(--accent); } +.conv-item:hover .conv-rename { opacity: 1; } +/* "No chat selected" placeholder shown in the chat pane when no + * conversation is active. */ +.no-chat-placeholder { + color: var(--muted); font-style: italic; font-size: 0.9em; + padding: 16px; text-align: center; +} +#skill-list { + flex: 1; overflow-y: auto; display: flex; + flex-direction: column; gap: 4px; +} +/* Skills list items — match ai.html skills styling: + * checkbox + skill name + tool badges, with delete on hover for + * user-authored skills. */ +.skill-item { + display: flex; align-items: center; gap: 6px; + padding: 6px 8px; border-radius: var(--radius); flex-wrap: wrap; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s; +} +.skill-item:hover { + background: rgba(128,128,128,0.06); + border-color: var(--border); +} +.skill-checkbox { flex-shrink: 0; margin: 0; min-width: auto; } +.skill-name { font-size: 0.88em; flex-shrink: 0; color: var(--fg); } +.skill-badge { + font-size: 0.7em; padding: 1px 6px; border-radius: 3px; + white-space: nowrap; +} +.skill-badge-tools { + background: rgba(0,180,0,0.15); color: #2a2; + border: 1px solid rgba(0,180,0,0.4); +} +html.dark .skill-badge-tools { + background: rgba(0,180,0,0.2); color: #6c6; + border-color: rgba(0,180,0,0.5); +} +.skill-tool-enabled { border-left: 3px solid rgba(0,180,0,0.5); } +.skill-item .conv-del { + opacity: 0; padding: 1px 6px; font-size: 0.75em; +} +.skill-item:hover .conv-del { opacity: 1; } + +/* ── Skill editor (inline, matching ai.html) ──────────────────── */ +.skill-badge-unsaved { + background: rgba(255,165,0,0.15); color: #a60; + border: 1px solid rgba(255,165,0,0.4); +} +html.dark .skill-badge-unsaved { + background: rgba(255,165,0,0.2); color: #fc6; + border-color: rgba(255,165,0,0.5); +} +.skillStackedItem { + border: 1px solid var(--border); border-radius: var(--radius); + padding: 6px 8px; margin-bottom: 4px; + background: rgba(128,128,128,0.04); +} +.skillStackedItem > summary { + display: flex; align-items: center; gap: 6px; + cursor: pointer; flex-wrap: wrap; list-style: none; + user-select: none; +} +.skillStackedItem > summary::-webkit-details-marker { display: none; } +.skillStackedItem > summary .skill-checkbox { + flex-shrink: 0; margin: 0; min-width: auto; +} +.skillStackedItem > summary .skill-name { + font-size: 0.88em; flex-shrink: 0; color: var(--fg); +} +.aiSkillControlsRow { + display: flex; flex-direction: column; gap: 6px; + margin-top: 8px; padding-top: 6px; + border-top: 1px solid var(--border); +} +.aiSkillInlineField { + display: flex; flex-direction: column; gap: 2px; +} +.aiSkillInlineField label { + font-size: 0.75em; color: var(--muted); font-weight: bold; +} +.aiInput { + background: var(--bg); color: var(--fg); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 4px 6px; font-family: var(--font); font-size: 0.85em; + min-width: 0; width: 100%; box-sizing: border-box; +} +.aiInput:focus { border-color: var(--accent); outline: none; } +.taSkillTemplateEditor { + background: var(--bg); color: var(--fg); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 4px 6px; font-family: var(--font); font-size: 0.85em; + width: 100%; box-sizing: border-box; min-height: 80px; + resize: vertical; +} +.taSkillTemplateEditor:focus { border-color: var(--accent); outline: none; } +.aiSkillActionsRow { + display: flex; gap: 6px; margin-top: 6px; +} +.aiSkillActionsRow .btn { margin-left: 0; } +.aiSkillActionsRow .btn:disabled { opacity: 0.5; cursor: not-allowed; } +#create-skill-form { + display: flex; flex-direction: column; gap: 4px; + margin-top: 4px; padding: 8px; + border: 1px solid var(--border); border-radius: var(--radius); + background: rgba(128,128,128,0.05); +} +.skill-input { + width: 100%; box-sizing: border-box; + font-family: var(--font); font-size: 0.85em; padding: 4px 6px; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg); color: var(--fg); +} +#skill-content { resize: vertical; min-height: 60px; } +/* The chat tab content fills the available height: messages scroll, + * input pinned to the bottom. */ +#tab-chat { min-width: 0; } +#messages { + flex: 1; overflow-y: auto; border: 1px solid #444; + padding: 8px; margin-bottom: 8px; background: rgba(0,0,0,0.05); +} +/* ── Message bubbles (ported from ai.html messaging-ui.css) ────── */ +.msg-bubble-row { + display: flex; width: 100%; +} +.msg-bubble-row.outgoing { justify-content: flex-end; } +.msg-bubble-row.incoming, +.msg-bubble-row.system { justify-content: flex-start; } +.msg-bubble { + max-width: 75%; + border: 1px solid var(--primary); + border-radius: 10px; + padding: 8px 10px; + white-space: normal; + word-wrap: break-word; + overflow-wrap: break-word; + font-size: 90%; + color: var(--fg); + background: var(--bg); + margin-bottom: 10px; +} +.msg-bubble.outgoing { border-color: var(--primary); } +.msg-bubble.incoming { border-color: var(--muted); } +.msg-bubble.system { + border-style: dashed; + border-color: var(--muted); + color: var(--muted); + font-size: 82%; +} +.msg-bubble-header { + display: flex; align-items: center; + justify-content: space-between; gap: 8px; margin-bottom: 6px; +} +.msg-bubble-header-main { + min-width: 0; flex: 1; display: flex; + align-items: center; gap: 8px; +} +.msg-bubble-sender-meta { + min-width: 0; display: flex; flex-direction: column; gap: 1px; +} +.msg-bubble-sender-name { + font-size: 78%; color: var(--fg); font-weight: bold; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.msg-bubble-menu-host { flex-shrink: 0; } +.msg-bubble-content { line-height: 1.35; } +.msg-bubble-content > *:first-child { margin-top: 0; } +.msg-bubble-content > *:last-child { margin-bottom: 0; } +.msg-bubble-content p { margin: 0 0 0.3em 0; } +.msg-bubble-content h1, .msg-bubble-content h2, .msg-bubble-content h3, +.msg-bubble-content h4, .msg-bubble-content h5, .msg-bubble-content h6 { + margin: 0.4em 0 0.2em 0; font-size: 1em; +} +.msg-bubble-content code { + background: rgba(128,128,128,0.18); + border-radius: 4px; padding: 1px 4px; font-size: 85%; +} +.msg-bubble-content pre { + background: rgba(0,0,0,0.15); + border-radius: 6px; padding: 6px 8px; margin: 4px 0; + overflow-x: auto; +} +.msg-bubble-content pre code { + background: transparent; padding: 0; + border-radius: 0; font-size: 90%; +} +.msg-bubble-content blockquote { + margin: 4px 0; padding: 2px 8px; + border-left: 3px solid var(--muted); + color: var(--muted); +} +.msg-bubble-content a { + color: var(--accent); text-decoration: underline; word-break: break-all; +} +.msg-bubble-content img { + display: block; max-width: 100%; + border-radius: 6px; margin: 4px 0; +} +.msg-bubble-content ul, .msg-bubble-content ol { + margin: 4px 0; padding-left: 20px; +} +.msg-bubble-content table { + width: 100%; border-collapse: collapse; + margin: 6px 0; font-size: 90%; +} +.msg-bubble-content th, .msg-bubble-content td { + border: 1px solid var(--border); + padding: 4px 8px; text-align: left; vertical-align: top; +} +.msg-bubble-content thead th { background: rgba(128,128,128,0.12); } +.msg-bubble-content tbody tr:nth-child(even) { background: rgba(128,128,128,0.06); } +.msg-bubble-content del, .msg-bubble-content s { opacity: 0.75; } +.msg-bubble-content strong { font-weight: bold; } +.msg-tcid { font-size: 0.8em; color: var(--muted); margin-top: 4px; } + +/* ── Dot-menu ──────────────────────────────────────────────────── + * The dot-menu styles now come from the shared sovereign://css/dot-menu.css + * (copied from the client). Only the host sizing is page-specific. */ +.msg-bubble-menu-host .dot-menu-wrap { flex-shrink: 0; } + +/* ── Tool calls, status message, input area ───────────────────── */ +.tool-calls { margin-top: 4px; } +.tool-calls pre { + font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; +} + +/* Status message — a temporary system bubble shown in the chat window + * (replaces the old #status-bar). It is italic and muted to distinguish + * it from real agent responses. */ +#status-msg { + margin-bottom: 8px; +} +#status-msg-bubble { + font-style: italic; color: var(--muted); + max-width: 90%; border-style: dashed; +} + +/* The input area is a flex row: the composer (textarea + send button) + * on the left taking all available space, and the Stop button on the + * right (only visible while the agent is running). */ +#input-area { + display: flex; align-items: flex-end; gap: 8px; + border: 1px solid var(--border); border-radius: var(--radius); + padding: 8px; background: var(--bg); +} +#composer-host { + flex: 1; min-width: 0; +} +/* The shared post-composer library wraps the host element: it inserts a + * .post-composer-wrapper around #composer-host and appends the send + * button as a SIBLING of the host (inside the wrapper). So the send + * button is NOT a descendant of #composer-host — it lives in + * #input-area > .post-composer-wrapper > [.post-composer-editor-shell > + * #composer-host, .post-composer-send-btn]. Target it via #input-area. + * Make the wrapper fill the row and ensure the send button has a + * visible border matching the page .btn style (post-composer.css uses + * --primary-color which is undefined here, so we override it). */ +#input-area .post-composer-wrapper { + width: 100%; min-width: 0; max-width: none; margin-bottom: 0; + border: none; padding: 0; background: transparent; + display: flex; flex-direction: column; gap: 0; +} +#input-area .post-composer-send-btn { + margin-left: 0; min-width: 80px; + border: 1px solid var(--primary); + color: var(--primary); +} +#input-area .post-composer-send-btn:hover, +#input-area .post-composer-send-btn:focus { + border-color: var(--accent); +} +#input-area .post-composer-send-btn:disabled { + opacity: 0.5; cursor: not-allowed; +} +/* Stop-mode: the send button transforms into a stop button while the + * agent is running. Use a distinct (red-ish) style to signal that + * clicking it will cancel the agent. */ +#input-area .post-composer-send-btn.is-stop-mode { + border-color: var(--danger, #c33); + color: var(--danger, #c33); + background: transparent; +} +#input-area .post-composer-send-btn.is-stop-mode:hover, +#input-area .post-composer-send-btn.is-stop-mode:focus { + background: var(--danger, #c33); + color: #fff; + border-color: var(--danger, #c33); +} + +/* ── Responsive: narrow sidebar viewport (< 400px) ────────────── + * When the chat page is loaded in the agent sidebar (~350px wide), + * switch to a compact layout: smaller fonts, shorter tab labels, + * full-width messages, tighter padding. */ +@media (max-width: 400px) { + #chat-root { padding: 4px; } + .tab { + padding: 6px 8px; font-size: 12px; + } + /* Shorten tab labels to single characters in narrow view. */ + .tab[data-tab="chat"] { font-size: 0; } + .tab[data-tab="chat"]::before { content: "💬"; font-size: 14px; } + .tab[data-tab="conversations"] { font-size: 0; } + .tab[data-tab="conversations"]::before { content: "☰"; font-size: 14px; } + .tab[data-tab="skills"] { font-size: 0; } + .tab[data-tab="skills"]::before { content: "⚙"; font-size: 14px; } + #messages { + padding: 4px; font-size: 12px; + } + .msg-bubble { + max-width: 92%; font-size: 85%; padding: 6px 8px; + } + .msg-bubble.system { font-size: 78%; } + #input-area { padding: 4px; gap: 4px; } + .tab-toolbar { gap: 4px; } + .tab-toolbar .btn { padding: 4px 10px; font-size: 12px; } + .conv-item { padding: 6px 8px; } + .conv-title { font-size: 0.85em; } + .skill-name { font-size: 0.82em; } + .skill-input { font-size: 12px; } +} diff --git a/www/agents/chat.html b/www/agents/chat.html new file mode 100644 index 0000000..c40ad25 --- /dev/null +++ b/www/agents/chat.html @@ -0,0 +1,62 @@ + + + + + + Agent Chat + + + + + +
+
+ + + +
+ +
+
+
+ No conversations yet. Click ‘+ New Chat’ to start. +
+ +
+
+
+
+ +
+
+ + +
+
+
+ +
+
+ + +
+
+ +
+
+ + + + + + + diff --git a/www/agents/chat.js b/www/agents/chat.js new file mode 100644 index 0000000..af0b802 --- /dev/null +++ b/www/agents/chat.js @@ -0,0 +1,1017 @@ +/* Agent Chat — sovereign://agents/chat + * + * Two-pane layout: conversations + skills (left), chat thread (right). + * + * Uses the shared dot-menu and post-composer libraries (loaded as regular + * + + diff --git a/www/agents/config.js b/www/agents/config.js new file mode 100644 index 0000000..60bb03a --- /dev/null +++ b/www/agents/config.js @@ -0,0 +1,289 @@ +/* Agent config page — sovereign://agents + * + * Fetches the current agent config from sovereign://agents/config (JSON) + * on page load and populates the form. Saves use sovereign://agents/set. + * Model refresh uses sovereign://agents/models. + */ +var savedModel = ''; + +function sovereignGet(url) { + return new Promise(function(resolve, reject) { + var x = new XMLHttpRequest(); + x.open('GET', url, true); + x.onreadystatechange = function() { + if (x.readyState !== 4) return; + try { resolve(JSON.parse(x.responseText)); } + catch (e) { reject(e); } + }; + x.onerror = function() { reject(new Error('XHR error')); }; + x.send(); + }); +} + +function showStatus(cls, msg) { + var s = document.getElementById('status'); + s.className = 'status ' + cls + ' show'; + s.textContent = msg; +} + +function toggleKeyHide() { + var cb = document.getElementById('api-key-hide'); + var ak = document.getElementById('agent.llm_api_key'); + ak.type = cb.checked ? 'password' : 'text'; +} + +function applyConfig(d) { + /* Provider dropdown. */ + var sel = document.getElementById('agent.provider'); + sel.innerHTML = ''; + (d.providers || []).forEach(function(name, i) { + var opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + if (i === d.active_provider) opt.selected = true; + sel.appendChild(opt); + }); + + /* Active provider fields. */ + document.getElementById('provider_name').value = d.provider_name || ''; + document.getElementById('agent.llm_base_url').value = d.base_url || ''; + document.getElementById('agent.llm_api_key').value = d.api_key || ''; + + /* Model dropdown. */ + savedModel = d.model || ''; + populateModels(d.models || []); + + /* Max iterations + Sovereign Browser Skill fields. */ + document.getElementById('agent.max_iterations').value = + d.max_iterations != null ? d.max_iterations : 100; + document.getElementById('agent.skill_name').value = + d.skill_name || 'Sovereign Browser Default'; + document.getElementById('agent.skill_description').value = + d.skill_description || 'Default agent skill for sovereign_browser'; + document.getElementById('agent.skill_template').value = + d.skill_template || d.system_prompt || ''; + document.getElementById('agent.skill_requires_tools').value = + d.skill_requires_tools || 'browser, fs, shell'; + + updateRefreshBtn(); +} + +function save(key) { + var el = document.getElementById(key); + var val = el ? el.value : ''; + return sovereignGet('sovereign://agents/set?key=' + encodeURIComponent(key) + + '&value=' + encodeURIComponent(val)) + .then(function(d) { + if (d.error) showStatus('err', d.message); + else showStatus('ok', d.key + ' saved'); + return d; + }) + .catch(function(e) { + showStatus('err', 'Error: ' + e.message); + throw e; + }); +} + +function saveAndRefresh(key) { + save(key).then(function() { + var bu = document.getElementById('agent.llm_base_url'); + var ak = document.getElementById('agent.llm_api_key'); + if (bu && bu.value && bu.value.trim() && ak && ak.value && ak.value.trim()) { + refreshModels(); + } + }).catch(function() {}); +} + +function updateRefreshBtn() { + var bu = document.getElementById('agent.llm_base_url'); + var ak = document.getElementById('agent.llm_api_key'); + var btn = document.getElementById('refresh-models-btn'); + var enabled = bu && bu.value && bu.value.trim() && + ak && ak.value && ak.value.trim(); + btn.disabled = !enabled; +} + +function onProviderChange() { + save('agent.provider').then(function() { window.location.reload(); }); +} + +function saveProviderName() { + var el = document.getElementById('provider_name'); + if (el && el.value && el.value.trim()) { + save('agent.provider_name').then(function() { window.location.reload(); }); + } +} + +function addProvider() { + var el = document.getElementById('new_provider_name'); + if (el && el.value && el.value.trim()) { + sovereignGet('sovereign://agents/set?key=agent.provider_add&value=' + + encodeURIComponent(el.value.trim())) + .then(function(d) { + if (d.error) showStatus('err', d.message); + else { showStatus('ok', 'Provider added'); window.location.reload(); } + }) + .catch(function(e) { showStatus('err', 'Error: ' + e.message); }); + } +} + +function removeProvider() { + var sel = document.getElementById('agent.provider'); + if (sel && sel.value) { + if (!confirm('Remove provider "' + sel.value + '"?')) return; + sovereignGet('sovereign://agents/set?key=agent.provider_remove&value=' + + encodeURIComponent(sel.value)) + .then(function(d) { + if (d.error) showStatus('err', d.message); + else { showStatus('ok', 'Provider removed'); window.location.reload(); } + }) + .catch(function(e) { showStatus('err', 'Error: ' + e.message); }); + } +} + +function onModelChange() { + var sel = document.getElementById('agent.llm_model'); + var txt = document.getElementById('agent.llm_model_text'); + if (sel.value === '__custom__') { + txt.style.display = ''; + txt.value = savedModel; + txt.focus(); + } else { + txt.style.display = 'none'; + savedModel = sel.value; + save('agent.llm_model'); + } +} + +function onModelTextInput() { + var txt = document.getElementById('agent.llm_model_text'); + savedModel = txt.value; +} + +function saveCustomModel() { + var txt = document.getElementById('agent.llm_model_text'); + if (txt && txt.value && txt.value.trim()) { + savedModel = txt.value.trim(); + save('agent.llm_model'); + } +} + +function populateModels(models) { + var sel = document.getElementById('agent.llm_model'); + var txt = document.getElementById('agent.llm_model_text'); + sel.innerHTML = ''; + models = models.slice().sort(function(a, b) { + return a.toLowerCase().localeCompare(b.toLowerCase()); + }); + var found = false; + for (var i = 0; i < models.length; i++) { + var opt = document.createElement('option'); + opt.value = models[i]; + opt.textContent = models[i]; + if (models[i] === savedModel) { opt.selected = true; found = true; } + sel.appendChild(opt); + } + var copt = document.createElement('option'); + copt.value = '__custom__'; + copt.textContent = '(custom...)'; + if (!found) { copt.selected = true; sel.value = '__custom__'; } + sel.appendChild(copt); + if (!found) { + txt.style.display = ''; + txt.value = savedModel; + } else { + txt.style.display = 'none'; + } +} + +function refreshModels() { + var bu = document.getElementById('agent.llm_base_url'); + var ak = document.getElementById('agent.llm_api_key'); + if (!bu || !bu.value || !bu.value.trim()) return; + var btn = document.getElementById('refresh-models-btn'); + btn.disabled = true; + btn.textContent = 'Loading...'; + var url = 'sovereign://agents/models?base_url=' + + encodeURIComponent(bu.value.trim()) + + '&api_key=' + encodeURIComponent(ak ? ak.value : ''); + sovereignGet(url) + .then(function(d) { + if (d && d.error) { + showStatus('err', 'Models: ' + d.error); + } else if (d && d.length) { + populateModels(d); + showStatus('ok', 'Loaded ' + d.length + ' models'); + } else { + showStatus('err', 'No models returned'); + } + }) + .catch(function(e) { showStatus('err', 'Models error: ' + e.message); }) + .then(function() { + btn.textContent = 'Refresh Models'; + updateRefreshBtn(); + }); +} + +/* ── Save as Skill (publish to Nostr as kind 31123) ──────────── */ +function saveAsSkill() { + var name = document.getElementById('agent.skill_name').value.trim(); + var desc = document.getElementById('agent.skill_description').value.trim(); + var content = document.getElementById('agent.skill_template').value.trim(); + var toolsRaw = document.getElementById('agent.skill_requires_tools').value.trim(); + if (!name || !content) { + showStatus('err', 'Name and template are required'); + return; + } + var tools = []; + if (toolsRaw) { + tools = toolsRaw.split(',').map(function(t) { + return t.trim(); + }).filter(function(t) { return t.length > 0; }); + } + /* Save the local fields first, then publish to Nostr. */ + save('agent.skill_name').then(function() { + return save('agent.skill_description'); + }).then(function() { + return save('agent.skill_template'); + }).then(function() { + return save('agent.skill_requires_tools'); + }).then(function() { + var url = 'sovereign://agents/skills/create?name=' + + encodeURIComponent(name) + + '&description=' + encodeURIComponent(desc) + + '&content=' + encodeURIComponent(content) + + '&requires_tools=' + encodeURIComponent(JSON.stringify(tools)); + return sovereignGet(url); + }).then(function(d) { + if (d.error) { + showStatus('err', 'Publish failed: ' + d.message); + } else { + showStatus('ok', 'Skill published to Nostr (d: ' + d.d + ')'); + } + }).catch(function(e) { + showStatus('err', 'Error: ' + e.message); + }); +} + +/* ── Init ───────────────────────────────────────────────────────── */ +sovereignGet('sovereign://agents/config?_=' + Date.now()) + .then(function(d) { applyConfig(d); }) + .catch(function(e) { showStatus('err', 'Failed to load config: ' + e.message); }); + +document.addEventListener('DOMContentLoaded', function() { + var bu = document.getElementById('agent.llm_base_url'); + var ak = document.getElementById('agent.llm_api_key'); + var txt = document.getElementById('agent.llm_model_text'); + if (bu) bu.addEventListener('input', updateRefreshBtn); + if (ak) ak.addEventListener('input', updateRefreshBtn); + if (txt) txt.addEventListener('change', saveCustomModel); +}); + +/* After config loads, auto-refresh models if base URL + API key are set. */ +sovereignGet('sovereign://agents/config?_=' + Date.now()) + .then(function(d) { + if (d.base_url && d.base_url.trim() && d.api_key && d.api_key.trim()) { + refreshModels(); + } + }) + .catch(function() {}); diff --git a/www/bookmarks.css b/www/bookmarks.css new file mode 100644 index 0000000..3cd7586 --- /dev/null +++ b/www/bookmarks.css @@ -0,0 +1,93 @@ +/* Bookmarks page — sovereign://bookmarks + * Imports the shared sovereign:// base theme, then adds bookmarks-specific + * layout. The base CSS lives in www/sovereign-base.css and is served at + * sovereign://sovereign-base.css. */ +@import url("sovereign://sovereign-base.css"); + +/* ── Tree view ─────────────────────────────────────────────────────── */ + +.tree-node { + margin: 2px 0; +} + +.folder-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 0; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.caret { + display: inline-block; + width: 14px; + cursor: pointer; + user-select: none; + color: var(--primary); + font-size: 10px; + transition: transform 0.15s ease; + transform: rotate(0deg); + flex-shrink: 0; +} + +.caret.expanded { + transform: rotate(90deg); +} + +.folder-icon { + font-size: 16px; + flex-shrink: 0; +} + +.folder-name { + font-weight: bold; + color: var(--primary); + flex-shrink: 0; +} + +.folder-actions { + display: flex; + gap: 6px; + margin-left: auto; + flex-wrap: wrap; +} + +/* Smaller buttons in folder actions so the row stays compact. */ +.folder-actions .btn { + padding: 3px 10px; + font-size: 11px; + margin-left: 0; +} + +/* Ensure every .btn renders the themed 1px solid border. WebKitGTK's UA + * stylesheet gives + +
+ + +
+ + +

Bookmarks

+
+ + + + diff --git a/www/bookmarks.js b/www/bookmarks.js new file mode 100644 index 0000000..3b9df23 --- /dev/null +++ b/www/bookmarks.js @@ -0,0 +1,300 @@ +/* Bookmarks page — sovereign://bookmarks + * + * Fetches the bookmark tree from sovereign://bookmarks/list (JSON) and + * renders it as an expandable/collapsible tree. Folders may be nested + * arbitrarily (e.g. "Work/Projects/Secret"). Add/delete/create/move/rename + * use the sovereign://bookmarks/{add,delete,createdir,deletedir,move,renamedir} + * endpoints. + * + * Expand/collapse state is persisted in localStorage so the user's + * expanded folders survive page reloads. + */ +function sovereignGet(url) { + return new Promise(function(resolve, reject) { + var x = new XMLHttpRequest(); + x.open('GET', url, true); + x.onreadystatechange = function() { + if (x.readyState !== 4) return; + try { resolve(JSON.parse(x.responseText)); } + catch (e) { reject(e); } + }; + x.onerror = function() { reject(new Error('XHR error')); }; + x.send(); + }); +} + +function esc(s) { + var d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; +} + +/* ── Expand/collapse state (persisted in localStorage) ─────────────── */ + +var EXPANDED_KEY = 'sovereign_bookmarks_expanded'; +function getExpanded() { + try { + var raw = localStorage.getItem(EXPANDED_KEY); + return raw ? JSON.parse(raw) : {}; + } catch (e) { return {}; } +} +function setExpanded(path, isExpanded) { + var state = getExpanded(); + if (isExpanded) state[path] = true; + else delete state[path]; + try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(state)); } + catch (e) { /* ignore quota errors */ } +} + +/* ── Tree rendering ────────────────────────────────────────────────── */ + +function renderTree(data) { + var haveSigner = !!data.have_signer; + document.getElementById('readonly-note').style.display = + haveSigner ? 'none' : ''; + document.getElementById('add-form').style.display = + haveSigner ? '' : 'none'; + + var c = document.getElementById('bm-tree'); + var tree = data.tree; + if (!tree || !tree.children || tree.children.length === 0) { + c.innerHTML = + '

No bookmarks yet. Click the bookmark button ' + + 'in the toolbar to save a page.

'; + return; + } + + c.innerHTML = ''; + (tree.children || []).forEach(function(child) { + c.appendChild(renderNode(child, haveSigner, 0)); + }); +} + +function renderNode(node, haveSigner, depth) { + /* A folder row + its bookmark list + its children (recursive). */ + var expandedState = getExpanded(); + var isExpanded = !!expandedState[node.path]; + + var wrap = document.createElement('div'); + wrap.className = 'tree-node'; + + /* Folder row. */ + var row = document.createElement('div'); + row.className = 'folder-row'; + + var caret = document.createElement('span'); + caret.className = 'caret' + (isExpanded ? ' expanded' : ''); + caret.textContent = '\u25B6'; /* right-pointing triangle */ + caret.onclick = function() { + var nowExpanded = caret.classList.toggle('expanded'); + childList.style.display = nowExpanded ? '' : 'none'; + setExpanded(node.path, nowExpanded); + }; + row.appendChild(caret); + + var icon = document.createElement('span'); + icon.className = 'folder-icon'; + icon.textContent = '\u{1F4C1}'; /* folder emoji */ + row.appendChild(icon); + + var name = document.createElement('span'); + name.className = 'folder-name'; + name.textContent = node.name + ' (' + node.count + ')'; + row.appendChild(name); + + /* Per-folder actions. */ + var actions = document.createElement('span'); + actions.className = 'folder-actions'; + if (haveSigner) { + var addHere = document.createElement('a'); + addHere.className = 'btn'; + addHere.href = '#'; + addHere.textContent = 'Add Here'; + addHere.onclick = function(e) { + e.preventDefault(); + document.getElementById('bm-path').value = node.path; + document.getElementById('bm-url').focus(); + }; + actions.appendChild(addHere); + + var newSub = document.createElement('a'); + newSub.className = 'btn'; + newSub.href = '#'; + newSub.textContent = 'New Subfolder'; + newSub.onclick = function(e) { + e.preventDefault(); + var sub = prompt('New subfolder name under "' + node.path + '/":'); + if (sub && sub.trim()) { + var newPath = node.path + '/' + sub.trim(); + location.href = 'sovereign://bookmarks/createdir?name=' + + encodeURIComponent(newPath); + } + }; + actions.appendChild(newSub); + + if (node.path !== 'General' && node.path !== 'Bookmarks Bar') { + var rename = document.createElement('a'); + rename.className = 'btn'; + rename.href = '#'; + rename.textContent = 'Rename'; + rename.onclick = function(e) { + e.preventDefault(); + var newName = prompt('Rename folder "' + node.path + '" to:', + node.path); + if (newName && newName.trim() && newName.trim() !== node.path) { + location.href = 'sovereign://bookmarks/renamedir?old=' + + encodeURIComponent(node.path) + '&new=' + + encodeURIComponent(newName.trim()); + } + }; + actions.appendChild(rename); + + var del = document.createElement('a'); + del.className = 'btn btn-del'; + del.href = '#'; + del.textContent = 'Delete'; + del.onclick = function(e) { + e.preventDefault(); + if (confirm('Delete folder "' + node.path + '"? ' + + 'Bookmarks will be moved to General.')) { + location.href = 'sovereign://bookmarks/deletedir?dir=' + + encodeURIComponent(node.path); + } + }; + actions.appendChild(del); + } + } + row.appendChild(actions); + + wrap.appendChild(row); + + /* Child list: bookmarks + subfolders. */ + var childList = document.createElement('div'); + childList.className = 'tree-children'; + childList.style.display = isExpanded ? '' : 'none'; + childList.style.marginLeft = '20px'; + + /* Bookmarks in this folder. */ + (node.bookmarks || []).forEach(function(bm) { + childList.appendChild(renderBookmark(bm, node, haveSigner)); + }); + + /* Subfolders. */ + (node.children || []).forEach(function(child) { + childList.appendChild(renderNode(child, haveSigner, depth + 1)); + }); + + wrap.appendChild(childList); + return wrap; +} + +function renderBookmark(bm, folder, haveSigner) { + var row = document.createElement('div'); + row.className = 'bm'; + + var info = document.createElement('div'); + var title = document.createElement('div'); + title.className = 'bm-title'; + title.textContent = (bm.title && bm.title.length) ? bm.title : '(untitled)'; + info.appendChild(title); + + var urlLink = document.createElement('div'); + var a = document.createElement('a'); + a.className = 'bm-url'; + a.href = bm.url; + a.textContent = bm.url; + urlLink.appendChild(a); + info.appendChild(urlLink); + + var meta = document.createElement('div'); + meta.className = 'bm-meta'; + meta.textContent = 'Added: ' + bm.added; + info.appendChild(meta); + + row.appendChild(info); + + if (haveSigner) { + var actions = document.createElement('div'); + actions.className = 'actions'; + + var editBtn = document.createElement('a'); + editBtn.className = 'btn'; + editBtn.href = '#'; + editBtn.textContent = 'Edit'; + editBtn.onclick = function(e) { + e.preventDefault(); + var curTitle = (bm.title && bm.title.length) ? bm.title : ''; + var newTitle = prompt('Edit bookmark name:', curTitle); + if (newTitle === null) return; + var t = newTitle.trim(); + if (t === curTitle.trim()) return; + location.href = 'sovereign://bookmarks/rename?dir=' + + encodeURIComponent(folder.path) + '&url=' + + encodeURIComponent(bm.url) + '&title=' + + encodeURIComponent(t); + }; + actions.appendChild(editBtn); + + var moveBtn = document.createElement('a'); + moveBtn.className = 'btn'; + moveBtn.href = '#'; + moveBtn.textContent = 'Move'; + moveBtn.onclick = function(e) { + e.preventDefault(); + var dest = prompt('Move bookmark to folder path:', folder.path); + if (dest && dest.trim() && dest.trim() !== folder.path) { + location.href = 'sovereign://bookmarks/move?from=' + + encodeURIComponent(folder.path) + '&to=' + + encodeURIComponent(dest.trim()) + '&url=' + + encodeURIComponent(bm.url); + } + }; + actions.appendChild(moveBtn); + + var delBtn = document.createElement('a'); + delBtn.className = 'btn btn-del'; + delBtn.href = '#'; + delBtn.textContent = 'Delete'; + delBtn.onclick = function(e) { + e.preventDefault(); + if (confirm('Delete this bookmark?')) { + location.href = 'sovereign://bookmarks/delete?dir=' + + encodeURIComponent(folder.path) + '&url=' + + encodeURIComponent(bm.url); + } + }; + actions.appendChild(delBtn); + + row.appendChild(actions); + } + + return row; +} + +/* ── Fetch + actions ───────────────────────────────────────────────── */ + +function fetchBookmarks() { + sovereignGet('sovereign://bookmarks/list?_=' + Date.now()) + .then(function(data) { renderTree(data); }) + .catch(function(e) { + document.getElementById('bm-tree').innerHTML = + '

Failed to load bookmarks: ' + esc(e.message) + '

'; + }); +} + +function addBookmark() { + var url = encodeURIComponent(document.getElementById('bm-url').value); + var title = encodeURIComponent(document.getElementById('bm-title').value); + var path = encodeURIComponent(document.getElementById('bm-path').value); + if (!url) { alert('URL is required'); return; } + location.href = 'sovereign://bookmarks/add?dir=' + path + '&url=' + url + + '&title=' + title; +} + +function createDir() { + var name = encodeURIComponent(document.getElementById('new-path').value); + if (!name) { alert('Folder path is required'); return; } + location.href = 'sovereign://bookmarks/createdir?name=' + name; +} + +fetchBookmarks(); diff --git a/www/css/dot-menu.css b/www/css/dot-menu.css new file mode 100644 index 0000000..0eef48c --- /dev/null +++ b/www/css/dot-menu.css @@ -0,0 +1,142 @@ +.dot-menu-wrap { + position: relative; + display: inline-flex; + align-items: center; + flex: 0 0 auto; +} + +.dot-menu-trigger { + width: var(--avatar-size-md); + height: var(--avatar-size-md); + border-radius: var(--avatar-radius); + border: var(--border); + background: var(--secondary-color); + color: var(--muted-color); + display: inline-flex; + align-items: center; + justify-content: center; + font-family: var(--font-family); + font-size: 22px; + line-height: 1; + cursor: pointer; + padding: 0; +} + +.dot-menu-trigger:hover { + color: var(--accent-color); + border-color: var(--accent-color); +} + +.dot-menu-trigger:focus-visible { + outline: var(--border-width2) solid var(--accent-color); + outline-offset: 1px; +} + +.dot-menu-panel { + position: absolute; + top: calc(100% + 5px); + right: 0; + z-index: 200; + display: none; + flex-direction: column; + gap: 2px; + min-width: 180px; + max-width: min(320px, calc(100vw - 16px)); + padding: 5px; + border: var(--border); + border-radius: var(--border-radius); + background: var(--secondary-color); +} + +.dot-menu-panel[data-position='left'] { + left: 0; + right: auto; +} + +.dot-menu-panel[data-position='right'] { + right: 0; + left: auto; +} + +.dot-menu-panel.is-open { + display: flex; +} + +.dot-menu-item, +.dot-menu-submenu-label { + width: 100%; + display: block; + border: none; + border-radius: var(--border-radius); + background: transparent; + color: var(--primary-color); + text-align: left; + font-family: var(--font-family); + font-size: 14px; + line-height: 1.3; + cursor: pointer; + padding: 2px 10px; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dot-menu-item:hover, +.dot-menu-submenu-label:hover { + background: var(--background-color); + color: var(--accent-color); +} + +.dot-menu-submenu { + position: relative; + margin: 0; + padding: 0; +} + +.dot-menu-submenu-label { + list-style: none; + display: block; +} + +.dot-menu-submenu-label::-webkit-details-marker { + display: none; +} + +.dot-menu-submenu-panel { + position: fixed; + z-index: 260; + display: none; + flex-direction: column; + align-items: stretch; + gap: 4px; + min-width: 180px; + max-width: min(320px, calc(100vw - 16px)); + max-height: min(60vh, 420px); + overflow-y: auto; + padding: 5px; + border: var(--border); + border-radius: var(--border-radius); + background: var(--secondary-color); +} + +.dot-menu-submenu[open] > .dot-menu-submenu-panel { + display: flex; +} + +.dot-menu-submenu-panel > .dot-menu-item, +.dot-menu-submenu-panel > .dot-menu-submenu { + flex: 0 0 auto; +} + +.dot-menu-submenu-panel > .dot-menu-item, +.dot-menu-submenu-panel > .dot-menu-submenu > .dot-menu-submenu-label { + min-height: 22.17px; + box-sizing: border-box; +} + +.dot-menu-divider { + height: var(--border-width); + margin: 2px 4px; + background: var(--muted-color); +} \ No newline at end of file diff --git a/www/css/post-composer.css b/www/css/post-composer.css new file mode 100644 index 0000000..c4f670c --- /dev/null +++ b/www/css/post-composer.css @@ -0,0 +1,321 @@ +.post-composer-wrapper { + width: 60%; + min-width: 300px; + max-width: 800px; + margin-bottom: 20px; +} + +.post-composer-wrapper--inline { + width: 100%; + min-width: 0; + max-width: none; + margin-bottom: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +/* Popup mode — wrapper fills its container, body scrolls, footer sticks */ +.post-composer-wrapper--popup { + width: 100%; + min-width: 0; + max-width: none; + margin-bottom: 0; + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: 0; +} + +.post-composer-popup-body { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.post-composer-popup-footer { + flex: 0 0 auto; + display: flex; + justify-content: flex-end; + padding-top: 10px; + border-top: 1px solid var(--muted-color, #ccc); + margin-top: 8px; +} + +.post-composer-popup-footer .post-composer-send-btn { + display: block !important; + width: 100%; + margin-top: 0; +} + +.post-composer-inline-row { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 4px 0; + margin: 0; +} + +.post-composer-wrapper--inline .post-composer-editor-shell { + flex: 1; + min-width: 0; + display: flex; + align-items: center; +} + +.post-composer-wrapper--inline .post-composer-editor-shell [contenteditable='true'] { + width: 100%; + flex: 1; + min-height: 40px; + height: auto; + overflow-y: visible; + display: block; + padding: 8px 10px; + line-height: 1.3; + border: 1px solid var(--primary-color); + border-radius: 8px; + background: var(--secondary-color); + box-sizing: border-box; +} + +.post-composer-wrapper--inline .post-composer-send-btn { + width: auto; + min-width: 78px; + height: 40px; + padding: 0 16px; + line-height: 1; + margin-top: 0; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.post-composer-editor-shell { + position: relative; +} + +.inlinePostComposerInput { + min-height: 0; + padding: 0; + line-height: 1.4; +} + +.post-composer-wrapper [contenteditable='true'] { + width: 100%; + margin-bottom: 0; + border: 1px solid var(--primary-color); + border-radius: 8px; + background: var(--secondary-color); +} + +.post-composer-upload-icon { + position: absolute; + right: 10px; + bottom: 10px; + width: 32px; + height: 32px; + border: 1px solid var(--muted-color); + border-radius: 50%; + background: transparent; + color: var(--muted-color); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 2; + transition: color 0.2s, border-color 0.2s, background-color 0.2s; + padding: 0; + line-height: 0; +} + +.post-composer-wrapper--inline .post-composer-upload-icon { + top: 50%; + bottom: auto; + transform: translateY(-50%); +} + +.post-composer-upload-icon:hover, +.post-composer-upload-icon:focus, +.post-composer-upload-icon:focus-visible { + color: var(--accent-color); + border-color: var(--accent-color); + background: transparent; + outline: none; +} + +.post-composer-upload-icon svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2; + display: block; +} + +.post-composer-upload-icon.is-image-icon svg { + transform: translateY(0.5px); +} + +.post-composer-drag-overlay { + position: absolute; + inset: 0; + border: 2px dashed var(--accent-color); + border-radius: 10px; + background: color-mix(in srgb, var(--secondary-color) 78%, transparent); + pointer-events: none; + display: none; + align-items: center; + justify-content: center; + text-align: center; + color: var(--accent-color); + font-size: 90%; + z-index: 3; +} + +.post-composer-editor-shell.is-dragover .post-composer-drag-overlay { + display: flex; +} + +.post-composer-uploading { + position: absolute; + right: 52px; + bottom: 15px; + font-size: 75%; + color: var(--muted-color); + z-index: 2; +} + +.post-composer-preview { + margin-top: 10px; + border: 1px solid var(--muted-color); + border-radius: 10px; + padding: 10px 12px; + background: var(--secondary-color); + display: none; +} + +.post-composer-wrapper--inline .post-composer-preview { + margin-top: 0; +} + +.post-composer-preview.is-visible { + display: block; +} + +.post-composer-preview-label { + font-size: 75%; + color: var(--muted-color); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.post-composer-preview-content { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.post-composer-preview-content img, +.post-composer-preview-content video, +.post-composer-preview-content canvas, +.post-composer-preview-content iframe { + max-width: 100%; + height: auto; +} + +.post-composer-send-btn { + width: 100%; + padding: 10px 20px; + border: 2px solid var(--primary-color); + border-radius: 10px; + font-family: var(--font-family); + font-size: 100%; + color: var(--muted-color); + background-color: var(--secondary-color); + cursor: pointer; + text-align: center; + margin-top: 10px; + transition: color 0.2s, border-color 0.2s; + display: none; +} + +.post-composer-send-btn.is-visible, +.post-composer-send-btn.is-persistent-visible { + display: block; +} + +.post-composer-send-btn:hover, +.post-composer-send-btn:focus, +.post-composer-send-btn:focus-visible { + color: var(--primary-color); + border-color: var(--primary-color); + outline: none; +} + +.post-composer-mention-dropdown { + position: fixed; + min-width: 240px; + max-width: 320px; + max-height: 260px; + overflow-y: auto; + border: 1px solid var(--muted-color); + border-radius: 8px; + background: var(--secondary-color); + z-index: 50; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.2); + display: none; +} + +.post-composer-mention-dropdown.is-visible { + display: block; +} + +.post-composer-mention-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + cursor: pointer; + border-bottom: 1px solid color-mix(in srgb, var(--muted-color) 35%, transparent); +} + +.post-composer-mention-item:last-child { + border-bottom: none; +} + +.post-composer-mention-item:hover, +.post-composer-mention-item.is-active { + background: color-mix(in srgb, var(--secondary-color) 70%, var(--accent-color)); +} + +.post-composer-mention-avatar { + width: 24px; + height: 24px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; +} + +.post-composer-mention-meta { + min-width: 0; +} + +.post-composer-mention-name { + color: var(--primary-color); + font-size: 85%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.post-composer-mention-handle { + color: var(--muted-color); + font-size: 75%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/www/fips.css b/www/fips.css new file mode 100644 index 0000000..8d740f3 --- /dev/null +++ b/www/fips.css @@ -0,0 +1,193 @@ +/* FIPS management page — sovereign://fips + * Imports the shared sovereign:// base theme, then adds FIPS-specific + * layout for status cards, the peers table, and the add-peer form. */ +@import url("sovereign://sovereign-base.css"); + +.card { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 16px; + margin: 10px 0 20px; +} + +.card .actions { + margin-top: 12px; +} + +.mono { + font-family: var(--font); + word-break: break-all; +} + +.err-text { + color: var(--accent); +} + +/* State indicator dot. */ +.state-dot { + display: inline-block; + width: 8px; height: 8px; + border-radius: 50%; + margin-left: 6px; + vertical-align: middle; + background: var(--muted); +} +.state-dot.ready { background: var(--primary); } +.state-dot.failed { background: var(--accent); } +.state-dot.starting, +.state-dot.stopping, +.state-dot.bootstrapping, +.state-dot.discovering, +.state-dot.attaching { background: var(--accent); animation: pulse 1s infinite; } + +/* Peers table. */ +#peers-table { + width: 100%; + border-collapse: collapse; + margin: 10px 0; +} +#peers-table th, +#peers-table td { + text-align: left; + padding: 6px 10px; + border-bottom: 1px solid var(--border); + font-size: 13px; +} +#peers-table th { + color: var(--muted); + font-weight: bold; + border-bottom: 2px solid var(--primary); +} +#peers-table td.mono { word-break: break-all; } + +.peer-state-connected { color: var(--primary); } +.peer-state-connecting, +.peer-state-disconnected { color: var(--muted); } +.peer-state-failed { color: var(--accent); } + +#refresh-btn { margin-left: 12px; } + +/* Tab navigation. */ +.tabs { + display: flex; gap: 4px; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} +.tab-btn { + background: var(--bg); color: var(--muted); + border: 1px solid transparent; border-bottom: none; + border-radius: var(--radius) var(--radius) 0 0; + padding: 6px 16px; cursor: pointer; + font-family: var(--font); font-size: 13px; font-weight: bold; + margin-left: 0; min-width: auto; + transition: color 0.2s, border-color 0.2s; +} +.tab-btn:hover { color: var(--primary); } +.tab-btn.active { + color: var(--primary); + border-color: var(--border); + border-bottom: 1px solid var(--bg); + margin-bottom: -1px; +} +.tab-panel { display: none; } +.tab-panel.active { display: block; } + +/* Known-nodes / tree-peers tables. */ +#nodes-table, #tree-peers-table { + width: 100%; + border-collapse: collapse; + margin: 10px 0; +} +#nodes-table th, #nodes-table td, +#tree-peers-table th, #tree-peers-table td { + text-align: left; + padding: 6px 10px; + border-bottom: 1px solid var(--border); + font-size: 13px; +} +#nodes-table th, #tree-peers-table th { + color: var(--muted); + font-weight: bold; + border-bottom: 2px solid var(--primary); +} +#nodes-table td.mono, #tree-peers-table td.mono { word-break: break-all; } + +/* Direct-peer badge in the known-nodes table. */ +.peer-badge { + display: inline-block; width: 8px; height: 8px; + border-radius: 50%; background: var(--primary); +} +.peer-badge.indirect { background: var(--muted); } + +/* Staleness coloring for last-seen. */ +.last-seen-fresh { color: var(--primary); } +.last-seen-stale { color: var(--muted); } +.last-seen-old { color: var(--muted); font-style: italic; } + +/* Tree role tags. */ +.tree-role { font-size: 11px; padding: 1px 6px; border-radius: 3px; + border: 1px solid var(--border); } +.tree-role.root { color: var(--accent); border-color: var(--accent); } +.tree-role.parent { color: var(--primary); } +.tree-role.child { color: var(--muted); } + +/* Directory tab — compact one-line rows, wider table, smaller font. */ +body.wide { max-width: 1400px; } +#tab-directory { max-width: 100%; } +#dir-table { + width: 100%; + border-collapse: collapse; + margin: 10px 0; + table-layout: fixed; +} +#dir-table th, #dir-table td { + text-align: left; + padding: 3px 8px; + border-bottom: 1px solid var(--border); + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +#dir-table th { + color: var(--muted); + font-weight: bold; + border-bottom: 2px solid var(--primary); +} +/* Column widths: badge | npub | endpoint | connect */ +#dir-table th:nth-child(1), #dir-table td:nth-child(1) { width: 20px; } +#dir-table th:nth-child(3), #dir-table td:nth-child(3) { width: 35%; } +#dir-table th:nth-child(4), #dir-table td:nth-child(4) { width: 80px; } +#dir-table td.mono { + font-family: var(--font); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Pinned "this is you" row — highlighted border. */ +#dir-table tr.dir-ours-row { + border: 2px solid var(--accent); + background: rgba(255, 0, 0, 0.05); +} +#dir-table tr.dir-ours-row td { + border-bottom: 2px solid var(--accent); + font-weight: bold; +} +.dir-ours-tag { + color: var(--accent); + font-size: 10px; + font-weight: bold; + border: 1px solid var(--accent); + padding: 1px 4px; + border-radius: 3px; +} + +/* Connect as clickable text, not a button. */ +.dir-connect-link { + color: var(--accent); + font-size: 11px; + cursor: pointer; + text-decoration: underline; +} +.dir-connect-link:hover { text-decoration: none; } diff --git a/www/fips.html b/www/fips.html new file mode 100644 index 0000000..153bd22 --- /dev/null +++ b/www/fips.html @@ -0,0 +1,160 @@ + + + + + + FIPS Mesh Network + + + +

FIPS Mesh Network

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

Status

+
+
State—
+
Node—
+
TUN—
+
Peers—
+
Tree—
+
Ownership—
+
Control—
+ +
+ +
+
+ +

Tor Status (read-only)

+
+
State—
+
Bootstrap—
+
SOCKS—
+
Ownership—
+ +
+
+ + +
+

Peers

+
+

Loading peers…

+ + + + + + + + +
+ +

Add Peer

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

Mesh Summary

+
+
Known nodes—
+
Direct peers—
+
Tree depth—
+
Root—
+
Our parent—
+ +
+ +

Known Nodes

+
+

Loading network…

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

FIPS Node Directory

+

All nodes that have published a FIPS service advert (Kind 37195) to the Nostr advert relays. This is the global network directory, like join.fips.network.

+
+

Click to load the directory from Nostr relays…

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

Spanning Tree

+
+
Root—
+
Is root—
+
Our depth—
+
Our parent—
+
Our node addr—
+ +
+ +

Tree Peers (1-hop neighbors)

+
+

Loading tree…

+ + + + + + + + +
+
+ + + + diff --git a/www/fips.js b/www/fips.js new file mode 100644 index 0000000..34e646d --- /dev/null +++ b/www/fips.js @@ -0,0 +1,564 @@ +/* FIPS management page — sovereign://fips + * + * Tabs: Status | Peers | Network | Tree + * + * - Status: FIPS + Tor status cards (sovereign://fips/status) + * - Peers: direct peers table + add-peer form (sovereign://fips/peers) + * - Network: mesh summary + known-nodes table (sovereign://fips/network) + * - Tree: spanning tree view (sovereign://fips/tree) + * + * Uses XMLHttpRequest via sovereignGet() because WebKit's custom scheme + * handler does not reliably support fetch() for sovereign:// URLs. + */ + +function sovereignGet(url) { + return new Promise(function(resolve, reject) { + var x = new XMLHttpRequest(); + x.open('GET', url, true); + x.onreadystatechange = function() { + if (x.readyState !== 4) return; + try { resolve(JSON.parse(x.responseText)); } + catch (e) { reject(e); } + }; + x.onerror = function() { reject(new Error('XHR error')); }; + x.send(); + }); +} + +function esc(s) { + var d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; +} + +function showMsg(text, isErr) { + var el = document.getElementById('status-msg'); + el.textContent = text || ''; + el.className = 'status show ' + (isErr ? 'err' : 'ok'); + if (!text) el.className = 'status'; +} + +function stateDotClass(state) { + return 'state-dot ' + (state || ''); +} + +/* Global: our own FIPS node npub (from show_status), used to pin our + * node in the Directory tab. */ +var gOurNpub = ''; + +/* ── Tab switching ──────────────────────────────────────────────── */ + +function switchTab(tabName) { + document.querySelectorAll('.tab-btn').forEach(function(btn) { + btn.classList.toggle('active', btn.dataset.tab === tabName); + }); + document.querySelectorAll('.tab-panel').forEach(function(panel) { + panel.classList.toggle('active', panel.id === 'tab-' + tabName); + }); + /* Widen the page for the directory tab (long npub list needs width). */ + document.body.classList.toggle('wide', tabName === 'directory'); + /* Load tab data on demand. */ + if (tabName === 'network') fetchNetwork(); + if (tabName === 'tree') fetchTree(); + if (tabName === 'directory') fetchDirectory(); +} + +document.querySelectorAll('.tab-btn').forEach(function(btn) { + btn.addEventListener('click', function() { switchTab(btn.dataset.tab); }); +}); + +/* ── Status tab ─────────────────────────────────────────────────── */ + +function renderFipsStatus(data) { + var f = data.fips || {}; + var state = f.state || 'disabled'; + /* Store our npub for the Directory tab pinning. */ + gOurNpub = f.node_npub || ''; + document.getElementById('fips-state').innerHTML = + esc(state) + ''; + document.getElementById('fips-node').textContent = f.node_npub || '—'; + var tun = '—'; + if (f.tun_name && f.tun_name.length) { + tun = f.tun_name + (f.tun_active ? ' (active)' : ' (inactive)'); + } + document.getElementById('fips-tun').textContent = tun; + document.getElementById('fips-peers').textContent = + (f.peer_count != null ? f.peer_count : '—') + ' connected'; + document.getElementById('fips-tree').textContent = f.tree_state || '—'; + document.getElementById('fips-ownership').textContent = + f.ownership || '—'; + document.getElementById('fips-control').textContent = + f.control_socket || '—'; + + var errRow = document.getElementById('fips-error-row'); + var errEl = document.getElementById('fips-error'); + if (f.error && f.error.length) { + errEl.textContent = f.error; + errRow.style.display = ''; + } else { + errRow.style.display = 'none'; + } +} + +function renderTorStatus(data) { + var t = data.tor || {}; + var state = t.state || 'disabled'; + document.getElementById('tor-state').innerHTML = + esc(state) + ''; + document.getElementById('tor-bootstrap').textContent = + (t.bootstrap_progress != null ? t.bootstrap_progress : '—') + '%'; + document.getElementById('tor-socks').textContent = + t.socks_endpoint || '—'; + document.getElementById('tor-ownership').textContent = + t.ownership || '—'; + + var errRow = document.getElementById('tor-error-row'); + var errEl = document.getElementById('tor-error'); + if (t.error && t.error.length) { + errEl.textContent = t.error; + errRow.style.display = ''; + } else { + errRow.style.display = 'none'; + } +} + +function fetchStatus() { + return sovereignGet('sovereign://fips/status?_=' + Date.now()) + .then(function(data) { + renderFipsStatus(data); + renderTorStatus(data); + }) + .catch(function(e) { + showMsg('Failed to load status: ' + e.message, true); + }); +} + +/* ── Peers tab ──────────────────────────────────────────────────── */ + +function renderPeers(data) { + var body = document.getElementById('peers-body'); + var table = document.getElementById('peers-table'); + var empty = document.getElementById('peers-empty'); + + var peers = data.peers || []; + if (!peers.length) { + table.style.display = 'none'; + empty.style.display = ''; + empty.textContent = data.error + ? 'No peers (' + data.error + ')' + : 'No peers connected.'; + return; + } + + empty.style.display = 'none'; + table.style.display = ''; + var html = ''; + peers.forEach(function(p) { + var npub = p.npub || p.id || ''; + var addr = p.transport_addr || p.address || p.addr || ''; + var transport = p.transport_type || p.transport || ''; + var state = p.connectivity || p.state || p.status || ''; + var label = p.display_name || ''; + html += ''; + html += '' + esc(npub) + + (label ? '
' + esc(label) + '' : '') + + ''; + html += '' + esc(addr) + ''; + html += '' + esc(transport) + ''; + html += '' + esc(state) + ''; + html += ''; + html += ''; + }); + body.innerHTML = html; +} + +function fetchPeers() { + return sovereignGet('sovereign://fips/peers?_=' + Date.now()) + .then(function(data) { renderPeers(data); }) + .catch(function(e) { + document.getElementById('peers-empty').textContent = + 'Failed to load peers: ' + e.message; + }); +} + +function connectPeer() { + var npub = document.getElementById('peer-npub').value.trim(); + var address = document.getElementById('peer-address').value.trim(); + var transport = document.getElementById('peer-transport').value; + if (!npub || !address) { + showMsg('npub and address are required', true); + return; + } + var url = 'sovereign://fips/connect?npub=' + encodeURIComponent(npub) + + '&address=' + encodeURIComponent(address) + + '&transport=' + encodeURIComponent(transport); + sovereignGet(url) + .then(function(d) { + if (d.error) { + showMsg('Connect failed: ' + d.error, true); + } else { + showMsg('Peer added'); + document.getElementById('peer-npub').value = ''; + document.getElementById('peer-address').value = ''; + fetchPeers(); + } + }) + .catch(function(e) { showMsg('Connect failed: ' + e.message, true); }); +} + +function disconnectPeer(npub) { + if (!npub) return; + if (!confirm('Disconnect peer ' + npub + '?')) return; + var url = 'sovereign://fips/disconnect?npub=' + encodeURIComponent(npub); + sovereignGet(url) + .then(function(d) { + if (d.error) { + showMsg('Disconnect failed: ' + d.error, true); + } else { + showMsg('Peer disconnected'); + fetchPeers(); + } + }) + .catch(function(e) { showMsg('Disconnect failed: ' + e.message, true); }); +} + +function restartService() { + if (!confirm('Restart the FIPS service? This will briefly drop all peer connections.')) return; + showMsg('Restarting FIPS service…'); + sovereignGet('sovereign://fips/restart') + .then(function(d) { + if (d.error) showMsg('Restart failed: ' + d.error, true); + else showMsg('Restart initiated'); + }) + .catch(function(e) { showMsg('Restart failed: ' + e.message, true); }); +} + +/* ── Network tab ────────────────────────────────────────────────── */ + +function formatAge(ageMs) { + if (ageMs == null) return '—'; + var s = Math.floor(ageMs / 1000); + if (s < 60) return s + 's ago'; + var m = Math.floor(s / 60); + if (m < 60) return m + 'm ago'; + var h = Math.floor(m / 60); + if (h < 24) return h + 'h ago'; + return Math.floor(h / 24) + 'd ago'; +} + +function ageClass(ageMs) { + if (ageMs == null) return ''; + if (ageMs < 10000) return 'last-seen-fresh'; + if (ageMs < 60000) return 'last-seen-stale'; + return 'last-seen-old'; +} + +function renderNetwork(data) { + var s = data.summary || {}; + var nodes = data.nodes || []; + + /* Mesh summary. */ + document.getElementById('mesh-known').textContent = + (s.known_nodes != null ? s.known_nodes : '—') + + (s.max_cache_entries != null ? ' / ' + s.max_cache_entries : ''); + document.getElementById('mesh-direct').textContent = + (data.peer_count != null ? data.peer_count : '—'); + document.getElementById('mesh-depth').textContent = + (s.tree_depth != null ? s.tree_depth : '—'); + document.getElementById('mesh-root').textContent = + s.root_npub ? esc(s.root_npub) : '—'; + document.getElementById('mesh-parent').textContent = + s.parent_display_name ? esc(s.parent_display_name) : + (s.is_root ? '(this node is root)' : '—'); + + var errRow = document.getElementById('mesh-error-row'); + var errEl = document.getElementById('mesh-error'); + if (data.error) { + errEl.textContent = data.error; + errRow.style.display = ''; + } else { + errRow.style.display = 'none'; + } + + /* Known nodes table. */ + var body = document.getElementById('nodes-body'); + var table = document.getElementById('nodes-table'); + var empty = document.getElementById('nodes-empty'); + var countLabel = document.getElementById('nodes-count-label'); + + countLabel.textContent = '(' + nodes.length + ' nodes)'; + + if (!nodes.length) { + table.style.display = 'none'; + empty.style.display = ''; + empty.textContent = data.error + ? 'Network unavailable: ' + data.error + : 'No known nodes (FIPS may not be ready).'; + return; + } + + /* Sort by last_seen descending (most recent first). */ + nodes.sort(function(a, b) { + var aT = a.last_seen_ms || 0; + var bT = b.last_seen_ms || 0; + return bT - aT; + }); + + empty.style.display = 'none'; + table.style.display = ''; + var html = ''; + nodes.forEach(function(n) { + var npub = n.npub || ''; + var name = n.display_name || '—'; + var ipv6 = n.ipv6_addr || ''; + var age = n.age_ms != null ? n.age_ms : null; + var direct = n.is_direct_peer; + var badge = ''; + html += ''; + html += '' + badge + ''; + html += '' + esc(npub) + ''; + html += '' + esc(name) + ''; + html += '' + esc(ipv6) + ''; + html += '' + formatAge(age) + ''; + html += ''; + }); + body.innerHTML = html; +} + +function fetchNetwork() { + return sovereignGet('sovereign://fips/network?_=' + Date.now()) + .then(function(data) { renderNetwork(data); }) + .catch(function(e) { + document.getElementById('nodes-empty').textContent = + 'Failed to load network: ' + e.message; + }); +} + +/* ── Tree tab ───────────────────────────────────────────────────── */ + +function renderTree(data) { + var t = data.tree || {}; + var errRow = document.getElementById('tree-error-row'); + var errEl = document.getElementById('tree-error'); + + if (data.error) { + errEl.textContent = data.error; + errRow.style.display = ''; + document.getElementById('tree-peers-empty').textContent = + 'Tree unavailable: ' + data.error; + document.getElementById('tree-peers-table').style.display = 'none'; + /* Clear summary. */ + ['tree-root','tree-is-root','tree-depth','tree-parent','tree-node-addr'] + .forEach(function(id) { document.getElementById(id).textContent = '—'; }); + return; + } + errRow.style.display = 'none'; + + document.getElementById('tree-root').textContent = + t.root_npub || t.root || '—'; + document.getElementById('tree-is-root').textContent = + t.is_root ? 'yes' : 'no'; + document.getElementById('tree-depth').textContent = + (t.depth != null ? t.depth : '—'); + document.getElementById('tree-parent').textContent = + t.parent_display_name || t.parent || '—'; + document.getElementById('tree-node-addr').textContent = + t.my_node_addr || '—'; + + /* Tree peers (1-hop neighbors). */ + var body = document.getElementById('tree-peers-body'); + var table = document.getElementById('tree-peers-table'); + var empty = document.getElementById('tree-peers-empty'); + var peers = t.peers || []; + + if (!peers.length) { + table.style.display = 'none'; + empty.style.display = ''; + empty.textContent = 'No tree peers.'; + return; + } + + empty.style.display = 'none'; + table.style.display = ''; + var myAddr = t.my_node_addr || ''; + var parentAddr = t.parent || ''; + var html = ''; + peers.forEach(function(p) { + var addr = p.node_addr || ''; + var name = p.display_name || '—'; + var depth = p.depth != null ? p.depth : '—'; + var dist = p.distance_to_us != null ? p.distance_to_us : '—'; + var role = ''; + if (addr === parentAddr && !t.is_root) { + role = 'parent'; + } else if (addr === t.root) { + role = 'root'; + } else { + role = 'peer'; + } + html += ''; + html += '' + esc(addr) + ''; + html += '' + esc(name) + ''; + html += '' + esc(depth) + ''; + html += '' + esc(dist) + ''; + html += '' + role + ''; + html += ''; + }); + body.innerHTML = html; +} + +function fetchTree() { + return sovereignGet('sovereign://fips/tree?_=' + Date.now()) + .then(function(data) { renderTree(data); }) + .catch(function(e) { + document.getElementById('tree-peers-empty').textContent = + 'Failed to load tree: ' + e.message; + }); +} + +/* ── Directory tab ───────────────────────────────────────────────── */ + +function renderDirectory(data) { + var body = document.getElementById('dir-body'); + var table = document.getElementById('dir-table'); + var empty = document.getElementById('dir-empty'); + var countLabel = document.getElementById('dir-count-label'); + var actions = document.getElementById('dir-actions'); + var nodes = data.nodes || []; + + countLabel.textContent = '(' + nodes.length + ' nodes)'; + + if (!nodes.length) { + table.style.display = 'none'; + actions.style.display = 'none'; + empty.style.display = ''; + empty.textContent = data.error + ? 'Directory unavailable: ' + data.error + : 'No advertised nodes found on the advert relays.'; + return; + } + + /* Sort: our own node first (pinned), then by created_at descending. */ + nodes.sort(function(a, b) { + var aOurs = (a.npub && a.npub === gOurNpub) ? 1 : 0; + var bOurs = (b.npub && b.npub === gOurNpub) ? 1 : 0; + if (aOurs !== bOurs) return bOurs - aOurs; + var aT = a.created_at || 0; + var bT = b.created_at || 0; + return bT - aT; + }); + + empty.style.display = 'none'; + table.style.display = ''; + actions.style.display = ''; + var html = ''; + nodes.forEach(function(n) { + var npub = n.npub || ''; + var direct = n.is_direct_peer; + var isOurs = (npub && npub === gOurNpub); + var badge = ''; + + /* Compact endpoints: "udp 1.2.3.4:2121" (first one only, +N if more). */ + var endpointsStr = '—'; + if (n.endpoints && n.endpoints.length) { + var ep = n.endpoints[0]; + endpointsStr = esc(ep.transport || '?') + ' ' + esc(ep.addr || ''); + if (n.endpoints.length > 1) endpointsStr += ' +' + (n.endpoints.length - 1); + } + + /* Connect: clickable text, not a button. */ + var firstEp = (n.endpoints && n.endpoints[0]) || {}; + var addr = firstEp.addr || ''; + var transport = firstEp.transport || 'udp'; + var connectLink = ''; + if (isOurs) { + connectLink = 'this is you'; + } else if (addr && !direct) { + connectLink = 'connect'; + } else if (direct) { + connectLink = 'connected'; + } + + var rowClass = isOurs ? ' class="dir-ours-row"' : ''; + html += ''; + html += '' + badge + ''; + html += '' + esc(npub) + ''; + html += '' + endpointsStr + ''; + html += '' + connectLink + ''; + html += ''; + }); + body.innerHTML = html; +} + +function fetchDirectory() { + var empty = document.getElementById('dir-empty'); + empty.style.display = ''; + empty.textContent = 'Querying Nostr advert relays (this takes a few seconds)…'; + document.getElementById('dir-table').style.display = 'none'; + document.getElementById('dir-actions').style.display = 'none'; + return sovereignGet('sovereign://fips/directory?_=' + Date.now()) + .then(function(data) { renderDirectory(data); }) + .catch(function(e) { + empty.textContent = 'Failed to load directory: ' + e.message; + }); +} + +function connectFromDirectory(npub, address, transport) { + /* Switch to the Peers tab, pre-fill the add-peer form, and connect. */ + switchTab('peers'); + document.getElementById('peer-npub').value = npub; + document.getElementById('peer-address').value = address; + var sel = document.getElementById('peer-transport'); + for (var i = 0; i < sel.options.length; i++) { + if (sel.options[i].value === transport) { + sel.selectedIndex = i; + break; + } + } + connectPeer(); +} + +/* ── Refresh + auto-refresh ─────────────────────────────────────── */ + +function refresh() { + showMsg(''); + var activeTab = document.querySelector('.tab-btn.active'); + var tabName = activeTab ? activeTab.dataset.tab : 'status'; + var promises = [fetchStatus(), fetchPeers()]; + if (tabName === 'network') promises.push(fetchNetwork()); + if (tabName === 'tree') promises.push(fetchTree()); + Promise.all(promises).then(function() {}); +} + +document.getElementById('refresh-btn').addEventListener('click', refresh); +document.getElementById('connect-btn').addEventListener('click', connectPeer); +document.getElementById('restart-btn').addEventListener('click', restartService); +var dirRefreshBtn = document.getElementById('dir-refresh-btn'); +if (dirRefreshBtn) dirRefreshBtn.addEventListener('click', fetchDirectory); + +/* Initial load. */ +refresh(); + +/* Auto-refresh status + peers every 5s. Network/tree refresh on + * tab activation and every 10s when active. */ +setInterval(function() { + fetchStatus(); + fetchPeers(); +}, 5000); + +setInterval(function() { + var activeTab = document.querySelector('.tab-btn.active'); + if (!activeTab) return; + var tabName = activeTab.dataset.tab; + if (tabName === 'network') fetchNetwork(); + if (tabName === 'tree') fetchTree(); +}, 10000); diff --git a/www/js/dot-menu.js b/www/js/dot-menu.js new file mode 100644 index 0000000..e5dfe6e --- /dev/null +++ b/www/js/dot-menu.js @@ -0,0 +1,232 @@ +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function normalizeItems(items) { + return Array.isArray(items) ? items.filter(Boolean) : []; +} + +function isSubmenuItem(item) { + return item && typeof item === 'object' && Array.isArray(item.submenu); +} + +function positionSubmenuPanel(detailsEl) { + if (!detailsEl) return; + const summaryEl = detailsEl.querySelector('.dot-menu-submenu-label'); + const panelEl = detailsEl.querySelector('.dot-menu-submenu-panel'); + if (!summaryEl || !panelEl) return; + + const gap = 6; + const pad = 8; + + const summaryRect = summaryEl.getBoundingClientRect(); + const panelRect = panelEl.getBoundingClientRect(); + + const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; + + const panelWidth = panelRect.width || panelEl.offsetWidth || 180; + const panelHeight = panelRect.height || panelEl.offsetHeight || 40; + + let left = summaryRect.right + gap; + if (left + panelWidth > viewportWidth - pad) { + left = summaryRect.left - panelWidth - gap; + } + left = clamp(left, pad, Math.max(pad, viewportWidth - panelWidth - pad)); + + let top = summaryRect.top; + if (top + panelHeight > viewportHeight - pad) { + top = viewportHeight - panelHeight - pad; + } + top = clamp(top, pad, Math.max(pad, viewportHeight - panelHeight - pad)); + + panelEl.style.left = `${Math.round(left)}px`; + panelEl.style.top = `${Math.round(top)}px`; +} + +function closeSiblingSubmenus(parentEl, exceptEl = null) { + if (!parentEl) return; + const siblings = parentEl.querySelectorAll(':scope > .dot-menu-submenu[open]'); + siblings.forEach((detailsEl) => { + if (exceptEl && detailsEl === exceptEl) return; + detailsEl.open = false; + }); +} + +function renderItems(containerEl, items, closeMenu) { + containerEl.innerHTML = ''; + + const normalized = normalizeItems(items); + normalized.forEach((item) => { + if (!item || typeof item !== 'object') return; + + if (item.divider) { + const divider = document.createElement('div'); + divider.className = 'dot-menu-divider'; + divider.setAttribute('role', 'separator'); + containerEl.appendChild(divider); + return; + } + + if (isSubmenuItem(item)) { + const detailsEl = document.createElement('details'); + detailsEl.className = 'dot-menu-submenu'; + + const summaryEl = document.createElement('summary'); + summaryEl.className = 'dot-menu-submenu-label'; + summaryEl.textContent = String(item.label || 'More'); + + const submenuPanel = document.createElement('div'); + submenuPanel.className = 'dot-menu-submenu-panel'; + + renderItems(submenuPanel, item.submenu, closeMenu); + + detailsEl.appendChild(summaryEl); + detailsEl.appendChild(submenuPanel); + containerEl.appendChild(detailsEl); + + detailsEl.addEventListener('toggle', () => { + if (!detailsEl.open) return; + closeSiblingSubmenus(containerEl, detailsEl); + requestAnimationFrame(() => positionSubmenuPanel(detailsEl)); + }); + + summaryEl.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + submenuPanel.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + return; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'dot-menu-item'; + button.textContent = String(item.label || 'Untitled'); + + button.addEventListener('click', (event) => { + event.stopPropagation(); + try { + if (typeof item.onClick === 'function') { + item.onClick(); + } + } finally { + closeMenu(); + } + }); + + containerEl.appendChild(button); + }); +} + +window.mountDotMenu = function mountDotMenu(hostEl, config = {}) { + if (!(hostEl instanceof Element)) { + throw new Error('mountDotMenu(hostEl, config) requires a valid host element.'); + } + + let currentItems = normalizeItems(config.items); + const triggerLabel = typeof config.triggerLabel === 'string' ? config.triggerLabel : '⋯'; + const ariaLabel = typeof config.ariaLabel === 'string' && config.ariaLabel.trim() + ? config.ariaLabel.trim() + : 'Menu options'; + const position = config.position === 'left' ? 'left' : 'right'; + + const wrap = document.createElement('div'); + wrap.className = 'dot-menu-wrap'; + + const trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'dot-menu-trigger'; + trigger.setAttribute('aria-label', ariaLabel); + trigger.setAttribute('aria-haspopup', 'menu'); + trigger.setAttribute('aria-expanded', 'false'); + trigger.textContent = triggerLabel; + + const panel = document.createElement('div'); + panel.className = 'dot-menu-panel'; + panel.dataset.position = position; + panel.setAttribute('role', 'menu'); + + wrap.appendChild(trigger); + wrap.appendChild(panel); + + hostEl.innerHTML = ''; + hostEl.appendChild(wrap); + + const close = () => { + panel.classList.remove('is-open'); + wrap.classList.remove('is-open'); + trigger.setAttribute('aria-expanded', 'false'); + panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => { + detailsEl.open = false; + }); + }; + + const open = () => { + panel.classList.add('is-open'); + wrap.classList.add('is-open'); + trigger.setAttribute('aria-expanded', 'true'); + }; + + const toggle = () => { + if (panel.classList.contains('is-open')) { + close(); + } else { + open(); + } + }; + + const onTriggerClick = (event) => { + event.stopPropagation(); + toggle(); + }; + + const onDocumentClick = (event) => { + if (!wrap.contains(event.target)) { + close(); + } + }; + + const onDocumentKeyDown = (event) => { + if (event.key === 'Escape') { + close(); + } + }; + + const onViewportChange = () => { + panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => { + positionSubmenuPanel(detailsEl); + }); + }; + + trigger.addEventListener('click', onTriggerClick); + panel.addEventListener('click', (event) => event.stopPropagation()); + document.addEventListener('click', onDocumentClick); + document.addEventListener('keydown', onDocumentKeyDown); + window.addEventListener('resize', onViewportChange); + window.addEventListener('scroll', onViewportChange, true); + + renderItems(panel, currentItems, close); + + const updateItems = (newItems) => { + currentItems = normalizeItems(newItems); + renderItems(panel, currentItems, close); + }; + + const destroy = () => { + close(); + trigger.removeEventListener('click', onTriggerClick); + document.removeEventListener('click', onDocumentClick); + document.removeEventListener('keydown', onDocumentKeyDown); + window.removeEventListener('resize', onViewportChange); + window.removeEventListener('scroll', onViewportChange, true); + if (wrap.parentNode) { + wrap.parentNode.removeChild(wrap); + } + }; + + return { open, close, destroy, updateItems }; +} diff --git a/www/js/dot-menu.mjs b/www/js/dot-menu.mjs new file mode 100644 index 0000000..14f8b4e --- /dev/null +++ b/www/js/dot-menu.mjs @@ -0,0 +1,232 @@ +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function normalizeItems(items) { + return Array.isArray(items) ? items.filter(Boolean) : []; +} + +function isSubmenuItem(item) { + return item && typeof item === 'object' && Array.isArray(item.submenu); +} + +function positionSubmenuPanel(detailsEl) { + if (!detailsEl) return; + const summaryEl = detailsEl.querySelector('.dot-menu-submenu-label'); + const panelEl = detailsEl.querySelector('.dot-menu-submenu-panel'); + if (!summaryEl || !panelEl) return; + + const gap = 6; + const pad = 8; + + const summaryRect = summaryEl.getBoundingClientRect(); + const panelRect = panelEl.getBoundingClientRect(); + + const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; + + const panelWidth = panelRect.width || panelEl.offsetWidth || 180; + const panelHeight = panelRect.height || panelEl.offsetHeight || 40; + + let left = summaryRect.right + gap; + if (left + panelWidth > viewportWidth - pad) { + left = summaryRect.left - panelWidth - gap; + } + left = clamp(left, pad, Math.max(pad, viewportWidth - panelWidth - pad)); + + let top = summaryRect.top; + if (top + panelHeight > viewportHeight - pad) { + top = viewportHeight - panelHeight - pad; + } + top = clamp(top, pad, Math.max(pad, viewportHeight - panelHeight - pad)); + + panelEl.style.left = `${Math.round(left)}px`; + panelEl.style.top = `${Math.round(top)}px`; +} + +function closeSiblingSubmenus(parentEl, exceptEl = null) { + if (!parentEl) return; + const siblings = parentEl.querySelectorAll(':scope > .dot-menu-submenu[open]'); + siblings.forEach((detailsEl) => { + if (exceptEl && detailsEl === exceptEl) return; + detailsEl.open = false; + }); +} + +function renderItems(containerEl, items, closeMenu) { + containerEl.innerHTML = ''; + + const normalized = normalizeItems(items); + normalized.forEach((item) => { + if (!item || typeof item !== 'object') return; + + if (item.divider) { + const divider = document.createElement('div'); + divider.className = 'dot-menu-divider'; + divider.setAttribute('role', 'separator'); + containerEl.appendChild(divider); + return; + } + + if (isSubmenuItem(item)) { + const detailsEl = document.createElement('details'); + detailsEl.className = 'dot-menu-submenu'; + + const summaryEl = document.createElement('summary'); + summaryEl.className = 'dot-menu-submenu-label'; + summaryEl.textContent = String(item.label || 'More'); + + const submenuPanel = document.createElement('div'); + submenuPanel.className = 'dot-menu-submenu-panel'; + + renderItems(submenuPanel, item.submenu, closeMenu); + + detailsEl.appendChild(summaryEl); + detailsEl.appendChild(submenuPanel); + containerEl.appendChild(detailsEl); + + detailsEl.addEventListener('toggle', () => { + if (!detailsEl.open) return; + closeSiblingSubmenus(containerEl, detailsEl); + requestAnimationFrame(() => positionSubmenuPanel(detailsEl)); + }); + + summaryEl.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + submenuPanel.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + return; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'dot-menu-item'; + button.textContent = String(item.label || 'Untitled'); + + button.addEventListener('click', (event) => { + event.stopPropagation(); + try { + if (typeof item.onClick === 'function') { + item.onClick(); + } + } finally { + closeMenu(); + } + }); + + containerEl.appendChild(button); + }); +} + +export function mountDotMenu(hostEl, config = {}) { + if (!(hostEl instanceof Element)) { + throw new Error('mountDotMenu(hostEl, config) requires a valid host element.'); + } + + let currentItems = normalizeItems(config.items); + const triggerLabel = typeof config.triggerLabel === 'string' ? config.triggerLabel : '⋯'; + const ariaLabel = typeof config.ariaLabel === 'string' && config.ariaLabel.trim() + ? config.ariaLabel.trim() + : 'Menu options'; + const position = config.position === 'left' ? 'left' : 'right'; + + const wrap = document.createElement('div'); + wrap.className = 'dot-menu-wrap'; + + const trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'dot-menu-trigger'; + trigger.setAttribute('aria-label', ariaLabel); + trigger.setAttribute('aria-haspopup', 'menu'); + trigger.setAttribute('aria-expanded', 'false'); + trigger.textContent = triggerLabel; + + const panel = document.createElement('div'); + panel.className = 'dot-menu-panel'; + panel.dataset.position = position; + panel.setAttribute('role', 'menu'); + + wrap.appendChild(trigger); + wrap.appendChild(panel); + + hostEl.innerHTML = ''; + hostEl.appendChild(wrap); + + const close = () => { + panel.classList.remove('is-open'); + wrap.classList.remove('is-open'); + trigger.setAttribute('aria-expanded', 'false'); + panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => { + detailsEl.open = false; + }); + }; + + const open = () => { + panel.classList.add('is-open'); + wrap.classList.add('is-open'); + trigger.setAttribute('aria-expanded', 'true'); + }; + + const toggle = () => { + if (panel.classList.contains('is-open')) { + close(); + } else { + open(); + } + }; + + const onTriggerClick = (event) => { + event.stopPropagation(); + toggle(); + }; + + const onDocumentClick = (event) => { + if (!wrap.contains(event.target)) { + close(); + } + }; + + const onDocumentKeyDown = (event) => { + if (event.key === 'Escape') { + close(); + } + }; + + const onViewportChange = () => { + panel.querySelectorAll('.dot-menu-submenu[open]').forEach((detailsEl) => { + positionSubmenuPanel(detailsEl); + }); + }; + + trigger.addEventListener('click', onTriggerClick); + panel.addEventListener('click', (event) => event.stopPropagation()); + document.addEventListener('click', onDocumentClick); + document.addEventListener('keydown', onDocumentKeyDown); + window.addEventListener('resize', onViewportChange); + window.addEventListener('scroll', onViewportChange, true); + + renderItems(panel, currentItems, close); + + const updateItems = (newItems) => { + currentItems = normalizeItems(newItems); + renderItems(panel, currentItems, close); + }; + + const destroy = () => { + close(); + trigger.removeEventListener('click', onTriggerClick); + document.removeEventListener('click', onDocumentClick); + document.removeEventListener('keydown', onDocumentKeyDown); + window.removeEventListener('resize', onViewportChange); + window.removeEventListener('scroll', onViewportChange, true); + if (wrap.parentNode) { + wrap.parentNode.removeChild(wrap); + } + }; + + return { open, close, destroy, updateItems }; +} diff --git a/www/js/perf-probe.js b/www/js/perf-probe.js new file mode 100644 index 0000000..2413c22 --- /dev/null +++ b/www/js/perf-probe.js @@ -0,0 +1,369 @@ +/* + * perf-probe.js — per-tab performance probe for sovereign_browser + * + * Injected by WebKitUserContentManager into every non-sovereign:// page + * (see process_info.c / nostr_inject.c wiring in main.c). Collects: + * - long tasks (PerformanceObserver 'longtask') -> cpu_busy_percent + * - resource entries (PerformanceObserver 'resource') -> net counts + * - requestAnimationFrame cadence -> fps + * - patched setTimeout/setInterval -> active timer count + top intervals + * - patched addEventListener -> event listener count + * - patched Worker -> worker count + * - performance.memory -> heap used + * - document.getElementsByTagName('*').length -> DOM node count + * + * Batches a report every 1s and POSTs it to: + * sovereign://processes/probe-report?tab_index=N&body= + * + * The tab_index is supplied by the browser via a + * tag injected before this script runs (see inject_perf_probe() in + * nostr_bridge.c / main.c). If absent, the report is dropped. + * + * The probe is a no-op on sovereign:// pages (the injector skips them) + * to avoid self-noise and recursion. + */ +(function () { + 'use strict'; + + // Honor the skip flag set by the preamble on sovereign:// / about: + // pages. The skip flag is re-evaluated on every navigation (the + // preamble runs at document_start for each new document), so we also + // reset our installed flag here to allow re-injection after a + // navigation from an internal page to a normal one. + if (window.__sbPerfProbeSkip) return; + if (window.__sbPerfProbe) return; // already running in this document + window.__sbPerfProbe = true; + + // Tab index is injected by the browser via window.__sbPerfTabIndex + // (set by the preamble that runs before this script). Read it once. + var TAB_INDEX = window.__sbPerfTabIndex; + if (TAB_INDEX == null || TAB_INDEX < 0 || isNaN(TAB_INDEX)) return; + + // Capture the native timers early — the heartbeat (below) needs + // origSetTimeout before the timer-patching block runs. + var origSetTimeout = window.setTimeout; + var origSetInterval = window.setInterval; + var origClearTimeout = window.clearTimeout; + var origClearInterval = window.clearInterval; + + // ── Long tasks ────────────────────────────────────────────────── + var longTasks = []; // last 30s of {t, duration, name} + var longTasksLastSec = []; // tasks in the current 1s window + var longTaskSources = {}; // name -> {total_ms, count} + var longTaskSupported = false; + + if (typeof PerformanceObserver !== 'undefined') { + try { + var ltObs = new PerformanceObserver(function (list) { + list.getEntries().forEach(function (e) { + var rec = { + t: performance.now(), + duration: e.duration, + name: e.name || 'unknown', + attribution: (e.attribution && e.attribution[0]) + ? (e.attribution[0].name + ':' + (e.attribution[0].containerName || '')) + : '' + }; + longTasks.push(rec); + longTasksLastSec.push(rec); + var key = rec.name || 'unknown'; + if (!longTaskSources[key]) { + longTaskSources[key] = { total_ms: 0, count: 0 }; + } + longTaskSources[key].total_ms += rec.duration; + longTaskSources[key].count++; + }); + }); + ltObs.observe({ entryTypes: ['longtask'] }); + longTaskSupported = true; + } catch (e) { /* longtask not supported */ } + } + + // ── Main-thread blockage fallback ─────────────────────────────── + // WebKitGTK does not fire 'longtask' entries, so we measure main- + // thread blockage directly: schedule a 0ms heartbeat and measure + // how late it fires. If the main thread is blocked by a busy loop, + // the heartbeat is delayed. The total delay in a 1s window is a + // proxy for cpu_busy_percent. + var heartbeatDelayMs = 0; // accumulated delay in the current window + var heartbeatLast = performance.now(); + function heartbeat() { + var now = performance.now(); + var expected = heartbeatLast + 50; // we schedule every 50ms + var delay = now - expected; + if (delay > 5) { // ignore small scheduler jitter + heartbeatDelayMs += delay; + // Record as a synthetic long task if > 50ms. + if (delay > 50) { + var rec = { t: now, duration: delay, name: 'main-thread-block', + attribution: '' }; + longTasks.push(rec); + longTasksLastSec.push(rec); + var key = 'main-thread-block'; + if (!longTaskSources[key]) { + longTaskSources[key] = { total_ms: 0, count: 0 }; + } + longTaskSources[key].total_ms += delay; + longTaskSources[key].count++; + } + } + heartbeatLast = now; + origSetTimeout.call(window, heartbeat, 50); + } + // Always start the heartbeat. On engines where longtask fires + // reliably, the heartbeat delay will be near-zero (long tasks are + // captured by the observer). On WebKitGTK, where longtask is + // accepted but never fires, the heartbeat is the sole source of + // cpu_busy measurement. The report() function takes the max of + // both, so there's no double-counting. + origSetTimeout.call(window, heartbeat, 50); + + // ── Resource entries (network) ──────────────────────────────── + var netRequestsLastSec = []; + var netEndpoints = {}; // url-prefix -> count + if (typeof PerformanceObserver !== 'undefined') { + try { + var resObs = new PerformanceObserver(function (list) { + list.getEntries().forEach(function (e) { + netRequestsLastSec.push({ name: e.name, type: e.initiatorType }); + var key = e.name.split('?')[0]; + netEndpoints[key] = (netEndpoints[key] || 0) + 1; + }); + }); + resObs.observe({ entryTypes: ['resource'] }); + } catch (e) { /* resource not supported */ } + } + + // ── FPS via requestAnimationFrame ─────────────────────────────── + var rafFrames = 0; + var fps = 0; + function rafLoop(ts) { + rafFrames++; + requestAnimationFrame(rafLoop); + } + requestAnimationFrame(rafLoop); + + // ── Timer tracking (patched setTimeout/setInterval) ───────────── + var activeTimers = {}; // id -> {code, interval_ms, count} + var timerTop = {}; // "code|interval" -> count + var nextTimerId = 1; + + function labelFor(fn) { + try { + var s = (fn && fn.name) ? fn.name : 'anonymous'; + // Truncate long inline-function bodies. + if (s.length > 40) s = s.slice(0, 40); + return s; + } catch (e) { return 'anonymous'; } + } + + // origSetTimeout / origSetInterval / origClearTimeout / + // origClearInterval were captured at the top of the IIFE (before the + // heartbeat was installed), so they reference the native timers. + + window.setTimeout = function (fn, delay) { + var id = nextTimerId++; + var label = labelFor(fn); + activeTimers[id] = { code: label, interval_ms: 0, count: 0 }; + var key = label + '|0'; + timerTop[key] = (timerTop[key] || 0) + 1; + var args = Array.prototype.slice.call(arguments, 2); + var wrapped = function () { + delete activeTimers[id]; + return fn.apply(this, args); + }; + return origSetTimeout.call(this, wrapped, delay); + }; + + window.setInterval = function (fn, delay) { + var id = nextTimerId++; + var label = labelFor(fn); + var interval = delay | 0; + activeTimers[id] = { code: label, interval_ms: interval, count: 0 }; + var key = label + '|' + interval; + timerTop[key] = (timerTop[key] || 0) + 1; + var args = Array.prototype.slice.call(arguments, 2); + var wrapped = function () { + if (activeTimers[id]) activeTimers[id].count++; + return fn.apply(this, args); + }; + return origSetInterval.call(this, wrapped, delay); + }; + + window.clearTimeout = function (id) { + delete activeTimers[id]; + return origClearTimeout.call(this, id); + }; + window.clearInterval = function (id) { + delete activeTimers[id]; + return origClearInterval.call(this, id); + }; + + // ── Event listener count (patched addEventListener) ───────────── + var eventListenerCount = 0; + var origAEL = EventTarget.prototype.addEventListener; + EventTarget.prototype.addEventListener = function (type, fn, opts) { + // Skip our own internal listeners (tagged with __sbInternal). + if (!fn || !fn.__sbInternal) eventListenerCount++; + return origAEL.call(this, type, fn, opts); + }; + + // ── Worker count (patched Worker constructor) ─────────────────── + var workerCount = 0; + var OrigWorker = window.Worker; + if (OrigWorker) { + window.Worker = function (url, opts) { + workerCount++; + return new OrigWorker(url, opts); + }; + window.Worker.prototype = OrigWorker.prototype; + } + + // ── In-flight network (patched fetch + XHR.open) ──────────────── + var netInFlight = 0; + var origFetch = window.fetch; + if (origFetch) { + window.fetch = function () { + netInFlight++; + var p = origFetch.apply(this, arguments); + var dec = function () { if (netInFlight > 0) netInFlight--; }; + p.then(dec, dec); + return p; + }; + } + var origXhrOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (method, url) { + netInFlight++; + var onEnd = function () { + if (netInFlight > 0) netInFlight--; + }; + onEnd.__sbInternal = true; + this.addEventListener('loadend', onEnd); + return origXhrOpen.apply(this, arguments); + }; + + // ── Reporting ─────────────────────────────────────────────────── + function report() { + // Trim long-task timeline to last 30s. + var now = performance.now(); + while (longTasks.length && (now - longTasks[0].t) > 30000) { + longTasks.shift(); + } + + // cpu_busy_percent: sum of long-task durations in the last 1s + // window. If the longtask PerformanceObserver isn't supported + // (WebKitGTK), fall back to the heartbeat-delay measurement. + var busyMs = 0; + for (var i = 0; i < longTasksLastSec.length; i++) { + busyMs += longTasksLastSec[i].duration; + } + // If the heartbeat measured more blockage than the longtask + // observer (e.g. on WebKitGTK where longtask never fires), use + // the heartbeat measurement. + if (heartbeatDelayMs > busyMs) { + busyMs = heartbeatDelayMs; + } + var cpuBusy = busyMs / 10.0; // ms -> percent of 1000ms + if (cpuBusy > 100) cpuBusy = 100; + heartbeatDelayMs = 0; // reset for next window + + // Top timer sources. + var timerTopArr = []; + Object.keys(timerTop).forEach(function (k) { + var parts = k.split('|'); + timerTopArr.push({ + code: parts[0], + interval_ms: parseInt(parts[1], 10) || 0, + count: timerTop[k] + }); + }); + timerTopArr.sort(function (a, b) { return b.count - a.count; }); + timerTopArr = timerTopArr.slice(0, 5); + + // Top network endpoints (last window). + var netTopArr = []; + Object.keys(netEndpoints).forEach(function (u) { + netTopArr.push({ url: u, count: netEndpoints[u] }); + }); + netTopArr.sort(function (a, b) { return b.count - a.count; }); + netTopArr = netTopArr.slice(0, 5); + + // Top long-task sources. + var ltSourcesArr = []; + Object.keys(longTaskSources).forEach(function (k) { + ltSourcesArr.push({ + name: k, + total_ms: Math.round(longTaskSources[k].total_ms), + count: longTaskSources[k].count + }); + }); + ltSourcesArr.sort(function (a, b) { return b.total_ms - a.total_ms; }); + ltSourcesArr = ltSourcesArr.slice(0, 8); + + // Heap. + var heapMb = -1; + if (performance.memory && performance.memory.usedJSHeapSize) { + heapMb = performance.memory.usedJSHeapSize / (1024 * 1024); + } + + // DOM node count. + var domNodes = -1; + try { + domNodes = document.getElementsByTagName('*').length; + } catch (e) { /* document not ready */ } + + var payload = { + tab_index: TAB_INDEX, + cpu_busy_percent: Math.round(cpuBusy * 10) / 10, + fps: fps, + timer_count: Object.keys(activeTimers).length, + timer_top: timerTopArr, + net_in_flight: netInFlight, + net_requests_last_sec: netRequestsLastSec.length, + net_top_endpoints: netTopArr, + heap_used_mb: heapMb >= 0 ? Math.round(heapMb * 10) / 10 : -1, + event_listener_count: eventListenerCount, + worker_count: workerCount, + dom_node_count: domNodes, + long_tasks_last_sec: longTasksLastSec.map(function (r) { + return { + duration: Math.round(r.duration), + name: r.name, + attribution: r.attribution + }; + }), + long_task_top_sources: ltSourcesArr, + long_task_timeline: longTasks.map(function (r) { + return { t: Math.round(r.t), duration: Math.round(r.duration), name: r.name }; + }) + }; + + // Reset per-window accumulators. + longTasksLastSec = []; + netRequestsLastSec = []; + netEndpoints = {}; + timerTop = {}; + + // Send. Use sync XHR to sovereign:// (same convention as the + // window.nostr shim — WebKit's custom scheme handler doesn't + // reliably support fetch() or async XHR for sovereign:// URLs). + try { + var body = encodeURIComponent(JSON.stringify(payload)); + var url = 'sovereign://processes/probe-report?tab_index=' + TAB_INDEX + + '&body=' + body; + var x = new XMLHttpRequest(); + x.open('GET', url, false); // synchronous + x.send(); + } catch (e) { /* swallow — probe must never break the page */ } + } + + // FPS counter: count frames in each 1s window. + setInterval(function () { + fps = rafFrames; + rafFrames = 0; + }, 1000); + + // Report every 1s. Use the original setInterval so we don't track + // ourselves in activeTimers. + origSetInterval.call(window, report, 1000); +})(); diff --git a/www/js/post-composer.js b/www/js/post-composer.js new file mode 100644 index 0000000..8ca3917 --- /dev/null +++ b/www/js/post-composer.js @@ -0,0 +1,900 @@ +/* Inlined stubs for sovereign_browser — WebKit's custom sovereign:// + * scheme does not resolve relative ES module imports, so we inline the + * dependencies instead of importing from separate .mjs files. These + * stubs are sufficient for the chat composer (no uploads, no preview). */ +function uploadToAllServers() { + return Promise.reject(new Error('Blossom uploads are not available in sovereign_browser')); +} +function getBlobUrl(sha256, ext) { + return `blossom://${sha256}.${ext}`; +} +function htmlFormatText(text = '') { + const div = document.createElement('div'); + div.textContent = String(text || ''); + return div.innerHTML; +} +function hydrateNostrEntities() { + /* No-op in sovereign_browser. */ +} + +function debounce(fn, delay = 250) { + let timer = null; + return (...args) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; +} + +function getFileExtension(file) { + const fromName = String(file?.name || '').split('.').pop(); + if (fromName && fromName !== file?.name) return fromName.toLowerCase(); + const fromType = String(file?.type || '').split('/').pop(); + return fromType ? fromType.toLowerCase() : ''; +} + +function getCaretCharacterOffsetWithin(element) { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return 0; + + const range = selection.getRangeAt(0); + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(element); + preCaretRange.setEnd(range.endContainer, range.endOffset); + return preCaretRange.toString().length; +} + +function setCaretByCharacterOffset(element, offset) { + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + let currentOffset = 0; + let found = false; + + const walk = (node) => { + if (found) return; + if (node.nodeType === Node.TEXT_NODE) { + const textLength = node.textContent?.length || 0; + if (currentOffset + textLength >= offset) { + range.setStart(node, Math.max(0, offset - currentOffset)); + range.collapse(true); + found = true; + } else { + currentOffset += textLength; + } + return; + } + + for (const child of node.childNodes) { + walk(child); + if (found) return; + } + }; + + walk(element); + + if (!found) { + range.selectNodeContents(element); + range.collapse(false); + } + + selection.removeAllRanges(); + selection.addRange(range); +} + +function detectMentionQuery(text, caretOffset) { + const left = text.slice(0, caretOffset); + const match = left.match(/(^|\s)@([a-z0-9_.-]{1,32})$/i); + if (!match) return null; + + const query = match[2] || ''; + const start = left.length - query.length - 1; + const end = caretOffset; + return { query, start, end }; +} + +function escapeHtml(text = '') { + return String(text) + .replace(/&/g, '\u0026amp;') + .replace(//g, '\u0026gt;') + .replace(/"/g, '\u0026quot;') + .replace(/'/g, '\u0026#39;'); +} + +function toNpub(pubkeyOrNpub) { + if (!pubkeyOrNpub) return null; + if (String(pubkeyOrNpub).startsWith('npub1')) return String(pubkeyOrNpub); + try { + return window?.NostrTools?.nip19?.npubEncode?.(pubkeyOrNpub) || null; + } catch { + return null; + } +} + +function positionDropdownNearCaret(hostEl, dropdownEl) { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + + const range = selection.getRangeAt(0).cloneRange(); + range.collapse(true); + + let rect = range.getBoundingClientRect(); + if (!rect || (!rect.width && !rect.height)) { + const hostRect = hostEl.getBoundingClientRect(); + rect = { + left: hostRect.left + 8, + top: hostRect.top + hostRect.height - 8, + bottom: hostRect.top + hostRect.height - 8 + }; + } + + dropdownEl.style.left = `${Math.max(8, rect.left)}px`; + dropdownEl.style.top = `${Math.max(8, rect.bottom + 6)}px`; +} + +function filterMentionCandidates(followedProfiles, query) { + const q = String(query || '').toLowerCase().trim(); + if (!q) return []; + + return followedProfiles + .filter((p) => { + const name = String(p?.name || '').toLowerCase(); + const display = String(p?.display_name || '').toLowerCase(); + const nip05 = String(p?.nip05 || '').toLowerCase(); + const shortPk = String(p?.pubkey || '').slice(0, 12).toLowerCase(); + return name.includes(q) || display.includes(q) || nip05.includes(q) || shortPk.includes(q); + }) + .slice(0, 8); +} + +function createUploadIcon(kind = 'upload') { + const normalizedKind = kind === 'image' ? 'image' : 'upload'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'post-composer-upload-icon'; + if (normalizedKind === 'image') { + btn.classList.add('is-image-icon'); + } + + if (normalizedKind === 'image') { + btn.title = 'Attach image'; + btn.setAttribute('aria-label', 'Attach image'); + btn.innerHTML = ` + + `; + return btn; + } + + btn.title = 'Upload file'; + btn.setAttribute('aria-label', 'Upload file'); + btn.innerHTML = ` + + `; + return btn; +} + +window.mountComposer = function mountComposer(hostEl, options = {}) { + if (!hostEl) throw new Error('mountComposer requires a host element'); + + let followedProfiles = Array.isArray(options.followedProfiles) ? options.followedProfiles.slice() : []; + const showUploadIcon = options.showUploadIcon !== false; + const showPreview = options.showPreview !== false; + const autoHideOnSubmit = options.autoHideOnSubmit === true; + const submitOnEnter = options.submitOnEnter === true; + const hideOnEscape = options.hideOnEscape === true; + const alwaysShowSendButton = options.alwaysShowSendButton === true; + const clearBeforeSubmit = options.clearBeforeSubmit !== false; + const enableHistory = options.enableHistory !== false; + const historyKey = typeof options.historyKey === 'string' && options.historyKey.trim() + ? options.historyKey.trim() + : 'post_composer_history_v1'; + const historyMaxEntries = Math.max(1, Math.floor(Number(options.historyMaxEntries || 50))); + const layout = options.layout === 'inline' ? 'inline' : 'default'; + const popupMode = options.popupMode === true; + const onSubmit = typeof options.onSubmit === 'function' ? options.onSubmit : null; + const onFileAttach = typeof options.onFileAttach === 'function' ? options.onFileAttach : null; + const uploadMode = options.uploadMode === 'direct-image' ? 'direct-image' : 'blossom-url'; + const uploadIcon = options.uploadIcon === 'image' ? 'image' : (uploadMode === 'direct-image' ? 'image' : 'upload'); + const defaultFileAccept = uploadMode === 'direct-image' + ? 'image/*' + : 'image/*,video/*,audio/*,.pdf,.txt,.md,.zip'; + const fileAccept = typeof options.fileAccept === 'string' && options.fileAccept.trim() + ? options.fileAccept.trim() + : defaultFileAccept; + + hostEl.setAttribute('contenteditable', 'true'); + + let wrapper = hostEl.parentElement; + if (!wrapper || !wrapper.classList.contains('post-composer-wrapper')) { + wrapper = document.createElement('div'); + wrapper.className = 'post-composer-wrapper'; + hostEl.parentNode?.insertBefore(wrapper, hostEl); + wrapper.appendChild(hostEl); + } + + const editorShell = document.createElement('div'); + editorShell.className = 'post-composer-editor-shell'; + wrapper.insertBefore(editorShell, hostEl); + editorShell.appendChild(hostEl); + + const dragOverlay = document.createElement('div'); + dragOverlay.className = 'post-composer-drag-overlay'; + dragOverlay.textContent = 'Drop files to upload'; + editorShell.appendChild(dragOverlay); + + const uploadStatus = document.createElement('div'); + uploadStatus.className = 'post-composer-uploading'; + uploadStatus.style.display = 'none'; + editorShell.appendChild(uploadStatus); + + let uploadButton = null; + if (showUploadIcon) { + uploadButton = createUploadIcon(uploadIcon); + editorShell.appendChild(uploadButton); + } + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.multiple = true; + fileInput.accept = fileAccept; + fileInput.style.display = 'none'; + wrapper.appendChild(fileInput); + + const previewEl = document.createElement('div'); + previewEl.className = 'post-composer-preview'; + previewEl.innerHTML = ` +
Preview
+
+ `; + const previewContentEl = previewEl.querySelector('.post-composer-preview-content'); + + const sendButton = document.createElement('button'); + sendButton.type = 'button'; + sendButton.className = 'post-composer-send-btn'; + if (alwaysShowSendButton) sendButton.classList.add('is-persistent-visible'); + sendButton.textContent = 'Send'; + sendButton.disabled = true; + + /* Stop-mode state: when active, the send button transforms into a + * stop button (different label, click handler, and styling). This is + * used by the agent chat to let the user cancel an in-progress agent + * run without needing a separate stop button. */ + let stopModeActive = false; + let stopModeHandler = null; + const sendLabel = 'Send'; + const stopLabel = 'Stop'; + + if (layout === 'inline') { + wrapper.classList.add('post-composer-wrapper--inline'); + if (showPreview) wrapper.appendChild(previewEl); + const inlineRow = document.createElement('div'); + inlineRow.className = 'post-composer-inline-row'; + wrapper.appendChild(inlineRow); + inlineRow.appendChild(editorShell); + inlineRow.appendChild(sendButton); + } else if (popupMode) { + wrapper.classList.add('post-composer-wrapper--popup'); + const scrollBody = document.createElement('div'); + scrollBody.className = 'post-composer-popup-body'; + scrollBody.appendChild(editorShell); + if (showPreview) scrollBody.appendChild(previewEl); + wrapper.appendChild(scrollBody); + const stickyFooter = document.createElement('div'); + stickyFooter.className = 'post-composer-popup-footer'; + stickyFooter.appendChild(sendButton); + wrapper.appendChild(stickyFooter); + } else { + if (showPreview) wrapper.appendChild(previewEl); + wrapper.appendChild(sendButton); + } + + const mentionDropdown = document.createElement('div'); + mentionDropdown.className = 'post-composer-mention-dropdown'; + document.body.appendChild(mentionDropdown); + + let dragDepth = 0; + let uploadingCount = 0; + let externallyDisabled = options.disabled === true; + let pendingAttachments = []; + let mentionState = { + query: '', + start: -1, + end: -1, + candidates: [], + activeIndex: 0, + visible: false + }; + + function loadHistory() { + if (!enableHistory) return []; + try { + const raw = localStorage.getItem(historyKey); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) + ? parsed.map((entry) => String(entry || '').trim()).filter(Boolean) + : []; + } catch { + return []; + } + } + + function saveHistory(nextHistory) { + if (!enableHistory) return; + try { + localStorage.setItem(historyKey, JSON.stringify(nextHistory)); + } catch { + // Ignore localStorage failures. + } + } + + let history = loadHistory(); + let historyIndex = -1; + let historyDraft = ''; + + function pushToHistory(text) { + if (!enableHistory) return; + const normalized = String(text || '').trim(); + if (!normalized) return; + + history = history.filter((item) => item !== normalized); + history.unshift(normalized); + if (history.length > historyMaxEntries) { + history = history.slice(0, historyMaxEntries); + } + saveHistory(history); + historyIndex = -1; + historyDraft = ''; + } + + function resetHistoryBrowseState() { + historyIndex = -1; + historyDraft = ''; + } + + function setEditorText(text = '') { + hostEl.innerText = String(text || ''); + setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length); + renderPreview(); + updateSendButtonVisibility(); + } + + function updateUploadingUi() { + if (uploadingCount > 0) { + uploadStatus.style.display = 'block'; + uploadStatus.textContent = `Uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}…`; + return; + } + + if (pendingAttachments.length > 0) { + uploadStatus.style.display = 'block'; + uploadStatus.textContent = `${pendingAttachments.length} image${pendingAttachments.length > 1 ? 's' : ''} attached`; + return; + } + + uploadStatus.style.display = 'none'; + uploadStatus.textContent = ''; + } + + function hideMentionDropdown() { + mentionState.visible = false; + mentionState.candidates = []; + mentionState.activeIndex = 0; + mentionDropdown.classList.remove('is-visible'); + mentionDropdown.innerHTML = ''; + } + + function renderMentionDropdown() { + if (!mentionState.visible || mentionState.candidates.length === 0) { + hideMentionDropdown(); + return; + } + + mentionDropdown.innerHTML = mentionState.candidates + .map((profile, idx) => { + const display = profile.display_name || profile.name || `${String(profile.pubkey || '').slice(0, 8)}…`; + const handle = profile.name ? `@${profile.name}` : `${String(profile.pubkey || '').slice(0, 12)}…`; + const avatar = profile.picture + ? `` + : `
`; + + return ` +
+ ${avatar} +
+
${escapeHtml(display)}
+
${escapeHtml(handle)}
+
+
+ `; + }) + .join(''); + + mentionDropdown.classList.add('is-visible'); + positionDropdownNearCaret(hostEl, mentionDropdown); + } + + function replaceMentionWithProfile(profile) { + const npub = toNpub(profile?.npub || profile?.pubkey); + if (!npub) return; + + const currentText = hostEl.innerText || ''; + if (mentionState.start < 0 || mentionState.end < mentionState.start) return; + + const replacement = `nostr:${npub}`; + const nextText = currentText.slice(0, mentionState.start) + replacement + currentText.slice(mentionState.end); + hostEl.innerText = nextText; + const nextCaret = mentionState.start + replacement.length; + setCaretByCharacterOffset(hostEl, nextCaret); + + hideMentionDropdown(); + queuePreviewRender(); + updateSendButtonVisibility(); + } + + function updateMentionStateFromCaret() { + const text = hostEl.innerText || ''; + const caret = getCaretCharacterOffsetWithin(hostEl); + const mention = detectMentionQuery(text, caret); + + if (!mention) { + hideMentionDropdown(); + return; + } + + const candidates = filterMentionCandidates(followedProfiles, mention.query); + if (candidates.length === 0) { + hideMentionDropdown(); + return; + } + + mentionState = { + ...mention, + candidates, + activeIndex: 0, + visible: true + }; + + renderMentionDropdown(); + } + + function normalizePendingAttachments(attachments) { + if (!Array.isArray(attachments)) return []; + return attachments + .map((att) => { + const dataUrl = String(att?.dataUrl || '').trim(); + if (!dataUrl.startsWith('data:image/')) return null; + return { + id: String(att?.id || crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`), + name: String(att?.name || 'image').trim() || 'image', + mimeType: String(att?.mimeType || '').trim() || dataUrl.slice(5, dataUrl.indexOf(';') > 0 ? dataUrl.indexOf(';') : undefined), + dataUrl + }; + }) + .filter(Boolean); + } + + async function insertUploadedFiles(files) { + const arr = Array.from(files || []).filter((f) => f && f.size > 0); + if (arr.length === 0) return; + + uploadingCount += arr.length; + updateUploadingUi(); + + try { + if (uploadMode === 'direct-image') { + if (!onFileAttach) { + console.warn('[post-composer] uploadMode "direct-image" is enabled but onFileAttach is missing.'); + return; + } + const attached = await onFileAttach(arr); + const normalized = normalizePendingAttachments(attached); + if (normalized.length > 0) { + pendingAttachments = [...pendingAttachments, ...normalized]; + } + updateSendButtonVisibility(); + return; + } + + for (const file of arr) { + const { sha256 } = await uploadToAllServers(file); + const ext = getFileExtension(file); + const url = getBlobUrl(sha256, ext); + + hostEl.focus(); + const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : ''; + document.execCommand('insertText', false, `${prefix}${url}\n`); + } + } finally { + uploadingCount = Math.max(0, uploadingCount - arr.length); + updateUploadingUi(); + queuePreviewRender(); + } + } + + function updateSendButtonVisibility() { + const hasContent = !!(hostEl.innerText || '').trim(); + const hasAttachments = pendingAttachments.length > 0; + const canSubmit = hasContent || hasAttachments; + const interactive = canSubmit && uploadingCount === 0 && !externallyDisabled; + sendButton.classList.toggle('is-visible', canSubmit); + if (alwaysShowSendButton) sendButton.classList.add('is-visible'); + sendButton.disabled = !interactive; + sendButton.tabIndex = (canSubmit || alwaysShowSendButton) ? 0 : -1; + } + + function clearComposerContent() { + hostEl.innerHTML = ''; + hostEl.innerText = ''; + pendingAttachments = []; + renderPreview(); + updateUploadingUi(); + updateSendButtonVisibility(); + hideMentionDropdown(); + resetHistoryBrowseState(); + } + + function hideComposer() { + wrapper.style.display = 'none'; + } + + function showComposer() { + wrapper.style.display = ''; + } + + function renderPreview() { + if (!showPreview || !previewContentEl) return; + + const raw = hostEl.innerText || ''; + if (!raw.trim()) { + previewEl.classList.remove('is-visible'); + previewContentEl.innerHTML = ''; + return; + } + + previewEl.classList.add('is-visible'); + previewContentEl.innerHTML = htmlFormatText(raw); + hydrateNostrEntities(previewContentEl); + } + + const queuePreviewRender = debounce(renderPreview, 250); + + function onInput() { + queuePreviewRender(); + updateMentionStateFromCaret(); + updateSendButtonVisibility(); + if (historyIndex !== -1) { + resetHistoryBrowseState(); + } + } + + function onKeydown(event) { + if (event.key === 'Tab' && !event.shiftKey) { + const hasContent = !!(hostEl.innerText || '').trim(); + if (hasContent && !mentionState.visible) { + event.preventDefault(); + sendButton.focus(); + return; + } + } + + const isEnterKey = event.key === 'Enter' || event.code === 'Enter' || event.code === 'NumpadEnter'; + if (isEnterKey && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && !mentionState.visible) { + event.preventDefault(); + event.stopPropagation(); + onSendClick(); + return; + } + + if (submitOnEnter && event.key === 'Enter' && !event.shiftKey && !mentionState.visible) { + event.preventDefault(); + onSendClick(); + return; + } + + if (event.key === 'Escape') { + if (mentionState.visible) { + event.preventDefault(); + hideMentionDropdown(); + return; + } + + if (hideOnEscape) { + event.preventDefault(); + hideComposer(); + return; + } + } + + if (!mentionState.visible && enableHistory) { + const currentText = hostEl.innerText || ''; + const caretOffset = getCaretCharacterOffsetWithin(hostEl); + const singleLine = !currentText.includes('\n'); + + if (event.key === 'ArrowUp') { + const atStart = caretOffset <= 0; + if (atStart || singleLine) { + event.preventDefault(); + if (historyIndex === -1) { + historyDraft = currentText; + } + if (historyIndex < history.length - 1) { + historyIndex += 1; + setEditorText(history[historyIndex] || ''); + } + return; + } + } + + if (event.key === 'ArrowDown') { + const atEnd = caretOffset >= currentText.length; + if ((atEnd || singleLine) && historyIndex >= 0) { + event.preventDefault(); + historyIndex -= 1; + if (historyIndex < 0) { + setEditorText(historyDraft || ''); + } else { + setEditorText(history[historyIndex] || ''); + } + return; + } + } + } + + if (!mentionState.visible) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + mentionState.activeIndex = (mentionState.activeIndex + 1) % mentionState.candidates.length; + renderMentionDropdown(); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + mentionState.activeIndex = (mentionState.activeIndex - 1 + mentionState.candidates.length) % mentionState.candidates.length; + renderMentionDropdown(); + return; + } + + if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault(); + const selected = mentionState.candidates[mentionState.activeIndex]; + if (selected) replaceMentionWithProfile(selected); + return; + } + } + + function onSelectionChange() { + if (!mentionState.visible) return; + updateMentionStateFromCaret(); + } + + function onDocumentClick(event) { + if (wrapper.contains(event.target) || mentionDropdown.contains(event.target)) return; + hideMentionDropdown(); + } + + function onDragEnter(event) { + if (!event.dataTransfer?.types?.includes('Files')) return; + event.preventDefault(); + dragDepth += 1; + editorShell.classList.add('is-dragover'); + } + + function onDragOver(event) { + if (!event.dataTransfer?.types?.includes('Files')) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + } + + function onDragLeave(event) { + if (!event.dataTransfer?.types?.includes('Files')) return; + event.preventDefault(); + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) editorShell.classList.remove('is-dragover'); + } + + async function onDrop(event) { + if (!event.dataTransfer?.files?.length) return; + event.preventDefault(); + dragDepth = 0; + editorShell.classList.remove('is-dragover'); + await insertUploadedFiles(event.dataTransfer.files); + } + + async function onFileInputChange(event) { + const files = event.target?.files; + if (!files || files.length === 0) return; + await insertUploadedFiles(files); + fileInput.value = ''; + } + + async function onPaste(event) { + const clipboardItems = Array.from(event.clipboardData?.items || []); + const imageFiles = clipboardItems + .filter((item) => item.kind === 'file' && String(item.type || '').startsWith('image/')) + .map((item) => item.getAsFile()) + .filter(Boolean); + + if (imageFiles.length === 0) return; + + event.preventDefault(); + await insertUploadedFiles(imageFiles); + } + + function onMentionDropdownMouseDown(event) { + const item = event.target.closest('.post-composer-mention-item'); + if (!item) return; + event.preventDefault(); + const index = Number(item.dataset.index); + if (Number.isNaN(index)) return; + const selected = mentionState.candidates[index]; + if (selected) replaceMentionWithProfile(selected); + } + + async function onSendClick() { + /* In stop mode, the button acts as Stop — route to the stop handler. */ + if (stopModeActive) { + if (stopModeHandler) stopModeHandler(); + return; + } + if (sendButton.disabled) return; + if (!onSubmit) return; + + const text = (hostEl.innerText || '').trim(); + const attachments = pendingAttachments.slice(); + if (!text && attachments.length === 0) return; + + const previousText = text; + const previousAttachments = attachments; + + if (clearBeforeSubmit) { + clearComposerContent(); + if (!autoHideOnSubmit) { + hostEl.focus(); + setCaretByCharacterOffset(hostEl, 0); + } + } + + try { + const submitted = await onSubmit(previousText, { attachments: previousAttachments }); + if (submitted === false) { + if (clearBeforeSubmit) { + setEditorText(previousText); + pendingAttachments = previousAttachments; + updateUploadingUi(); + updateSendButtonVisibility(); + } + return; + } + + if (previousText) { + pushToHistory(previousText); + } + + if (autoHideOnSubmit) { + if (!clearBeforeSubmit) { + clearComposerContent(); + } + hideComposer(); + } else if (!clearBeforeSubmit) { + pendingAttachments = []; + queuePreviewRender(); + updateUploadingUi(); + updateSendButtonVisibility(); + hostEl.focus(); + setCaretByCharacterOffset(hostEl, 0); + } + } catch (error) { + if (clearBeforeSubmit) { + setEditorText(previousText); + pendingAttachments = previousAttachments; + updateUploadingUi(); + updateSendButtonVisibility(); + hostEl.focus(); + setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length); + } + console.error('[post-composer] Submit failed:', error); + } + } + + hostEl.addEventListener('input', onInput); + hostEl.addEventListener('keydown', onKeydown); + hostEl.addEventListener('dragenter', onDragEnter); + hostEl.addEventListener('dragover', onDragOver); + hostEl.addEventListener('dragleave', onDragLeave); + hostEl.addEventListener('drop', onDrop); + document.addEventListener('selectionchange', onSelectionChange); + document.addEventListener('mousedown', onDocumentClick); + hostEl.addEventListener('paste', onPaste); + + if (uploadButton) { + uploadButton.addEventListener('click', () => fileInput.click()); + } + fileInput.addEventListener('change', onFileInputChange); + mentionDropdown.addEventListener('mousedown', onMentionDropdownMouseDown); + sendButton.addEventListener('click', onSendClick); + + queuePreviewRender(); + updateSendButtonVisibility(); + + return { + refresh(nextOptions = {}) { + if (Array.isArray(nextOptions.followedProfiles)) { + followedProfiles = nextOptions.followedProfiles.slice(); + } + queuePreviewRender(); + }, + clear() { + clearComposerContent(); + }, + setDisabled(disabled) { + externallyDisabled = !!disabled; + updateSendButtonVisibility(); + }, + /* Convert the send button into a stop button. The provided handler + * is called when the user clicks the (now stop) button. The text + * area is NOT disabled — the user can type their next message while + * the agent runs, but the button acts as Stop until clearStopMode() + * is called. */ + setStopMode(handler) { + stopModeActive = true; + stopModeHandler = typeof handler === 'function' ? handler : null; + sendButton.textContent = stopLabel; + sendButton.classList.add('is-stop-mode'); + sendButton.disabled = false; + sendButton.classList.add('is-visible'); + }, + /* Revert the stop button back to a send button. */ + clearStopMode() { + stopModeActive = false; + stopModeHandler = null; + sendButton.textContent = sendLabel; + sendButton.classList.remove('is-stop-mode'); + updateSendButtonVisibility(); + }, + show() { + showComposer(); + }, + hide() { + hideComposer(); + }, + isUploading() { + return uploadingCount > 0; + }, + destroy() { + hostEl.removeEventListener('input', onInput); + hostEl.removeEventListener('keydown', onKeydown); + hostEl.removeEventListener('dragenter', onDragEnter); + hostEl.removeEventListener('dragover', onDragOver); + hostEl.removeEventListener('dragleave', onDragLeave); + hostEl.removeEventListener('drop', onDrop); + document.removeEventListener('selectionchange', onSelectionChange); + document.removeEventListener('mousedown', onDocumentClick); + hostEl.removeEventListener('paste', onPaste); + fileInput.removeEventListener('change', onFileInputChange); + mentionDropdown.removeEventListener('mousedown', onMentionDropdownMouseDown); + sendButton.removeEventListener('click', onSendClick); + + if (mentionDropdown.parentNode) mentionDropdown.parentNode.removeChild(mentionDropdown); + if (uploadButton?.parentNode) uploadButton.parentNode.removeChild(uploadButton); + if (dragOverlay.parentNode) dragOverlay.parentNode.removeChild(dragOverlay); + if (uploadStatus.parentNode) uploadStatus.parentNode.removeChild(uploadStatus); + if (previewEl.parentNode) previewEl.parentNode.removeChild(previewEl); + if (sendButton.parentNode) sendButton.parentNode.removeChild(sendButton); + if (editorShell.parentNode) editorShell.parentNode.removeChild(editorShell); + if (fileInput.parentNode) fileInput.parentNode.removeChild(fileInput); + } + }; +} + +window.mountComposerDefault = window.mountComposer; diff --git a/www/js/post-composer.mjs b/www/js/post-composer.mjs new file mode 100644 index 0000000..ece2498 --- /dev/null +++ b/www/js/post-composer.mjs @@ -0,0 +1,894 @@ +/* Inlined stubs for sovereign_browser — WebKit's custom sovereign:// + * scheme does not resolve relative ES module imports, so we inline the + * dependencies instead of importing from separate .mjs files. These + * stubs are sufficient for the chat composer (no uploads, no preview). */ +function uploadToAllServers() { + return Promise.reject(new Error('Blossom uploads are not available in sovereign_browser')); +} +function getBlobUrl(sha256, ext) { + return `blossom://${sha256}.${ext}`; +} +function htmlFormatText(text = '') { + const div = document.createElement('div'); + div.textContent = String(text || ''); + return div.innerHTML; +} +function hydrateNostrEntities() { + /* No-op in sovereign_browser. */ +} + +function debounce(fn, delay = 250) { + let timer = null; + return (...args) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; +} + +function getFileExtension(file) { + const fromName = String(file?.name || '').split('.').pop(); + if (fromName && fromName !== file?.name) return fromName.toLowerCase(); + const fromType = String(file?.type || '').split('/').pop(); + return fromType ? fromType.toLowerCase() : ''; +} + +function getCaretCharacterOffsetWithin(element) { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return 0; + + const range = selection.getRangeAt(0); + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(element); + preCaretRange.setEnd(range.endContainer, range.endOffset); + return preCaretRange.toString().length; +} + +function setCaretByCharacterOffset(element, offset) { + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + let currentOffset = 0; + let found = false; + + const walk = (node) => { + if (found) return; + if (node.nodeType === Node.TEXT_NODE) { + const textLength = node.textContent?.length || 0; + if (currentOffset + textLength >= offset) { + range.setStart(node, Math.max(0, offset - currentOffset)); + range.collapse(true); + found = true; + } else { + currentOffset += textLength; + } + return; + } + + for (const child of node.childNodes) { + walk(child); + if (found) return; + } + }; + + walk(element); + + if (!found) { + range.selectNodeContents(element); + range.collapse(false); + } + + selection.removeAllRanges(); + selection.addRange(range); +} + +function detectMentionQuery(text, caretOffset) { + const left = text.slice(0, caretOffset); + const match = left.match(/(^|\s)@([a-z0-9_.-]{1,32})$/i); + if (!match) return null; + + const query = match[2] || ''; + const start = left.length - query.length - 1; + const end = caretOffset; + return { query, start, end }; +} + +function escapeHtml(text = '') { + return String(text) + .replace(/&/g, '\u0026amp;') + .replace(//g, '\u0026gt;') + .replace(/"/g, '\u0026quot;') + .replace(/'/g, '\u0026#39;'); +} + +function toNpub(pubkeyOrNpub) { + if (!pubkeyOrNpub) return null; + if (String(pubkeyOrNpub).startsWith('npub1')) return String(pubkeyOrNpub); + try { + return window?.NostrTools?.nip19?.npubEncode?.(pubkeyOrNpub) || null; + } catch { + return null; + } +} + +function positionDropdownNearCaret(hostEl, dropdownEl) { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + + const range = selection.getRangeAt(0).cloneRange(); + range.collapse(true); + + let rect = range.getBoundingClientRect(); + if (!rect || (!rect.width && !rect.height)) { + const hostRect = hostEl.getBoundingClientRect(); + rect = { + left: hostRect.left + 8, + top: hostRect.top + hostRect.height - 8, + bottom: hostRect.top + hostRect.height - 8 + }; + } + + dropdownEl.style.left = `${Math.max(8, rect.left)}px`; + dropdownEl.style.top = `${Math.max(8, rect.bottom + 6)}px`; +} + +function filterMentionCandidates(followedProfiles, query) { + const q = String(query || '').toLowerCase().trim(); + if (!q) return []; + + return followedProfiles + .filter((p) => { + const name = String(p?.name || '').toLowerCase(); + const display = String(p?.display_name || '').toLowerCase(); + const nip05 = String(p?.nip05 || '').toLowerCase(); + const shortPk = String(p?.pubkey || '').slice(0, 12).toLowerCase(); + return name.includes(q) || display.includes(q) || nip05.includes(q) || shortPk.includes(q); + }) + .slice(0, 8); +} + +function createUploadIcon(kind = 'upload') { + const normalizedKind = kind === 'image' ? 'image' : 'upload'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'post-composer-upload-icon'; + if (normalizedKind === 'image') { + btn.classList.add('is-image-icon'); + } + + if (normalizedKind === 'image') { + btn.title = 'Attach image'; + btn.setAttribute('aria-label', 'Attach image'); + btn.innerHTML = ` + + `; + return btn; + } + + btn.title = 'Upload file'; + btn.setAttribute('aria-label', 'Upload file'); + btn.innerHTML = ` + + `; + return btn; +} + +export function mountComposer(hostEl, options = {}) { + if (!hostEl) throw new Error('mountComposer requires a host element'); + + let followedProfiles = Array.isArray(options.followedProfiles) ? options.followedProfiles.slice() : []; + const showUploadIcon = options.showUploadIcon !== false; + const showPreview = options.showPreview !== false; + const autoHideOnSubmit = options.autoHideOnSubmit === true; + const submitOnEnter = options.submitOnEnter === true; + const hideOnEscape = options.hideOnEscape === true; + const alwaysShowSendButton = options.alwaysShowSendButton === true; + const clearBeforeSubmit = options.clearBeforeSubmit !== false; + const enableHistory = options.enableHistory !== false; + const historyKey = typeof options.historyKey === 'string' && options.historyKey.trim() + ? options.historyKey.trim() + : 'post_composer_history_v1'; + const historyMaxEntries = Math.max(1, Math.floor(Number(options.historyMaxEntries || 50))); + const layout = options.layout === 'inline' ? 'inline' : 'default'; + const popupMode = options.popupMode === true; + const onSubmit = typeof options.onSubmit === 'function' ? options.onSubmit : null; + const onFileAttach = typeof options.onFileAttach === 'function' ? options.onFileAttach : null; + const uploadMode = options.uploadMode === 'direct-image' ? 'direct-image' : 'blossom-url'; + const uploadIcon = options.uploadIcon === 'image' ? 'image' : (uploadMode === 'direct-image' ? 'image' : 'upload'); + const defaultFileAccept = uploadMode === 'direct-image' + ? 'image/*' + : 'image/*,video/*,audio/*,.pdf,.txt,.md,.zip'; + const fileAccept = typeof options.fileAccept === 'string' && options.fileAccept.trim() + ? options.fileAccept.trim() + : defaultFileAccept; + + hostEl.setAttribute('contenteditable', 'true'); + + let wrapper = hostEl.parentElement; + if (!wrapper || !wrapper.classList.contains('post-composer-wrapper')) { + wrapper = document.createElement('div'); + wrapper.className = 'post-composer-wrapper'; + hostEl.parentNode?.insertBefore(wrapper, hostEl); + wrapper.appendChild(hostEl); + } + + const editorShell = document.createElement('div'); + editorShell.className = 'post-composer-editor-shell'; + wrapper.insertBefore(editorShell, hostEl); + editorShell.appendChild(hostEl); + + const dragOverlay = document.createElement('div'); + dragOverlay.className = 'post-composer-drag-overlay'; + dragOverlay.textContent = 'Drop files to upload'; + editorShell.appendChild(dragOverlay); + + const uploadStatus = document.createElement('div'); + uploadStatus.className = 'post-composer-uploading'; + uploadStatus.style.display = 'none'; + editorShell.appendChild(uploadStatus); + + let uploadButton = null; + if (showUploadIcon) { + uploadButton = createUploadIcon(uploadIcon); + editorShell.appendChild(uploadButton); + } + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.multiple = true; + fileInput.accept = fileAccept; + fileInput.style.display = 'none'; + wrapper.appendChild(fileInput); + + const previewEl = document.createElement('div'); + previewEl.className = 'post-composer-preview'; + previewEl.innerHTML = ` +
Preview
+
+ `; + const previewContentEl = previewEl.querySelector('.post-composer-preview-content'); + + const sendButton = document.createElement('button'); + sendButton.type = 'button'; + sendButton.className = 'post-composer-send-btn'; + if (alwaysShowSendButton) sendButton.classList.add('is-persistent-visible'); + sendButton.textContent = 'Send'; + sendButton.disabled = true; + + /* Stop-mode state: when active, the send button transforms into a + * stop button (different label, click handler, and styling). This is + * used by the agent chat to let the user cancel an in-progress agent + * run without needing a separate stop button. */ + let stopModeActive = false; + let stopModeHandler = null; + const sendLabel = 'Send'; + const stopLabel = 'Stop'; + + if (layout === 'inline') { + wrapper.classList.add('post-composer-wrapper--inline'); + if (showPreview) wrapper.appendChild(previewEl); + const inlineRow = document.createElement('div'); + inlineRow.className = 'post-composer-inline-row'; + wrapper.appendChild(inlineRow); + inlineRow.appendChild(editorShell); + inlineRow.appendChild(sendButton); + } else if (popupMode) { + wrapper.classList.add('post-composer-wrapper--popup'); + const scrollBody = document.createElement('div'); + scrollBody.className = 'post-composer-popup-body'; + scrollBody.appendChild(editorShell); + if (showPreview) scrollBody.appendChild(previewEl); + wrapper.appendChild(scrollBody); + const stickyFooter = document.createElement('div'); + stickyFooter.className = 'post-composer-popup-footer'; + stickyFooter.appendChild(sendButton); + wrapper.appendChild(stickyFooter); + } else { + if (showPreview) wrapper.appendChild(previewEl); + wrapper.appendChild(sendButton); + } + + const mentionDropdown = document.createElement('div'); + mentionDropdown.className = 'post-composer-mention-dropdown'; + document.body.appendChild(mentionDropdown); + + let dragDepth = 0; + let uploadingCount = 0; + let externallyDisabled = options.disabled === true; + let pendingAttachments = []; + let mentionState = { + query: '', + start: -1, + end: -1, + candidates: [], + activeIndex: 0, + visible: false + }; + + function loadHistory() { + if (!enableHistory) return []; + try { + const raw = localStorage.getItem(historyKey); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) + ? parsed.map((entry) => String(entry || '').trim()).filter(Boolean) + : []; + } catch { + return []; + } + } + + function saveHistory(nextHistory) { + if (!enableHistory) return; + try { + localStorage.setItem(historyKey, JSON.stringify(nextHistory)); + } catch { + // Ignore localStorage failures. + } + } + + let history = loadHistory(); + let historyIndex = -1; + let historyDraft = ''; + + function pushToHistory(text) { + if (!enableHistory) return; + const normalized = String(text || '').trim(); + if (!normalized) return; + + history = history.filter((item) => item !== normalized); + history.unshift(normalized); + if (history.length > historyMaxEntries) { + history = history.slice(0, historyMaxEntries); + } + saveHistory(history); + historyIndex = -1; + historyDraft = ''; + } + + function resetHistoryBrowseState() { + historyIndex = -1; + historyDraft = ''; + } + + function setEditorText(text = '') { + hostEl.innerText = String(text || ''); + setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length); + renderPreview(); + updateSendButtonVisibility(); + } + + function updateUploadingUi() { + if (uploadingCount > 0) { + uploadStatus.style.display = 'block'; + uploadStatus.textContent = `Uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}…`; + return; + } + + if (pendingAttachments.length > 0) { + uploadStatus.style.display = 'block'; + uploadStatus.textContent = `${pendingAttachments.length} image${pendingAttachments.length > 1 ? 's' : ''} attached`; + return; + } + + uploadStatus.style.display = 'none'; + uploadStatus.textContent = ''; + } + + function hideMentionDropdown() { + mentionState.visible = false; + mentionState.candidates = []; + mentionState.activeIndex = 0; + mentionDropdown.classList.remove('is-visible'); + mentionDropdown.innerHTML = ''; + } + + function renderMentionDropdown() { + if (!mentionState.visible || mentionState.candidates.length === 0) { + hideMentionDropdown(); + return; + } + + mentionDropdown.innerHTML = mentionState.candidates + .map((profile, idx) => { + const display = profile.display_name || profile.name || `${String(profile.pubkey || '').slice(0, 8)}…`; + const handle = profile.name ? `@${profile.name}` : `${String(profile.pubkey || '').slice(0, 12)}…`; + const avatar = profile.picture + ? `` + : `
`; + + return ` +
+ ${avatar} +
+
${escapeHtml(display)}
+
${escapeHtml(handle)}
+
+
+ `; + }) + .join(''); + + mentionDropdown.classList.add('is-visible'); + positionDropdownNearCaret(hostEl, mentionDropdown); + } + + function replaceMentionWithProfile(profile) { + const npub = toNpub(profile?.npub || profile?.pubkey); + if (!npub) return; + + const currentText = hostEl.innerText || ''; + if (mentionState.start < 0 || mentionState.end < mentionState.start) return; + + const replacement = `nostr:${npub}`; + const nextText = currentText.slice(0, mentionState.start) + replacement + currentText.slice(mentionState.end); + hostEl.innerText = nextText; + const nextCaret = mentionState.start + replacement.length; + setCaretByCharacterOffset(hostEl, nextCaret); + + hideMentionDropdown(); + queuePreviewRender(); + updateSendButtonVisibility(); + } + + function updateMentionStateFromCaret() { + const text = hostEl.innerText || ''; + const caret = getCaretCharacterOffsetWithin(hostEl); + const mention = detectMentionQuery(text, caret); + + if (!mention) { + hideMentionDropdown(); + return; + } + + const candidates = filterMentionCandidates(followedProfiles, mention.query); + if (candidates.length === 0) { + hideMentionDropdown(); + return; + } + + mentionState = { + ...mention, + candidates, + activeIndex: 0, + visible: true + }; + + renderMentionDropdown(); + } + + function normalizePendingAttachments(attachments) { + if (!Array.isArray(attachments)) return []; + return attachments + .map((att) => { + const dataUrl = String(att?.dataUrl || '').trim(); + if (!dataUrl.startsWith('data:image/')) return null; + return { + id: String(att?.id || crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`), + name: String(att?.name || 'image').trim() || 'image', + mimeType: String(att?.mimeType || '').trim() || dataUrl.slice(5, dataUrl.indexOf(';') > 0 ? dataUrl.indexOf(';') : undefined), + dataUrl + }; + }) + .filter(Boolean); + } + + async function insertUploadedFiles(files) { + const arr = Array.from(files || []).filter((f) => f && f.size > 0); + if (arr.length === 0) return; + + uploadingCount += arr.length; + updateUploadingUi(); + + try { + if (uploadMode === 'direct-image') { + if (!onFileAttach) { + console.warn('[post-composer] uploadMode "direct-image" is enabled but onFileAttach is missing.'); + return; + } + const attached = await onFileAttach(arr); + const normalized = normalizePendingAttachments(attached); + if (normalized.length > 0) { + pendingAttachments = [...pendingAttachments, ...normalized]; + } + updateSendButtonVisibility(); + return; + } + + for (const file of arr) { + const { sha256 } = await uploadToAllServers(file); + const ext = getFileExtension(file); + const url = getBlobUrl(sha256, ext); + + hostEl.focus(); + const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : ''; + document.execCommand('insertText', false, `${prefix}${url}\n`); + } + } finally { + uploadingCount = Math.max(0, uploadingCount - arr.length); + updateUploadingUi(); + queuePreviewRender(); + } + } + + function updateSendButtonVisibility() { + const hasContent = !!(hostEl.innerText || '').trim(); + const hasAttachments = pendingAttachments.length > 0; + const canSubmit = hasContent || hasAttachments; + const interactive = canSubmit && uploadingCount === 0 && !externallyDisabled; + sendButton.classList.toggle('is-visible', canSubmit); + if (alwaysShowSendButton) sendButton.classList.add('is-visible'); + sendButton.disabled = !interactive; + sendButton.tabIndex = (canSubmit || alwaysShowSendButton) ? 0 : -1; + } + + function clearComposerContent() { + hostEl.innerHTML = ''; + hostEl.innerText = ''; + pendingAttachments = []; + renderPreview(); + updateUploadingUi(); + updateSendButtonVisibility(); + hideMentionDropdown(); + resetHistoryBrowseState(); + } + + function hideComposer() { + wrapper.style.display = 'none'; + } + + function showComposer() { + wrapper.style.display = ''; + } + + function renderPreview() { + if (!showPreview || !previewContentEl) return; + + const raw = hostEl.innerText || ''; + if (!raw.trim()) { + previewEl.classList.remove('is-visible'); + previewContentEl.innerHTML = ''; + return; + } + + previewEl.classList.add('is-visible'); + previewContentEl.innerHTML = htmlFormatText(raw); + hydrateNostrEntities(previewContentEl); + } + + const queuePreviewRender = debounce(renderPreview, 250); + + function onInput() { + queuePreviewRender(); + updateMentionStateFromCaret(); + updateSendButtonVisibility(); + if (historyIndex !== -1) { + resetHistoryBrowseState(); + } + } + + function onKeydown(event) { + if (event.key === 'Tab' && !event.shiftKey) { + const hasContent = !!(hostEl.innerText || '').trim(); + if (hasContent && !mentionState.visible) { + event.preventDefault(); + sendButton.focus(); + return; + } + } + + const isEnterKey = event.key === 'Enter' || event.code === 'Enter' || event.code === 'NumpadEnter'; + if (isEnterKey && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && !mentionState.visible) { + event.preventDefault(); + event.stopPropagation(); + onSendClick(); + return; + } + + if (submitOnEnter && event.key === 'Enter' && !event.shiftKey && !mentionState.visible) { + event.preventDefault(); + onSendClick(); + return; + } + + if (event.key === 'Escape') { + if (mentionState.visible) { + event.preventDefault(); + hideMentionDropdown(); + return; + } + + if (hideOnEscape) { + event.preventDefault(); + hideComposer(); + return; + } + } + + if (!mentionState.visible && enableHistory) { + const currentText = hostEl.innerText || ''; + const caretOffset = getCaretCharacterOffsetWithin(hostEl); + const singleLine = !currentText.includes('\n'); + + if (event.key === 'ArrowUp') { + const atStart = caretOffset <= 0; + if (atStart || singleLine) { + event.preventDefault(); + if (historyIndex === -1) { + historyDraft = currentText; + } + if (historyIndex < history.length - 1) { + historyIndex += 1; + setEditorText(history[historyIndex] || ''); + } + return; + } + } + + if (event.key === 'ArrowDown') { + const atEnd = caretOffset >= currentText.length; + if ((atEnd || singleLine) && historyIndex >= 0) { + event.preventDefault(); + historyIndex -= 1; + if (historyIndex < 0) { + setEditorText(historyDraft || ''); + } else { + setEditorText(history[historyIndex] || ''); + } + return; + } + } + } + + if (!mentionState.visible) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + mentionState.activeIndex = (mentionState.activeIndex + 1) % mentionState.candidates.length; + renderMentionDropdown(); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + mentionState.activeIndex = (mentionState.activeIndex - 1 + mentionState.candidates.length) % mentionState.candidates.length; + renderMentionDropdown(); + return; + } + + if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault(); + const selected = mentionState.candidates[mentionState.activeIndex]; + if (selected) replaceMentionWithProfile(selected); + return; + } + } + + function onSelectionChange() { + if (!mentionState.visible) return; + updateMentionStateFromCaret(); + } + + function onDocumentClick(event) { + if (wrapper.contains(event.target) || mentionDropdown.contains(event.target)) return; + hideMentionDropdown(); + } + + function onDragEnter(event) { + if (!event.dataTransfer?.types?.includes('Files')) return; + event.preventDefault(); + dragDepth += 1; + editorShell.classList.add('is-dragover'); + } + + function onDragOver(event) { + if (!event.dataTransfer?.types?.includes('Files')) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + } + + function onDragLeave(event) { + if (!event.dataTransfer?.types?.includes('Files')) return; + event.preventDefault(); + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) editorShell.classList.remove('is-dragover'); + } + + async function onDrop(event) { + if (!event.dataTransfer?.files?.length) return; + event.preventDefault(); + dragDepth = 0; + editorShell.classList.remove('is-dragover'); + await insertUploadedFiles(event.dataTransfer.files); + } + + async function onFileInputChange(event) { + const files = event.target?.files; + if (!files || files.length === 0) return; + await insertUploadedFiles(files); + fileInput.value = ''; + } + + async function onPaste(event) { + const clipboardItems = Array.from(event.clipboardData?.items || []); + const imageFiles = clipboardItems + .filter((item) => item.kind === 'file' && String(item.type || '').startsWith('image/')) + .map((item) => item.getAsFile()) + .filter(Boolean); + + if (imageFiles.length === 0) return; + + event.preventDefault(); + await insertUploadedFiles(imageFiles); + } + + function onMentionDropdownMouseDown(event) { + const item = event.target.closest('.post-composer-mention-item'); + if (!item) return; + event.preventDefault(); + const index = Number(item.dataset.index); + if (Number.isNaN(index)) return; + const selected = mentionState.candidates[index]; + if (selected) replaceMentionWithProfile(selected); + } + + async function onSendClick() { + /* In stop mode, the button acts as Stop — route to the stop handler. */ + if (stopModeActive) { + if (stopModeHandler) stopModeHandler(); + return; + } + if (sendButton.disabled) return; + if (!onSubmit) return; + + const text = (hostEl.innerText || '').trim(); + const attachments = pendingAttachments.slice(); + if (!text && attachments.length === 0) return; + + const previousText = text; + const previousAttachments = attachments; + + if (clearBeforeSubmit) { + clearComposerContent(); + if (!autoHideOnSubmit) { + hostEl.focus(); + setCaretByCharacterOffset(hostEl, 0); + } + } + + try { + const submitted = await onSubmit(previousText, { attachments: previousAttachments }); + if (submitted === false) { + if (clearBeforeSubmit) { + setEditorText(previousText); + pendingAttachments = previousAttachments; + updateUploadingUi(); + updateSendButtonVisibility(); + } + return; + } + + if (previousText) { + pushToHistory(previousText); + } + + if (autoHideOnSubmit) { + if (!clearBeforeSubmit) { + clearComposerContent(); + } + hideComposer(); + } else if (!clearBeforeSubmit) { + pendingAttachments = []; + queuePreviewRender(); + updateUploadingUi(); + updateSendButtonVisibility(); + hostEl.focus(); + setCaretByCharacterOffset(hostEl, 0); + } + } catch (error) { + if (clearBeforeSubmit) { + setEditorText(previousText); + pendingAttachments = previousAttachments; + updateUploadingUi(); + updateSendButtonVisibility(); + hostEl.focus(); + setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length); + } + console.error('[post-composer] Submit failed:', error); + } + } + + hostEl.addEventListener('input', onInput); + hostEl.addEventListener('keydown', onKeydown); + hostEl.addEventListener('dragenter', onDragEnter); + hostEl.addEventListener('dragover', onDragOver); + hostEl.addEventListener('dragleave', onDragLeave); + hostEl.addEventListener('drop', onDrop); + document.addEventListener('selectionchange', onSelectionChange); + document.addEventListener('mousedown', onDocumentClick); + hostEl.addEventListener('paste', onPaste); + + if (uploadButton) { + uploadButton.addEventListener('click', () => fileInput.click()); + } + fileInput.addEventListener('change', onFileInputChange); + mentionDropdown.addEventListener('mousedown', onMentionDropdownMouseDown); + sendButton.addEventListener('click', onSendClick); + + queuePreviewRender(); + updateSendButtonVisibility(); + + return { + refresh(nextOptions = {}) { + if (Array.isArray(nextOptions.followedProfiles)) { + followedProfiles = nextOptions.followedProfiles.slice(); + } + queuePreviewRender(); + }, + clear() { + clearComposerContent(); + }, + setDisabled(disabled) { + externallyDisabled = !!disabled; + updateSendButtonVisibility(); + }, + setStopMode(handler) { + stopModeActive = true; + stopModeHandler = typeof handler === 'function' ? handler : null; + sendButton.textContent = stopLabel; + sendButton.classList.add('is-stop-mode'); + sendButton.disabled = false; + sendButton.classList.add('is-visible'); + }, + clearStopMode() { + stopModeActive = false; + stopModeHandler = null; + sendButton.textContent = sendLabel; + sendButton.classList.remove('is-stop-mode'); + updateSendButtonVisibility(); + }, + show() { + showComposer(); + }, + hide() { + hideComposer(); + }, + isUploading() { + return uploadingCount > 0; + }, + destroy() { + hostEl.removeEventListener('input', onInput); + hostEl.removeEventListener('keydown', onKeydown); + hostEl.removeEventListener('dragenter', onDragEnter); + hostEl.removeEventListener('dragover', onDragOver); + hostEl.removeEventListener('dragleave', onDragLeave); + hostEl.removeEventListener('drop', onDrop); + document.removeEventListener('selectionchange', onSelectionChange); + document.removeEventListener('mousedown', onDocumentClick); + hostEl.removeEventListener('paste', onPaste); + fileInput.removeEventListener('change', onFileInputChange); + mentionDropdown.removeEventListener('mousedown', onMentionDropdownMouseDown); + sendButton.removeEventListener('click', onSendClick); + + if (mentionDropdown.parentNode) mentionDropdown.parentNode.removeChild(mentionDropdown); + if (uploadButton?.parentNode) uploadButton.parentNode.removeChild(uploadButton); + if (dragOverlay.parentNode) dragOverlay.parentNode.removeChild(dragOverlay); + if (uploadStatus.parentNode) uploadStatus.parentNode.removeChild(uploadStatus); + if (previewEl.parentNode) previewEl.parentNode.removeChild(previewEl); + if (sendButton.parentNode) sendButton.parentNode.removeChild(sendButton); + if (editorShell.parentNode) editorShell.parentNode.removeChild(editorShell); + if (fileInput.parentNode) fileInput.parentNode.removeChild(fileInput); + } + }; +} + +export default mountComposer; diff --git a/www/json-viewer/json-viewer.css b/www/json-viewer/json-viewer.css new file mode 100644 index 0000000..1555df0 --- /dev/null +++ b/www/json-viewer/json-viewer.css @@ -0,0 +1,212 @@ +:root { + color-scheme: light dark; + --jv-bg: #ffffff; + --jv-fg: #24292f; + --jv-muted: #6e7781; + --jv-border: #d0d7de; + --jv-guide: #d8dee4; + --jv-hover: #f3f4f6; + --jv-control-bg: #f6f8fa; + --jv-control-hover: #eaeef2; + --jv-string: #16803a; + --jv-number: #0969da; + --jv-boolean: #bc4c00; + --jv-null: #6e7781; + --jv-key: #8250df; + --jv-match: #fff3a3; +} + +* { box-sizing: border-box; } + +html, +body { + margin: 0; + min-height: 100%; + background: var(--jv-bg); + color: var(--jv-fg); +} + +body.json-viewer-page { + font-family: Menlo, Consolas, "Liberation Mono", monospace; + font-size: 14px; + line-height: 1.55; +} + +.json-viewer { min-height: 100vh; } + +.json-viewer__toolbar { + position: sticky; + z-index: 10; + top: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--jv-border); + background: color-mix(in srgb, var(--jv-bg) 94%, transparent); + box-shadow: 0 1px 4px rgb(0 0 0 / 8%); +} + +.json-viewer__title { + margin-right: auto; + font-weight: 700; +} + +.json-viewer__button, +.json-viewer__search { + min-height: 32px; + border: 1px solid var(--jv-border); + border-radius: 6px; + background: var(--jv-control-bg); + color: var(--jv-fg); + font: inherit; +} + +.json-viewer__button { + padding: 4px 10px; + cursor: pointer; +} + +.json-viewer__button:hover { background: var(--jv-control-hover); } +.json-viewer__button:focus-visible, +.json-viewer__search:focus-visible { + outline: 2px solid var(--jv-number); + outline-offset: 1px; +} + +.json-viewer__helper-apps { + display: flex; + align-items: center; + gap: 6px; +} + +.json-viewer__helper-label { + color: var(--jv-muted); + font-size: 12px; +} + +.json-viewer__helper-link { + display: inline-flex; + align-items: center; + text-decoration: none; +} + +.json-viewer__search { + width: min(280px, 50vw); + padding: 5px 10px; +} + +.json-viewer__status { + min-width: 9em; + color: var(--jv-muted); + font-size: 12px; +} + +.json-viewer__tree { + overflow-x: auto; + overflow-y: visible; + padding: 14px 16px 40px; +} + +.json-viewer__node { min-width: 0; } + +.json-viewer__children { + margin-left: 8px; + padding-left: 17px; + border-left: 1px solid var(--jv-guide); +} + +.json-viewer__node.is-collapsed > .json-viewer__children { display: none; } + +.json-viewer__row { + display: flex; + align-items: baseline; + min-height: 23px; + padding: 1px 7px 1px 2px; + border-radius: 4px; + white-space: pre; +} + +.json-viewer__row:hover { background: var(--jv-hover); } +.json-viewer__node.is-filtered-out { display: none; } +.json-viewer__node.is-match > .json-viewer__row { background: var(--jv-match); } + +.json-viewer__toggle { + width: 18px; + flex: 0 0 18px; + padding: 0; + border: 0; + background: transparent; + color: var(--jv-muted); + cursor: pointer; + font: inherit; + line-height: 1; +} + +.json-viewer__toggle::before { content: "▼"; } +.json-viewer__node.is-collapsed > .json-viewer__row > .json-viewer__toggle::before { content: "▶"; } +.json-viewer__toggle--empty { cursor: default; } +.json-viewer__toggle--empty::before { content: ""; } + +.json-viewer__key { color: var(--jv-key); } +.json-viewer__key::after { content: ":"; color: var(--jv-fg); } +.json-viewer__punctuation { color: var(--jv-fg); } +.json-viewer__string { + min-width: 0; + color: var(--jv-string); + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-all; +} +.json-viewer__number { color: var(--jv-number); } +.json-viewer__boolean { color: var(--jv-boolean); font-weight: 600; } +.json-viewer__null { color: var(--jv-null); font-style: italic; } + +.json-viewer__badge { + display: none; + margin-left: 7px; + padding: 0 6px; + border: 1px solid var(--jv-border); + border-radius: 999px; + color: var(--jv-muted); + font-size: 11px; + line-height: 17px; +} + +.json-viewer__node.is-collapsed > .json-viewer__row > .json-viewer__badge { display: inline; } + +.json-viewer__preview { + display: none; + margin-left: 7px; + color: var(--jv-muted); +} + +.json-viewer__node.is-collapsed > .json-viewer__row > .json-viewer__preview { display: inline; } + +@media (prefers-color-scheme: dark) { + :root { + --jv-bg: #1e1e1e; + --jv-fg: #d4d4d4; + --jv-muted: #9b9b9b; + --jv-border: #454545; + --jv-guide: #3f3f46; + --jv-hover: #2a2d2e; + --jv-control-bg: #2d2d30; + --jv-control-hover: #3a3d41; + --jv-string: #6a9955; + --jv-number: #569cd6; + --jv-boolean: #ce9178; + --jv-null: #9b9b9b; + --jv-key: #9cdcfe; + --jv-match: #514b20; + } +} + +@media (max-width: 700px) { + .json-viewer__title { width: 100%; } + .json-viewer__search { order: 2; width: 100%; } + .json-viewer__status { order: 3; } + .json-viewer__helper-apps { width: 100%; flex-wrap: wrap; } +} diff --git a/www/json-viewer/json-viewer.js b/www/json-viewer/json-viewer.js new file mode 100644 index 0000000..30456a4 --- /dev/null +++ b/www/json-viewer/json-viewer.js @@ -0,0 +1,233 @@ +(function () { + 'use strict'; + + if (document.contentType !== 'application/json' || document.querySelector('.json-viewer')) { + return; + } + + var CSS = `:root{color-scheme:light dark;--jv-bg:#fff;--jv-fg:#24292f;--jv-muted:#6e7781;--jv-border:#d0d7de;--jv-guide:#d8dee4;--jv-hover:#f3f4f6;--jv-control-bg:#f6f8fa;--jv-control-hover:#eaeef2;--jv-string:#16803a;--jv-number:#0969da;--jv-boolean:#bc4c00;--jv-null:#6e7781;--jv-key:#8250df;--jv-match:#fff3a3}*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:var(--jv-bg);color:var(--jv-fg)}body.json-viewer-page{font-family:Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;line-height:1.55}.json-viewer{min-height:100vh}.json-viewer__toolbar{position:sticky;z-index:10;top:0;display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--jv-border);background:var(--jv-bg);box-shadow:0 1px 4px rgb(0 0 0/8%)}.json-viewer__title{margin-right:auto;font-weight:700}.json-viewer__button,.json-viewer__search{min-height:32px;border:1px solid var(--jv-border);border-radius:6px;background:var(--jv-control-bg);color:var(--jv-fg);font:inherit}.json-viewer__button{padding:4px 10px;cursor:pointer}.json-viewer__button:hover{background:var(--jv-control-hover)}.json-viewer__button:focus-visible,.json-viewer__search:focus-visible{outline:2px solid var(--jv-number);outline-offset:1px}.json-viewer__helper-apps{display:flex;align-items:center;gap:6px}.json-viewer__helper-label{color:var(--jv-muted);font-size:12px}.json-viewer__helper-link{display:inline-flex;align-items:center;text-decoration:none}.json-viewer__search{width:min(280px,50vw);padding:5px 10px}.json-viewer__status{min-width:9em;color:var(--jv-muted);font-size:12px}.json-viewer__tree{overflow-x:auto;overflow-y:visible;padding:14px 16px 40px}.json-viewer__node{min-width:0}.json-viewer__children{margin-left:8px;padding-left:17px;border-left:1px solid var(--jv-guide)}.json-viewer__node.is-collapsed>.json-viewer__children{display:none}.json-viewer__row{display:flex;align-items:baseline;min-height:23px;padding:1px 7px 1px 2px;border-radius:4px;white-space:pre}.json-viewer__row:hover{background:var(--jv-hover)}.json-viewer__node.is-filtered-out{display:none}.json-viewer__node.is-match>.json-viewer__row{background:var(--jv-match)}.json-viewer__toggle{width:18px;flex:0 0 18px;padding:0;border:0;background:transparent;color:var(--jv-muted);cursor:pointer;font:inherit;line-height:1}.json-viewer__toggle:before{content:"▼"}.json-viewer__node.is-collapsed>.json-viewer__row>.json-viewer__toggle:before{content:"▶"}.json-viewer__toggle--empty{cursor:default}.json-viewer__toggle--empty:before{content:""}.json-viewer__key{color:var(--jv-key)}.json-viewer__key:after{content:":";color:var(--jv-fg)}.json-viewer__punctuation{color:var(--jv-fg)}.json-viewer__string{min-width:0;color:var(--jv-string);white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;word-break:break-all}.json-viewer__number{color:var(--jv-number)}.json-viewer__boolean{color:var(--jv-boolean);font-weight:600}.json-viewer__null{color:var(--jv-null);font-style:italic}.json-viewer__badge{display:none;margin-left:7px;padding:0 6px;border:1px solid var(--jv-border);border-radius:999px;color:var(--jv-muted);font-size:11px;line-height:17px}.json-viewer__node.is-collapsed>.json-viewer__row>.json-viewer__badge{display:inline}.json-viewer__preview{display:none;margin-left:7px;color:var(--jv-muted)}.json-viewer__node.is-collapsed>.json-viewer__row>.json-viewer__preview{display:inline}@media(prefers-color-scheme:dark){:root{--jv-bg:#1e1e1e;--jv-fg:#d4d4d4;--jv-muted:#9b9b9b;--jv-border:#454545;--jv-guide:#3f3f46;--jv-hover:#2a2d2e;--jv-control-bg:#2d2d30;--jv-control-hover:#3a3d41;--jv-string:#6a9955;--jv-number:#569cd6;--jv-boolean:#ce9178;--jv-null:#9b9b9b;--jv-key:#9cdcfe;--jv-match:#514b20}}@media(max-width:700px){.json-viewer__title{width:100%}.json-viewer__search{order:2;width:100%}.json-viewer__status{order:3}.json-viewer__helper-apps{width:100%;flex-wrap:wrap}}`; + + function element(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function valueType(value) { + if (value === null) return 'null'; + return typeof value; + } + + function primitive(value) { + var type = valueType(value); + var text = type === 'string' ? JSON.stringify(value) : String(value); + return element('span', 'json-viewer__' + type, text); + } + + function countLabel(value) { + var count = Array.isArray(value) ? value.length : Object.keys(value).length; + return count + ' ' + (Array.isArray(value) ? (count === 1 ? 'item' : 'items') : (count === 1 ? 'key' : 'keys')); + } + + function buildNode(value, key, path) { + var compound = value !== null && typeof value === 'object'; + var node = element('div', 'json-viewer__node'); + node.dataset.key = key === null ? '' : String(key).toLocaleLowerCase(); + node.dataset.path = path.toLocaleLowerCase(); + + var row = element('div', 'json-viewer__row'); + var toggle = element('button', 'json-viewer__toggle' + (compound ? '' : ' json-viewer__toggle--empty')); + toggle.type = 'button'; + toggle.tabIndex = compound ? 0 : -1; + toggle.setAttribute('aria-label', compound ? 'Collapse node' : 'Value'); + row.appendChild(toggle); + + if (key !== null) { + row.appendChild(element('span', 'json-viewer__key', JSON.stringify(String(key)))); + row.appendChild(document.createTextNode(' ')); + } + + if (!compound) { + row.appendChild(primitive(value)); + node.appendChild(row); + return node; + } + + var isArray = Array.isArray(value); + var opening = isArray ? '[' : '{'; + var closing = isArray ? ']' : '}'; + row.appendChild(element('span', 'json-viewer__punctuation', opening)); + row.appendChild(element('span', 'json-viewer__badge', countLabel(value))); + row.appendChild(element('span', 'json-viewer__preview', '… ' + closing)); + node.appendChild(row); + + var children = element('div', 'json-viewer__children'); + var entries = isArray ? value.map(function (item, index) { return [index, item]; }) : Object.keys(value).map(function (itemKey) { return [itemKey, value[itemKey]]; }); + entries.forEach(function (entry) { + var childPath = path ? path + '.' + entry[0] : String(entry[0]); + children.appendChild(buildNode(entry[1], isArray ? null : entry[0], childPath)); + }); + children.appendChild(element('div', 'json-viewer__row json-viewer__punctuation', ' ' + closing)); + node.appendChild(children); + + toggle.addEventListener('click', function () { + var collapsed = node.classList.toggle('is-collapsed'); + toggle.setAttribute('aria-label', collapsed ? 'Expand node' : 'Collapse node'); + }); + return node; + } + + function setAllCollapsed(root, collapsed) { + root.querySelectorAll('.json-viewer__node').forEach(function (node) { + if (node.querySelector(':scope > .json-viewer__children')) { + node.classList.toggle('is-collapsed', collapsed); + var button = node.querySelector(':scope > .json-viewer__row > .json-viewer__toggle'); + if (button) button.setAttribute('aria-label', collapsed ? 'Expand node' : 'Collapse node'); + } + }); + } + + function copyText(text, button) { + var copied = function () { + var old = button.textContent; + button.textContent = 'Copied!'; + window.setTimeout(function () { button.textContent = old; }, 1200); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(copied).catch(function () { fallbackCopy(text, copied); }); + } else { + fallbackCopy(text, copied); + } + } + + function fallbackCopy(text, done) { + var area = element('textarea'); + area.value = text; + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.select(); + try { document.execCommand('copy'); done(); } catch (ignore) { /* no-op */ } + area.remove(); + } + + function addNostrHelperApps(toolbar) { + var match = location.href.match(/^nostr:\/\/([^/?#]+)/i); + if (!match || match[1] === 'helper-apps') return; + + var entity = decodeURIComponent(match[1]); + fetch('nostr://' + encodeURIComponent(entity) + '/helper-apps') + .then(function (response) { + if (!response.ok) throw new Error('Unable to load Nostr helper apps'); + return response.json(); + }) + .then(function (apps) { + if (!Array.isArray(apps) || !apps.length) return; + var group = element('span', 'json-viewer__helper-apps'); + group.appendChild(element('span', 'json-viewer__helper-label', 'Open in:')); + apps.forEach(function (app) { + if (!app || typeof app.name !== 'string' || typeof app.url_template !== 'string') return; + var url = app.url_template.split('{entity}').join(entity); + var link = element('a', 'json-viewer__button json-viewer__helper-link', app.name); + link.href = url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.addEventListener('click', function (event) { + event.preventDefault(); + window.open(url, '_blank', 'noopener'); + }); + group.appendChild(link); + }); + if (group.children.length > 1) toolbar.appendChild(group); + }) + .catch(function () { /* Keep the standard JSON toolbar on endpoint failure. */ }); + } + + var source = document.querySelector('body > pre') || document.querySelector('pre'); + if (!source) return; + + var raw = source.textContent; + var data; + try { + data = JSON.parse(raw); + } catch (error) { + return; + } + + var style = element('style'); + style.id = 'sovereign-json-viewer-styles'; + style.textContent = CSS; + (document.head || document.documentElement).appendChild(style); + + var viewer = element('main', 'json-viewer'); + var toolbar = element('div', 'json-viewer__toolbar'); + toolbar.appendChild(element('span', 'json-viewer__title', 'JSON Viewer')); + + var copyButton = element('button', 'json-viewer__button', 'Copy JSON'); + copyButton.type = 'button'; + copyButton.addEventListener('click', function () { copyText(raw, copyButton); }); + toolbar.appendChild(copyButton); + + var collapseButton = element('button', 'json-viewer__button', 'Collapse all'); + collapseButton.type = 'button'; + toolbar.appendChild(collapseButton); + + var expandButton = element('button', 'json-viewer__button', 'Expand all'); + expandButton.type = 'button'; + toolbar.appendChild(expandButton); + + addNostrHelperApps(toolbar); + + var search = element('input', 'json-viewer__search'); + search.type = 'search'; + search.placeholder = 'Filter keys…'; + search.setAttribute('aria-label', 'Filter JSON keys'); + toolbar.appendChild(search); + + var status = element('span', 'json-viewer__status'); + status.setAttribute('aria-live', 'polite'); + toolbar.appendChild(status); + viewer.appendChild(toolbar); + + var tree = element('div', 'json-viewer__tree'); + tree.setAttribute('role', 'tree'); + tree.appendChild(buildNode(data, null, '')); + viewer.appendChild(tree); + + collapseButton.addEventListener('click', function () { setAllCollapsed(tree, true); }); + expandButton.addEventListener('click', function () { setAllCollapsed(tree, false); }); + + search.addEventListener('input', function () { + var query = search.value.trim().toLocaleLowerCase(); + var nodes = Array.prototype.slice.call(tree.querySelectorAll('.json-viewer__node')); + var matches = 0; + nodes.forEach(function (node) { + node.classList.remove('is-filtered-out', 'is-match'); + }); + if (!query) { + status.textContent = ''; + return; + } + nodes.forEach(function (node) { + var directMatch = node.dataset.key.indexOf(query) !== -1; + node.classList.toggle('is-match', directMatch); + if (directMatch) { + matches += 1; + var parent = node.parentElement; + while (parent && parent !== tree) { + if (parent.classList.contains('json-viewer__node')) parent.classList.remove('is-collapsed'); + parent = parent.parentElement; + } + } + }); + nodes.forEach(function (node) { + var visible = node.classList.contains('is-match') || !!node.querySelector('.json-viewer__node.is-match'); + node.classList.toggle('is-filtered-out', !visible); + }); + status.textContent = matches + (matches === 1 ? ' matching key' : ' matching keys'); + }); + + document.body.className = 'json-viewer-page'; + document.body.replaceChildren(viewer); +})(); diff --git a/www/processes.css b/www/processes.css new file mode 100644 index 0000000..9350aed --- /dev/null +++ b/www/processes.css @@ -0,0 +1,144 @@ +/* Processes page — sovereign://processes + * Imports the shared sovereign:// base theme, then adds processes-specific + * layout for the process/tab tables, heat bars, and drill-down panel. */ +@import url("sovereign://sovereign-base.css"); + +.note { font-size: 12px; font-weight: normal; } +code { font-family: var(--font); background: var(--border); padding: 1px 4px; border-radius: 3px; } +.url-sub { font-size: 11px; } +.dim { color: var(--muted); } + +/* Tab navigation (shared with fips.css pattern). */ +.tabs { + display: flex; gap: 4px; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} +.tab-btn { + background: var(--bg); color: var(--muted); + border: 1px solid transparent; border-bottom: none; + border-radius: var(--radius) var(--radius) 0 0; + padding: 6px 16px; cursor: pointer; + font-family: var(--font); font-size: 13px; font-weight: bold; + margin-left: 0; min-width: auto; + transition: color 0.2s, border-color 0.2s; +} +.tab-btn:hover { color: var(--primary); } +.tab-btn.active { + color: var(--primary); + border-color: var(--border); + border-bottom: 1px solid var(--bg); + margin-bottom: -1px; +} +.tab-panel { display: none; } +.tab-panel.active { display: block; } + +#refresh-btn { margin-left: 12px; } + +/* Process + tab tables. */ +#proc-table, #tabs-table { + width: 100%; + border-collapse: collapse; + margin: 10px 0; +} +#proc-table th, #proc-table td, +#tabs-table th, #tabs-table td { + text-align: left; + padding: 6px 10px; + border-bottom: 1px solid var(--border); + font-size: 13px; + white-space: nowrap; +} +#proc-table th, #tabs-table th { + color: var(--muted); + font-weight: bold; + border-bottom: 2px solid var(--primary); + cursor: pointer; + user-select: none; +} +#proc-table th:hover, #tabs-table th:hover { color: var(--primary); } +#proc-table th.sorted-asc::after { content: " ▲"; color: var(--primary); } +#proc-table th.sorted-desc::after { content: " ▼"; color: var(--primary); } +#tabs-table th.sorted-asc::after { content: " ▲"; color: var(--primary); } +#tabs-table th.sorted-desc::after { content: " ▼"; color: var(--primary); } + +#proc-table td.title-cell, +#tabs-table td.title-cell { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; +} +#proc-table td.url-cell, +#tabs-table td.url-cell { + max-width: 420px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* CPU values: plain text, no shaded background. Red only for >= 90%, + * otherwise the default foreground color. */ +.heat { + font-weight: bold; + color: var(--fg); +} +.heat.hot { color: var(--accent); } + +/* Renderer row: clickable to expand hosted tabs. */ +.renderer-row { cursor: pointer; } +.renderer-row:hover { background: var(--border); } +.hosted-tabs-row td { padding-left: 24px; color: var(--muted); } +.hosted-tabs-row .ht-entry { display: block; padding: 2px 0; } +.hosted-tabs-row .ht-idx { color: var(--primary); font-weight: bold; margin-right: 6px; } + +/* Drill-down panel. */ +#drilldown { + margin-top: 24px; + border-top: 2px solid var(--primary); + padding-top: 12px; +} +#drilldown h2 { margin-bottom: 8px; } +#drilldown h3 { margin: 16px 0 6px; color: var(--muted); font-size: 14px; } + +.sub-table { + width: 100%; + border-collapse: collapse; + margin: 6px 0 12px; +} +.sub-table th, .sub-table td { + text-align: left; + padding: 4px 10px; + border-bottom: 1px solid var(--border); + font-size: 12px; +} +.sub-table th { color: var(--muted); font-weight: bold; } +.sub-table td.mono { font-family: var(--font); word-break: break-all; } + +/* Long-task timeline: a horizontal bar of vertical lines, one per + * long task, height scaled by duration. */ +.timeline { + height: 80px; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px; + overflow: hidden; + position: relative; + background: var(--bg); +} +.timeline .tl-bar { + display: inline-block; + width: 2px; + margin-right: 1px; + background: var(--accent); + vertical-align: bottom; +} +.timeline .tl-empty { + color: var(--muted); + font-size: 12px; + padding: 4px 8px; +} + +.actions { margin-top: 16px; } +.actions .btn { margin-right: 8px; } + +.empty { color: var(--muted); font-style: italic; } +.mono { font-family: var(--font); word-break: break-all; } diff --git a/www/processes.html b/www/processes.html new file mode 100644 index 0000000..e6afd9c --- /dev/null +++ b/www/processes.html @@ -0,0 +1,110 @@ + + + + + + Processes — Sovereign Browser + + + +

Processes

+ +
+ + +
+ +
+ + +
+

+ All sovereign_browser processes: the main browser, WebKit renderer + / network / GPU processes, and managed Tor/FIPS subprocesses. + CPU% is computed from /proc utime+stime deltas + (first sample is always 0). Renderer rows expand to show their + hosted tabs. +

+ + + + + + + + + + + + + + + + +
PIDNameCPU%RSSPSSThreadsUptimeStateTypeTabs
+
+ + +
+

+ Per-tab performance from the in-page probe. CPU-busy% is + the fraction of the last second spent in long (>50ms) + main-thread tasks — the direct measure of which tab is hogging + CPU. Click a row for a drill-down (long-task sources, timers, + network endpoints, heap, workers). +

+ + + + + + + + + + + + + + + +
#TitleCPU-busy%FPSTimersNetHeapDOMWPID
+ + + +
+ + + + diff --git a/www/processes.js b/www/processes.js new file mode 100644 index 0000000..3c2c383 --- /dev/null +++ b/www/processes.js @@ -0,0 +1,451 @@ +/* Processes page — sovereign://processes + * + * Tabs: Processes | Tabs + * + * - Processes: /proc-level view of all sovereign_browser PIDs (main, + * WebKit renderers with their hosted tabs, WebKit network/gpu, Tor, + * FIPS). Polled from sovereign://processes/list every 1s. Sortable + * columns. Renderer rows expand to show hosted tabs. + * - Tabs: per-tab probe view (cpu_busy_percent, fps, timers, net, heap, + * DOM, WPID). Polled from sovereign://processes/tabs every 1s. + * Click a row to load the drill-down (sovereign://processes/tab_probe). + * + * Uses XMLHttpRequest via sovereignGet() because WebKit's custom scheme + * handler does not reliably support fetch() for sovereign:// URLs. + */ + +function sovereignGet(url) { + return new Promise(function (resolve, reject) { + var x = new XMLHttpRequest(); + x.open('GET', url, true); + x.onreadystatechange = function () { + if (x.readyState !== 4) return; + try { resolve(JSON.parse(x.responseText)); } + catch (e) { reject(e); } + }; + x.onerror = function () { reject(new Error('XHR error')); }; + x.send(); + }); +} + +function esc(s) { + var d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; +} + +function showMsg(text, isErr) { + var el = document.getElementById('status-msg'); + el.textContent = text || ''; + el.className = 'status show ' + (isErr ? 'err' : 'ok'); + if (!text) el.className = 'status'; +} + +/* ── Formatting helpers ─────────────────────────────────────────── */ + +function fmtKb(kb) { + if (kb == null || kb < 0) return '—'; + if (kb >= 1024 * 1024) return (kb / (1024 * 1024)).toFixed(1) + 'G'; + if (kb >= 1024) return (kb / 1024).toFixed(0) + 'M'; + return kb + 'K'; +} + +function fmtUptime(sec) { + if (sec == null || sec < 0) return '—'; + var d = Math.floor(sec / 86400); + var h = Math.floor((sec % 86400) / 3600); + var m = Math.floor((sec % 3600) / 60); + var s = Math.floor(sec % 60); + if (d > 0) return d + 'd' + h + 'h'; + if (h > 0) return h + 'h' + m + 'm'; + if (m > 0) return m + 'm' + s + 's'; + return s + 's'; +} + +function heatClass(pct) { + if (pct == null) return ''; + if (pct >= 90) return 'hot'; + return ''; +} + +function heatHtml(pct) { + if (pct == null) return '—'; + return '' + + pct.toFixed(1) + ''; +} + +/* ── Tab switching ──────────────────────────────────────────────── */ + +function switchTab(tabName) { + document.querySelectorAll('.tab-btn').forEach(function (btn) { + btn.classList.toggle('active', btn.dataset.tab === tabName); + }); + document.querySelectorAll('.tab-panel').forEach(function (panel) { + panel.classList.toggle('active', panel.id === 'tab-' + tabName); + }); +} + +document.querySelectorAll('.tab-btn').forEach(function (btn) { + btn.addEventListener('click', function () { switchTab(btn.dataset.tab); }); +}); + +/* ── Processes tab ──────────────────────────────────────────────── */ + +var gProcSortKey = 'cpu_percent'; +var gProcSortDir = 'desc'; +var gProcData = []; +var gExpandedRenderers = {}; // pid -> bool + +function setProcSort(key) { + if (gProcSortKey === key) { + gProcSortDir = gProcSortDir === 'asc' ? 'desc' : 'asc'; + } else { + gProcSortKey = key; + gProcSortDir = (key === 'name' || key === 'state' || key === 'ownership') + ? 'asc' : 'desc'; + } + renderProcTable(); +} + +document.querySelectorAll('#proc-table th[data-sort]').forEach(function (th) { + th.addEventListener('click', function () { setProcSort(th.dataset.sort); }); +}); + +function procSorter(a, b) { + var ka = a[gProcSortKey]; + var kb = b[gProcSortKey]; + var cmp; + if (typeof ka === 'number' || typeof kb === 'number') { + cmp = (ka || 0) - (kb || 0); + } else { + cmp = String(ka || '').localeCompare(String(kb || '')); + } + return gProcSortDir === 'asc' ? cmp : -cmp; +} + +function renderProcTable() { + var body = document.getElementById('proc-body'); + body.innerHTML = ''; + var sorted = gProcData.slice().sort(procSorter); + + // Update sort indicators. + document.querySelectorAll('#proc-table th[data-sort]').forEach(function (th) { + th.classList.remove('sorted-asc', 'sorted-desc'); + if (th.dataset.sort === gProcSortKey) { + th.classList.add(gProcSortDir === 'asc' ? 'sorted-asc' : 'sorted-desc'); + } + }); + + sorted.forEach(function (p) { + var tr = document.createElement('tr'); + var isRenderer = p.ownership === 'webkit-renderer'; + tr.className = isRenderer ? 'renderer-row' : ''; + var tabsCount = (p.hosted_tabs || []).length; + var tabsCell; + if (isRenderer) { + tabsCell = String(tabsCount); + } else { + tabsCell = '—'; + } + tr.innerHTML = + '' + p.pid + '' + + '' + esc(p.name) + '' + + '' + heatHtml(p.cpu_percent) + '' + + '' + fmtKb(p.rss_kb) + '' + + '' + fmtKb(p.pss_kb) + '' + + '' + (p.threads || 0) + '' + + '' + fmtUptime(p.uptime_sec) + '' + + '' + esc(p.state) + '' + + '' + esc(p.ownership) + + (p.service_state ? ' (' + esc(p.service_state) + ')' : '') + '' + + '' + tabsCell + ''; + if (isRenderer) { + (function (pid, hosted) { + tr.addEventListener('click', function () { + gExpandedRenderers[pid] = !gExpandedRenderers[pid]; + renderProcTable(); + }); + })(p.pid, p.hosted_tabs); + } + body.appendChild(tr); + + // Expanded hosted-tabs row. + if (isRenderer && gExpandedRenderers[p.pid] && tabsCount > 0) { + var sub = document.createElement('tr'); + sub.className = 'hosted-tabs-row'; + var html = ''; + p.hosted_tabs.forEach(function (t) { + html += '#' + + t.index + '' + esc(t.title || '(untitled)') + + ' ' + esc(t.url) + ''; + }); + html += ''; + sub.innerHTML = html; + body.appendChild(sub); + } + }); + + if (sorted.length === 0) { + body.innerHTML = 'No processes.'; + } +} + +async function fetchProcesses() { + try { + var data = await sovereignGet('sovereign://processes/list?_t=' + Date.now()); + gProcData = Array.isArray(data) ? data : []; + renderProcTable(); + } catch (e) { + showMsg('Failed to load processes: ' + e.message, true); + } +} + +/* ── Tabs tab ───────────────────────────────────────────────────── */ + +var gTabsSortKey = 'cpu_busy_percent'; +var gTabsSortDir = 'desc'; +var gTabsData = []; +var gSelectedTab = -1; + +function setTabsSort(key) { + if (gTabsSortKey === key) { + gTabsSortDir = gTabsSortDir === 'asc' ? 'desc' : 'asc'; + } else { + gTabsSortKey = key; + gTabsSortDir = (key === 'title') ? 'asc' : 'desc'; + } + renderTabsTable(); +} + +document.querySelectorAll('#tabs-table th[data-sort]').forEach(function (th) { + th.addEventListener('click', function () { setTabsSort(th.dataset.sort); }); +}); + +function tabSorter(a, b) { + var ka = a[gTabsSortKey]; + var kb = b[gTabsSortKey]; + // For probe fields, fall back to -1 if probe is null. + if (gTabsSortKey !== 'index' && gTabsSortKey !== 'title' && + gTabsSortKey !== 'webprocess_pid') { + ka = (a.probe && a.probe[gTabsSortKey] != null) ? a.probe[gTabsSortKey] : -1; + kb = (b.probe && b.probe[gTabsSortKey] != null) ? b.probe[gTabsSortKey] : -1; + } + var cmp; + if (typeof ka === 'number' || typeof kb === 'number') { + cmp = (ka || 0) - (kb || 0); + } else { + cmp = String(ka || '').localeCompare(String(kb || '')); + } + return gTabsSortDir === 'asc' ? cmp : -cmp; +} + +function probeField(tab, key, fmt) { + if (!tab.probe) return '—'; + var v = tab.probe[key]; + if (v == null || v < 0) return '—'; + return fmt ? fmt(v) : v; +} + +function renderTabsTable() { + var body = document.getElementById('tabs-body'); + body.innerHTML = ''; + var sorted = gTabsData.slice().sort(tabSorter); + + document.querySelectorAll('#tabs-table th[data-sort]').forEach(function (th) { + th.classList.remove('sorted-asc', 'sorted-desc'); + if (th.dataset.sort === gTabsSortKey) { + th.classList.add(gTabsSortDir === 'asc' ? 'sorted-asc' : 'sorted-desc'); + } + }); + + sorted.forEach(function (t) { + var tr = document.createElement('tr'); + tr.className = 'tab-row' + (t.index === gSelectedTab ? ' selected' : ''); + var busy = (t.probe && t.probe.cpu_busy_percent != null) + ? t.probe.cpu_busy_percent : null; + var titleClass = (t.index === gSelectedTab) ? '' : 'dim'; + tr.innerHTML = + '' + t.index + '' + + '' + + esc(t.title || '(untitled)') + '' + + (t.is_internal ? ' (internal)' : '') + + '
' + esc(t.url) + '' + + '' + (t.is_internal ? '—' + : heatHtml(busy)) + '' + + '' + probeField(t, 'fps') + '' + + '' + probeField(t, 'timer_count') + '' + + '' + probeField(t, 'net_in_flight') + '' + + '' + probeField(t, 'heap_used_mb', function (v) { return v + 'M'; }) + '' + + '' + probeField(t, 'dom_node_count') + '' + + '' + (t.webprocess_pid || 0) + ''; + (function (idx) { + tr.addEventListener('click', function () { openDrilldown(idx); }); + })(t.index); + body.appendChild(tr); + }); + + if (sorted.length === 0) { + body.innerHTML = 'No tabs.'; + } +} + +async function fetchTabs() { + try { + var data = await sovereignGet('sovereign://processes/tabs?_t=' + Date.now()); + gTabsData = Array.isArray(data) ? data : []; + renderTabsTable(); + // If a tab is selected, refresh its drill-down too. + if (gSelectedTab >= 0) refreshDrilldown(); + } catch (e) { + showMsg('Failed to load tabs: ' + e.message, true); + } +} + +/* ── Drill-down ─────────────────────────────────────────────────── */ + +async function openDrilldown(idx) { + gSelectedTab = idx; + renderTabsTable(); + document.getElementById('drilldown').style.display = 'block'; + await refreshDrilldown(); +} + +async function refreshDrilldown() { + if (gSelectedTab < 0) return; + try { + var d = await sovereignGet('sovereign://processes/tab_probe?index=' + + gSelectedTab); + renderDrilldown(d); + } catch (e) { + document.getElementById('dd-title').textContent = 'Tab #' + gSelectedTab; + document.getElementById('dd-summary').innerHTML = + '

Failed to load drill-down: ' + esc(e.message) + '

'; + } +} + +function renderDrilldown(d) { + document.getElementById('dd-title').textContent = + 'Tab #' + d.index + ' — ' + (d.title || '(untitled)'); + + var p = d.probe || {}; + var rows = [ + ['URL', esc(d.url)], + ['WebProcess PID', d.webprocess_pid || 0], + ['CPU-busy %', p.cpu_busy_percent != null ? heatHtml(p.cpu_busy_percent) : '—'], + ['FPS', p.fps != null ? p.fps : '—'], + ['Active timers', p.timer_count != null ? p.timer_count : '—'], + ['Net in-flight', p.net_in_flight != null ? p.net_in_flight : '—'], + ['Net reqs (last sec)', p.net_requests_last_sec != null ? p.net_requests_last_sec : '—'], + ['Heap used', p.heap_used_mb >= 0 ? p.heap_used_mb + 'M' : '—'], + ['Event listeners', p.event_listener_count != null ? p.event_listener_count : '—'], + ['Workers', p.worker_count != null ? p.worker_count : '—'], + ['DOM nodes', p.dom_node_count >= 0 ? p.dom_node_count : '—'] + ]; + var html = ''; + rows.forEach(function (r) { + html += '
' + r[0] + '' + + r[1] + '
'; + }); + document.getElementById('dd-summary').innerHTML = html; + + // Long-task sources. + var ltBody = document.getElementById('dd-lt-sources-body'); + ltBody.innerHTML = ''; + (p.long_task_top_sources || []).forEach(function (s) { + ltBody.innerHTML += '' + esc(s.name) + '' + + s.total_ms + '' + s.count + ''; + }); + if (!p.long_task_top_sources || p.long_task_top_sources.length === 0) { + ltBody.innerHTML = 'No long tasks.'; + } + + // Timers. + var tmBody = document.getElementById('dd-timers-body'); + tmBody.innerHTML = ''; + (p.timer_top || []).forEach(function (s) { + tmBody.innerHTML += '' + esc(s.code) + '' + + s.interval_ms + '' + s.count + ''; + }); + if (!p.timer_top || p.timer_top.length === 0) { + tmBody.innerHTML = 'No active timers.'; + } + + // Network endpoints. + var netBody = document.getElementById('dd-net-body'); + netBody.innerHTML = ''; + (p.net_top_endpoints || []).forEach(function (s) { + netBody.innerHTML += '' + esc(s.url) + '' + + s.count + ''; + }); + if (!p.net_top_endpoints || p.net_top_endpoints.length === 0) { + netBody.innerHTML = 'No network requests.'; + } + + // Timeline. + var tl = document.getElementById('dd-timeline'); + tl.innerHTML = ''; + var timeline = p.long_task_timeline || []; + if (timeline.length === 0) { + tl.innerHTML = '
No long tasks in the last 30s.
'; + } else { + var maxDur = 1; + timeline.forEach(function (r) { if (r.duration > maxDur) maxDur = r.duration; }); + timeline.forEach(function (r) { + var h = Math.max(2, Math.round((r.duration / maxDur) * 72)); + var bar = document.createElement('span'); + bar.className = 'tl-bar'; + bar.style.height = h + 'px'; + bar.title = r.duration + 'ms — ' + (r.name || 'unknown'); + tl.appendChild(bar); + }); + } +} + +/* ── Drill-down actions ─────────────────────────────────────────── */ + +document.getElementById('dd-reload').addEventListener('click', function () { + if (gSelectedTab < 0) return; + // Trigger reload via the bridge — there's no dedicated endpoint, so + // we use the agent eval path indirectly. Simplest: navigate the tab + // by opening its URL again via sovereign://processes/tab_action. + sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab + + '&action=reload').then(function () { refreshDrilldown(); }); +}); + +document.getElementById('dd-suspend').addEventListener('click', function () { + if (gSelectedTab < 0) return; + sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab + + '&action=suspend').then(function () { refreshDrilldown(); }); +}); + +document.getElementById('dd-close').addEventListener('click', function () { + if (gSelectedTab < 0) return; + if (!confirm('Close tab #' + gSelectedTab + '?')) return; + sovereignGet('sovereign://processes/tab_action?index=' + gSelectedTab + + '&action=close').then(function () { + document.getElementById('drilldown').style.display = 'none'; + gSelectedTab = -1; + fetchTabs(); + }); +}); + +document.getElementById('dd-close-panel').addEventListener('click', function () { + document.getElementById('drilldown').style.display = 'none'; + gSelectedTab = -1; + renderTabsTable(); +}); + +/* ── Refresh button + polling ───────────────────────────────────── */ + +document.getElementById('refresh-btn').addEventListener('click', function () { + fetchProcesses(); + fetchTabs(); +}); + +function poll() { + fetchProcesses(); + fetchTabs(); +} + +poll(); +setInterval(poll, 1000); diff --git a/www/profile.css b/www/profile.css new file mode 100644 index 0000000..a674cd9 --- /dev/null +++ b/www/profile.css @@ -0,0 +1,8 @@ +/* Profile page — sovereign://profile + * Imports the shared sovereign:// base theme. */ +@import url("sovereign://sovereign-base.css"); + +.profile-picture { + max-width: 120px; max-height: 120px; border-radius: 8px; + float: right; margin-left: 16px; +} diff --git a/www/profile.html b/www/profile.html new file mode 100644 index 0000000..63aedb8 --- /dev/null +++ b/www/profile.html @@ -0,0 +1,25 @@ + + + + + + Profile + + + +

Profile

+ +
+

Loading profile…

+
+ +

+ This page shows data cached in the local SQLite database from the + bootstrap relay fetch. If fields show + (not found), the relay fetch hasn't + completed or the user has no events of that kind on the bootstrap relays. +

+ + + + diff --git a/www/profile.js b/www/profile.js new file mode 100644 index 0000000..7bd48d8 --- /dev/null +++ b/www/profile.js @@ -0,0 +1,102 @@ +/* Profile page — sovereign://profile + * + * Fetches profile data (identity, kind 0 metadata, kind 3 contacts, + * kind 10002 relay list, npub, QR code URL) from sovereign://profile/data + * (JSON) and renders it. + */ +function sovereignGet(url) { + return new Promise(function(resolve, reject) { + var x = new XMLHttpRequest(); + x.open('GET', url, true); + x.onreadystatechange = function() { + if (x.readyState !== 4) return; + try { resolve(JSON.parse(x.responseText)); } + catch (e) { reject(e); } + }; + x.onerror = function() { reject(new Error('XHR error')); }; + x.send(); + }); +} + +function esc(s) { + var d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; +} + +function infoRow(label, value) { + return '
' + esc(label) + '' + + '' + (value == null || value === '' ? + '(not found)' : esc(value)) + + '
'; +} + +function renderProfile(d) { + var c = document.getElementById('profile-body'); + + if (!d || !d.pubkey) { + c.innerHTML = '

No identity loaded. Log in to see your profile.

'; + return; + } + + var html = ''; + + /* Profile picture (kind 0). */ + if (d.picture) { + html += 'Profile picture'; + } + + /* QR code for the npub URI. */ + if (d.qr_url) { + html += '
npub QR code
'; + } + + /* Identity section. */ + html += '

Identity

'; + html += infoRow('Pubkey', d.pubkey_short); + html += infoRow('npub URI', d.npub); + html += infoRow('Method', d.method); + html += infoRow('Signing', d.signing); + + /* Profile (kind 0). */ + html += '

Profile (kind 0)

'; + if (!d.kind0_present) { + html += '

(no kind 0 event in database)

'; + } + html += infoRow('Name', d.name); + html += infoRow('Display Name', d.display_name); + if (d.about) { + html += '
About' + + '' + esc(d.about) + + '
'; + } + html += infoRow('Website', d.website); + html += infoRow('Lightning (lud16)', d.lud16); + html += infoRow('Last updated', d.kind0_created); + html += infoRow('Events in DB', d.kind0_count); + + /* Contacts (kind 3). */ + html += '

Contacts (kind 3)

'; + if (!d.kind3_present) { + html += '

(no kind 3 event in database)

'; + } + html += infoRow('Following', (d.contact_count != null ? + d.contact_count + ' pubkey(s)' : '')); + html += infoRow('Last updated', d.kind3_created); + html += infoRow('Events in DB', d.kind3_count); + + /* Relay list (kind 10002). */ + html += '

Relay List (kind 10002)

'; + html += infoRow('Events in DB', d.kind10002_count); + + c.innerHTML = html; +} + +sovereignGet('sovereign://profile/data?_=' + Date.now()) + .then(function(d) { renderProfile(d); }) + .catch(function(e) { + document.getElementById('profile-body').innerHTML = + '

Failed to load profile: ' + esc(e.message) + '

'; + }); diff --git a/www/settings.css b/www/settings.css new file mode 100644 index 0000000..7a52b9e --- /dev/null +++ b/www/settings.css @@ -0,0 +1,3 @@ +/* Settings page — sovereign://settings + * Imports the shared sovereign:// base theme. */ +@import url("sovereign://sovereign-base.css"); diff --git a/www/settings.html b/www/settings.html new file mode 100644 index 0000000..10e229e --- /dev/null +++ b/www/settings.html @@ -0,0 +1,162 @@ + + + + + + Settings + + + +

Settings

+ +

Appearance

+
+
Dark mode (Night)
+
+
+ +

Tabs

+ +
+
Restore session on startup
+
+
+ +
+
New tab URL
+
+
+
+ +
+
Tab bar position
+
+
+
+ +
+
Show tab close buttons
+
+
+ +
+
Middle-click to close tab
+
+
+ +
+
Ctrl+Tab to switch tabs
+
+
+ +
+
Allow drag to reorder tabs
+
+
+ +
+
Maximum tabs
+
+
+
+ +

Keyboard Shortcuts

+
+
+
Reset all shortcuts
+
+
+ +

Agent Server

+ +
+
Enable agent server
+
+
+ +
+
Agent server port
+
+
+
+ +
+
Allowed origins
+
+
+
+ +
+
Login timeout (ms)
+
+
+
+ +

Bootstrap Relays

+
+
Relay URLs
+
+
+
+ +

Search

+
+
Search engine
+
+
+
+ +

Network

+
+
FIPS Mesh Network
+
Manage the FIPS mesh daemon: status, peers, and service control.
+ +
+ +

Security

+ +
+
Developer Tools (Inspector)
+
+
+ +
+
File Access from file://
+
+
+ +
+
Universal Access from file://
+
+
+ +
+
CORS Enforcement
+
+
+ +
+
TLS Certificate Check
+
+
+ +
+ + + + diff --git a/www/settings.js b/www/settings.js new file mode 100644 index 0000000..e0ef191 --- /dev/null +++ b/www/settings.js @@ -0,0 +1,326 @@ +/* Settings page — sovereign://settings + * + * Fetches all settings values from sovereign://settings/config (JSON) on + * page load and populates the DOM. Toggles, saves, and shortcut capture + * use the existing sovereign://settings/set endpoint. + */ +function sovereignGet(url) { + return new Promise(function(resolve, reject) { + var x = new XMLHttpRequest(); + x.open('GET', url, true); + x.onreadystatechange = function() { + if (x.readyState !== 4) return; + try { resolve(JSON.parse(x.responseText)); } + catch (e) { reject(e); } + }; + x.onerror = function() { reject(new Error('XHR error')); }; + x.send(); + }); +} + +function esc(s) { + var d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; +} + +function showStatus(cls, msg) { + var s = document.getElementById('status'); + s.className = 'status ' + cls + ' show'; + s.textContent = msg; +} + +/* Set a toggle element's on/off class. */ +function setToggleClass(el, on) { + el.classList.remove('on', 'off'); + el.classList.add(on ? 'on' : 'off'); +} + +/* Apply the settings/config JSON to the DOM. */ +function applyConfig(d) { + /* Toggles — each has a data-feature attribute identifying the key. */ + var toggleMap = { + theme_dark: d.theme_dark, + restore_session: d.restore_session, + show_tab_close_buttons: d.show_tab_close_buttons, + middle_click_close: d.middle_click_close, + ctrl_tab_switch: d.ctrl_tab_switch, + tab_drag_reorder: d.tab_drag_reorder, + agent_server_enabled: d.agent_server_enabled, + dev_extras: d.dev_extras, + file_access: d.file_access, + universal_access: d.universal_access + }; + document.querySelectorAll('.toggle[data-feature]').forEach(function(el) { + var key = el.getAttribute('data-feature'); + if (key === 'theme_dark') el.id = 'toggle_theme_dark'; + setToggleClass(el, !!toggleMap[key]); + }); + + /* Text/number inputs. */ + if (d.new_tab_url != null) + document.getElementById('new_tab_url').value = d.new_tab_url; + if (d.max_tabs != null) + document.getElementById('max_tabs').value = d.max_tabs; + if (d.agent_server_port != null) + document.getElementById('agent_server_port').value = d.agent_server_port; + if (d.agent_allowed_origins != null) + document.getElementById('agent_allowed_origins').value = d.agent_allowed_origins; + if (d.agent_login_timeout_ms != null) + document.getElementById('agent_login_timeout_ms').value = d.agent_login_timeout_ms; + if (d.bootstrap_relays != null) + document.getElementById('bootstrap_relays').value = d.bootstrap_relays; + + /* Tab bar position select. */ + if (d.tab_bar_position != null) { + var sel = document.getElementById('tab_bar_position'); + sel.value = d.tab_bar_position; + } + + /* Search engine select. */ + var se = document.getElementById('search_engine'); + se.innerHTML = ''; + (d.search_engines || []).forEach(function(eng) { + var opt = document.createElement('option'); + opt.value = eng.id; + opt.textContent = eng.name; + if (eng.id === d.search_engine) opt.selected = true; + se.appendChild(opt); + }); + + /* Keyboard shortcuts. */ + renderShortcuts(d.shortcuts || []); +} + +/* ── Keyboard shortcut capture ──────────────────────────────────── */ +var capturingAction = null; +var capturingDisplay = null; + +function jsKeyToGdk(e) { + var k = e.key; + if (k.length === 1) { + var c = k.toLowerCase(); + if (c >= 'a' && c <= 'z') return c; + if (c >= '0' && c <= '9') return c; + if (k === ',') return 'comma'; + if (k === ' ') return 'space'; + if (k === '.') return 'period'; + if (k === '/') return 'slash'; + if (k === '-') return 'minus'; + if (k === '=') return 'equal'; + if (k === '+') return 'plus'; + } + var special = { + 'Tab':'Tab','Enter':'Return','Escape':'Escape', + 'Backspace':'BackSpace','Delete':'Delete', + 'Home':'Home','End':'End', + 'PageUp':'Page_Up','PageDown':'Page_Down', + 'ArrowLeft':'Left','ArrowRight':'Right', + 'ArrowUp':'Up','ArrowDown':'Down', + 'F1':'F1','F2':'F2','F3':'F3','F4':'F4','F5':'F5','F6':'F6', + 'F7':'F7','F8':'F8','F9':'F9','F10':'F10','F11':'F11','F12':'F12' + }; + if (special[k]) return special[k]; + return null; +} + +function buildAccel(e) { + var parts = []; + if (e.ctrlKey) parts.push(''); + if (e.altKey) parts.push(''); + if (e.shiftKey) parts.push(''); + if (e.metaKey) parts.push(''); + var gdkKey = jsKeyToGdk(e); + if (!gdkKey) return null; + parts.push(gdkKey); + return parts.join(''); +} + +function displayAccel(accel) { + return accel.replace(//g,'Ctrl+') + .replace(//g,'Alt+') + .replace(//g,'Shift+') + .replace(//g,'Super+'); +} + +function renderShortcuts(shortcuts) { + var c = document.getElementById('shortcuts-section'); + var html = ''; + shortcuts.forEach(function(sc) { + var accel = sc.accel || ''; + html += '
'; + html += '
' + esc(sc.label) + '
'; + html += '
' + + esc(displayAccel(accel)) + ''; + html += ' '; + html += '
'; + html += '
'; + }); + c.innerHTML = html; + bindShortcutButtons(); + checkDuplicates(); +} + +function checkDuplicates() { + var rows = document.querySelectorAll('.shortcut-row'); + var seen = {}; + rows.forEach(function(r) { + r.classList.remove('duplicate'); + var a = r.getAttribute('data-accel'); + if (a) { + if (seen[a]) seen[a].push(r); + else seen[a] = [r]; + } + }); + Object.keys(seen).forEach(function(a) { + if (seen[a].length > 1) { + seen[a].forEach(function(r) { r.classList.add('duplicate'); }); + } + }); +} + +function bindShortcutButtons() { + document.querySelectorAll('.sc-change').forEach(function(btn) { + btn.addEventListener('click', function() { + var action = btn.getAttribute('data-action'); + var disp = document.getElementById('sc_' + action); + if (!disp) return; + if (capturingDisplay) capturingDisplay.classList.remove('capturing'); + capturingAction = action; + capturingDisplay = disp; + disp.setAttribute('data-prev', disp.textContent); + disp.classList.add('capturing'); + disp.textContent = 'Press keys…'; + }); + }); + document.querySelectorAll('.sc-reset').forEach(function(btn) { + btn.addEventListener('click', function() { + resetShortcut(btn.getAttribute('data-action')); + }); + }); + var ra = document.getElementById('sc-reset-all'); + if (ra) ra.onclick = resetAllShortcuts; +} + +function commitShortcut(action, accel) { + sovereignGet('sovereign://settings/set?key=shortcut.' + action + + '&value=' + encodeURIComponent(accel)) + .then(function(d) { + if (d.error) showStatus('err', d.message); + else { + showStatus('ok', action + ' = ' + displayAccel(accel)); + setTimeout(function() { location.reload(); }, 400); + } + }) + .catch(function(e) { showStatus('err', 'Error: ' + e.message); }); +} + +function resetShortcut(action) { + sovereignGet('sovereign://settings/set?key=shortcut.reset&action=' + action) + .then(function(d) { + if (d.error) showStatus('err', d.message); + else { + showStatus('ok', action + ' reset to default'); + setTimeout(function() { location.reload(); }, 400); + } + }) + .catch(function(e) { showStatus('err', 'Error: ' + e.message); }); +} + +function resetAllShortcuts() { + sovereignGet('sovereign://settings/set?key=shortcut.reset_all') + .then(function(d) { + if (d.error) showStatus('err', d.message); + else { + showStatus('ok', 'All shortcuts reset to defaults'); + setTimeout(function() { location.reload(); }, 400); + } + }) + .catch(function(e) { showStatus('err', 'Error: ' + e.message); }); +} + +document.addEventListener('keydown', function(e) { + if (!capturingAction) return; + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'Escape') { + capturingDisplay.classList.remove('capturing'); + capturingDisplay.textContent = + capturingDisplay.getAttribute('data-prev') || ''; + capturingAction = null; + capturingDisplay = null; + showStatus('ok', 'Cancelled'); + return; + } + var bareMod = ['Control','Shift','Alt','Meta'].indexOf(e.key) >= 0; + if (bareMod) return; + var accel = buildAccel(e); + if (!accel) return; + capturingDisplay.classList.remove('capturing'); + capturingDisplay.textContent = displayAccel(accel); + var action = capturingAction; + capturingAction = null; + capturingDisplay = null; + commitShortcut(action, accel); +}, true); + +/* ── Toggle / save actions ──────────────────────────────────────── */ +function toggle(feature) { + if (feature === 'theme_dark') { + var h = document.documentElement; + var isDark = h.classList.contains('dark'); + if (isDark) h.classList.remove('dark'); + else h.classList.add('dark'); + var btn = document.getElementById('toggle_theme_dark'); + if (btn) setToggleClass(btn, !isDark); + var x = new XMLHttpRequest(); + x.open('GET', 'sovereign://settings/set?feature=' + feature, true); + x.send(); + showStatus('ok', 'theme_dark: ' + (isDark ? 'OFF' : 'ON')); + return; + } + var x = new XMLHttpRequest(); + x.open('GET', 'sovereign://settings/set?feature=' + feature, true); + x.onreadystatechange = function() { + if (x.readyState !== 4) return; + try { + var d = JSON.parse(x.responseText); + if (d.error) showStatus('err', d.message); + else { + showStatus('ok', d.key + ': ' + (d.value ? 'ON' : 'OFF')); + setTimeout(function() { location.reload(); }, 300); + } + } catch (e) { showStatus('err', 'Error: ' + e.message); } + }; + x.send(); +} + +function save(key) { + var el = document.getElementById(key); + var val = el ? el.value : ''; + var x = new XMLHttpRequest(); + x.open('GET', 'sovereign://settings/set?key=' + encodeURIComponent(key) + + '&value=' + encodeURIComponent(val), true); + x.onreadystatechange = function() { + if (x.readyState !== 4) return; + try { + var d = JSON.parse(x.responseText); + if (d.error) showStatus('err', d.message); + else { + showStatus('ok', d.key + ' = ' + d.value); + if (d.reload) setTimeout(function() { location.reload(); }, 300); + } + } catch (e) { showStatus('err', 'Error: ' + e.message); } + }; + x.send(); +} + +/* ── Init ───────────────────────────────────────────────────────── */ +sovereignGet('sovereign://settings/config?_=' + Date.now()) + .then(function(d) { applyConfig(d); }) + .catch(function(e) { + showStatus('err', 'Failed to load settings: ' + e.message); + }); diff --git a/www/sovereign-base.css b/www/sovereign-base.css new file mode 100644 index 0000000..7b97532 --- /dev/null +++ b/www/sovereign-base.css @@ -0,0 +1,149 @@ +/* Shared sovereign:// page CSS (theme variables, base elements, buttons). + * Mirrors sovereign_page_css() in src/nostr_bridge.c so the embedded + * file-based pages stay consistent with the legacy C-string pages. + * Linked by settings.html, bookmarks.html, profile.html, agents/config.html. */ +:root { + --font: monospace; + --primary: #000000; + --secondary: #ffffff; + --accent: #ff0000; + --muted: #dddddd; + --border: var(--muted); + --radius: 5px; + --bg: var(--secondary); + --fg: var(--primary); + --img-gray: 100%; + --img-gray-hover: 20%; + color-scheme: light; +} +html.dark { + --primary: #ffffff; + --secondary: #000000; + --accent: #ff0000; + --muted: #777777; + --border: var(--muted); + color-scheme: dark; +} +* { font-family: var(--font); margin: 0; padding: 0; box-sizing: border-box; } +html { transition: background-color 0.2s ease, color 0.2s ease; } +body { + color: var(--fg); background: var(--bg); + max-width: 800px; margin: 40px auto; padding: 20px; + line-height: 1.5; +} +h1 { + color: var(--primary); text-align: center; + border-bottom: 2px solid var(--primary); + padding-bottom: 8px; margin-bottom: 20px; +} +h2 { + color: var(--primary); margin-top: 30px; margin-bottom: 10px; + border-bottom: 1px solid var(--border); padding-bottom: 4px; +} +h3 { color: var(--primary); margin-top: 20px; margin-bottom: 8px; } +a { color: var(--accent); text-decoration: none; transition: 0.2s; } +a:hover { text-decoration: underline; } +img { filter: grayscale(var(--img-gray)); transition: filter 0.2s; } +img:hover { filter: grayscale(var(--img-gray-hover)); } +.note { color: var(--muted); font-size: 12px; margin: 10px 0; } +.info { color: var(--muted); font-size: 12px; word-break: break-all; } +.missing { color: var(--muted); font-style: italic; } +.info-row { + display: flex; justify-content: space-between; + gap: 20px; padding: 6px 0; + border-bottom: 1px solid var(--border); +} +.info-row span:first-child { font-weight: bold; flex-shrink: 0; } +.setting, .field { + display: flex; justify-content: space-between; + align-items: center; gap: 20px; padding: 12px 0; + border-bottom: 1px solid var(--border); +} +.setting-name { font-weight: bold; } +.setting-desc { color: var(--muted); font-size: 12px; margin-top: 4px; } +.toggle { + position: relative; width: 50px; height: 24px; + background: var(--muted); border-radius: 12px; cursor: pointer; + transition: background 0.2s; flex-shrink: 0; +} +.toggle.on { background: var(--primary); } +.toggle.off { background: var(--muted); } +.toggle::after { + content: ''; position: absolute; top: 2px; left: 2px; + width: 20px; height: 20px; border-radius: 50%; background: var(--secondary); + transition: transform 0.2s; +} +.toggle.on::after { transform: translateX(26px); } +input, select, textarea { + background: var(--bg); color: var(--fg); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 6px 10px; font-family: var(--font); font-size: 13px; + min-width: 200px; +} +input:focus, select:focus, textarea:focus { + border-color: var(--accent); outline: none; +} +.btn, .save-btn, button.btn { + background: var(--bg); color: var(--primary); + -webkit-appearance: none !important; appearance: none !important; + border-width: 1px !important; border-style: solid !important; + border-color: var(--primary) !important; border-radius: var(--radius); + padding: 6px 16px; cursor: pointer; font-family: var(--font); + font-size: 13px; font-weight: bold; margin-left: 8px; + display: inline-block; text-decoration: none; + transition: border-color 0.2s, background 0.2s, color 0.2s; +} +.btn:hover, .save-btn:hover, button.btn:hover { border-color: var(--accent) !important; text-decoration: none; } +.btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-del { border-color: var(--accent); color: var(--accent); } +.btn-del:hover { background: var(--accent); color: var(--secondary); } +.status { + padding: 8px; margin: 10px 0; border-radius: var(--radius); + display: none; border: 1px solid var(--border); +} +.status.show { display: block; } +.status.ok { border-color: var(--primary); color: var(--primary); } +.status.err { border-color: var(--accent); color: var(--accent); } +.shortcut-display { + display: inline-block; min-width: 120px; + padding: 4px 10px; background: var(--bg); border: 1px solid var(--border); + border-radius: var(--radius); font-family: var(--font); font-size: 13px; + text-align: center; +} +.shortcut-display.capturing { + border-color: var(--accent); + animation: pulse 1s infinite; +} +@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} } +.shortcut-row.duplicate .shortcut-display { border-color: var(--accent); } +.sc-change, .sc-reset { min-width: 70px; } +.qr { text-align: center; margin: 12px 0; } +.qr img { + image-rendering: pixelated; border: 4px solid var(--secondary); + border-radius: 4px; +} +.theme-toggle { + position: fixed; top: 20px; right: 20px; + display: flex; align-items: center; gap: 8px; cursor: pointer; + text-decoration: none; user-select: none; + font-size: 12px; color: var(--muted); user-select: none; + border: 1px solid var(--border); border-radius: var(--radius); + padding: 4px 10px; background: var(--bg); z-index: 10; +} +.theme-toggle:hover { border-color: var(--accent); color: var(--accent); } +.bm { + display: flex; justify-content: space-between; align-items: center; + gap: 20px; padding: 8px 0; border-bottom: 1px solid var(--border); +} +.bm-url { color: var(--accent); text-decoration: none; word-break: break-all; } +.bm-url:hover { text-decoration: underline; } +.bm-title { color: var(--primary); font-weight: bold; } +.bm-meta { color: var(--muted); font-size: 11px; } +.actions { display: flex; gap: 8px; flex-shrink: 0; } +.form { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; } +.empty { color: var(--muted); font-style: italic; padding: 8px 0; } +.dir-header { + display: flex; justify-content: space-between; + align-items: center; gap: 20px; +} diff --git a/www/vendor/marked.min.js b/www/vendor/marked.min.js new file mode 100644 index 0000000..ce72a25 --- /dev/null +++ b/www/vendor/marked.min.js @@ -0,0 +1,6 @@ +/** + * marked v9.0.3 - a markdown parser + * Copyright (c) 2011-2023, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;const p=/(^|[^\[])\^/g;function u(e,t){e="string"==typeof e?e:e.source,t=t||"";const n={replace:(t,s)=>(s=(s="object"==typeof s&&"source"in s?s.source:s).replace(p,"$1"),e=e.replace(t,s),n),getRegex:()=>new RegExp(e,t)};return n}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const k={exec:()=>null};function f(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:d(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline._escapes,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=d(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){const e=t[0].replace(/^ *>[ \t]?/gm,""),n=this.lexer.state.top;this.lexer.state.top=!0;const s=this.lexer.blockTokens(e);return this.lexer.state.top=n,{type:"blockquote",raw:t[0],tokens:s,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l="",o="",a=!1;for(;e;){let n=!1;if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;l=t[0],e=e.substring(l.length);let s=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=0;this.options.pedantic?(h=2,o=s.trimStart()):(h=t[2].search(/[^ ]/),h=h>4?1:h,o=s.slice(h),h+=t[1].length);let p=!1;if(!s&&/^ *$/.test(c)&&(l+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,h-1)}}#`);for(;e;){const a=e.split("\n",1)[0];if(c=a,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=h||!c.trim())o+="\n"+c.slice(h);else{if(p)break;if(s.search(/[^ ]/)>=4)break;if(r.test(s))break;if(i.test(s))break;if(n.test(s))break;o+="\n"+c}p||c.trim()||(p=!0),l+=a+"\n",e=e.substring(a.length+1),s=c.slice(h)}}r.loose||(a?r.loose=!0:/\n *\n *$/.test(l)&&(a=!0));let u,g=null;this.options.gfm&&(g=/^\[[ xX]\] /.exec(o),g&&(u="[ ] "!==g[0],o=o.replace(/^\[[ xX]\] +/,""))),r.items.push({type:"list_item",raw:l,task:!!g,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=l}r.items[r.items.length-1].raw=l.trimEnd(),r.items[r.items.length-1].text=o.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>/\n.*\n/.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e$/,"$1").replace(this.rules.inline._escapes,"$1"):"",s=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline._escapes,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:n,title:s}}}table(e){const t=this.rules.block.table.exec(e);if(t){if(!/[:|]/.test(t[2]))return;const e={type:"table",raw:t[0],header:f(t[1]).map((e=>({text:e,tokens:[]}))),align:t[2].replace(/^\||\| *$/g,"").split("|"),rows:t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[]};if(e.header.length===e.align.length){let t,n,s,r,i=e.align.length;for(t=0;t({text:e,tokens:[]})));for(i=e.header.length,n=0;n/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=d(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),/^$/.test(e)?n.slice(1):n.slice(1,-1)),x(t,{href:n?n.replace(this.rules.inline._escapes,"$1"):n,title:s?s.replace(this.rules.inline._escapes,"$1"):s},t[0],this.lexer)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let e=(n[2]||n[1]).replace(/\s+/g," ");if(e=t[e.toLowerCase()],!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return x(n,e,n[0],this.lexer)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrong.lDelim.exec(e);if(!s)return;if(s[3]&&n.match(/[\p{L}\p{N}]/u))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrong.rDelimAst:this.rules.inline.emStrong.rDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+s[0].length-1);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...e].slice(0,n+s.index+i+1).join("");if(Math.min(n,i)%2){const e=t.slice(1,-1);return{type:"em",raw:t,text:e,tokens:this.lexer.inlineTokens(e)}}const a=t.slice(2,-2);return{type:"strong",raw:t,text:a,tokens:this.lexer.inlineTokens(a)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const n=/[^ ]/.test(e),s=/^ /.test(e)&&/ $/.test(e);return n&&s&&(e=e.substring(1,e.length-1)),e=c(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=c(t[1]),n="mailto:"+e):(e=c(t[1]),n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=c(t[0]),n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])[0]}while(s!==t[0]);e=c(t[0]),n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){let e;return e=this.lexer.state.inRawBlock?t[0]:c(t[0]),{type:"text",raw:t[0],text:e}}}}const m={newline:/^(?: *(?:\n|$))+/,code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,hr:/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,blockquote:/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/,list:/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/,html:"^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))",def:/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/,table:k,lheading:/^(?!bull )((?:.|\n(?!\s*?\n|bull ))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,_paragraph:/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,text:/^[^\n]+/,_label:/(?!\s*\])(?:\\.|[^\[\]\\])+/,_title:/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/};m.def=u(m.def).replace("label",m._label).replace("title",m._title).getRegex(),m.bullet=/(?:[*+-]|\d{1,9}[.)])/,m.listItemStart=u(/^( *)(bull) */).replace("bull",m.bullet).getRegex(),m.list=u(m.list).replace(/bull/g,m.bullet).replace("hr","\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))").replace("def","\\n+(?="+m.def.source+")").getRegex(),m._tag="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",m._comment=/|$)/,m.html=u(m.html,"i").replace("comment",m._comment).replace("tag",m._tag).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),m.lheading=u(m.lheading).replace(/bull/g,m.bullet).getRegex(),m.paragraph=u(m._paragraph).replace("hr",m.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",m._tag).getRegex(),m.blockquote=u(m.blockquote).replace("paragraph",m.paragraph).getRegex(),m.normal={...m},m.gfm={...m.normal,table:"^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)"},m.gfm.table=u(m.gfm.table).replace("hr",m.hr).replace("heading"," {0,3}#{1,6} ").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",m._tag).getRegex(),m.gfm.paragraph=u(m._paragraph).replace("hr",m.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("table",m.gfm.table).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",m._tag).getRegex(),m.pedantic={...m.normal,html:u("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",m._comment).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:k,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:u(m.normal._paragraph).replace("hr",m.hr).replace("heading"," *#{1,6} *[^\n]").replace("lheading",m.lheading).replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").getRegex()};const w={escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,autolink:/^<(scheme:[^\s\x00-\x1f<>]*|email)>/,url:k,tag:"^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^",link:/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,reflink:/^!?\[(label)\]\[(ref)\]/,nolink:/^!?\[(ref)\](?:\[\])?/,reflinkSearch:"reflink|nolink(?!\\()",emStrong:{lDelim:/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,rDelimAst:/^[^_*]*?__[^_*]*?\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\*)[punct](\*+)(?=[\s]|$)|[^punct\s](\*+)(?!\*)(?=[punct\s]|$)|(?!\*)[punct\s](\*+)(?=[^punct\s])|[\s](\*+)(?!\*)(?=[punct])|(?!\*)[punct](\*+)(?!\*)(?=[punct])|[^punct\s](\*+)(?=[^punct\s])/,rDelimUnd:/^[^_*]*?\*\*[^_*]*?_[^_*]*?(?=\*\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\s]|$)|[^punct\s](_+)(?!_)(?=[punct\s]|$)|(?!_)[punct\s](_+)(?=[^punct\s])|[\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])/},code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,br:/^( {2,}|\\)\n(?!\s*$)/,del:k,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`^|~"};w.punctuation=u(w.punctuation,"u").replace(/punctuation/g,w._punctuation).getRegex(),w.blockSkip=/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,w.anyPunctuation=/\\[punct]/g,w._escapes=/\\([punct])/g,w._comment=u(m._comment).replace("(?:--\x3e|$)","--\x3e").getRegex(),w.emStrong.lDelim=u(w.emStrong.lDelim,"u").replace(/punct/g,w._punctuation).getRegex(),w.emStrong.rDelimAst=u(w.emStrong.rDelimAst,"gu").replace(/punct/g,w._punctuation).getRegex(),w.emStrong.rDelimUnd=u(w.emStrong.rDelimUnd,"gu").replace(/punct/g,w._punctuation).getRegex(),w.anyPunctuation=u(w.anyPunctuation,"gu").replace(/punct/g,w._punctuation).getRegex(),w._escapes=u(w._escapes,"gu").replace(/punct/g,w._punctuation).getRegex(),w._scheme=/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/,w._email=/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/,w.autolink=u(w.autolink).replace("scheme",w._scheme).replace("email",w._email).getRegex(),w._attribute=/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/,w.tag=u(w.tag).replace("comment",w._comment).replace("attribute",w._attribute).getRegex(),w._label=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,w._href=/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/,w._title=/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/,w.link=u(w.link).replace("label",w._label).replace("href",w._href).replace("title",w._title).getRegex(),w.reflink=u(w.reflink).replace("label",w._label).replace("ref",m._label).getRegex(),w.nolink=u(w.nolink).replace("ref",m._label).getRegex(),w.reflinkSearch=u(w.reflinkSearch,"g").replace("reflink",w.reflink).replace("nolink",w.nolink).getRegex(),w.normal={...w},w.pedantic={...w.normal,strong:{start:/^__|\*\*/,middle:/^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,endAst:/\*\*(?!\*)/g,endUnd:/__(?!_)/g},em:{start:/^_|\*/,middle:/^()\*(?=\S)([\s\S]*?\S)\*(?!\*)|^_(?=\S)([\s\S]*?\S)_(?!_)/,endAst:/\*(?!\*)/g,endUnd:/_(?!_)/g},link:u(/^!?\[(label)\]\((.*?)\)/).replace("label",w._label).getRegex(),reflink:u(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",w._label).getRegex()},w.gfm={...w.normal,escape:u(w.escape).replace("])","~|])").getRegex(),_extended_email:/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/,url:/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\t+" ".repeat(n.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.space(e))e=e.substring(n.raw.length),1===n.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(n);else if(n=this.tokenizer.code(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?t.push(n):(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.fences(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.heading(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.hr(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.blockquote(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.list(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.html(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.def(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title}):(s.raw+="\n"+n.raw,s.text+="\n"+n.raw,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.table(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.lheading(e))e=e.substring(n.raw.length),t.push(n);else{if(r=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(n=this.tokenizer.paragraph(r)))s=t[t.length-1],i&&"paragraph"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n),i=r.length!==e.length,e=e.substring(n.raw.length);else if(n=this.tokenizer.text(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n,s,r,i,l,o,a=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(i=this.tokenizer.rules.inline.reflinkSearch.exec(a));)e.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(i=this.tokenizer.rules.inline.blockSkip.exec(a));)a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(i=this.tokenizer.rules.inline.anyPunctuation.exec(a));)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;e;)if(l||(o=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,a,o))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e))){if(r=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(n=this.tokenizer.inlineText(r))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(o=n.raw.slice(-1)),l=!0,s=t[t.length-1],s&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(n.raw.length),t.push(n);return t}}class y{options;constructor(t){this.options=t||e.defaults}code(e,t,n){const s=(t||"").match(/^\S*/)?.[0];return e=e.replace(/\n$/,"")+"\n",s?'
'+(n?e:c(e,!0))+"
\n":"
"+(n?e:c(e,!0))+"
\n"}blockquote(e){return`
\n${e}
\n`}html(e,t){return e}heading(e,t,n){return`${e}\n`}hr(){return"
\n"}list(e,t,n){const s=t?"ol":"ul";return"<"+s+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+"\n"}listitem(e,t,n){return`
  • ${e}
  • \n`}checkbox(e){return"'}paragraph(e){return`

    ${e}

    \n`}table(e,t){return t&&(t=`${t}`),"\n\n"+e+"\n"+t+"
    \n"}tablerow(e){return`\n${e}\n`}tablecell(e,t){const n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+`\n`}strong(e){return`${e}`}em(e){return`${e}`}codespan(e){return`${e}`}br(){return"
    "}del(e){return`${e}`}link(e,t,n){const s=g(e);if(null===s)return n;let r='",r}image(e,t,n){const s=g(e);if(null===s)return n;let r=`${n}"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):"")));continue}case"code":{const e=r;n+=this.renderer.code(e.text,e.lang,!!e.escaped);continue}case"table":{const e=r;let t="",s="";for(let t=0;t0&&"paragraph"===n.tokens[0].type?(n.tokens[0].text=e+" "+n.tokens[0].text,n.tokens[0].tokens&&n.tokens[0].tokens.length>0&&"text"===n.tokens[0].tokens[0].type&&(n.tokens[0].tokens[0].text=e+" "+n.tokens[0].tokens[0].text)):n.tokens.unshift({type:"text",text:e+" "}):o+=e+" "}o+=this.parse(n.tokens,i),l+=this.renderer.listitem(o,r,!!s)}n+=this.renderer.list(l,t,s);continue}case"html":{const e=r;n+=this.renderer.html(e.text,e.block);continue}case"paragraph":{const e=r;n+=this.renderer.paragraph(this.parseInline(e.tokens));continue}case"text":{let i=r,l=i.tokens?this.parseInline(i.tokens):i.text;for(;s+1{n=n.concat(this.walkTokens(e[s],t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new y(this.defaults);for(const n in e.renderer){const s=e.renderer[n],r=n,i=t[r];t[r]=(...e)=>{let n=s.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new b(this.defaults);for(const n in e.tokenizer){const s=e.tokenizer[n],r=n,i=t[r];t[r]=(...e)=>{let n=s.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new T;for(const n in e.hooks){const s=e.hooks[n],r=n,i=t[r];T.passThroughHooks.has(n)?t[r]=e=>{if(this.defaults.async)return Promise.resolve(s.call(t,e)).then((e=>i.call(t,e)));const n=s.call(t,e);return i.call(t,n)}:t[r]=(...e)=>{let n=s.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}#e(e,t){return(n,s)=>{const r={...s},i={...this.defaults,...r};!0===this.defaults.async&&!1===r.async&&(i.silent||console.warn("marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored."),i.async=!0);const l=this.#t(!!i.silent,!!i.async);if(null==n)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof n)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i),i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then((t=>e(t,i))).then((e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then((()=>e)):e)).then((e=>t(e,i))).then((e=>i.hooks?i.hooks.postprocess(e):e)).catch(l);try{i.hooks&&(n=i.hooks.preprocess(n));const s=e(n,i);i.walkTokens&&this.walkTokens(s,i.walkTokens);let r=t(s,i);return i.hooks&&(r=i.hooks.postprocess(r)),r}catch(e){return l(e)}}}#t(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+c(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const S=new R;function A(e,t){return S.parse(e,t)}A.options=A.setOptions=function(e){return S.setOptions(e),A.defaults=S.defaults,n(A.defaults),A},A.getDefaults=t,A.defaults=e.defaults,A.use=function(...e){return S.use(...e),A.defaults=S.defaults,n(A.defaults),A},A.walkTokens=function(e,t){return S.walkTokens(e,t)},A.parseInline=S.parseInline,A.Parser=z,A.parser=z.parse,A.Renderer=y,A.TextRenderer=$,A.Lexer=_,A.lexer=_.lex,A.Tokenizer=b,A.Hooks=T,A.parse=A;const I=A.options,E=A.setOptions,Z=A.use,q=A.walkTokens,L=A.parseInline,D=A,P=z.parse,v=_.lex;e.Hooks=T,e.Lexer=_,e.Marked=R,e.Parser=z,e.Renderer=y,e.TextRenderer=$,e.Tokenizer=b,e.getDefaults=t,e.lexer=v,e.marked=A,e.options=I,e.parse=D,e.parseInline=L,e.parser=P,e.setOptions=E,e.use=Z,e.walkTokens=q})); diff --git a/www/vendor/purify.min.js b/www/vendor/purify.min.js new file mode 100644 index 0000000..73df78d --- /dev/null +++ b/www/vendor/purify.min.js @@ -0,0 +1,3 @@ +/*! @license DOMPurify 3.2.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.6/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=R(Array.prototype.forEach),m=R(Array.prototype.lastIndexOf),p=R(Array.prototype.pop),f=R(Array.prototype.push),d=R(Array.prototype.splice),h=R(String.prototype.toLowerCase),g=R(String.prototype.toString),T=R(String.prototype.match),y=R(String.prototype.replace),E=R(String.prototype.indexOf),A=R(String.prototype.trim),_=R(Object.prototype.hasOwnProperty),S=R(RegExp.prototype.test),b=(N=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:h;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function O(e){for(let t=0;t/gm),G=a(/\$\{[\w\W]*/gm),Y=a(/^data-[\-\w.\u00B7-\uFFFF]+$/),j=a(/^aria-[\-\w]+$/),X=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),q=a(/^(?:\w+script|data):/i),$=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),K=a(/^html$/i),V=a(/^[a-z][.\w]*(-[.\w]+)+$/i);var Z=Object.freeze({__proto__:null,ARIA_ATTR:j,ATTR_WHITESPACE:$,CUSTOM_ELEMENT:V,DATA_ATTR:Y,DOCTYPE_NAME:K,ERB_EXPR:W,IS_ALLOWED_URI:X,IS_SCRIPT_OR_DATA:q,MUSTACHE_EXPR:B,TMPLIT_EXPR:G});const J=1,Q=3,ee=7,te=8,ne=9,oe=function(){return"undefined"==typeof window?null:window};var re=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:oe();const o=e=>t(e);if(o.version="3.2.6",o.removed=[],!n||!n.document||n.document.nodeType!==ne||!n.Element)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:N,Node:R,Element:O,NodeFilter:B,NamedNodeMap:W=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:G,DOMParser:Y,trustedTypes:j}=n,q=O.prototype,$=v(q,"cloneNode"),V=v(q,"remove"),re=v(q,"nextSibling"),ie=v(q,"childNodes"),ae=v(q,"parentNode");if("function"==typeof N){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let le,ce="";const{implementation:se,createNodeIterator:ue,createDocumentFragment:me,getElementsByTagName:pe}=r,{importNode:fe}=a;let de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof e&&"function"==typeof ae&&se&&void 0!==se.createHTMLDocument;const{MUSTACHE_EXPR:he,ERB_EXPR:ge,TMPLIT_EXPR:Te,DATA_ATTR:ye,ARIA_ATTR:Ee,IS_SCRIPT_OR_DATA:Ae,ATTR_WHITESPACE:_e,CUSTOM_ELEMENT:Se}=Z;let{IS_ALLOWED_URI:be}=Z,Ne=null;const Re=w({},[...L,...C,...x,...M,...U]);let we=null;const Oe=w({},[...z,...P,...H,...F]);let De=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ve=null,Le=null,Ce=!0,xe=!0,Ie=!1,Me=!0,ke=!1,Ue=!0,ze=!1,Pe=!1,He=!1,Fe=!1,Be=!1,We=!1,Ge=!0,Ye=!1,je=!0,Xe=!1,qe={},$e=null;const Ke=w({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Ve=null;const Ze=w({},["audio","video","img","source","image","track"]);let Je=null;const Qe=w({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),et="http://www.w3.org/1998/Math/MathML",tt="http://www.w3.org/2000/svg",nt="http://www.w3.org/1999/xhtml";let ot=nt,rt=!1,it=null;const at=w({},[et,tt,nt],g);let lt=w({},["mi","mo","mn","ms","mtext"]),ct=w({},["annotation-xml"]);const st=w({},["title","style","font","a","script"]);let ut=null;const mt=["application/xhtml+xml","text/html"];let pt=null,ft=null;const dt=r.createElement("form"),ht=function(e){return e instanceof RegExp||e instanceof Function},gt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ft||ft!==e){if(e&&"object"==typeof e||(e={}),e=D(e),ut=-1===mt.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,pt="application/xhtml+xml"===ut?g:h,Ne=_(e,"ALLOWED_TAGS")?w({},e.ALLOWED_TAGS,pt):Re,we=_(e,"ALLOWED_ATTR")?w({},e.ALLOWED_ATTR,pt):Oe,it=_(e,"ALLOWED_NAMESPACES")?w({},e.ALLOWED_NAMESPACES,g):at,Je=_(e,"ADD_URI_SAFE_ATTR")?w(D(Qe),e.ADD_URI_SAFE_ATTR,pt):Qe,Ve=_(e,"ADD_DATA_URI_TAGS")?w(D(Ze),e.ADD_DATA_URI_TAGS,pt):Ze,$e=_(e,"FORBID_CONTENTS")?w({},e.FORBID_CONTENTS,pt):Ke,ve=_(e,"FORBID_TAGS")?w({},e.FORBID_TAGS,pt):D({}),Le=_(e,"FORBID_ATTR")?w({},e.FORBID_ATTR,pt):D({}),qe=!!_(e,"USE_PROFILES")&&e.USE_PROFILES,Ce=!1!==e.ALLOW_ARIA_ATTR,xe=!1!==e.ALLOW_DATA_ATTR,Ie=e.ALLOW_UNKNOWN_PROTOCOLS||!1,Me=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,ke=e.SAFE_FOR_TEMPLATES||!1,Ue=!1!==e.SAFE_FOR_XML,ze=e.WHOLE_DOCUMENT||!1,Fe=e.RETURN_DOM||!1,Be=e.RETURN_DOM_FRAGMENT||!1,We=e.RETURN_TRUSTED_TYPE||!1,He=e.FORCE_BODY||!1,Ge=!1!==e.SANITIZE_DOM,Ye=e.SANITIZE_NAMED_PROPS||!1,je=!1!==e.KEEP_CONTENT,Xe=e.IN_PLACE||!1,be=e.ALLOWED_URI_REGEXP||X,ot=e.NAMESPACE||nt,lt=e.MATHML_TEXT_INTEGRATION_POINTS||lt,ct=e.HTML_INTEGRATION_POINTS||ct,De=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(De.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(De.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(De.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),ke&&(xe=!1),Be&&(Fe=!0),qe&&(Ne=w({},U),we=[],!0===qe.html&&(w(Ne,L),w(we,z)),!0===qe.svg&&(w(Ne,C),w(we,P),w(we,F)),!0===qe.svgFilters&&(w(Ne,x),w(we,P),w(we,F)),!0===qe.mathMl&&(w(Ne,M),w(we,H),w(we,F))),e.ADD_TAGS&&(Ne===Re&&(Ne=D(Ne)),w(Ne,e.ADD_TAGS,pt)),e.ADD_ATTR&&(we===Oe&&(we=D(we)),w(we,e.ADD_ATTR,pt)),e.ADD_URI_SAFE_ATTR&&w(Je,e.ADD_URI_SAFE_ATTR,pt),e.FORBID_CONTENTS&&($e===Ke&&($e=D($e)),w($e,e.FORBID_CONTENTS,pt)),je&&(Ne["#text"]=!0),ze&&w(Ne,["html","head","body"]),Ne.table&&(w(Ne,["tbody"]),delete ve.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');le=e.TRUSTED_TYPES_POLICY,ce=le.createHTML("")}else void 0===le&&(le=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(j,c)),null!==le&&"string"==typeof ce&&(ce=le.createHTML(""));i&&i(e),ft=e}},Tt=w({},[...C,...x,...I]),yt=w({},[...M,...k]),Et=function(e){f(o.removed,{element:e});try{ae(e).removeChild(e)}catch(t){V(e)}},At=function(e,t){try{f(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){f(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e)if(Fe||Be)try{Et(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},_t=function(e){let t=null,n=null;if(He)e=""+e;else{const t=T(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===ut&&ot===nt&&(e=''+e+"");const o=le?le.createHTML(e):e;if(ot===nt)try{t=(new Y).parseFromString(o,ut)}catch(e){}if(!t||!t.documentElement){t=se.createDocument(ot,"template",null);try{t.documentElement.innerHTML=rt?ce:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),ot===nt?pe.call(t,ze?"html":"body")[0]:ze?t.documentElement:i},St=function(e){return ue.call(e.ownerDocument||e,e,B.SHOW_ELEMENT|B.SHOW_COMMENT|B.SHOW_TEXT|B.SHOW_PROCESSING_INSTRUCTION|B.SHOW_CDATA_SECTION,null)},bt=function(e){return e instanceof G&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof W)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Nt=function(e){return"function"==typeof R&&e instanceof R};function Rt(e,t,n){u(e,(e=>{e.call(o,t,n,ft)}))}const wt=function(e){let t=null;if(Rt(de.beforeSanitizeElements,e,null),bt(e))return Et(e),!0;const n=pt(e.nodeName);if(Rt(de.uponSanitizeElement,e,{tagName:n,allowedTags:Ne}),Ue&&e.hasChildNodes()&&!Nt(e.firstElementChild)&&S(/<[/\w!]/g,e.innerHTML)&&S(/<[/\w!]/g,e.textContent))return Et(e),!0;if(e.nodeType===ee)return Et(e),!0;if(Ue&&e.nodeType===te&&S(/<[/\w]/g,e.data))return Et(e),!0;if(!Ne[n]||ve[n]){if(!ve[n]&&Dt(n)){if(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n))return!1;if(De.tagNameCheck instanceof Function&&De.tagNameCheck(n))return!1}if(je&&!$e[n]){const t=ae(e)||e.parentNode,n=ie(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o){const r=$(n[o],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,re(e))}}}return Et(e),!0}return e instanceof O&&!function(e){let t=ae(e);t&&t.tagName||(t={namespaceURI:ot,tagName:"template"});const n=h(e.tagName),o=h(t.tagName);return!!it[e.namespaceURI]&&(e.namespaceURI===tt?t.namespaceURI===nt?"svg"===n:t.namespaceURI===et?"svg"===n&&("annotation-xml"===o||lt[o]):Boolean(Tt[n]):e.namespaceURI===et?t.namespaceURI===nt?"math"===n:t.namespaceURI===tt?"math"===n&&ct[o]:Boolean(yt[n]):e.namespaceURI===nt?!(t.namespaceURI===tt&&!ct[o])&&!(t.namespaceURI===et&&!lt[o])&&!yt[n]&&(st[n]||!Tt[n]):!("application/xhtml+xml"!==ut||!it[e.namespaceURI]))}(e)?(Et(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!S(/<\/no(script|embed|frames)/i,e.innerHTML)?(ke&&e.nodeType===Q&&(t=e.textContent,u([he,ge,Te],(e=>{t=y(t,e," ")})),e.textContent!==t&&(f(o.removed,{element:e.cloneNode()}),e.textContent=t)),Rt(de.afterSanitizeElements,e,null),!1):(Et(e),!0)},Ot=function(e,t,n){if(Ge&&("id"===t||"name"===t)&&(n in r||n in dt))return!1;if(xe&&!Le[t]&&S(ye,t));else if(Ce&&S(Ee,t));else if(!we[t]||Le[t]){if(!(Dt(e)&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,e)||De.tagNameCheck instanceof Function&&De.tagNameCheck(e))&&(De.attributeNameCheck instanceof RegExp&&S(De.attributeNameCheck,t)||De.attributeNameCheck instanceof Function&&De.attributeNameCheck(t))||"is"===t&&De.allowCustomizedBuiltInElements&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n)||De.tagNameCheck instanceof Function&&De.tagNameCheck(n))))return!1}else if(Je[t]);else if(S(be,y(n,_e,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==E(n,"data:")||!Ve[e]){if(Ie&&!S(Ae,y(n,_e,"")));else if(n)return!1}else;return!0},Dt=function(e){return"annotation-xml"!==e&&T(e,Se)},vt=function(e){Rt(de.beforeSanitizeAttributes,e,null);const{attributes:t}=e;if(!t||bt(e))return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:we,forceKeepAttr:void 0};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=pt(a),m=c;let f="value"===a?m:A(m);if(n.attrName=s,n.attrValue=f,n.keepAttr=!0,n.forceKeepAttr=void 0,Rt(de.uponSanitizeAttribute,e,n),f=n.attrValue,!Ye||"id"!==s&&"name"!==s||(At(a,e),f="user-content-"+f),Ue&&S(/((--!?|])>)|<\/(style|title)/i,f)){At(a,e);continue}if(n.forceKeepAttr)continue;if(!n.keepAttr){At(a,e);continue}if(!Me&&S(/\/>/i,f)){At(a,e);continue}ke&&u([he,ge,Te],(e=>{f=y(f,e," ")}));const d=pt(e.nodeName);if(Ot(d,s,f)){if(le&&"object"==typeof j&&"function"==typeof j.getAttributeType)if(l);else switch(j.getAttributeType(d,s)){case"TrustedHTML":f=le.createHTML(f);break;case"TrustedScriptURL":f=le.createScriptURL(f)}if(f!==m)try{l?e.setAttributeNS(l,a,f):e.setAttribute(a,f),bt(e)?Et(e):p(o.removed)}catch(t){At(a,e)}}else At(a,e)}Rt(de.afterSanitizeAttributes,e,null)},Lt=function e(t){let n=null;const o=St(t);for(Rt(de.beforeSanitizeShadowDOM,t,null);n=o.nextNode();)Rt(de.uponSanitizeShadowNode,n,null),wt(n),vt(n),n.content instanceof s&&e(n.content);Rt(de.afterSanitizeShadowDOM,t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(rt=!e,rt&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Nt(e)){if("function"!=typeof e.toString)throw b("toString is not a function");if("string"!=typeof(e=e.toString()))throw b("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Pe||gt(t),o.removed=[],"string"==typeof e&&(Xe=!1),Xe){if(e.nodeName){const t=pt(e.nodeName);if(!Ne[t]||ve[t])throw b("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof R)n=_t("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===J&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!Fe&&!ke&&!ze&&-1===e.indexOf("<"))return le&&We?le.createHTML(e):e;if(n=_t(e),!n)return Fe?null:We?ce:""}n&&He&&Et(n.firstChild);const c=St(Xe?e:n);for(;i=c.nextNode();)wt(i),vt(i),i.content instanceof s&&Lt(i.content);if(Xe)return e;if(Fe){if(Be)for(l=me.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(we.shadowroot||we.shadowrootmode)&&(l=fe.call(a,l,!0)),l}let m=ze?n.outerHTML:n.innerHTML;return ze&&Ne["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&S(K,n.ownerDocument.doctype.name)&&(m="\n"+m),ke&&u([he,ge,Te],(e=>{m=y(m,e," ")})),le&&We?le.createHTML(m):m},o.setConfig=function(){gt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Pe=!0},o.clearConfig=function(){ft=null,Pe=!1},o.isValidAttribute=function(e,t,n){ft||gt({});const o=pt(e),r=pt(t);return Ot(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&f(de[e],t)},o.removeHook=function(e,t){if(void 0!==t){const n=m(de[e],t);return-1===n?void 0:d(de[e],n,1)[0]}return p(de[e])},o.removeHooks=function(e){de[e]=[]},o.removeAllHooks=function(){de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return re})); +//# sourceMappingURL=purify.min.js.map