diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bca7605 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + fmt: + name: 'Format (rustfmt)' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Rust (stable + rustfmt) + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + + - name: cargo fmt --check + run: cargo fmt --all --check + + test: + name: 'Test / Clippy (${{ matrix.rust }}, ${{ matrix.os }})' + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + rust: + - stable + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libglib2.0-dev pkg-config libgtk-3-dev libxdo-dev libappindicator3-dev + + - name: Install Rust (${{ matrix.rust }}) + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + + - name: cargo clippy + run: cargo clippy --all-targets + + - name: cargo test + run: cargo test --all-targets diff --git a/.github/workflows/merges.yml b/.github/workflows/merges.yml deleted file mode 100644 index 5869a39..0000000 --- a/.github/workflows/merges.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Merge Checks - -on: - push: - branches: "*" - pull_request: - branches: ["**"] - -jobs: - rust-checks: - name: Rust fmt and macOS ARM64 build - runs-on: macos-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Check formatting - run: cargo fmt --all --check - - - name: Cargo check for macOS ARM64 - run: cargo check --target aarch64-apple-darwin diff --git a/Cargo.lock b/Cargo.lock index 0216a74..4413c1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "aligned" version = "0.4.3" @@ -149,6 +158,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + [[package]] name = "arrayref" version = "0.3.9" @@ -394,6 +409,30 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "binrw" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81419ff39e6ed10a92a7f125290859776ced35d9a08a665ae40b23e7ca702f30" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "376404e55ec40d0d6f8b4b7df3f87b87954bd987f0cf9a7207ea3b6ea5c9add4" +dependencies = [ + "either", + "owo-colors", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -476,6 +515,17 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "built" version = "0.8.0" @@ -604,9 +654,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.54" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", "jobserver", @@ -642,6 +692,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -681,6 +745,15 @@ dependencies = [ "x11rb", ] +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -997,6 +1070,15 @@ dependencies = [ "phf", ] +[[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 = "endi" version = "1.1.1" @@ -1168,9 +1250,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" @@ -1262,6 +1344,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freedesktop-desktop-entry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28273c5c6b97a5f07724f6652f064c0c7f637f9aa5e7c09c83bc3bc4ad4ea245" +dependencies = [ + "bstr", + "gettext-rs", + "log", + "memchr", + "thiserror 2.0.18", + "unicase", + "xdg", +] + [[package]] name = "futures" version = "0.3.31" @@ -1456,6 +1553,38 @@ dependencies = [ "wasip2", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "gettext-rs" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5857dc1b7f0fee86961de833f434e29494d72af102ce5355738c0664222bdf" +dependencies = [ + "gettext-sys", + "locale_config", +] + +[[package]] +name = "gettext-sys" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea859ab0dd7e70ff823032b3e077d03d39c965d68c6c10775add60e999d8ee9" +dependencies = [ + "cc", + "temp-dir", +] + [[package]] name = "gif" version = "0.14.1" @@ -1562,6 +1691,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "global-hotkey" version = "0.7.0" @@ -1797,6 +1932,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "iced" version = "0.14.0" @@ -2118,7 +2277,7 @@ dependencies = [ "rgb", "tiff", "zune-core 0.5.1", - "zune-jpeg 0.5.11", + "zune-jpeg 0.5.12", ] [[package]] @@ -2158,6 +2317,25 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2167,6 +2345,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "jni" version = "0.21.1" @@ -2256,6 +2440,12 @@ dependencies = [ "smallvec", ] +[[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" @@ -2397,6 +2587,41 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lnk" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e8628ddc5b5ce2b0411d8f389886d3bb265f91f9413662fa81fdd108a9ae33" +dependencies = [ + "binrw", + "bitflags 2.10.0", + "chrono", + "encoding_rs", + "getset", + "log", + "num-derive 0.4.2", + "num-traits", + "serde", + "serde_json", + "substring", + "thiserror 2.0.18", + "uuid", + "winstructs", +] + +[[package]] +name = "locale_config" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934" +dependencies = [ + "lazy_static", + "objc", + "objc-foundation", + "regex", + "winapi", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -2654,6 +2879,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2664,6 +2898,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -2746,6 +2991,17 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -3107,12 +3363,32 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3157,6 +3433,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + [[package]] name = "pango" version = "0.18.3" @@ -3223,6 +3505,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3440,6 +3728,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3580,7 +3890,7 @@ dependencies = [ "maybe-rayon", "new_debug_unreachable", "noop_proc_macro", - "num-derive", + "num-derive 0.4.2", "num-traits", "paste", "profiling", @@ -3682,6 +3992,35 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3723,28 +4062,41 @@ dependencies = [ [[package]] name = "rustcast" -version = "0.1.0" +version = "0.5.2" dependencies = [ "anyhow", "arboard", + "codepage", + "dirs", "emojis", + "freedesktop-desktop-entry", + "glob", "global-hotkey", "iced", "icns", "image", + "lnk", "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-application-services", "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", + "open", "rand", "rayon", + "regex", "serde", "tokio", "toml 0.9.11+spec-1.1.0", + "tracing", + "tracing-subscriber", "tray-icon", "url", + "walkdir", + "widestring", + "windows 0.58.0", + "winreg", ] [[package]] @@ -3855,6 +4207,19 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3884,6 +4249,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3917,9 +4291,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skrifa" @@ -4092,6 +4466,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + [[package]] name = "svg_fmt" version = "0.4.5" @@ -4116,6 +4499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -4169,6 +4553,12 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "temp-dir" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964" + [[package]] name = "tempfile" version = "3.24.0" @@ -4231,6 +4621,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.10.3" @@ -4462,6 +4861,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -4505,6 +4930,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -4550,6 +4981,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -4580,6 +5012,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version-compare" version = "0.2.1" @@ -5002,6 +5440,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -5221,6 +5665,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5272,6 +5725,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5320,6 +5788,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5338,6 +5812,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5356,6 +5836,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5386,6 +5872,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5404,6 +5896,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5422,6 +5920,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5440,6 +5944,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5522,6 +6032,33 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winstructs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dc7406cd936173d9cc3a4fd5dc5b295bc59612439d72038e3d7ac4e5dd42de9" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "chrono", + "log", + "num-derive 0.3.3", + "num-traits", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5582,6 +6119,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +[[package]] +name = "xdg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" + [[package]] name = "xkbcommon-dl" version = "0.4.2" @@ -5711,18 +6254,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.34" +version = "0.8.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.34" +version = "0.8.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" dependencies = [ "proc-macro2", "quote", @@ -5783,6 +6326,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zmij" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" + [[package]] name = "zune-core" version = "0.4.12" @@ -5815,9 +6364,9 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2959ca473aae96a14ecedf501d20b3608d2825ba280d5adb57d651721885b0c2" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" dependencies = [ "zune-core 0.5.1", ] diff --git a/Cargo.toml b/Cargo.toml index 386a1b5..7e7cf09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,20 @@ [package] name = "rustcast" -version = "0.1.0" +version = "0.5.2" edition = "2024" -[dependencies] -anyhow = "1.0.100" -arboard = "3.6.1" -emojis = "0.8.0" -global-hotkey = "0.7.0" -iced = { version = "0.14.0", features = ["image", "tokio"] } -icns = "0.3.1" -image = "0.25.9" +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "0.52" +windows = { version = "0.58", features = [ + "Win32_UI_WindowsAndMessaging", + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_System_Com", + "Win32_UI_Shell", + "Win32_Globalization" +]} + +[target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.3" objc2-app-kit = { version = "0.3.2", features = ["NSImage"] } objc2-application-services = { version = "0.3.2", default-features = false, features = [ @@ -19,11 +23,53 @@ objc2-application-services = { version = "0.3.2", default-features = false, feat ] } objc2-core-foundation = "0.3.2" objc2-foundation = { version = "0.3.2", features = ["NSString"] } +icns = "0.3.1" + +[target.'cfg(target_os = "linux")'.dependencies] +freedesktop-desktop-entry = "0.8.1" + +[target.'cfg(not(target_os = "linux"))'.dependencies] +global-hotkey = "0.7.0" + +[dependencies] +anyhow = "1.0.100" +emojis = "0.8.0" +arboard = "3.6.1" +iced = { version = "0.14.0", features = ["image", "tokio"] } +image = "0.25.9" once_cell = "1.21.3" rand = "0.9.2" rayon = "1.11.0" serde = { version = "1.0.228", features = ["derive"] } tokio = { version = "1.48.0", features = ["full"] } toml = "0.9.8" +walkdir = "2" tray-icon = "0.21.3" -url = { version = "2.5.8", default-features = false } +url = "2.5.8" +tracing = "0.1.44" +tracing-subscriber = "0.3.22" +dirs = "6.0.0" +glob = "0.3.3" +open = "5.3.3" +regex = "1.12.2" +lnk = "0.6.3" +codepage = "0.1.2" +widestring = "1.2.1" + +[package.metadata.bundle] +name = "RustCast" +identifier = "com.umangsurana.rustcast" +icon = ["bundling/icon.icns"] +version = "1.0.0" +resources = [] +copyright = "Copyright Umang Surana (c) 2025" +category = "Developer Tool" +short_description = "An open source alternative to Raycast, and in rust" +osx_minimum_system_version = "10.15" + +[package.metadata.bundle.osx] +info_plist_path = "bundling/Info.plist" + +[package.metadata.bundle.osx.info] +LSUIElement = true +NSHighResolutionCapable = true diff --git a/assets/icon/icon.svg b/assets/icon/icon.svg new file mode 100644 index 0000000..2221ac1 --- /dev/null +++ b/assets/icon/icon.svg @@ -0,0 +1,101 @@ + + + + diff --git a/assets/icon/icon128.png b/assets/icon/icon128.png new file mode 100644 index 0000000..1c4d4e7 Binary files /dev/null and b/assets/icon/icon128.png differ diff --git a/assets/icon/icon256.png b/assets/icon/icon256.png new file mode 100644 index 0000000..a2164c2 Binary files /dev/null and b/assets/icon/icon256.png differ diff --git a/assets/icon/icon512.png b/assets/icon/icon512.png new file mode 100644 index 0000000..0dfc1ea Binary files /dev/null and b/assets/icon/icon512.png differ diff --git a/assets/icon/icon64.png b/assets/icon/icon64.png new file mode 100644 index 0000000..2a5d909 Binary files /dev/null and b/assets/icon/icon64.png differ diff --git a/assets/icon/icon_opt.svg b/assets/icon/icon_opt.svg new file mode 100644 index 0000000..cc37872 --- /dev/null +++ b/assets/icon/icon_opt.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/icon_opt.svg b/assets/icon_opt.svg new file mode 100644 index 0000000..339e59d --- /dev/null +++ b/assets/icon_opt.svg @@ -0,0 +1,101 @@ + + + + diff --git a/build.rs b/build.rs index a4b0b05..e2dfc45 100644 --- a/build.rs +++ b/build.rs @@ -1,5 +1,8 @@ fn main() { - println!("cargo:rustc-link-search=framework=/System/Library/PrivateFrameworks"); - println!("cargo:rustc-link-lib=framework=IOKit"); - println!("cargo:rustc-link-lib=framework=MultitouchSupport"); + #[cfg(target_os = "macos")] + { + println!("cargo:rustc-link-search=framework=/System/Library/PrivateFrameworks"); + println!("cargo:rustc-link-lib=framework=IOKit"); + println!("cargo:rustc-link-lib=framework=MultitouchSupport"); + } } diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..9a31461 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +rustcast (0.5.2) unstable; urgency=medium + + * Fix app discovery: [Merge pull request #149 from unsecretised/fix-app…] + + -- Umang Surana Fri, 13 Feb 2026 07:23:44 +0200 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..8d1703e --- /dev/null +++ b/debian/control @@ -0,0 +1,11 @@ +Source: rustcast +Section: utils +Priority: optional +Maintainer: Umang Surana +Build-Depends: debhelper-compat (= 13), pkg-config, libgtk-3-dev, libssl-dev, libayatana-appindicator3-dev +Standards-Version: 4.5.0 + +Package: rustcast +Architecture: any +Depends: ${shlibs:Depends}, ${misc:Depends}, libgtk-3-0, libayatana-appindicator3-1 +Description: An open source alternative to Raycast, and in rust \ No newline at end of file diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..d459c86 --- /dev/null +++ b/debian/rules @@ -0,0 +1,13 @@ +#!/usr/bin/make -f + +export PATH := $(HOME)/.cargo/bin:$(PATH) + +%: + dh $@ + +override_dh_auto_build: + cargo build --release --target x86_64-unknown-linux-gnu + +override_dh_auto_install: + install -d $(CURDIR)/debian/rustcast/usr/bin + install -m 755 target/x86_64-unknown-linux-gnu/release/rustcast $(CURDIR)/debian/rustcast/usr/bin/rustcast \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f70f2c3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,62 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1770115704, + "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1770174315, + "narHash": "sha256-GUaMxDmJB1UULsIYpHtfblskVC6zymAaQ/Zqfo+13jc=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "095c394bb91342882f27f6c73f64064fb9de9f2a", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..284aacf --- /dev/null +++ b/flake.nix @@ -0,0 +1,61 @@ +{ + description = "Rust dev shell"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + rust-overlay.url = "github:oxalica/rust-overlay"; + }; + + outputs = + { nixpkgs, rust-overlay, ... }: + let + system = "x86_64-linux"; + + pkgs = import nixpkgs { + inherit system; + overlays = [ + rust-overlay.overlays.default + (_: prev: { + rust-toolchain = prev.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + }) + ]; + }; + in + { + devShells.${system}.default = pkgs.mkShell rec { + strictDeps = true; + + nativeBuildInputs = with pkgs; [ + gcc + pkg-config + sqlx-cli + rust-toolchain + ]; + + buildInputs = with pkgs; [ + atk + glib + gtk3 + cairo + pango + openssl + xdotool + wayland + xorg.libXi + gdk-pixbuf + xorg.libxcb + xorg.libX11 + libxkbcommon + vulkan-loader + xorg.libXrandr + xorg.libXcursor + gobject-introspection + libayatana-appindicator + ]; + + shellHook = '' + export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${toString (pkgs.lib.makeLibraryPath buildInputs)}"; + ''; + }; + }; +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..1f2fe88 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy", "cargo"] +targets = ["x86_64-apple-darwin", "x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu"] +profile = "default" diff --git a/src/app.rs b/src/app.rs index 803342b..dc6fb38 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,8 @@ //! Main logic for the app + use crate::commands::Function; +use iced::window::{self, Id, Settings}; + use crate::{app::tile::ExtSender, clipboard::ClipBoardContentType}; pub mod apps; @@ -7,7 +10,6 @@ pub mod menubar; pub mod pages; pub mod tile; -use iced::window::{self, Id, Settings}; /// The default window width pub const WINDOW_WIDTH: f32 = 500.; @@ -27,7 +29,7 @@ pub enum Page { /// The types of arrow keys #[allow(dead_code)] -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub enum ArrowKey { Up, Down, @@ -36,7 +38,7 @@ pub enum ArrowKey { } /// The ways the cursor can move when a key is pressed -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub enum Move { Back, Forwards(String), @@ -47,12 +49,16 @@ pub enum Move { pub enum Message { OpenWindow, SearchQueryChanged(String, Id), + #[cfg(not(target_os = "linux"))] + HotkeyPressed(u32), + #[allow(unused)] KeyPressed(u32), FocusTextInput(Move), HideWindow(Id), RunFunction(Function), OpenFocused, ReturnFocus, + OpenToPage(Page), EscKeyPressed(Id), ClearSearchResults, WindowFocusChanged(Id, bool), @@ -72,12 +78,26 @@ pub fn default_settings() -> Settings { decorations: false, minimizable: false, level: window::Level::AlwaysOnTop, + position: window::Position::Centered, transparent: true, blur: false, size: iced::Size { width: WINDOW_WIDTH, + #[cfg(not(target_os = "linux"))] height: DEFAULT_WINDOW_HEIGHT, + #[cfg(target_os = "linux")] + height: ((5 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, }, + icon: Some(crate::icon::iced_icon::icon_256()), ..Default::default() } } + +// Message::ReloadConfig => { +// self.config = toml::from_str( +// &fs::read_to_string(get_config_file_path()).unwrap_or("".to_owned()), +// ) +// .unwrap(); +// +// Task::none() +// } diff --git a/src/app/apps.rs b/src/app/apps.rs index d55c2d0..52f7fb3 100644 --- a/src/app/apps.rs +++ b/src/app/apps.rs @@ -1,20 +1,24 @@ //! This modules handles the logic for each "app" that rustcast can load //! //! An "app" is effectively, one of the results that rustcast returns when you search for something -use std::path::Path; + +use std::{ + path::{Path, PathBuf}, + sync::atomic::{AtomicUsize, Ordering}, +}; use iced::{ Alignment, Length::Fill, - widget::{Button, Row, Text, container, image::Viewer, text::Wrapping}, + widget::{self, Button, Row, Text, container, image::Viewer, text::Wrapping}, }; use crate::{ app::{Message, Page, RUSTCAST_DESC_NAME}, clipboard::ClipBoardContentType, commands::Function, + cross_platform::get_img_handle, styles::{result_button_style, result_row_container_style}, - utils::handle_from_icns, }; /// This tells each "App" what to do when it is clicked, whether it is a function, a message, or a display @@ -26,42 +30,133 @@ pub enum AppCommand { Display, } +impl PartialEq for AppCommand { + fn eq(&self, other: &Self) -> bool { + // TODO: make an *actual* impl of PartialEq for Message + match (&self, &other) { + (Self::Function(a), Self::Function(b)) => a == b, + (Self::Display, Self::Display) => true, + _ => false, + } + } +} + +/// A container for [`App`] data specific to a certain type of app. +#[derive(Debug, Clone, PartialEq)] +pub enum AppData { + /// A platform specific executable + Executable { + /// The executable path + path: PathBuf, + /// The executable icon + icon: Option, + }, + /// A shell command to be run + Command { + /// The command to run + command: String, + alias: String, + /// The icon to display in search results + icon: Option, + }, + /// Any builtin function + Builtin { + /// The [`AppCommand`] to run + command: AppCommand, + }, +} + /// The main app struct, that represents an "App" /// /// This struct represents a command that rustcast can perform, providing the rustcast /// the data needed to search for the app, to display the app in search results, and to actually -/// "run" the app. -#[derive(Debug, Clone)] +/// run the app. +#[derive(Clone, Debug)] pub struct App { - pub open_command: AppCommand, - pub desc: String, - pub icons: Option, + /// The app name pub name: String, - pub name_lc: String, + + /// An alias to use while searching + pub alias: String, + + /// The description for the app + pub desc: String, + + /// The information specific to a certain type of app + pub data: AppData, + + /// A unique ID generated for each instance of an App. + #[allow(unused)] + id: usize, } impl PartialEq for App { fn eq(&self, other: &Self) -> bool { - self.name_lc == other.name_lc - && self.icons == other.icons - && self.desc == other.desc - && self.name == other.name + self.data == other.data && self.name == other.name } } impl App { + /// Get the numeric id of an app + #[allow(unused)] + pub fn id(&self) -> usize { + self.id + } + + /// Creates a new instance + pub fn new(name: &str, name_lc: &str, desc: &str, data: AppData) -> Self { + static ID: AtomicUsize = AtomicUsize::new(0); + + Self { + alias: name_lc.to_string(), + name: name.to_string(), + desc: desc.to_string(), + id: ID.fetch_add(1, Ordering::Relaxed), + data, + } + } + + /// Creates a new instance of the type [`AppData::Builtin`]. + /// + /// This is mainly for convenience. + pub fn new_builtin(name: &str, name_lc: &str, desc: &str, command: AppCommand) -> Self { + Self::new(name, name_lc, desc, AppData::Builtin { command }) + } + + /// Creates a new instance of the type [`AppData::Executable`]. + /// + /// This is mainly for convenience. + pub fn new_executable( + name: &str, + name_lc: &str, + desc: &str, + path: impl AsRef, + icon: Option, + ) -> Self { + Self::new( + name, + name_lc, + desc, + AppData::Executable { + path: path.as_ref().to_path_buf(), + icon, + }, + ) + } + /// A vec of all the emojis as App structs pub fn emoji_apps() -> Vec { emojis::iter() .filter(|x| x.unicode_version() < emojis::UnicodeVersion::new(17, 13)) - .map(|x| App { - icons: None, - name: x.to_string(), - name_lc: x.name().to_string(), - open_command: AppCommand::Function(Function::CopyToClipboard( - ClipBoardContentType::Text(x.to_string()), - )), - desc: x.name().to_string(), + .map(|x| { + App::new_builtin( + x.name(), + x.name(), + "emoji", + AppCommand::Function(Function::CopyToClipboard(ClipBoardContentType::Text( + x.to_string(), + ))), + ) }) .collect() } @@ -70,71 +165,52 @@ impl App { let app_version = option_env!("APP_VERSION").unwrap_or("Unknown Version"); vec![ - App { - open_command: AppCommand::Function(Function::Quit), - desc: RUSTCAST_DESC_NAME.to_string(), - icons: handle_from_icns(Path::new( - "/Applications/Rustcast.app/Contents/Resources/icon.icns", - )), - name: "Quit RustCast".to_string(), - name_lc: "quit".to_string(), - }, - App { - open_command: AppCommand::Function(Function::OpenPrefPane), - desc: RUSTCAST_DESC_NAME.to_string(), - icons: handle_from_icns(Path::new( - "/Applications/Rustcast.app/Contents/Resources/icon.icns", - )), - name: "Open RustCast Preferences".to_string(), - name_lc: "settings".to_string(), - }, - App { - open_command: AppCommand::Message(Message::SwitchToPage(Page::EmojiSearch)), - desc: RUSTCAST_DESC_NAME.to_string(), - icons: handle_from_icns(Path::new( - "/Applications/Rustcast.app/Contents/Resources/icon.icns", - )), - name: "Search for an Emoji".to_string(), - name_lc: "emoji".to_string(), - }, - App { - open_command: AppCommand::Message(Message::SwitchToPage(Page::ClipboardHistory)), - desc: RUSTCAST_DESC_NAME.to_string(), - icons: handle_from_icns(Path::new( - "/Applications/Rustcast.app/Contents/Resources/icon.icns", - )), - name: "Clipboard History".to_string(), - name_lc: "clipboard".to_string(), - }, - App { - open_command: AppCommand::Message(Message::ReloadConfig), - desc: RUSTCAST_DESC_NAME.to_string(), - icons: handle_from_icns(Path::new( - "/Applications/Rustcast.app/Contents/Resources/icon.icns", - )), - name: "Reload RustCast".to_string(), - name_lc: "refresh".to_string(), - }, - App { - open_command: AppCommand::Display, - desc: RUSTCAST_DESC_NAME.to_string(), - icons: handle_from_icns(Path::new( - "/Applications/Rustcast.app/Contents/Resources/icon.icns", - )), - name: format!("Current RustCast Version: {app_version}"), - name_lc: "version".to_string(), - }, - App { - open_command: AppCommand::Function(Function::OpenApp( - "/System/Library/CoreServices/Finder.app".to_string(), - )), - desc: "Application".to_string(), - icons: handle_from_icns(Path::new( + Self::new_builtin( + "Quit RustCast", + "quit", + RUSTCAST_DESC_NAME, + AppCommand::Function(Function::Quit), + ), + Self::new_builtin( + "Open RustCast Preferences", + "settings", + RUSTCAST_DESC_NAME, + AppCommand::Function(Function::OpenPrefPane), + ), + Self::new_builtin( + "Search for an Emoji", + "emoji", + RUSTCAST_DESC_NAME, + AppCommand::Message(Message::SwitchToPage(Page::EmojiSearch)), + ), + Self::new_builtin( + "Clipboard History", + "clipboard", + RUSTCAST_DESC_NAME, + AppCommand::Message(Message::SwitchToPage(Page::ClipboardHistory)), + ), + Self::new_builtin( + "Reload RustCast", + "refresh", + RUSTCAST_DESC_NAME, + AppCommand::Message(Message::ReloadConfig), + ), + Self::new_builtin( + &format!("Current RustCast Version: {app_version}"), + "version", + RUSTCAST_DESC_NAME, + AppCommand::Display, + ), + #[cfg(target_os = "macos")] + Self::new_executable( + "Finder", + "finder", + "Application", + PathBuf::from("/System/Library/CoreServices/Finder.app"), + get_img_handle(Path::new( "/System/Library/CoreServices/Finder.app/Contents/Resources/Finder.icns", )), - name: "Finder".to_string(), - name_lc: "finder".to_string(), - }, + ), ] } @@ -170,21 +246,56 @@ impl App { .spacing(10) .height(50); - if theme.show_icons - && let Some(icon) = &self.icons - { - row = row.push( - container(Viewer::new(icon).height(40).width(40)) - .width(40) - .height(40), - ); + if theme.show_icons { + match self.data { + AppData::Command { + icon: Some(ref icon), + .. + } + | AppData::Executable { + icon: Some(ref icon), + .. + } => { + row = row.push( + container(Viewer::new(icon).height(40).width(40)) + .width(40) + .height(40), + ); + } + AppData::Builtin { .. } => { + let icon = get_img_handle(Path::new( + "/Applications/Rustcast.app/Contents/Resources/icon.icns", + )); + if let Some(icon) = icon { + row = row.push( + container(Viewer::new(icon).height(40).width(40)) + .width(40) + .height(40), + ); + } + } + _ => {} + } } row = row.push(container(text_block).width(Fill)); - let msg = match self.open_command.clone() { - AppCommand::Function(func) => Some(Message::RunFunction(func)), - AppCommand::Message(msg) => Some(msg), - AppCommand::Display => None, + let msg = match self.data { + AppData::Builtin { + command: AppCommand::Function(func), + .. + } => Some(Message::RunFunction(func)), + AppData::Builtin { + command: AppCommand::Message(msg), + .. + } => Some(msg), + AppData::Builtin { + command: AppCommand::Display, + .. + } => None, + AppData::Executable { path, .. } => Some(Message::RunFunction(Function::OpenApp(path))), + AppData::Command { command, alias, .. } => Some(Message::RunFunction( + Function::RunShellCommand(command, alias), + )), }; let theme_clone = theme.clone(); @@ -197,7 +308,7 @@ impl App { .height(50); container(content) - .id(format!("result-{}", id_num)) + .id(format!("result-{id_num}")) .style(move |_| result_row_container_style(&theme, focused)) .padding(8) .width(Fill) diff --git a/src/app/menubar.rs b/src/app/menubar.rs index 2f87195..6910982 100644 --- a/src/app/menubar.rs +++ b/src/app/menubar.rs @@ -1,32 +1,29 @@ //! This has the menubar icon logic for the app +#[cfg(not(target_os = "linux"))] use global_hotkey::hotkey::{Code, HotKey, Modifiers}; -use image::{DynamicImage, ImageReader}; +use image::DynamicImage; +use tokio::runtime::Runtime; +#[cfg(not(target_os = "linux"))] +use tray_icon::menu::accelerator::Accelerator; use tray_icon::{ Icon, TrayIcon, TrayIconBuilder, - menu::{ - AboutMetadataBuilder, Icon as Ico, Menu, MenuEvent, MenuItem, PredefinedMenuItem, - accelerator::Accelerator, - }, + menu::{AboutMetadataBuilder, Icon as Ico, Menu, MenuEvent, MenuItem, PredefinedMenuItem}, }; use crate::{ - app::{Message, tile::ExtSender}, - utils::{open_settings, open_url}, + app::{Message, Page, tile::ExtSender}, + cross_platform::open_settings, }; -const DISCORD_LINK: &str = "https://discord.gg/bDfNYPbnC5"; - -use tokio::runtime::Runtime; - -/// This create a new menubar icon for the app -pub fn menu_icon(hotkey: HotKey, sender: ExtSender) -> TrayIcon { +/// This creates a new menubar icon for the app +pub fn menu_icon(#[cfg(not(target_os = "linux"))] hotkey: HotKey, sender: ExtSender) -> TrayIcon { let builder = TrayIconBuilder::new(); let image = get_image(); let icon = Icon::from_rgba(image.as_bytes().to_vec(), image.width(), image.height()).unwrap(); - init_event_handler(sender, hotkey.id()); + init_event_handler(sender); let menu = Menu::with_items(&[ &version_item(), @@ -34,7 +31,10 @@ pub fn menu_icon(hotkey: HotKey, sender: ExtSender) -> TrayIcon { &open_github_item(), &PredefinedMenuItem::separator(), &refresh_item(), - &open_item(hotkey), + &open_item( + #[cfg(not(target_os = "linux"))] + hotkey, + ), &PredefinedMenuItem::separator(), &open_issue_item(), &get_help_item(), @@ -54,16 +54,32 @@ pub fn menu_icon(hotkey: HotKey, sender: ExtSender) -> TrayIcon { } fn get_image() -> DynamicImage { - let image_path = if cfg!(debug_assertions) { - "docs/icon.png" - } else { - "/Applications/Rustcast.app/Contents/Resources/icon.png" - }; - - ImageReader::open(image_path).unwrap().decode().unwrap() + #[cfg(target_os = "macos")] + { + use image::ImageReader; + + let image_path = if cfg!(debug_assertions) && !cfg!(target_os = "macos") { + "docs/icon.png" + } else { + "/Applications/Rustcast.app/Contents/Resources/icon.png" + }; + + ImageReader::open(image_path).unwrap().decode().unwrap() + } + + // TODO: make it load the image + #[cfg(any(target_os = "windows", target_os = "linux"))] + { + DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 64, + 64, + image::Rgba([0, 0, 0, 255]), + )) + } } -fn init_event_handler(sender: ExtSender, hotkey_id: u32) { +fn init_event_handler(sender: ExtSender) { + tracing::debug!("Initing event handler"); let runtime = Runtime::new().unwrap(); MenuEvent::set_event_handler(Some(move |x: MenuEvent| { @@ -80,27 +96,32 @@ fn init_event_handler(sender: ExtSender, hotkey_id: u32) { .spawn(async move { sender.clone().try_send(Message::HideTrayIcon).unwrap() }); } "open_issue_page" => { - open_url("https://github.com/unsecretised/rustcast/issues/new"); + if let Err(e) = open::that("https://github.com/unsecretised/rustcast/issues/new") { + tracing::error!("Error opening url: {}", e) + } } "show_rustcast" => { runtime.spawn(async move { sender .clone() - .try_send(Message::KeyPressed(hotkey_id)) + .try_send(Message::OpenToPage(Page::Main)) .unwrap(); }); } - "open_discord" => { - open_url(DISCORD_LINK); - } "open_help_page" => { - open_url("https://github.com/unsecretised/rustcast/discussions/new?category=q-a"); + if let Err(e) = open::that( + "https://github.com/unsecretised/rustcast/discussions/new?category=q-a", + ) { + tracing::error!("Error opening url: {}", e) + } } "open_preferences" => { open_settings(); } "open_github_page" => { - open_url("https://github.com/unsecretised/rustcast"); + if let Err(e) = open::that("https://github.com/unsecretised/rustcast") { + tracing::error!("Error opening url: {}", e) + } } _ => {} } @@ -120,12 +141,15 @@ fn hide_tray_icon() -> MenuItem { MenuItem::with_id("hide_tray_icon", "Hide Tray Icon", true, None) } -fn open_item(hotkey: HotKey) -> MenuItem { +fn open_item(#[cfg(not(target_os = "linux"))] hotkey: HotKey) -> MenuItem { MenuItem::with_id( "show_rustcast", "Toggle View", true, + #[cfg(not(target_os = "linux"))] Some(Accelerator::new(Some(hotkey.mods), hotkey.key)), + #[cfg(target_os = "linux")] + None, ) } @@ -142,10 +166,13 @@ fn refresh_item() -> MenuItem { "refresh_rustcast", "Refresh", true, + #[cfg(not(target_os = "linux"))] Some(Accelerator::new( Some(Modifiers::SUPER), global_hotkey::hotkey::Code::KeyR, )), + #[cfg(target_os = "linux")] + None, ) } @@ -154,7 +181,10 @@ fn open_settings_item() -> MenuItem { "open_preferences", "Open Preferences", true, + #[cfg(not(target_os = "linux"))] Some(Accelerator::new(Some(Modifiers::SUPER), Code::Comma)), + #[cfg(target_os = "linux")] + None, ) } diff --git a/src/app/pages/clipboard.rs b/src/app/pages/clipboard.rs index 6bf5473..106137b 100644 --- a/src/app/pages/clipboard.rs +++ b/src/app/pages/clipboard.rs @@ -30,7 +30,7 @@ pub fn clipboard_view( Text::new( clipboard_content .get(focussed_id as usize) - .map(|x| x.to_app().name_lc) + .map(|x| x.to_app().alias) .unwrap_or("".to_string()), ) .height(385) diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 42a9a23..2e839d9 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -1,6 +1,7 @@ //! This module handles the logic for the new and view functions according to the elm //! architecture. If the subscription function becomes too large, it should be moved to this file +#[cfg(not(target_os = "linux"))] use global_hotkey::hotkey::HotKey; use iced::border::Radius; use iced::widget::scrollable::{Anchor, Direction, Scrollbar}; @@ -10,55 +11,115 @@ use iced::{Alignment, Color, Length, Vector, window}; use iced::{Element, Task}; use iced::{Length::Fill, widget::text_input}; -use rayon::{ - iter::{IntoParallelRefIterator, ParallelIterator}, - slice::ParallelSliceMut, -}; +use rayon::slice::ParallelSliceMut; +#[cfg(target_os = "windows")] +use crate::app; use crate::app::WINDOW_WIDTH; use crate::app::pages::clipboard::clipboard_view; use crate::app::pages::emoji::emoji_page; use crate::app::tile::AppIndex; use crate::config::Theme; use crate::styles::{contents_style, rustcast_text_input_style, tint, with_alpha}; +use crate::utils::index_installed_apps; use crate::{ app::{Message, Page, apps::App, default_settings, tile::Tile}, config::Config, - macos::{self, transform_process_to_ui_element}, - utils::get_installed_apps, }; +#[cfg(target_os = "macos")] +use crate::cross_platform::macos::{self, transform_process_to_ui_element}; + pub fn default_app_paths() -> Vec { - let user_local_path = std::env::var("HOME").unwrap() + "/Applications/"; + #[cfg(target_os = "macos")] + { + let user_local_path = std::env::var("HOME").unwrap() + "/Applications/"; + + let paths = vec![ + "/Applications/".to_string(), + user_local_path, + "/System/Applications/".to_string(), + "/System/Applications/Utilities/".to_string(), + ]; + paths + } + + #[cfg(target_os = "windows")] + { + Vec::new() + } + + #[cfg(target_os = "linux")] + { + use std::path::PathBuf; + + let mut dirs = Vec::new(); + + let user_dir: PathBuf = std::env::var("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| dirs::home_dir().unwrap().join(".local/share")); + dirs.push(user_dir.join("applications").to_string_lossy().to_string()); + + let sys_dirs = std::env::var("XDG_DATA_DIRS") + .unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string()); - let paths = vec![ - "/Applications/".to_string(), - user_local_path, - "/System/Applications/".to_string(), - "/System/Applications/Utilities/".to_string(), - ]; + for dir in sys_dirs.split(':') { + dirs.push(PathBuf::from(dir).to_string_lossy().to_string()); + } - paths + dirs + } } /// Initialise the base window -pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { - let (id, open) = window::open(default_settings()); +pub fn new( + #[cfg(not(target_os = "linux"))] hotkey: HotKey, + config: &Config, +) -> (Tile, Task) { + tracing::trace!(target: "elm_init", "Initing ELM"); + + #[allow(unused_mut)] + let mut settings = default_settings(); + + // get normal settings and modify position + #[cfg(target_os = "windows")] + { + use iced::window::Position; + + use crate::cross_platform::windows::open_on_focused_monitor; + let pos = open_on_focused_monitor(); + settings.position = Position::Specific(pos); + } + + tracing::trace!(target: "elm_init", "Opening window"); + + // id unused on windows, but not macos + #[allow(unused)] + let (id, open) = window::open(settings); + + #[cfg(target_os = "windows")] + let open: Task = open.discard(); + + #[cfg(target_os = "linux")] + let open = open + .discard() + .chain(window::run(id, |_| Message::OpenWindow)); + #[cfg(target_os = "macos")] let open = open.discard().chain(window::run(id, |handle| { macos::macos_window_config(&handle.window_handle().expect("Unable to get window handle")); transform_process_to_ui_element(); + Message::OpenWindow })); - let store_icons = config.theme.show_icons; + let options = index_installed_apps(config); - let paths = default_app_paths(); + if let Err(ref e) = options { + tracing::error!("Error indexing apps: {e}") + } - let mut options: Vec = paths - .par_iter() - .map(|path| get_installed_apps(path, store_icons)) - .flatten() - .collect(); + // Still try to load the rest + let mut options = options.unwrap_or_default(); options.extend(config.shells.iter().map(|x| x.to_app())); options.extend(App::basic_apps()); @@ -73,13 +134,7 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { results: vec![], options, emoji_apps: AppIndex::from_apps(App::emoji_apps()), - hotkey, visible: true, - clipboard_hotkey: config - .clipboard_hotkey - .clone() - .and_then(|x| x.parse::().ok()), - frontmost: None, focused: false, config: config.clone(), theme: config.theme.to_owned().into(), @@ -87,8 +142,27 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { tray_icon: None, sender: None, page: Page::Main, + + #[cfg(target_os = "macos")] + frontmost: None, + + #[cfg(target_os = "windows")] + frontmost: unsafe { + use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; + + Some(GetForegroundWindow()) + }, + + #[cfg(not(target_os = "linux"))] + hotkey, + + #[cfg(not(target_os = "linux"))] + clipboard_hotkey: config + .clipboard_hotkey + .clone() + .and_then(|x| x.parse::().ok()), }, - Task::batch([open.map(|_| Message::OpenWindow)]), + open, ) } @@ -171,6 +245,10 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { .push(footer(tile.config.theme.clone(), results_count)) .spacing(0), ) + .width(Length::Fixed(WINDOW_WIDTH)) + .height(Length::Shrink) + .align_x(Alignment::Center) + .align_y(Alignment::Start) .style(|_| container::Style { text_color: None, background: None, @@ -184,6 +262,10 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { container(contents.clip(false)) .style(|_| contents_style(&tile.config.theme)) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Start) .into() } else { space().into() diff --git a/src/app/tile.rs b/src/app/tile/mod.rs similarity index 75% rename from src/app/tile.rs rename to src/app/tile/mod.rs index 830abdb..3105bc0 100644 --- a/src/app/tile.rs +++ b/src/app/tile/mod.rs @@ -2,37 +2,43 @@ pub mod elm; pub mod update; -use crate::app::apps::App; -use crate::app::tile::elm::default_app_paths; -use crate::app::{ArrowKey, Message, Move, Page}; -use crate::clipboard::ClipBoardContentType; -use crate::config::Config; -use crate::utils::open_settings; +mod search_query; -use arboard::Clipboard; -use global_hotkey::hotkey::HotKey; -use global_hotkey::{GlobalHotKeyEvent, HotKeyState}; +#[cfg(target_os = "windows")] +use { + windows::Win32::Foundation::HWND, windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow, +}; + +use std::{collections::BTreeMap, fs, ops::Bound, path::PathBuf, time::Duration}; -use iced::futures::SinkExt; -use iced::futures::channel::mpsc::{Sender, channel}; -use iced::keyboard::Modifiers; use iced::{ - Subscription, Theme, futures, - keyboard::{self, key::Named}, - stream, + Subscription, Theme, event, futures, + futures::{ + SinkExt, + channel::mpsc::{Sender, channel}, + }, + keyboard::{self, Modifiers, key::Named}, + stream, window, }; -use iced::{event, window}; -use objc2::rc::Retained; -use objc2_app_kit::NSRunningApplication; -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +#[cfg(not(target_os = "linux"))] +use global_hotkey::{GlobalHotKeyEvent, HotKeyState, hotkey::HotKey}; + +use crate::{ + app::{ArrowKey, Message, Move, Page, apps::App, tile::elm::default_app_paths}, + clipboard::ClipBoardContentType, + config::Config, + cross_platform::open_settings, +}; + +use arboard::Clipboard; +use rayon::prelude::*; use tray_icon::TrayIcon; -use std::collections::BTreeMap; -use std::fs; -use std::ops::Bound; -use std::path::PathBuf; -use std::time::Duration; +#[cfg(target_os = "macos")] +use objc2::rc::Retained; +#[cfg(target_os = "macos")] +use objc2_app_kit::NSRunningApplication; /// This is a wrapper around the sender to disable dropping #[derive(Clone, Debug)] @@ -62,7 +68,7 @@ impl AppIndex { pub fn from_apps(options: Vec) -> Self { let mut bmap = BTreeMap::new(); for app in options { - bmap.insert(app.name_lc.clone(), app); + bmap.insert(app.alias.clone(), app); } AppIndex { by_name: bmap } @@ -95,10 +101,15 @@ pub struct Tile { emoji_apps: AppIndex, visible: bool, focused: bool, + #[cfg(target_os = "macos")] frontmost: Option>, + #[cfg(target_os = "windows")] + frontmost: Option, pub config: Config, /// The opening hotkey + #[cfg(not(target_os = "linux"))] hotkey: HotKey, + #[cfg(not(target_os = "linux"))] clipboard_hotkey: Option, clipboard_content: Vec, tray_icon: Option, @@ -140,7 +151,10 @@ impl Tile { _ => None, }); Subscription::batch([ + #[cfg(not(target_os = "linux"))] Subscription::run(handle_hotkeys), + #[cfg(target_os = "linux")] + Subscription::run(handle_socket), keyboard, Subscription::run(handle_recipient), Subscription::run(handle_hot_reloading), @@ -224,7 +238,26 @@ impl Tile { self.results = results; } + // Unused, keeping it for now + // pub fn capture_frontmost(&mut self) { + // #[cfg(target_os = "macos")] + // { + // use objc2_app_kit::NSWorkspace; + + // let ws = NSWorkspace::sharedWorkspace(); + // self.frontmost = ws.frontmostApplication(); + // }; + + // #[cfg(target_os = "windows")] + // { + // use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; + + // self.frontmost = Some(unsafe { GetForegroundWindow() }); + // } + // } + /// Gets the frontmost application to focus later. + #[cfg(target_os = "macos")] pub fn capture_frontmost(&mut self) { use objc2_app_kit::NSWorkspace; @@ -233,12 +266,24 @@ impl Tile { } /// Restores the frontmost application. - #[allow(deprecated)] + #[allow(deprecated, unused)] pub fn restore_frontmost(&mut self) { - use objc2_app_kit::NSApplicationActivationOptions; + #[cfg(target_os = "macos")] + { + if let Some(app) = self.frontmost.take() { + use objc2_app_kit::NSApplicationActivationOptions; + + app.activateWithOptions(NSApplicationActivationOptions::ActivateIgnoringOtherApps); + } + } - if let Some(app) = self.frontmost.take() { - app.activateWithOptions(NSApplicationActivationOptions::ActivateIgnoringOtherApps); + #[cfg(target_os = "windows")] + { + if let Some(handle) = self.frontmost { + unsafe { + let _ = SetForegroundWindow(handle); + } + } } } } @@ -295,6 +340,7 @@ fn count_dirs_in_dir(dir: &PathBuf) -> usize { } /// This is the subscription function that handles hotkeys for hiding / showing the window +#[cfg(not(target_os = "linux"))] fn handle_hotkeys() -> impl futures::Stream { stream::channel(100, async |mut output| { let receiver = GlobalHotKeyEvent::receiver(); @@ -302,13 +348,51 @@ fn handle_hotkeys() -> impl futures::Stream { if let Ok(event) = receiver.recv() && event.state == HotKeyState::Pressed { - output.try_send(Message::KeyPressed(event.id)).unwrap(); + output.try_send(Message::HotkeyPressed(event.id)).unwrap(); } tokio::time::sleep(Duration::from_millis(10)).await; } }) } +#[cfg(target_os = "linux")] +fn handle_socket() -> impl futures::Stream { + stream::channel(100, async |mut output| { + let clipboard = env::args().any(|arg| arg.trim() == "--cphist"); + if clipboard { + output + .try_send(Message::OpenToPage(Page::ClipboardHistory)) + .unwrap(); + } + + use std::env; + + use tokio::net::UnixListener; + + let _ = fs::remove_file(crate::SOCKET_PATH); + let listener = UnixListener::bind(crate::SOCKET_PATH).unwrap(); + + while let Ok((mut stream, _address)) = listener.accept().await { + let mut output = output.clone(); + tokio::spawn(async move { + use tokio::io::AsyncReadExt; + use tracing::info; + + let mut s = String::new(); + let _ = stream.read_to_string(&mut s).await; + info!("received socket command {s}"); + if s.trim() == "toggle" { + output.try_send(Message::OpenToPage(Page::Main)).unwrap(); + } else if s.trim() == "clipboard" { + output + .try_send(Message::OpenToPage(Page::ClipboardHistory)) + .unwrap(); + } + }); + } + }) +} + /// This is the subscription function that handles the change in clipboard history fn handle_clipboard_history() -> impl futures::Stream { stream::channel(100, async |mut output| { @@ -341,10 +425,9 @@ fn handle_clipboard_history() -> impl futures::Stream { fn handle_recipient() -> impl futures::Stream { stream::channel(100, async |mut output| { let (sender, mut recipient) = channel(100); - output - .send(Message::SetSender(ExtSender(sender))) - .await - .expect("Sender not sent"); + let msg = Message::SetSender(ExtSender(sender)); + tracing::debug!("Sending ExtSender"); + output.send(msg).await.expect("Sender not sent"); loop { let abcd = recipient .try_next() diff --git a/src/app/tile/search_query.rs b/src/app/tile/search_query.rs new file mode 100644 index 0000000..64c710b --- /dev/null +++ b/src/app/tile/search_query.rs @@ -0,0 +1,190 @@ +use iced::{ + Task, + window::{self, Id}, +}; +use std::cmp; + +use super::Tile; +use crate::{ + app::{ + ArrowKey, DEFAULT_WINDOW_HEIGHT, Message, Page, RUSTCAST_DESC_NAME, WINDOW_WIDTH, + apps::{App, AppCommand}, + }, + calculator::Expr, + clipboard::ClipBoardContentType, + commands::Function, + unit_conversion, +}; + +#[cfg(target_os = "macos")] +use crate::cross_platform::macos::haptics::{HapticPattern, perform_haptic}; + +pub(super) fn handle_change(tile: &mut Tile, input: &str, id: Id) -> iced::Task { + tile.focus_id = 0; + #[cfg(target_os = "macos")] + if tile.config.haptic_feedback { + perform_haptic(HapticPattern::Alignment); + } + + tile.query_lc = input.trim().to_lowercase(); + tile.query = input.to_string(); + let prev_size = tile.results.len(); + if tile.query_lc.is_empty() && tile.page != Page::ClipboardHistory { + tile.results = vec![]; + return window::resize( + id, + iced::Size { + width: WINDOW_WIDTH, + height: DEFAULT_WINDOW_HEIGHT, + }, + ); + } else if tile.query_lc == "randomvar" { + let rand_num = rand::random_range(0..100); + tile.results = vec![App::new_builtin( + &rand_num.to_string(), + "", + "Easter egg", + AppCommand::Function(Function::RandomVar(rand_num)), + )]; + return window::resize( + id, + iced::Size { + width: WINDOW_WIDTH, + height: 55. + DEFAULT_WINDOW_HEIGHT, + }, + ); + } else { + if tile.query_lc == "67" { + tile.results = vec![App::new_builtin( + "67", + "", + "Easter egg", + AppCommand::Function(Function::RandomVar(67)), + )]; + return window::resize( + id, + iced::Size { + width: WINDOW_WIDTH, + height: 55. + DEFAULT_WINDOW_HEIGHT, + }, + ); + } + if tile.query_lc.ends_with("?") { + tile.results = vec![App::new_builtin( + &format!("Search for: {}", tile.query), + "", + "Web Search", + AppCommand::Function(Function::GoogleSearch(tile.query.clone())), + )]; + return window::resize( + id, + iced::Size::new(WINDOW_WIDTH, 55. + DEFAULT_WINDOW_HEIGHT), + ); + } else if tile.query_lc == "cbhist" { + tile.page = Page::ClipboardHistory + } else if tile.query_lc == "main" { + tile.page = Page::Main + } + } + tile.handle_search_query_changed(); + + if tile.results.is_empty() + && let Some(res) = Expr::from_str(&tile.query).ok() + { + let res_string = res.eval().map(|x| x.to_string()).unwrap_or(String::new()); + tile.results.push(App::new_builtin( + RUSTCAST_DESC_NAME, + &res_string, + "", + AppCommand::Function(Function::Calculate(res.clone())), + )); + } else if tile.results.is_empty() + && let Some(conversions) = unit_conversion::convert_query(&tile.query) + { + tile.results = conversions + .into_iter() + .map(|conversion| { + let source = format!( + "{} {}", + unit_conversion::format_number(conversion.source_value), + conversion.source_unit.name + ); + let target = format!( + "{} {}", + unit_conversion::format_number(conversion.target_value), + conversion.target_unit.name + ); + App::new_builtin( + &source, + &target, + "Copy to clipboard", + AppCommand::Function(Function::CopyToClipboard(ClipBoardContentType::Text( + target.clone(), + ))), + ) + }) + .collect(); + } else if tile.results.is_empty() && url::Url::parse(input).is_ok() { + tile.results.push(App::new_builtin( + "Web Browsing", + "", + &format!("Open website: {}", tile.query), + AppCommand::Function(Function::OpenWebsite(tile.query.clone())), + )); + } else if tile.query_lc.split(' ').count() > 1 { + tile.results.push(App::new_builtin( + &format!("Search for: {}", tile.query), + "", + "Web Search", + AppCommand::Function(Function::GoogleSearch(tile.query.clone())), + )); + } else if tile.results.is_empty() && tile.query_lc == "lemon" { + #[cfg(target_os = "macos")] + { + use std::path::Path; + + tile.results.push(App::new_builtin( + "Easter Egg", + "Lemon", + "", + AppCommand::Display, + )); + } + } + if !tile.query_lc.is_empty() && tile.page == Page::EmojiSearch { + tile.results = tile + .emoji_apps + .search_prefix("") + .map(|x| x.to_owned()) + .collect(); + } + + let new_length = tile.results.len(); + let max_elem = cmp::min(5, new_length); + + if prev_size != new_length && tile.page != Page::ClipboardHistory { + Task::batch([ + window::resize( + id, + iced::Size { + width: WINDOW_WIDTH, + height: ((max_elem * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, + }, + ), + Task::done(Message::ChangeFocus(ArrowKey::Left)), + ]) + } else if tile.page == Page::ClipboardHistory { + Task::batch([ + window::resize( + id, + iced::Size { + width: WINDOW_WIDTH, + height: ((7 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, + }, + ), + Task::done(Message::ChangeFocus(ArrowKey::Left)), + ]) + } else { + Task::none() + } +} diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 67eaf5d..91c3d63 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -1,48 +1,36 @@ //! This handles the update logic for the tile (AKA rustcast's main window) -use std::cmp::min; use std::fs; -use std::path::Path; use std::thread; use iced::Task; -use iced::widget::image::Handle; -use iced::widget::operation; -use iced::widget::operation::AbsoluteOffset; +use iced::widget::{operation, operation::AbsoluteOffset}; use iced::window; -use rayon::iter::IntoParallelRefIterator; -use rayon::iter::ParallelIterator; use rayon::slice::ParallelSliceMut; -use crate::app::ArrowKey; -use crate::app::DEFAULT_WINDOW_HEIGHT; -use crate::app::Move; -use crate::app::RUSTCAST_DESC_NAME; -use crate::app::WINDOW_WIDTH; -use crate::app::apps::App; -use crate::app::apps::AppCommand; -use crate::app::default_settings; -use crate::app::menubar::menu_icon; -use crate::app::tile::AppIndex; -use crate::app::tile::elm::default_app_paths; -use crate::calculator::Expr; -use crate::clipboard::ClipBoardContentType; +use crate::app::apps::AppData; +use crate::app::{ + ArrowKey, DEFAULT_WINDOW_HEIGHT, Message, Move, Page, WINDOW_WIDTH, apps::App, + apps::AppCommand, default_settings, menubar::menu_icon, tile::AppIndex, tile::Tile, + tile::search_query, +}; + +#[cfg(target_os = "macos")] +use crate::cross_platform::macos; + use crate::commands::Function; use crate::config::Config; -use crate::haptics::HapticPattern; -use crate::haptics::perform_haptic; -use crate::unit_conversion; -use crate::utils::get_installed_apps; -use crate::utils::is_valid_url; -use crate::{ - app::{Message, Page, tile::Tile}, - macos::focus_this_app, -}; +use crate::utils::index_installed_apps; pub fn handle_update(tile: &mut Tile, message: Message) -> Task { + tracing::trace!(target: "update", "{:?}", message); + match message { Message::OpenWindow => { - tile.capture_frontmost(); - focus_this_app(); + #[cfg(target_os = "macos")] + { + tile.capture_frontmost(); + macos::focus_this_app(); + } tile.focused = true; tile.visible = true; Task::none() @@ -59,7 +47,16 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Message::SetSender(sender) => { tile.sender = Some(sender.clone()); if tile.config.show_trayicon { - tile.tray_icon = Some(menu_icon(tile.hotkey, sender)); + // Tray icon seems to not work on linux (gdk only but this is wgpu?) + // I do not know so much abt rendering stuff + #[cfg(not(target_os = "linux"))] + { + tile.tray_icon = Some(menu_icon( + #[cfg(not(target_os = "linux"))] + tile.hotkey, + sender, + )); + } } Task::none() } @@ -154,20 +151,20 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { ]) } - Message::OpenFocused => match tile.results.get(tile.focus_id as usize) { - Some(App { - open_command: AppCommand::Function(func), + Message::OpenFocused => match tile.results.get(tile.focus_id as usize).map(|x| &x.data) { + Some(AppData::Builtin { + command: AppCommand::Function(func), .. }) => Task::done(Message::RunFunction(func.to_owned())), - Some(App { - open_command: AppCommand::Message(msg), + Some(AppData::Builtin { + command: AppCommand::Message(msg), .. }) => Task::done(msg.to_owned()), - Some(App { - open_command: AppCommand::Display, + Some(AppData::Builtin { + command: AppCommand::Display, .. }) => Task::done(Message::ReturnFocus), - None => Task::none(), + _ => Task::none(), }, Message::ReloadConfig => { @@ -181,57 +178,60 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Ok(a) => a, Err(_) => return Task::none(), }; + let mut options = Vec::new(); - let mut new_options: Vec = default_app_paths() - .par_iter() - .map(|path| get_installed_apps(path, new_config.theme.show_icons)) - .flatten() - .collect(); + match index_installed_apps(&new_config) { + Ok(x) => options.extend(x), + Err(e) => tracing::error!("Error indexing apps: {e}"), + } - new_options.extend(new_config.shells.iter().map(|x| x.to_app())); - new_options.extend(App::basic_apps()); - new_options.par_sort_by_key(|x| x.name.len()); + options.extend(new_config.shells.iter().map(|x| x.to_app())); + options.extend(App::basic_apps()); + options.par_sort_by_key(|x| x.name.len()); tile.theme = new_config.theme.to_owned().into(); tile.config = new_config; - tile.options = AppIndex::from_apps(new_options); + tile.options = AppIndex::from_apps(options); Task::none() } - Message::KeyPressed(hk_id) => { - let is_clipboard_hotkey = tile - .clipboard_hotkey - .map(|hotkey| hotkey.id == hk_id) - .unwrap_or(false); - let is_open_hotkey = hk_id == tile.hotkey.id; + Message::OpenToPage(page) => { + if !tile.visible { + return Task::batch([open_window(), Task::done(Message::SwitchToPage(page))]); + } - let clipboard_page_task = if is_clipboard_hotkey { - Task::done(Message::SwitchToPage(Page::ClipboardHistory)) - } else if is_open_hotkey { - Task::done(Message::SwitchToPage(Page::Main)) + tile.visible = !tile.visible; + + let clear_search_query = if tile.config.buffer_rules.clear_on_hide { + Task::done(Message::ClearSearchQuery) } else { Task::none() }; - if is_open_hotkey || is_clipboard_hotkey { - if !tile.visible { - return Task::batch([open_window(), clipboard_page_task]); - } + let to_close = window::latest().map(|x| x.unwrap()); + Task::batch([ + to_close.map(Message::HideWindow), + clear_search_query, + Task::done(Message::ReturnFocus), + ]) + } - tile.visible = !tile.visible; + Message::KeyPressed(_) => Task::none(), - let clear_search_query = if tile.config.buffer_rules.clear_on_hide { - Task::done(Message::ClearSearchQuery) - } else { - Task::none() - }; + #[cfg(not(target_os = "linux"))] + Message::HotkeyPressed(hk_id) => { + // Linux Clipboard and Open Hotkey are gonna be handled via a socket + let is_clipboard_hotkey = tile + .clipboard_hotkey + .map(|hotkey| hotkey.id == hk_id) + .unwrap_or(false); - let to_close = window::latest().map(|x| x.unwrap()); - Task::batch([ - to_close.map(Message::HideWindow), - clear_search_query, - Task::done(Message::ReturnFocus), - ]) + let is_open_hotkey = hk_id == tile.hotkey.id; + + if is_clipboard_hotkey { + handle_update(tile, Message::OpenToPage(Page::ClipboardHistory)) + } else if is_open_hotkey { + handle_update(tile, Message::OpenToPage(Page::Main)) } else { Task::none() } @@ -302,10 +302,17 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { tile.results = vec![]; Task::none() } + Message::WindowFocusChanged(wid, focused) => { tile.focused = focused; if !focused { - Task::done(Message::HideWindow(wid)).chain(Task::done(Message::ClearSearchQuery)) + if cfg!(target_os = "macos") { + Task::done(Message::HideWindow(wid)) + .chain(Task::done(Message::ClearSearchQuery)) + } else { + // linux seems to not wanna unfocus it on start making it not show + Task::none() + } } else { Task::none() } @@ -316,176 +323,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Task::none() } - Message::SearchQueryChanged(input, id) => { - tile.focus_id = 0; - #[cfg(target_os = "macos")] - if tile.config.haptic_feedback { - perform_haptic(HapticPattern::Alignment); - } - - tile.query_lc = input.trim().to_lowercase(); - tile.query = input; - let prev_size = tile.results.len(); - if tile.query_lc.is_empty() && tile.page != Page::ClipboardHistory { - tile.results = vec![]; - return window::resize( - id, - iced::Size { - width: WINDOW_WIDTH, - height: DEFAULT_WINDOW_HEIGHT, - }, - ); - } else if tile.query_lc == "randomvar" { - let rand_num = rand::random_range(0..100); - tile.results = vec![App { - open_command: AppCommand::Function(Function::RandomVar(rand_num)), - desc: "Easter egg".to_string(), - icons: None, - name: rand_num.to_string(), - name_lc: String::new(), - }]; - return window::resize( - id, - iced::Size { - width: WINDOW_WIDTH, - height: 55. + DEFAULT_WINDOW_HEIGHT, - }, - ); - } else if tile.query_lc == "67" { - tile.results = vec![App { - open_command: AppCommand::Function(Function::RandomVar(67)), - desc: "Easter egg".to_string(), - icons: None, - name: 67.to_string(), - name_lc: String::new(), - }]; - return window::resize( - id, - iced::Size { - width: WINDOW_WIDTH, - height: 55. + DEFAULT_WINDOW_HEIGHT, - }, - ); - } else if tile.query_lc.ends_with("?") { - tile.results = vec![App { - open_command: AppCommand::Function(Function::GoogleSearch(tile.query.clone())), - icons: None, - desc: "Web Search".to_string(), - name: format!("Search for: {}", tile.query), - name_lc: String::new(), - }]; - return window::resize( - id, - iced::Size::new(WINDOW_WIDTH, 55. + DEFAULT_WINDOW_HEIGHT), - ); - } else if tile.query_lc == "cbhist" { - tile.page = Page::ClipboardHistory - } else if tile.query_lc == "main" { - tile.page = Page::Main - } - tile.handle_search_query_changed(); - - if tile.results.is_empty() - && let Some(res) = Expr::from_str(&tile.query).ok() - { - tile.results.push(App { - open_command: AppCommand::Function(Function::Calculate(res.clone())), - desc: RUSTCAST_DESC_NAME.to_string(), - icons: None, - name: res.eval().map(|x| x.to_string()).unwrap_or("".to_string()), - name_lc: "".to_string(), - }); - } else if tile.results.is_empty() - && let Some(conversions) = unit_conversion::convert_query(&tile.query) - { - tile.results = conversions - .into_iter() - .map(|conversion| { - let source = format!( - "{} {}", - unit_conversion::format_number(conversion.source_value), - conversion.source_unit.name - ); - let target = format!( - "{} {}", - unit_conversion::format_number(conversion.target_value), - conversion.target_unit.name - ); - App { - open_command: AppCommand::Function(Function::CopyToClipboard( - ClipBoardContentType::Text(target.clone()), - )), - desc: source, - icons: None, - name: target, - name_lc: String::new(), - } - }) - .collect(); - } else if tile.results.is_empty() && is_valid_url(&tile.query) { - tile.results.push(App { - open_command: AppCommand::Function(Function::OpenWebsite(tile.query.clone())), - desc: "Web Browsing".to_string(), - icons: None, - name: "Open Website: ".to_string() + &tile.query, - name_lc: "".to_string(), - }); - } else if tile.query_lc.split(' ').count() > 1 { - tile.results.push(App { - open_command: AppCommand::Function(Function::GoogleSearch(tile.query.clone())), - icons: None, - desc: "Web Search".to_string(), - name: format!("Search for: {}", tile.query), - name_lc: String::new(), - }); - } else if tile.results.is_empty() && tile.query_lc == "lemon" { - tile.results.push(App { - open_command: AppCommand::Display, - desc: "Easter Egg".to_string(), - icons: Some(Handle::from_path(Path::new( - "/Applications/Rustcast.app/Contents/Resources/lemon.png", - ))), - name: "Lemon".to_string(), - name_lc: "".to_string(), - }); - } - if !tile.query_lc.is_empty() && tile.page == Page::EmojiSearch { - tile.results = tile - .emoji_apps - .search_prefix("") - .map(|x| x.to_owned()) - .collect(); - } - - let new_length = tile.results.len(); - let max_elem = min(5, new_length); - - if prev_size != new_length && tile.page != Page::ClipboardHistory { - Task::batch([ - window::resize( - id, - iced::Size { - width: WINDOW_WIDTH, - height: ((max_elem * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, - }, - ), - Task::done(Message::ChangeFocus(ArrowKey::Left)), - ]) - } else if tile.page == Page::ClipboardHistory { - Task::batch([ - window::resize( - id, - iced::Size { - width: WINDOW_WIDTH, - height: ((7 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, - }, - ), - Task::done(Message::ChangeFocus(ArrowKey::Left)), - ]) - } else { - Task::none() - } - } + Message::SearchQueryChanged(input, id) => search_query::handle_change(tile, &input, id), } } diff --git a/src/app_finding/linux.rs b/src/app_finding/linux.rs new file mode 100644 index 0000000..727e10d --- /dev/null +++ b/src/app_finding/linux.rs @@ -0,0 +1,129 @@ +use std::{fs, path::Path}; + +use freedesktop_desktop_entry::DesktopEntry; +use glob::glob; +use iced::widget::image::Handle; +use image::{ImageReader, RgbaImage}; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; + +use crate::{ + app::{ + apps::{App, AppCommand, AppData}, + tile::elm::default_app_paths, + }, + config::Config, +}; + +pub fn get_installed_linux_apps(config: &Config) -> Vec { + let paths = default_app_paths(); + let store_icons = config.theme.show_icons; + + let apps: Vec = paths + .par_iter() + .map(|path| { + let mut pattern = path.clone(); + if !pattern.ends_with('/') { + pattern.push('/'); + } + pattern.push_str("**/*.desktop"); + + get_installed_apps_glob(&pattern, store_icons) + }) + .flatten() + .collect(); + + apps +} + +fn get_installed_apps_glob(pattern: &str, store_icons: bool) -> Vec { + glob(pattern) + .unwrap() + .flatten() + .flat_map(|entry| get_installed_apps(entry.as_path(), store_icons)) + .collect() +} + +fn get_installed_apps(path: &Path, store_icons: bool) -> Vec { + let mut apps = Vec::new(); + + let Ok(content) = fs::read_to_string(path) else { + return apps; + }; + + let Ok(de) = DesktopEntry::from_str(path, &content, None::<&[String]>) else { + return apps; + }; + + if de.no_display() || de.hidden() { + return apps; + } + + let Some(name) = de.desktop_entry("Name") else { + return apps; + }; + let desc = de.desktop_entry("Comment").unwrap_or(""); + let Some(exec) = de.exec() else { + return apps; + }; + + let exec = exec.to_string(); + let mut parts = exec.split_whitespace().filter(|p| !p.starts_with("%")); + + let Some(cmd) = parts.next() else { + return apps; + }; + + let args = parts.map(str::to_owned).collect::>().join(" "); + + let icon = if store_icons { + de.icon() + .map(str::to_owned) + .and_then(|icon_name| find_icon_handle(&icon_name)) + } else { + None + }; + + apps.push(App::new( + &name, + &name.to_lowercase(), + &desc, + AppData::Command { + command: cmd.to_string(), + alias: args, + icon, + }, + )); + + apps +} + +pub fn handle_from_png(path: &Path) -> Option { + let img = ImageReader::open(path).ok()?.decode().ok()?.to_rgba8(); + let image = RgbaImage::from_raw(img.width(), img.height(), img.to_vec())?; + Some(Handle::from_rgba( + image.width(), + image.height(), + image.into_raw(), + )) +} + +fn find_icon_handle(name: &str) -> Option { + let paths = default_app_paths(); + + for dir in paths { + let mut pattern = dir.clone(); + + if !pattern.ends_with('/') { + pattern.push('/'); + } + pattern.push_str(&format!("icons/**/{}*", name)); + + for entry in glob(&pattern).ok()?.flatten() { + if let Some(handle) = handle_from_png(&entry) { + return Some(handle); + } + } + } + + None +} diff --git a/src/app_finding/macos.rs b/src/app_finding/macos.rs new file mode 100644 index 0000000..b823743 --- /dev/null +++ b/src/app_finding/macos.rs @@ -0,0 +1,147 @@ +use std::path::PathBuf; +use std::process::exit; +use crate::{app::apps::App, config::Config, utils::index_installed_apps}; + + +fn get_installed_apps(dir: impl AsRef, store_icons: bool) -> Vec { + let entries: Vec<_> = fs::read_dir(dir.as_ref()) + .unwrap_or_else(|x| { + tracing::error!( + "An error occurred while reading dir ({}) {}", + dir.as_ref().to_str().unwrap_or(""), + x + ); + exit(-1) + }) + .filter_map(|x| x.ok()) + .collect(); + + entries + .into_par_iter() + .filter_map(|x| { + let file_type = x.file_type().unwrap_or_else(|e| { + tracing::error!("Failed to get file type: {}", e.to_string()); + exit(-1) + }); + if !file_type.is_dir() { + return None; + } + + let file_name_os = x.file_name(); + let file_name = file_name_os.into_string().unwrap_or_else(|e| { + tracing::error!("Failed to to get file_name_os: {}", e.to_string_lossy()); + exit(-1) + }); + if !file_name.ends_with(".app") { + return None; + } + + let path = x.path(); + let path_str = path.to_str().map(|x| x.to_string()).unwrap_or_else(|| { + tracing::error!("Unable to get file_name"); + exit(-1) + }); + + let icons = if store_icons { + match fs::read_to_string(format!("{}/Contents/Info.plist", path_str)).map( + |content| { + let icon_line = content + .lines() + .scan(false, |expect_next, line| { + if *expect_next { + *expect_next = false; + // Return this line to the iterator + return Some(Some(line)); + } + + if line.trim() == "CFBundleIconFile" { + *expect_next = true; + } + + // For lines that are not the one after the key, return None to skip + Some(None) + }) + .flatten() // remove the Nones + .next() + .map(|x| { + x.trim() + .strip_prefix("") + .unwrap_or("") + .strip_suffix("") + .unwrap_or("") + }); + + handle_from_icns(Path::new(&format!( + "{}/Contents/Resources/{}", + path_str, + icon_line.unwrap_or("AppIcon.icns") + ))) + }, + ) { + Ok(Some(a)) => Some(a), + _ => { + // Fallback method + let direntry = fs::read_dir(format!("{}/Contents/Resources", path_str)) + .into_iter() + .flatten() + .filter_map(|x| { + let file = x.ok()?; + let name = file.file_name(); + let file_name = name.to_str()?; + if file_name.ends_with(".icns") { + Some(file.path()) + } else { + None + } + }) + .collect::>(); + + if direntry.len() > 1 { + let icns_vec = direntry + .iter() + .filter(|x| x.ends_with("AppIcon.icns")) + .collect::>(); + handle_from_icns(icns_vec.first().unwrap_or(&&PathBuf::new())) + } else if !direntry.is_empty() { + handle_from_icns(direntry.first().unwrap_or(&PathBuf::new())) + } else { + None + } + } + } + } else { + None + }; + + let name = file_name.strip_suffix(".app").unwrap().to_string(); + Some(App::new_executable( + &name, + &name.to_lowercase(), + "Application", + path, + icons, + )) + }) + .collect() +} + +pub fn get_installed_macos_apps(config: &Config) -> anyhow::Result> { + let store_icons = config.theme.show_icons; + let user_local_path = std::env::var("HOME").unwrap() + "/Applications/"; + let paths: Vec = vec![ + "/Applications/".to_string(), + user_local_path.to_string(), + "/System/Applications/".to_string(), + "/System/Applications/Utilities/".to_string(), + ]; + + let mut apps = index_installed_apps(config)?; + apps.par_extend( + paths + .par_iter() + .map(|path| get_installed_apps(path, store_icons)) + .flatten(), + ); + + Ok(apps) +} \ No newline at end of file diff --git a/src/app_finding/mod.rs b/src/app_finding/mod.rs new file mode 100644 index 0000000..fb35e85 --- /dev/null +++ b/src/app_finding/mod.rs @@ -0,0 +1,6 @@ +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; \ No newline at end of file diff --git a/src/app_finding/windows.rs b/src/app_finding/windows.rs new file mode 100644 index 0000000..66917c2 --- /dev/null +++ b/src/app_finding/windows.rs @@ -0,0 +1,140 @@ +use { + crate::{app::apps::App, cross_platform::windows::get_acp}, + std::path::PathBuf, + walkdir::WalkDir, + windows::{ + Win32::{ + System::Com::CoTaskMemFree, + UI::Shell::{ + FOLDERID_LocalAppData, FOLDERID_ProgramFiles, FOLDERID_ProgramFilesX86, + KF_FLAG_DEFAULT, SHGetKnownFolderPath, + }, + }, + core::GUID, + }, +}; + +/// Loads apps from the registry keys `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` and +/// `SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall`. `apps` has the relvant items +/// appended to it. +/// +/// Based on https://stackoverflow.com/questions/2864984 +pub fn get_apps_from_registry(apps: &mut Vec) { + use std::ffi::OsString; + let hkey = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE); + + let registers = [ + hkey.open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall") + .unwrap(), + hkey.open_subkey("SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall") + .unwrap(), + ]; + + registers.iter().for_each(|reg| { + reg.enum_keys().for_each(|key| { + // Not debug only just because it doesn't run too often + tracing::trace!("App added [reg]: {:?}", key); + + // https://learn.microsoft.com/en-us/windows/win32/msi/uninstall-registry-key + let name = key.unwrap(); + let key = reg.open_subkey(&name).unwrap(); + let display_name: OsString = key.get_value("DisplayName").unwrap_or_default(); + + // they might be useful one day ? + // let publisher = key.get_value("Publisher").unwrap_or(OsString::new()); + // let version = key.get_value("DisplayVersion").unwrap_or(OsString::new()); + + // Trick, I saw on internet to point to the exe location.. + let exe_path: OsString = key.get_value("DisplayIcon").unwrap_or_default(); + if exe_path.is_empty() { + return; + } + // if there is something, it will be in the form of + // "C:\Program Files\Microsoft Office\Office16\WINWORD.EXE",0 + let exe_string = exe_path.to_string_lossy(); + let exe_string = exe_string.split(",").next().unwrap(); + + // make sure it ends with .exe + if !exe_string.ends_with(".exe") { + return; + } + + if !display_name.is_empty() { + apps.push(App::new_executable( + &display_name.clone().to_string_lossy(), + &display_name.clone().to_string_lossy().to_lowercase(), + "Application", + exe_path, + None, + )) + } + }); + }); +} + +/// Returns the set of known paths +pub fn get_known_paths() -> Vec { + let paths = vec![ + get_windows_path(&FOLDERID_ProgramFiles).unwrap_or_default(), + get_windows_path(&FOLDERID_ProgramFilesX86).unwrap_or_default(), + (get_windows_path(&FOLDERID_LocalAppData) + .unwrap_or_default() + .join("Programs")), + ]; + paths +} + +/// Wrapper around `SHGetKnownFolderPath` to get paths to known folders +fn get_windows_path(folder_id: &GUID) -> Option { + unsafe { + let folder = SHGetKnownFolderPath(folder_id, KF_FLAG_DEFAULT, None); + if let Ok(folder) = folder { + let path = folder.to_string().ok()?; + CoTaskMemFree(Some(folder.0 as *mut _)); + Some(path.into()) + } else { + None + } + } +} + +pub fn index_start_menu() -> Vec { + WalkDir::new(r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs") + .into_iter() + .filter_map(|x| x.ok()) + .filter_map(|path| { + let lnk = lnk::ShellLink::open(path.path(), get_acp()); + + match lnk { + Ok(x) => { + let target = x.link_target(); + let file_name = path.file_name().to_string_lossy().to_string(); + + match target { + Some(target) => Some(App::new_executable( + &file_name, + &file_name, + "", + PathBuf::from(target.clone()), + None, + )), + None => { + tracing::debug!( + "Link at {} has no target, skipped", + path.path().display() + ); + None + } + } + } + Err(e) => { + tracing::debug!( + "Error opening link {} ({e}), skipped", + path.path().to_string_lossy() + ); + None + } + } + }) + .collect() +} diff --git a/src/clipboard.rs b/src/clipboard.rs index 979432e..27d9c15 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -1,7 +1,10 @@ //! This has all the logic regarding the cliboard history use arboard::ImageData; -use crate::{app::apps::App, commands::Function}; +use crate::{ + app::apps::{App, AppCommand}, + commands::Function, +}; /// The kinds of clipboard content that rustcast can handle and their contents #[derive(Debug, Clone)] @@ -25,15 +28,12 @@ impl ClipBoardContentType { // only get the first line from the contents name = name.lines().next().unwrap_or("").to_string(); - App { - open_command: crate::app::apps::AppCommand::Function(Function::CopyToClipboard( - self_clone.to_owned(), - )), - desc: "Clipboard Item".to_string(), - icons: None, - name_lc, - name, - } + App::new_builtin( + &name, + &name_lc, + "Clipboard Item", + AppCommand::Function(Function::CopyToClipboard(self_clone.to_owned())), + ) } } diff --git a/src/commands.rs b/src/commands.rs index bfffee1..a43c8f0 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,17 +1,23 @@ //! This handles all the different commands that rustcast can perform, such as opening apps, //! copying to clipboard, etc. -use std::{process::Command, thread}; +use std::path::PathBuf; +use std::process::Command; +#[cfg(target_os = "macos")] +use std::thread; use arboard::Clipboard; +#[cfg(target_os = "macos")] use objc2_app_kit::NSWorkspace; +#[cfg(target_os = "macos")] use objc2_foundation::NSURL; +use crate::utils::open_application; use crate::{calculator::Expr, clipboard::ClipBoardContentType, config::Config}; /// The different functions that rustcast can perform #[derive(Debug, Clone, PartialEq)] pub enum Function { - OpenApp(String), + OpenApp(PathBuf), RunShellCommand(String, String), OpenWebsite(String), RandomVar(i32), // Easter egg function @@ -25,15 +31,9 @@ pub enum Function { impl Function { /// Run the command pub fn execute(&self, config: &Config, query: &str) { + tracing::debug!("Executing command: {:?}", self); match self { - Function::OpenApp(path) => { - let path = path.to_owned(); - thread::spawn(move || { - NSWorkspace::new().openURL(&NSURL::fileURLWithPath( - &objc2_foundation::NSString::from_str(&path), - )); - }); - } + Function::OpenApp(path) => open_application(path.clone()), // I think the clone is necessary Function::RunShellCommand(command, alias) => { let query = query.to_string(); let final_command = @@ -55,32 +55,19 @@ impl Function { let query_args = query_string.replace(" ", "+"); let query = config.search_url.replace("%s", &query_args); let query = query.strip_suffix("?").unwrap_or(&query).to_string(); - thread::spawn(move || { - NSWorkspace::new().openURL( - &NSURL::URLWithString_relativeToURL( - &objc2_foundation::NSString::from_str(&query), - None, - ) - .unwrap(), - ); - }); + + open::that(query).unwrap(); } Function::OpenWebsite(url) => { - let open = if url.starts_with("http") { + let open_url = if url.starts_with("http") { url.to_owned() } else { format!("https://{}", url) }; - thread::spawn(move || { - NSWorkspace::new().openURL( - &NSURL::URLWithString_relativeToURL( - &objc2_foundation::NSString::from_str(&open), - None, - ) - .unwrap(), - ); - }); + + // Should never get here without it being validated first + open::that(open_url).unwrap(); } Function::Calculate(expr) => { @@ -99,6 +86,7 @@ impl Function { } }, + #[cfg(target_os = "macos")] Function::OpenPrefPane => { thread::spawn(move || { NSWorkspace::new().openURL(&NSURL::fileURLWithPath( @@ -109,7 +97,12 @@ impl Function { )); }); } + Function::Quit => std::process::exit(0), + f => { + // TODO: something in the UI to show this + tracing::error!("The function {:?} is unimplemented for this platform", f); + } } } } diff --git a/src/config/include_patterns.rs b/src/config/include_patterns.rs new file mode 100644 index 0000000..8331fef --- /dev/null +++ b/src/config/include_patterns.rs @@ -0,0 +1,56 @@ +//! Parser for include patterns + +use std::{path::PathBuf, str::FromStr, sync::LazyLock}; + +use regex::Regex; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +static WITH_DEPTH_REGEX: LazyLock = LazyLock::new(|| + // (.*?) Matches the path (group 0) + // (?::[..])? Optionally match the depth specifier without capturing + // (?[0-9]+) Capture the number for maximum depth in the named group depth + Regex::new("^(.*?)(?::(?[0-9]+))?$").unwrap()); + +#[derive(Debug, PartialEq, Clone)] +pub struct Pattern { + pub path: PathBuf, + pub max_depth: usize, +} + +impl FromStr for Pattern { + type Err = anyhow::Error; + + fn from_str(str: &str) -> Result { + let matched = WITH_DEPTH_REGEX.captures(str); + + if let Some(x) = matched { + Ok(Pattern { + path: PathBuf::from(&x[1]), + max_depth: x.name("depth").map_or(1, |m| m.as_str().parse().unwrap()), + }) + } else { + Err(anyhow::Error::msg("Invalid pattern syntax: \"{x}\"")) + } + } +} + +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Vec::::deserialize(deserializer)? + .iter() + .map(|x| x.parse().map_err(serde::de::Error::custom)) + .collect() +} + +pub fn serialize(patterns: &[Pattern], serializer: S) -> Result +where + S: Serializer, +{ + patterns + .iter() + .map(|x| format!("{}:{}", x.path.to_string_lossy(), x.max_depth)) + .collect::>() + .serialize(serializer) +} diff --git a/src/config.rs b/src/config/mod.rs similarity index 78% rename from src/config.rs rename to src/config/mod.rs index c25f5b6..706abe3 100644 --- a/src/config.rs +++ b/src/config/mod.rs @@ -1,15 +1,19 @@ //! This is the config file type definitions for rustcast -use std::{path::Path, sync::Arc}; +use std::{path::PathBuf, sync::Arc}; -use iced::{Font, font::Family, theme::Custom, widget::image::Handle}; +use iced::{Font, font::Family, theme::Custom}; use serde::{Deserialize, Serialize}; +#[cfg(target_os = "windows")] +use crate::cross_platform::windows::app_finding::get_known_paths; use crate::{ - app::apps::{App, AppCommand}, - commands::Function, - utils::handle_from_icns, + app::apps::{App, AppData}, + cross_platform::get_img_handle, }; +mod include_patterns; +mod patterns; + /// The main config struct (effectively the config file's "schema") #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] @@ -18,16 +22,35 @@ pub struct Config { pub clipboard_hotkey: Option, pub buffer_rules: Buffer, pub theme: Theme, + pub placeholder: String, pub search_url: String, pub haptic_feedback: bool, pub show_trayicon: bool, pub shells: Vec, + + #[serde(with = "include_patterns")] + pub index_dirs: Vec, + + #[serde(with = "patterns")] + pub index_exclude_patterns: Vec, + + #[serde(with = "patterns")] + pub index_include_patterns: Vec, } impl Default for Config { /// The default config fn default() -> Self { + #[cfg(target_os = "windows")] + let index_dirs = get_known_paths() + .into_iter() + .map(|path| include_patterns::Pattern { path, max_depth: 3 }) + .collect(); + + #[cfg(not(target_os = "windows"))] + let index_dirs = Vec::new(); + Self { toggle_hotkey: "ALT+SPACE".to_string(), clipboard_hotkey: None, @@ -38,6 +61,9 @@ impl Default for Config { haptic_feedback: false, show_trayicon: true, shells: vec![], + index_dirs, + index_exclude_patterns: vec![], + index_include_patterns: vec![], } } } @@ -175,23 +201,19 @@ impl Shelly { /// Converts the shelly struct to an app so that it can be added to the app list pub fn to_app(&self) -> App { let self_clone = self.clone(); - let icon = self_clone.icon_path.and_then(|x| { + let icon = self_clone.icon_path.map(|x| { let x = x.replace("~", &std::env::var("HOME").unwrap()); - if x.ends_with(".icns") { - handle_from_icns(Path::new(&x)) - } else { - Some(Handle::from_path(Path::new(&x))) - } + get_img_handle(&PathBuf::from(x)) }); - App { - open_command: AppCommand::Function(Function::RunShellCommand( - self_clone.command, - self_clone.alias_lc.clone(), - )), - desc: "Shell Command".to_string(), - icons: icon, - name: self_clone.alias, - name_lc: self_clone.alias_lc, - } + App::new( + &self_clone.alias, + &self_clone.alias_lc, + "Shell Command", + AppData::Command { + alias: self_clone.alias_lc.clone(), + command: self_clone.command, + icon: icon.flatten(), + }, + ) } } diff --git a/src/config/patterns.rs b/src/config/patterns.rs new file mode 100644 index 0000000..514a860 --- /dev/null +++ b/src/config/patterns.rs @@ -0,0 +1,27 @@ +//! Parser for glob patterns + +use std::str::FromStr; + +use glob::Pattern; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Vec::::deserialize(deserializer)? + .iter() + .map(|x| Pattern::from_str(x).map_err(|e| serde::de::Error::custom(e.msg))) + .collect() +} + +pub fn serialize(patterns: &[Pattern], serializer: S) -> Result +where + S: Serializer, +{ + patterns + .iter() + .map(|x| x.as_str()) + .collect::>() + .serialize(serializer) +} diff --git a/src/cross_platform/linux.rs b/src/cross_platform/linux.rs new file mode 100644 index 0000000..727e10d --- /dev/null +++ b/src/cross_platform/linux.rs @@ -0,0 +1,129 @@ +use std::{fs, path::Path}; + +use freedesktop_desktop_entry::DesktopEntry; +use glob::glob; +use iced::widget::image::Handle; +use image::{ImageReader, RgbaImage}; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; + +use crate::{ + app::{ + apps::{App, AppCommand, AppData}, + tile::elm::default_app_paths, + }, + config::Config, +}; + +pub fn get_installed_linux_apps(config: &Config) -> Vec { + let paths = default_app_paths(); + let store_icons = config.theme.show_icons; + + let apps: Vec = paths + .par_iter() + .map(|path| { + let mut pattern = path.clone(); + if !pattern.ends_with('/') { + pattern.push('/'); + } + pattern.push_str("**/*.desktop"); + + get_installed_apps_glob(&pattern, store_icons) + }) + .flatten() + .collect(); + + apps +} + +fn get_installed_apps_glob(pattern: &str, store_icons: bool) -> Vec { + glob(pattern) + .unwrap() + .flatten() + .flat_map(|entry| get_installed_apps(entry.as_path(), store_icons)) + .collect() +} + +fn get_installed_apps(path: &Path, store_icons: bool) -> Vec { + let mut apps = Vec::new(); + + let Ok(content) = fs::read_to_string(path) else { + return apps; + }; + + let Ok(de) = DesktopEntry::from_str(path, &content, None::<&[String]>) else { + return apps; + }; + + if de.no_display() || de.hidden() { + return apps; + } + + let Some(name) = de.desktop_entry("Name") else { + return apps; + }; + let desc = de.desktop_entry("Comment").unwrap_or(""); + let Some(exec) = de.exec() else { + return apps; + }; + + let exec = exec.to_string(); + let mut parts = exec.split_whitespace().filter(|p| !p.starts_with("%")); + + let Some(cmd) = parts.next() else { + return apps; + }; + + let args = parts.map(str::to_owned).collect::>().join(" "); + + let icon = if store_icons { + de.icon() + .map(str::to_owned) + .and_then(|icon_name| find_icon_handle(&icon_name)) + } else { + None + }; + + apps.push(App::new( + &name, + &name.to_lowercase(), + &desc, + AppData::Command { + command: cmd.to_string(), + alias: args, + icon, + }, + )); + + apps +} + +pub fn handle_from_png(path: &Path) -> Option { + let img = ImageReader::open(path).ok()?.decode().ok()?.to_rgba8(); + let image = RgbaImage::from_raw(img.width(), img.height(), img.to_vec())?; + Some(Handle::from_rgba( + image.width(), + image.height(), + image.into_raw(), + )) +} + +fn find_icon_handle(name: &str) -> Option { + let paths = default_app_paths(); + + for dir in paths { + let mut pattern = dir.clone(); + + if !pattern.ends_with('/') { + pattern.push('/'); + } + pattern.push_str(&format!("icons/**/{}*", name)); + + for entry in glob(&pattern).ok()?.flatten() { + if let Some(handle) = handle_from_png(&entry) { + return Some(handle); + } + } + } + + None +} diff --git a/src/haptics.rs b/src/cross_platform/macos/haptics.rs similarity index 100% rename from src/haptics.rs rename to src/cross_platform/macos/haptics.rs diff --git a/src/cross_platform/macos/mod.rs b/src/cross_platform/macos/mod.rs new file mode 100644 index 0000000..48af4d8 --- /dev/null +++ b/src/cross_platform/macos/mod.rs @@ -0,0 +1,280 @@ +//! Macos specific logic, such as window settings, etc. +#![allow(deprecated)] + +pub mod haptics; + +use crate::app::apps::{App, AppCommand}; +use crate::commands::Function; +use crate::config::Config; +use crate::utils::index_installed_apps; +use icns::IconFamily; +use rayon::iter::ParallelExtend; +use { + iced::wgpu::rwh::RawWindowHandle, + iced::wgpu::rwh::WindowHandle, + iced::widget::image::Handle, + objc2::MainThreadMarker, + objc2::rc::Retained, + objc2_app_kit::NSView, + objc2_app_kit::{NSApp, NSApplicationActivationPolicy}, + objc2_app_kit::{NSFloatingWindowLevel, NSWindowCollectionBehavior}, + objc2_foundation::NSURL, +}; + +use objc2_app_kit::NSWorkspace; +use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}; +use std::path::{Path, PathBuf}; +use std::process::exit; +use std::{fs, thread}; + +/// This sets the activation policy of the app to Accessory, allowing rustcast to be visible ontop +/// of fullscreen apps +pub fn set_activation_policy_accessory() { + let mtm = MainThreadMarker::new().expect("must be on main thread"); + let app = NSApp(mtm); + app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); +} + +/// This carries out the window configuration for the macos window (only things that are macos specific) +pub fn macos_window_config(handle: &WindowHandle) { + match handle.as_raw() { + RawWindowHandle::AppKit(handle) => { + let ns_view = handle.ns_view.as_ptr(); + let ns_view: Retained = unsafe { Retained::retain(ns_view.cast()) }.unwrap(); + let ns_window = ns_view + .window() + .expect("view was not installed in a window"); + + ns_window.setLevel(NSFloatingWindowLevel); + + ns_window.setCollectionBehavior(NSWindowCollectionBehavior::CanJoinAllSpaces); + } + _ => { + panic!( + "Why are you running this as a non-appkit window? this is a macos only app as of now" + ); + } + } +} + +/// This is the function that forces focus onto rustcast +#[allow(deprecated)] +pub fn focus_this_app() { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApp; + + let mtm = MainThreadMarker::new().expect("must be on main thread"); + let app = NSApp(mtm); + + app.activateIgnoringOtherApps(true); +} + +/// This is the struct that represents the process serial number, allowing us to transform the process to a UI element +#[repr(C)] +struct ProcessSerialNumber { + low: u32, + hi: u32, +} + +/// This is the function that transforms the process to a UI element, and hides the dock icon +/// +/// see mostly +/// +/// returns ApplicationServices OSStatus (u32) +/// +/// doesn't seem to do anything if you haven't opened a window yet, so wait to call it until after that. +pub fn transform_process_to_ui_element() -> u32 { + use objc2_application_services::{ + TransformProcessType, kCurrentProcess, kProcessTransformToUIElementApplication, + }; + use std::ptr; + + let psn = ProcessSerialNumber { + low: 0, + hi: kCurrentProcess, + }; + + unsafe { + TransformProcessType( + ptr::from_ref(&psn).cast(), + kProcessTransformToUIElementApplication, + ) + } +} + +fn get_installed_apps(dir: impl AsRef, store_icons: bool) -> Vec { + let entries: Vec<_> = fs::read_dir(dir.as_ref()) + .unwrap_or_else(|x| { + tracing::error!( + "An error occurred while reading dir ({}) {}", + dir.as_ref().to_str().unwrap_or(""), + x + ); + exit(-1) + }) + .filter_map(|x| x.ok()) + .collect(); + + entries + .into_par_iter() + .filter_map(|x| { + let file_type = x.file_type().unwrap_or_else(|e| { + tracing::error!("Failed to get file type: {}", e.to_string()); + exit(-1) + }); + if !file_type.is_dir() { + return None; + } + + let file_name_os = x.file_name(); + let file_name = file_name_os.into_string().unwrap_or_else(|e| { + tracing::error!("Failed to to get file_name_os: {}", e.to_string_lossy()); + exit(-1) + }); + if !file_name.ends_with(".app") { + return None; + } + + let path = x.path(); + let path_str = path.to_str().map(|x| x.to_string()).unwrap_or_else(|| { + tracing::error!("Unable to get file_name"); + exit(-1) + }); + + let icons = if store_icons { + match fs::read_to_string(format!("{}/Contents/Info.plist", path_str)).map( + |content| { + let icon_line = content + .lines() + .scan(false, |expect_next, line| { + if *expect_next { + *expect_next = false; + // Return this line to the iterator + return Some(Some(line)); + } + + if line.trim() == "CFBundleIconFile" { + *expect_next = true; + } + + // For lines that are not the one after the key, return None to skip + Some(None) + }) + .flatten() // remove the Nones + .next() + .map(|x| { + x.trim() + .strip_prefix("") + .unwrap_or("") + .strip_suffix("") + .unwrap_or("") + }); + + handle_from_icns(Path::new(&format!( + "{}/Contents/Resources/{}", + path_str, + icon_line.unwrap_or("AppIcon.icns") + ))) + }, + ) { + Ok(Some(a)) => Some(a), + _ => { + // Fallback method + let direntry = fs::read_dir(format!("{}/Contents/Resources", path_str)) + .into_iter() + .flatten() + .filter_map(|x| { + let file = x.ok()?; + let name = file.file_name(); + let file_name = name.to_str()?; + if file_name.ends_with(".icns") { + Some(file.path()) + } else { + None + } + }) + .collect::>(); + + if direntry.len() > 1 { + let icns_vec = direntry + .iter() + .filter(|x| x.ends_with("AppIcon.icns")) + .collect::>(); + handle_from_icns(icns_vec.first().unwrap_or(&&PathBuf::new())) + } else if !direntry.is_empty() { + handle_from_icns(direntry.first().unwrap_or(&PathBuf::new())) + } else { + None + } + } + } + } else { + None + }; + + let name = file_name.strip_suffix(".app").unwrap().to_string(); + Some(App::new_executable( + &name, + &name.to_lowercase(), + "Application", + path, + icons, + )) + }) + .collect() +} + +pub fn get_installed_macos_apps(config: &Config) -> anyhow::Result> { + let store_icons = config.theme.show_icons; + let user_local_path = std::env::var("HOME").unwrap() + "/Applications/"; + let paths: Vec = vec![ + "/Applications/".to_string(), + user_local_path.to_string(), + "/System/Applications/".to_string(), + "/System/Applications/Utilities/".to_string(), + ]; + + let mut apps = index_installed_apps(config)?; + apps.par_extend( + paths + .par_iter() + .map(|path| get_installed_apps(path, store_icons)) + .flatten(), + ); + + Ok(apps) +} + +/// Open the settings file with the system default editor +pub fn open_settings() { + thread::spawn(move || { + NSWorkspace::new().openURL(&NSURL::fileURLWithPath( + &objc2_foundation::NSString::from_str( + &(std::env::var("HOME").unwrap_or("".to_string()) + + "/.config/rustcast/config.toml"), + ), + )); + }); +} + +/// Gets an iced image handle from a .icns file. +pub(crate) fn handle_from_icns(path: &Path) -> Option { + use image::RgbaImage; + + let data = std::fs::read(path).ok()?; + let family = IconFamily::read(std::io::Cursor::new(&data)).ok()?; + + let icon_type = family.available_icons(); + + let icon = family.get_icon_with_type(*icon_type.first()?).ok()?; + let image = RgbaImage::from_raw( + icon.width() as u32, + icon.height() as u32, + icon.data().to_vec(), + )?; + Some(Handle::from_rgba( + image.width(), + image.height(), + image.into_raw(), + )) +} diff --git a/src/cross_platform/mod.rs b/src/cross_platform/mod.rs new file mode 100644 index 0000000..eb304b6 --- /dev/null +++ b/src/cross_platform/mod.rs @@ -0,0 +1,31 @@ +#![warn(missing_docs)] + +use std::path::Path; + +#[cfg(target_os = "macos")] +pub mod macos; + +#[cfg(target_os = "windows")] +pub mod windows; + +#[cfg(target_os = "linux")] +pub mod linux; + +/// Opens the settings file +pub fn open_settings() { + #[cfg(target_os = "macos")] + macos::open_settings() +} + +/// Gets an iced image handle +pub fn get_img_handle(path: &Path) -> Option { + if !path.exists() { + return None; + } + + #[cfg(target_os = "macos")] + return macos::handle_from_icns(path); + + #[cfg(any(target_os = "windows", target_os = "linux"))] + return Some(iced::widget::image::Handle::from_path(path)); +} diff --git a/src/cross_platform/windows/app_finding.rs b/src/cross_platform/windows/app_finding.rs new file mode 100644 index 0000000..d2b30b4 --- /dev/null +++ b/src/cross_platform/windows/app_finding.rs @@ -0,0 +1,192 @@ +use { + crate::{ + app::apps::App, + cross_platform::windows::{appicon::get_first_icon, get_acp}, + }, + lnk::ShellLink, + std::path::{Path, PathBuf}, + walkdir::WalkDir, + windows::{ + Win32::{ + System::Com::CoTaskMemFree, + UI::Shell::{ + FOLDERID_LocalAppData, FOLDERID_ProgramFiles, FOLDERID_ProgramFilesX86, + KF_FLAG_DEFAULT, SHGetKnownFolderPath, + }, + }, + core::GUID, + }, +}; + +/// Loads apps from the registry keys `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` and +/// `SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall`. `apps` has the relvant items +/// appended to it. +/// +/// Based on https://stackoverflow.com/questions/2864984 +pub fn get_apps_from_registry(apps: &mut Vec) { + use std::ffi::OsString; + let hkey = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE); + + let registers = [ + hkey.open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall") + .unwrap(), + hkey.open_subkey("SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall") + .unwrap(), + ]; + + registers.iter().for_each(|reg| { + reg.enum_keys().for_each(|key| { + // Not debug only just because it doesn't run too often + tracing::trace!( + target: "reg_app_search", + "App added: {:?}", + key + ); + + // https://learn.microsoft.com/en-us/windows/win32/msi/uninstall-registry-key + let name = key.unwrap(); + let key = reg.open_subkey(&name).unwrap(); + let display_name: OsString = key.get_value("DisplayName").unwrap_or_default(); + + // they might be useful one day ? + // let publisher = key.get_value("Publisher").unwrap_or(OsString::new()); + // let version = key.get_value("DisplayVersion").unwrap_or(OsString::new()); + + // Trick, I saw on internet to point to the exe location.. + let exe_path: OsString = key.get_value("DisplayIcon").unwrap_or_default(); + if exe_path.is_empty() { + return; + } + // if there is something, it will be in the form of + // "C:\Program Files\Microsoft Office\Office16\WINWORD.EXE",0 + let exe_path = exe_path.to_string_lossy().to_string(); + let exe = PathBuf::from(exe_path.split(",").next().unwrap()); + + // make sure it ends with .exe + if exe.extension() != Some(&OsString::from("exe")) { + return; + } + + if !display_name.is_empty() { + let icon = get_first_icon(&exe) + .inspect_err(|e| tracing::error!("Error getting icons: {e}")) + .ok() + .flatten(); + + apps.push(App::new_executable( + &display_name.clone().to_string_lossy(), + &display_name.clone().to_string_lossy().to_lowercase(), + "Application", + exe, + icon, + )) + } + }); + }); +} + +/// Returns the set of known paths +pub fn get_known_paths() -> Vec { + let paths = vec![ + get_windows_path(&FOLDERID_ProgramFiles).unwrap_or_default(), + get_windows_path(&FOLDERID_ProgramFilesX86).unwrap_or_default(), + (get_windows_path(&FOLDERID_LocalAppData) + .unwrap_or_default() + .join("Programs")), + ]; + paths +} + +/// Wrapper around `SHGetKnownFolderPath` to get paths to known folders +fn get_windows_path(folder_id: &GUID) -> Option { + unsafe { + let folder = SHGetKnownFolderPath(folder_id, KF_FLAG_DEFAULT, None); + if let Ok(folder) = folder { + let path = folder.to_string().ok()?; + CoTaskMemFree(Some(folder.0 as *mut _)); + Some(path.into()) + } else { + None + } + } +} + +fn parse_link(lnk: ShellLink, link_path: impl AsRef) -> Option { + let link_path = link_path.as_ref(); + + let Some(target) = lnk.link_target() else { + tracing::trace!( + target: "smenu_app_search", + "Link at {} has no target, skipped", + link_path.display() + ); + return None; + }; + let target = PathBuf::from(target); + + tracing::trace!( + "Link at {} loaded (target: {:?})", + link_path.display(), + &target + ); + + let Some(file_name) = target.file_name() else { + tracing::trace!( + target: "smenu_app_search", + "Link at {} skipped (not pointing to a directory)", + link_path.display() + ); + return None; + }; + + tracing::trace!( + target: "smenu_app_search", + "Link at {} added", + link_path.display() + ); + + Some(App::new_executable( + &file_name.to_string_lossy(), + &file_name.to_string_lossy().to_lowercase(), + "Shortcut", + target.clone(), + None, + )) +} + +pub fn index_start_menu() -> Vec { + WalkDir::new(r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs") + .into_iter() + .filter_map(|x| x.ok()) + .filter_map(|entry| { + let ext = entry.path().extension(); + let path = entry.path(); + + if ext.is_none() { + tracing::trace!("{} has no extension (maybe a dir)", path.display()); + return None; + } + + if let Some(ext) = ext + && ext != "lnk" + { + tracing::trace!("{} not a .lnk file, skipping", path.display()); + return None; + } + + let lnk = lnk::ShellLink::open(path, get_acp()); + + match lnk { + Ok(x) => parse_link(x, path), + Err(e) => { + tracing::trace!( + target: "smenu_app_search", + "Error opening link {} ({e}), skipped", + entry.path().to_string_lossy() + ); + None + } + } + }) + .collect() +} diff --git a/src/cross_platform/windows/appicon.rs b/src/cross_platform/windows/appicon.rs new file mode 100644 index 0000000..15a44cb --- /dev/null +++ b/src/cross_platform/windows/appicon.rs @@ -0,0 +1,158 @@ +//! Extracts icons from executables etc. + +use std::path::Path; + +use iced::widget; +use widestring::U16CString; +use windows::{ + Win32::{ + Foundation::TRUE, + Graphics::Gdi::{ + BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleDC, DIB_RGB_COLORS, DeleteDC, + DeleteObject, GetDIBits, HBITMAP, SelectObject, + }, + UI::{ + Shell::ExtractIconExW, + WindowsAndMessaging::{DestroyIcon, GetIconInfoExW, HICON, ICONINFOEXW}, + }, + }, + core::PCWSTR, +}; + +use crate::utils::bgra_to_rgba; + +/// Gets the icons from an executable. +/// +/// Adapted from an answer to https://stackoverflow.com/questions/7819024 +/// +/// # Errors +/// +/// - If the path contains a NUL byte before the end +/// - Any internal win32 error +pub fn get_first_icon(path: impl AsRef) -> anyhow::Result> { + let path = path.as_ref(); + + let path_cstr = U16CString::from_os_str(path.as_os_str())?; + let path_pcwstr = PCWSTR(path_cstr.as_ptr()); + + let icon_count = unsafe { ExtractIconExW(path_pcwstr, -1, None, None, 0) }; + + // Don't bother doing the rest + if icon_count == 0 { + return Ok(None); + } + + let mut large_icons = vec![HICON::default(); icon_count as usize]; + let mut small_icons = vec![HICON::default(); icon_count as usize]; + + let icons_fetched = unsafe { + ExtractIconExW( + path_pcwstr, + 0, + Some(large_icons.as_mut_ptr()), + Some(small_icons.as_mut_ptr()), + icon_count, + ) + }; + + tracing::trace!( + target: "icon_fetch", + "{icons_fetched}/{icon_count} icons fetched for {}", + path.display() + ); + + let hicon = large_icons.iter().chain(small_icons.iter()).next(); + + if let Some(hicon) = hicon { + let res = hicon_to_imghandle(*hicon); + unsafe { DestroyIcon(*hicon) }?; + Ok(Some(res?)) // Error only gets propogated down here, so that hicon is always destroyed + } else { + Ok(None) + } +} + +fn hicon_to_imghandle(hicon: HICON) -> Result { + let mut icon_info = ICONINFOEXW { + cbSize: size_of::() as u32, + fIcon: TRUE, + xHotspot: 0, + yHotspot: 0, + hbmMask: HBITMAP::default(), + hbmColor: HBITMAP::default(), + wResID: 0, + szModName: unsafe { std::mem::zeroed() }, + szResName: unsafe { std::mem::zeroed() }, + }; + + let result = unsafe { GetIconInfoExW(hicon, &mut icon_info) }; + + // Nonzero return values indicate ok, while zero means error + if result.0 == 0 { + return Err(windows::core::Error::from_win32()); + } + + let (bitmap_info, bitmap) = get_icon_bitmap(icon_info)?; + + let BITMAPINFOHEADER { + biWidth, biHeight, .. + } = bitmap_info.bmiHeader; + + debug_assert_eq!(biWidth * -biHeight * 4, bitmap.len() as i32); + let data = widget::image::Handle::from_rgba(biWidth as u32, (-biHeight) as u32, bitmap); + + Ok(data) +} + +fn get_icon_bitmap(icon_info: ICONINFOEXW) -> Result<(BITMAPINFO, Vec), windows::core::Error> { + let hdc_screen = unsafe { CreateCompatibleDC(None) }; + let hdc_mem = unsafe { CreateCompatibleDC(hdc_screen) }; + let hbm_old = unsafe { SelectObject(hdc_mem, icon_info.hbmColor) }; + + let mut bmp_info = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: icon_info.xHotspot as i32 * 2, + biHeight: -(icon_info.yHotspot as i32 * 2), + biPlanes: 1, + biBitCount: 32, + biCompression: DIB_RGB_COLORS.0, + ..Default::default() + }, + ..Default::default() + }; + + let mut buffer: Vec = + vec![0; (icon_info.xHotspot * 2 * icon_info.yHotspot * 2 * 4) as usize]; + + let gdib_result = unsafe { + GetDIBits( + hdc_mem, + icon_info.hbmColor, + 0, + icon_info.yHotspot * 2, + Some(buffer.as_mut_ptr() as *mut _), + &mut bmp_info, + DIB_RGB_COLORS, + ) + }; + + // It's just stored here because it should still go through to the cleanup code + let val = if gdib_result == 0 { + Err(windows::core::Error::from_win32()) + } else { + bgra_to_rgba(buffer.as_mut_slice()); + Ok((bmp_info, buffer)) + }; + + // cleanup + unsafe { + SelectObject(hdc_mem, hbm_old); + DeleteDC(hdc_mem).ok()?; + DeleteDC(hdc_screen).ok()?; + DeleteObject(icon_info.hbmColor).ok()?; + DeleteObject(icon_info.hbmMask).ok()?; + } + + val +} diff --git a/src/cross_platform/windows/mod.rs b/src/cross_platform/windows/mod.rs new file mode 100644 index 0000000..c40a0f7 --- /dev/null +++ b/src/cross_platform/windows/mod.rs @@ -0,0 +1,43 @@ +use lnk::{Encoding, encoding::WINDOWS_1252}; +use windows::Win32::{Globalization::GetACP, UI::WindowsAndMessaging::GetCursorPos}; + +pub mod app_finding; +pub mod appicon; + +pub fn open_on_focused_monitor() -> iced::Point { + use windows::Win32::Foundation::POINT; + use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromPoint, + }; + + use crate::app::{DEFAULT_WINDOW_HEIGHT, WINDOW_WIDTH}; + let mut point = POINT { x: 0, y: 0 }; + let mut monitor_info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + let _cursor = unsafe { GetCursorPos(&mut point) }; + let monitor = unsafe { MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST) }; + let _monitor_infos = unsafe { GetMonitorInfoW(monitor, &mut monitor_info) }; + + let monitor_width = monitor_info.rcMonitor.right - monitor_info.rcMonitor.left; + let monitor_height = monitor_info.rcMonitor.bottom - monitor_info.rcMonitor.top; + let window_width = WINDOW_WIDTH; + let window_height = DEFAULT_WINDOW_HEIGHT; + + let x = monitor_info.rcMonitor.left as f32 + (monitor_width as f32 - window_width) / 2.0; + let y = monitor_info.rcMonitor.top as f32 + (monitor_height as f32 - window_height) / 2.0; + + iced::Point { x, y } +} + +/// Wrapper over GetACP that defaults to WINDOWS_1252 if the ACP isn't found +pub fn get_acp() -> Encoding { + unsafe { codepage::to_encoding(GetACP() as u16) }.unwrap_or_else(|| { + tracing::warn!( + "ACP not found, falling back to WINDOWS_1252 as the default shortcut encoding" + ); + WINDOWS_1252 + }) +} diff --git a/src/icon.rs b/src/icon.rs new file mode 100644 index 0000000..7d6e937 --- /dev/null +++ b/src/icon.rs @@ -0,0 +1,45 @@ +//! File with the functions to *statically* get an icon, bundled into the binary + +// Smol macros for DRY purposes +macro_rules! static_geticon { + ($name:ident, $bytes:expr, $sz:literal) => { + #[allow(unused)] + pub fn $name() -> iced::window::Icon { + let icon = image::load_from_memory($bytes).unwrap(); + + iced::window::icon::from_rgba(icon.as_bytes().to_vec(), $sz, $sz).unwrap() + } + }; +} + +macro_rules! static_geticon_imghandle { + ($name:ident, $bytes:expr) => { + #[allow(unused)] + pub fn $name() -> iced::widget::image::Handle { + iced::widget::image::Handle::from_bytes($bytes) + } + }; +} + +// const IMG_64: &[u8] = include_bytes!("../assets/icon/icon64.png"); +// const IMG_128: &[u8] = include_bytes!("../assets/icon/icon128.png"); +const IMG_256: &[u8] = include_bytes!("../assets/icon/icon256.png"); +// const IMG_512: &[u8] = include_bytes!("../assets/icon/icon512.png"); + +pub mod iced_icon { + use super::*; + + // static_geticon!(icon_64, IMG_64, 64); + // static_geticon!(icon_128, IMG_128, 128); + static_geticon!(icon_256, IMG_256, 256); + // static_geticon!(icon_512, IMG_512, 512); +} + +pub mod iced_img_handle { + use super::*; + + // static_geticon_imghandle!(icon_64, IMG_64); + // static_geticon_imghandle!(icon_128, IMG_128); + static_geticon_imghandle!(icon_256, IMG_256); + // static_geticon_imghandle!(icon_512, IMG_512); +} diff --git a/src/macos.rs b/src/macos.rs deleted file mode 100644 index 87f53dc..0000000 --- a/src/macos.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Macos specific logic, such as window settings, etc. -#[cfg(target_os = "macos")] -use iced::wgpu::rwh::WindowHandle; - -/// This sets the activation policy of the app to Accessory, allowing rustcast to be visible ontop -/// of fullscreen apps -#[cfg(target_os = "macos")] -pub fn set_activation_policy_accessory() { - use objc2::MainThreadMarker; - use objc2_app_kit::{NSApp, NSApplicationActivationPolicy}; - - let mtm = MainThreadMarker::new().expect("must be on main thread"); - let app = NSApp(mtm); - app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); -} - -/// This carries out the window configuration for the macos window (only things that are macos specific) -#[cfg(target_os = "macos")] -pub fn macos_window_config(handle: &WindowHandle) { - use iced::wgpu::rwh::RawWindowHandle; - use objc2::rc::Retained; - use objc2_app_kit::NSView; - - match handle.as_raw() { - RawWindowHandle::AppKit(handle) => { - let ns_view = handle.ns_view.as_ptr(); - let ns_view: Retained = unsafe { Retained::retain(ns_view.cast()) }.unwrap(); - let ns_window = ns_view - .window() - .expect("view was not installed in a window"); - - use objc2_app_kit::{NSFloatingWindowLevel, NSWindowCollectionBehavior}; - ns_window.setLevel(NSFloatingWindowLevel); - - ns_window.setCollectionBehavior(NSWindowCollectionBehavior::CanJoinAllSpaces); - } - _ => { - panic!( - "Why are you running this as a non-appkit window? this is a macos only app as of now" - ); - } - } -} - -/// This is the function that forces focus onto rustcast -#[allow(deprecated)] -#[cfg(target_os = "macos")] -pub fn focus_this_app() { - use objc2::MainThreadMarker; - use objc2_app_kit::NSApp; - - let mtm = MainThreadMarker::new().expect("must be on main thread"); - let app = NSApp(mtm); - - app.activateIgnoringOtherApps(true); -} - -/// This is the struct that represents the process serial number, allowing us to transform the process to a UI element -#[repr(C)] -struct ProcessSerialNumber { - low: u32, - hi: u32, -} - -/// This is the function that transforms the process to a UI element, and hides the dock icon -/// -/// see mostly -/// -/// returns ApplicationServices OSStatus (u32) -/// -/// doesn't seem to do anything if you haven't opened a window yet, so wait to call it until after that. -pub fn transform_process_to_ui_element() -> u32 { - use objc2_application_services::{ - TransformProcessType, kCurrentProcess, kProcessTransformToUIElementApplication, - }; - use std::ptr; - - let psn = ProcessSerialNumber { - low: 0, - hi: kCurrentProcess, - }; - - unsafe { - TransformProcessType( - ptr::from_ref(&psn).cast(), - kProcessTransformToUIElementApplication, - ) - } -} diff --git a/src/main.rs b/src/main.rs index 792367f..bcd5469 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,61 +3,160 @@ mod calculator; mod clipboard; mod commands; mod config; -mod haptics; -mod macos; +mod icon; mod styles; mod unit_conversion; mod utils; -use std::path::Path; +mod cross_platform; -use crate::{ - app::tile::{self, Tile}, - config::Config, -}; +use std::env::temp_dir; +use std::fs::{File, create_dir_all}; +use std::io; +// import from utils +use crate::utils::{get_config_file_path, get_config_installation_dir, read_config_file}; + +use crate::app::tile::{self, Tile}; + +#[cfg(not(target_os = "linux"))] use global_hotkey::GlobalHotKeyManager; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::SubscriberExt; + +#[cfg(target_os = "linux")] +const SOCKET_PATH: &str = "/tmp/rustcast.sock"; fn main() -> iced::Result { #[cfg(target_os = "macos")] - { - macos::set_activation_policy_accessory(); - } + cross_platform::macos::set_activation_policy_accessory(); - let home = std::env::var("HOME").unwrap(); + let config_dir = get_config_installation_dir(); + if let Err(e) = std::fs::metadata(config_dir.join("rustcast/")) { + if e.kind() == io::ErrorKind::NotFound { + let result = create_dir_all(config_dir.join("rustcast/")); - let file_path = home.clone() + "/.config/rustcast/config.toml"; - if !Path::new(&file_path).exists() { - std::fs::create_dir_all(home + "/.config/rustcast").unwrap(); - std::fs::write( - &file_path, - toml::to_string(&Config::default()).unwrap_or_else(|x| x.to_string()), - ) - .unwrap(); + if let Err(e) = result { + eprintln!("{}", e); + std::process::exit(1); + } + } else { + eprintln!("{}", e); + std::process::exit(1); + } } - let config: Config = match std::fs::read_to_string(&file_path) { - Ok(a) => toml::from_str(&a).unwrap_or(Config::default()), - Err(_) => Config::default(), - }; - let manager = GlobalHotKeyManager::new().unwrap(); + let file_path = get_config_file_path(); + let config = read_config_file(&file_path); + if let Err(e) = config { + // Tracing isn't inited yet + eprintln!("Error parsing config: {}", e); + std::process::exit(1); + } - let show_hide = config.toggle_hotkey.parse().unwrap(); + let config = config.unwrap(); - let mut hotkeys = vec![show_hide]; + { + let temp_dir = temp_dir().join("rustcast"); + let log_path = temp_dir.join("log.log"); + let vv_log_path = temp_dir.join("vv_log.log"); + if !temp_dir.exists() { + std::fs::create_dir_all(temp_dir).unwrap(); + } + + let file = File::create(&log_path).expect("Failed to create logfile"); + let vv_file = File::create(&vv_log_path).expect("Failed to create logfile"); + + let log_file = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(file) + .with_filter(LevelFilter::DEBUG); + let vv_log_file = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(vv_file); + let console_out = tracing_subscriber::fmt::layer().with_filter(LevelFilter::INFO); - if let Some(show_clipboard) = &config.clipboard_hotkey - && let Some(cb_page_hk) = show_clipboard.parse().ok() + let subscriber = tracing_subscriber::registry() + .with(log_file) + .with(vv_log_file) + .with(console_out); + + tracing::subscriber::set_global_default(subscriber).expect("Error initing tracing"); + + tracing::info!("Main log file at : {}", &vv_log_path.display()); + tracing::info!("Verbose log file at : {}", &log_path.display()); + tracing::info!("Config file at : {}", &file_path.display()); + } + + tracing::debug!("Loaded config data: {:#?}", &config); + + #[cfg(target_os = "linux")] { - hotkeys.push(cb_page_hk); + // error handling should really be improved soon (tm) + use std::fs; + use std::os::unix::net::UnixListener; + use std::{io::Write, os::unix::net::UnixStream}; + use tracing::info; + + if UnixListener::bind(SOCKET_PATH).is_err() { + match UnixStream::connect(SOCKET_PATH) { + Ok(mut stream) => { + use std::env; + + let clipboard = env::args().any(|arg| arg.trim() == "--cphist"); + let cmd = if clipboard { "clipboard" } else { "toggle" }; + info!("socket sending: {cmd}"); + let _ = stream.write_all(cmd.as_bytes()); + std::process::exit(0); + } + Err(_) => { + let _ = fs::remove_file(SOCKET_PATH); + } + } + } } - manager - .register_all(&hotkeys) - .expect("Unable to register hotkey"); + #[cfg(not(target_os = "linux"))] + let show_hide_bind = { + let manager = GlobalHotKeyManager::new().unwrap(); + + let show_hide = config.toggle_hotkey.parse().unwrap(); + + let mut hotkeys = vec![show_hide]; + + if let Some(show_clipboard) = &config.clipboard_hotkey + && let Some(cb_page_hk) = show_clipboard.parse().ok() + { + hotkeys.push(cb_page_hk); + } + + let result = manager.register_all(&hotkeys); + + if let Err(global_hotkey::Error::AlreadyRegistered(key)) = result { + if key == show_hide { + // It probably should give up here. + panic!("Couldn't register the key to open ({})", key) + } else { + tracing::warn!("Couldn't register hotkey {}", key) + } + } else if let Err(e) = result { + tracing::error!("{}", e.to_string()); + } + + show_hide + }; + + tracing::info!("Starting."); iced::daemon( - move || tile::elm::new(show_hide, &config), + move || { + tile::elm::new( + #[cfg(not(target_os = "linux"))] + show_hide_bind, + &config, + ) + }, tile::update::handle_update, tile::elm::view, ) diff --git a/src/unit_conversion.rs b/src/unit_conversion/defs.rs similarity index 54% rename from src/unit_conversion.rs rename to src/unit_conversion/defs.rs index 3bc6f7f..f467bcb 100644 --- a/src/unit_conversion.rs +++ b/src/unit_conversion/defs.rs @@ -1,12 +1,4 @@ -//! Unit conversion parsing and calculation. - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UnitCategory { - Length, - Mass, - Volume, - Temperature, -} +use crate::unit_conversion::UnitCategory; #[derive(Debug, Clone, Copy)] pub struct UnitDef { @@ -17,22 +9,7 @@ pub struct UnitDef { pub offset: f64, } -#[derive(Debug, Clone)] -pub struct ConversionResult { - pub source_value: f64, - pub source_unit: &'static UnitDef, - pub target_value: f64, - pub target_unit: &'static UnitDef, -} - -#[derive(Debug, Clone)] -struct ParsedQuery { - value: f64, - source_unit: &'static UnitDef, - target_unit: Option<&'static UnitDef>, -} - -const UNITS: &[UnitDef] = &[ +pub const UNITS: &[UnitDef] = &[ // Length (base: meter) UnitDef { name: "mm", @@ -231,152 +208,3 @@ const UNITS: &[UnitDef] = &[ offset: -273.15, }, ]; - -pub fn convert_query(query: &str) -> Option> { - let parsed = parse_query(query)?; - let base_value = to_base(parsed.value, parsed.source_unit); - - let mut results = Vec::new(); - let targets: Vec<&UnitDef> = match parsed.target_unit { - Some(target) => vec![target], - None => UNITS - .iter() - .filter(|unit| unit.category == parsed.source_unit.category) - .collect(), - }; - - for target_unit in targets { - if target_unit.name == parsed.source_unit.name { - continue; - } - let target_value = from_base(base_value, target_unit); - results.push(ConversionResult { - source_value: parsed.value, - source_unit: parsed.source_unit, - target_value, - target_unit, - }); - } - - if results.is_empty() { - None - } else { - Some(results) - } -} - -pub fn format_number(value: f64) -> String { - let value = if value.abs() < 1e-9 { 0.0 } else { value }; - let mut s = format!("{value:.6}"); - if let Some(dot_pos) = s.find('.') { - while s.ends_with('0') { - s.pop(); - } - if s.ends_with('.') && dot_pos == s.len() - 1 { - s.pop(); - } - } - s -} - -fn parse_query(query: &str) -> Option { - let (value_str, rest) = parse_number_prefix(query)?; - let value: f64 = value_str.parse().ok()?; - let rest = rest.trim_start(); - if rest.is_empty() { - return None; - } - - let rest_lc = rest.to_lowercase(); - let tokens: Vec<&str> = rest_lc.split_whitespace().collect(); - if tokens.is_empty() { - return None; - } - - let source_unit = find_unit(tokens[0])?; - match tokens.len() { - 1 => Some(ParsedQuery { - value, - source_unit, - target_unit: None, - }), - 2 => { - let target_unit = find_unit(tokens[1])?; - if target_unit.category != source_unit.category { - return None; - } - Some(ParsedQuery { - value, - source_unit, - target_unit: Some(target_unit), - }) - } - 3 if tokens[1] == "to" || tokens[1] == "in" => { - let target_unit = find_unit(tokens[2])?; - if target_unit.category != source_unit.category { - return None; - } - Some(ParsedQuery { - value, - source_unit, - target_unit: Some(target_unit), - }) - } - _ => None, - } -} - -fn parse_number_prefix(s: &str) -> Option<(&str, &str)> { - let s = s.trim_start(); - if s.is_empty() { - return None; - } - - let mut chars = s.char_indices().peekable(); - if let Some((_, c)) = chars.peek() - && (*c == '+' || *c == '-') - { - chars.next(); - } - - let mut end = 0; - let mut has_digit = false; - while let Some((idx, c)) = chars.peek().cloned() { - if c.is_ascii_digit() { - has_digit = true; - end = idx + c.len_utf8(); - chars.next(); - } else if c == '.' { - end = idx + c.len_utf8(); - chars.next(); - } else { - break; - } - } - - if !has_digit || end == 0 { - return None; - } - - let (num, rest) = s.split_at(end); - Some((num, rest)) -} - -fn find_unit(token: &str) -> Option<&'static UnitDef> { - let token = token.trim(); - if token.is_empty() { - return None; - } - - UNITS - .iter() - .find(|unit| unit.name == token || unit.aliases.contains(&token)) -} - -fn to_base(value: f64, unit: &UnitDef) -> f64 { - (value + unit.offset) * unit.scale -} - -fn from_base(value: f64, unit: &UnitDef) -> f64 { - value / unit.scale - unit.offset -} diff --git a/src/unit_conversion/mod.rs b/src/unit_conversion/mod.rs new file mode 100644 index 0000000..0d6da02 --- /dev/null +++ b/src/unit_conversion/mod.rs @@ -0,0 +1,177 @@ +//! Unit conversion parsing and calculation. + +use crate::unit_conversion::defs::{UNITS, UnitDef}; + +mod defs; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnitCategory { + Length, + Mass, + Volume, + Temperature, +} + +#[derive(Debug, Clone)] +pub struct ConversionResult { + pub source_value: f64, + pub source_unit: &'static UnitDef, + pub target_value: f64, + pub target_unit: &'static UnitDef, +} + +#[derive(Debug, Clone)] +struct ParsedQuery { + value: f64, + source_unit: &'static UnitDef, + target_unit: Option<&'static UnitDef>, +} + +pub fn convert_query(query: &str) -> Option> { + let parsed = parse_query(query)?; + let base_value = to_base(parsed.value, parsed.source_unit); + + let mut results = Vec::new(); + let targets: Vec<&UnitDef> = match parsed.target_unit { + Some(target) => vec![target], + None => UNITS + .iter() + .filter(|unit| unit.category == parsed.source_unit.category) + .collect(), + }; + + for target_unit in targets { + if target_unit.name == parsed.source_unit.name { + continue; + } + let target_value = from_base(base_value, target_unit); + results.push(ConversionResult { + source_value: parsed.value, + source_unit: parsed.source_unit, + target_value, + target_unit, + }); + } + + if results.is_empty() { + None + } else { + Some(results) + } +} + +pub fn format_number(value: f64) -> String { + let value = if value.abs() < 1e-9 { 0.0 } else { value }; + let mut s = format!("{value:.6}"); + if let Some(dot_pos) = s.find('.') { + while s.ends_with('0') { + s.pop(); + } + if s.ends_with('.') && dot_pos == s.len() - 1 { + s.pop(); + } + } + s +} + +fn parse_query(query: &str) -> Option { + let (value_str, rest) = parse_number_prefix(query)?; + let value: f64 = value_str.parse().ok()?; + let rest = rest.trim_start(); + if rest.is_empty() { + return None; + } + + let rest_lc = rest.to_lowercase(); + let tokens: Vec<&str> = rest_lc.split_whitespace().collect(); + if tokens.is_empty() { + return None; + } + + let source_unit = find_unit(tokens[0])?; + match tokens.len() { + 1 => Some(ParsedQuery { + value, + source_unit, + target_unit: None, + }), + 2 => { + let target_unit = find_unit(tokens[1])?; + if target_unit.category != source_unit.category { + return None; + } + Some(ParsedQuery { + value, + source_unit, + target_unit: Some(target_unit), + }) + } + 3 if tokens[1] == "to" || tokens[1] == "in" => { + let target_unit = find_unit(tokens[2])?; + if target_unit.category != source_unit.category { + return None; + } + Some(ParsedQuery { + value, + source_unit, + target_unit: Some(target_unit), + }) + } + _ => None, + } +} + +fn parse_number_prefix(s: &str) -> Option<(&str, &str)> { + let s = s.trim_start(); + if s.is_empty() { + return None; + } + + let mut chars = s.char_indices().peekable(); + if let Some((_, c)) = chars.peek() + && (*c == '+' || *c == '-') + { + chars.next(); + } + + let mut end = 0; + let mut has_digit = false; + while let Some((idx, c)) = chars.peek().cloned() { + if c.is_ascii_digit() { + has_digit = true; + end = idx + c.len_utf8(); + chars.next(); + } else if c == '.' { + end = idx + c.len_utf8(); + chars.next(); + } else { + break; + } + } + + if !has_digit || end == 0 { + return None; + } + + let (num, rest) = s.split_at(end); + Some((num, rest)) +} + +fn find_unit(token: &str) -> Option<&'static UnitDef> { + let token = token.trim(); + if token.is_empty() { + return None; + } + + UNITS + .iter() + .find(|unit| unit.name == token || unit.aliases.contains(&token)) +} + +fn to_base(value: f64, unit: &UnitDef) -> f64 { + (value + unit.offset) * unit.scale +} + +fn from_base(value: f64, unit: &UnitDef) -> f64 { + value / unit.scale - unit.offset +} diff --git a/src/utils.rs b/src/utils.rs index 263ca11..6f64ad2 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,215 +1,329 @@ //! This has all the utility functions that rustcast uses use std::{ - fs::{self, File}, - io::Write, + io, path::{Path, PathBuf}, - process::exit, - thread, + time::Instant, }; -use iced::widget::image::Handle; -use icns::IconFamily; -use image::RgbaImage; -use objc2_app_kit::NSWorkspace; -use objc2_foundation::NSURL; -use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use rayon::prelude::*; -use crate::{ - app::apps::{App, AppCommand}, - commands::Function, -}; +#[cfg(target_os = "macos")] +use {objc2_app_kit::NSWorkspace, objc2_foundation::NSURL}; + +#[cfg(target_os = "linux")] +use crate::cross_platform::linux::get_installed_linux_apps; + +#[cfg(any(target_os = "windows", target_os = "linux"))] +use std::process::Command; -/// The default error log path (works only on unix systems, and must be changed for windows -/// support) -const ERR_LOG_PATH: &str = "/tmp/rustscan-err.log"; +use crate::app::apps::App; -/// This logs an error to the error log file -pub(crate) fn log_error(msg: &str) { - if let Ok(mut file) = File::options().create(true).append(true).open(ERR_LOG_PATH) { - let _ = file.write_all(msg.as_bytes()).ok(); +pub fn get_config_installation_dir() -> PathBuf { + if cfg!(target_os = "windows") { + std::env::var("LOCALAPPDATA").unwrap().into() + } else { + std::env::var("HOME").unwrap().into() } } -/// This logs an error to the error log file, and exits the program -pub(crate) fn log_error_and_exit(msg: &str) { - log_error(msg); - exit(-1) -} +pub fn get_config_file_path() -> PathBuf { + let home = get_config_installation_dir(); -/// This converts an icns file to an iced image handle -pub(crate) fn handle_from_icns(path: &Path) -> Option { - let data = std::fs::read(path).ok()?; - let family = IconFamily::read(std::io::Cursor::new(&data)).ok()?; - - let icon_type = family.available_icons(); - - let icon = family.get_icon_with_type(*icon_type.first()?).ok()?; - let image = RgbaImage::from_raw( - icon.width() as u32, - icon.height() as u32, - icon.data().to_vec(), - )?; - Some(Handle::from_rgba( - image.width(), - image.height(), - image.into_raw(), - )) + if cfg!(target_os = "windows") { + home.join("rustcast/config.toml") + } else { + home.join(".config/rustcast/config.toml") + } } -/// This gets all the installed apps in the given directory +/// Recursively loads apps from a set of folders. /// -/// the directories are defined in [`crate::app::tile::Tile::new`] -pub(crate) fn get_installed_apps(dir: impl AsRef, store_icons: bool) -> Vec { - let entries: Vec<_> = fs::read_dir(dir.as_ref()) - .unwrap_or_else(|x| { - log_error_and_exit(&x.to_string()); - exit(-1) - }) - .filter_map(|x| x.ok()) - .collect(); - - entries - .into_par_iter() - .filter_map(|x| { - let file_type = x.file_type().unwrap_or_else(|e| { - log_error(&e.to_string()); - exit(-1) - }); - if !file_type.is_dir() { - return None; - } +/// [`exclude_patterns`] is a set of glob patterns to include, while [`include_patterns`] is a set of +/// patterns to include ignoring [`exclude_patterns`]. +fn search_dir( + path: impl AsRef, + exclude_patterns: &[glob::Pattern], + include_patterns: &[glob::Pattern], + max_depth: usize, +) -> impl ParallelIterator { + use walkdir::WalkDir; + + WalkDir::new(path.as_ref()) + .follow_links(false) + .max_depth(max_depth) + .into_iter() + .par_bridge() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "exe")) + .filter_map(|entry| { + let path = entry.path(); + + if exclude_patterns.iter().any(|x| x.matches_path(path)) + && !include_patterns.iter().any(|x| x.matches_path(path)) + { + #[cfg(debug_assertions)] + tracing::trace!( + target: "dir_app_search", + "App excluded: {:?}", path.to_str() + ); - let file_name_os = x.file_name(); - let file_name = file_name_os.into_string().unwrap_or_else(|e| { - log_error(e.to_str().unwrap_or("")); - exit(-1) - }); - if !file_name.ends_with(".app") { return None; } - let path = x.path(); - let path_str = path.to_str().map(|x| x.to_string()).unwrap_or_else(|| { - log_error("Unable to get file_name"); - exit(-1) - }); - - let icons = if store_icons { - match fs::read_to_string(format!("{}/Contents/Info.plist", path_str)).map( - |content| { - let icon_line = content - .lines() - .scan(false, |expect_next, line| { - if *expect_next { - *expect_next = false; - // Return this line to the iterator - return Some(Some(line)); - } - - if line.trim() == "CFBundleIconFile" { - *expect_next = true; - } - - // For lines that are not the one after the key, return None to skip - Some(None) - }) - .flatten() // remove the Nones - .next() - .map(|x| { - x.trim() - .strip_prefix("") - .unwrap_or("") - .strip_suffix("") - .unwrap_or("") - }); - - handle_from_icns(Path::new(&format!( - "{}/Contents/Resources/{}", - path_str, - icon_line.unwrap_or("AppIcon.icns") - ))) - }, - ) { - Ok(Some(a)) => Some(a), - _ => { - // Fallback method - let direntry = fs::read_dir(format!("{}/Contents/Resources", path_str)) - .into_iter() - .flatten() - .filter_map(|x| { - let file = x.ok()?; - let name = file.file_name(); - let file_name = name.to_str()?; - if file_name.ends_with(".icns") { - Some(file.path()) - } else { - None - } - }) - .collect::>(); - - if direntry.len() > 1 { - let icns_vec = direntry - .iter() - .filter(|x| x.ends_with("AppIcon.icns")) - .collect::>(); - handle_from_icns(icns_vec.first().unwrap_or(&&PathBuf::new())) - } else if !direntry.is_empty() { - handle_from_icns(direntry.first().unwrap_or(&PathBuf::new())) - } else { - None - } - } - } - } else { - None + let file_name = path.file_name().unwrap().to_string_lossy(); + let name = file_name.replace(".exe", ""); + + #[cfg(debug_assertions)] + tracing::trace!( + target: "dir_app_search", + "App added: {:?}", path.to_str() + ); + + #[cfg(target_os = "windows")] + let icon = { + use crate::cross_platform::windows::appicon::get_first_icon; + + get_first_icon(path) + .inspect_err(|e| { + tracing::error!("Error getting icon for {}: {e}", path.display()) + }) + .ok() + .flatten() }; - let name = file_name.strip_suffix(".app").unwrap().to_string(); - Some(App { - open_command: AppCommand::Function(Function::OpenApp(path_str)), - desc: "Application".to_string(), - icons, - name_lc: name.to_lowercase(), - name, - }) + #[cfg(not(target_os = "windows"))] + let icon = None; + + Some(App::new_executable( + &name, + &name.to_lowercase(), + "Application", + path, + icon, + )) }) - .collect() } -/// Open the settings file with the system default editor -pub fn open_settings() { - thread::spawn(move || { +use crate::config::Config; + +pub fn read_config_file(file_path: &Path) -> anyhow::Result { + match std::fs::read_to_string(file_path) { + Ok(a) => Ok(toml::from_str(&a)?), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + let cfg = Config::default(); + std::fs::write( + file_path, + toml::to_string(&cfg).unwrap_or_else(|x| x.to_string()), + )?; + Ok(cfg) + } + Err(e) => Err(e.into()), + } +} + +// TODO: this should also work with args +pub fn open_application(path: impl AsRef) { + let path = path.as_ref(); + + #[cfg(target_os = "windows")] + { + println!("Opening application: {}", path.display()); + + Command::new("powershell") + .arg(format!("Start-Process '{}'", path.display())) + .status() + .ok(); + } + + #[cfg(target_os = "macos")] + { NSWorkspace::new().openURL(&NSURL::fileURLWithPath( - &objc2_foundation::NSString::from_str( - &(std::env::var("HOME").unwrap_or("".to_string()) - + "/.config/rustcast/config.toml"), - ), + &objc2_foundation::NSString::from_str(&path.to_string_lossy()), )); - }); + } + + #[cfg(target_os = "linux")] + { + Command::new(path).status().ok(); + } + #[cfg(target_os = "linux")] + { + Command::new(path).status().ok(); + } } -/// Open a provided URL (Platform specific) -pub fn open_url(url: &str) { - let url = url.to_owned(); - thread::spawn(move || { - NSWorkspace::new().openURL( - &NSURL::URLWithString_relativeToURL(&objc2_foundation::NSString::from_str(&url), None) - .unwrap(), +pub fn index_installed_apps(config: &Config) -> anyhow::Result> { + tracing::debug!("Indexing installed apps"); + tracing::debug!("Exclude patterns: {:?}", &config.index_exclude_patterns); + tracing::debug!("Include patterns: {:?}", &config.index_include_patterns); + + let path = get_config_file_path(); + let config = read_config_file(path.as_path())?; + + if config.index_dirs.is_empty() { + tracing::debug!("No extra index dirs provided") + } + + #[cfg(target_os = "windows")] + { + use crate::cross_platform::windows::app_finding::get_apps_from_registry; + use crate::cross_platform::windows::app_finding::index_start_menu; + + let start = Instant::now(); + + let mut other_apps = index_start_menu(); + get_apps_from_registry(&mut other_apps); + + let res = config + .index_dirs + .par_iter() + .flat_map(|x| { + search_dir( + &x.path, + &config.index_exclude_patterns, + &config.index_include_patterns, + x.max_depth, + ) + }) + .chain(other_apps.into_par_iter()) + .collect(); + + let end = Instant::now(); + tracing::info!( + "Finished indexing apps (t = {}s)", + (end - start).as_secs_f32() + ); + + Ok(res) + } + + #[cfg(target_os = "macos")] + { + let start = Instant::now(); + + let res = config + .index_dirs + .par_iter() + .flat_map(|x| { + search_dir( + &x.path, + &config.index_exclude_patterns, + &config.index_include_patterns, + x.max_depth, + ) + }) + .collect(); + + let end = Instant::now(); + tracing::info!( + "Finished indexing apps (t = {}s)", + (end - start).as_secs_f32() + ); + + Ok(res) + } + + #[cfg(target_os = "linux")] + { + let start = Instant::now(); + + let other_apps = get_installed_linux_apps(&config); + + let start2 = Instant::now(); + + let res = config + .index_dirs + .par_iter() + .flat_map(|x| { + search_dir( + &x.path, + &config.index_exclude_patterns, + &config.index_include_patterns, + x.max_depth, + ) + }) + .chain(other_apps.into_par_iter()) + .collect(); + + let end = Instant::now(); + tracing::info!( + "Finished indexing apps (t = {}s) (t2 = {}s)", + (end - start).as_secs_f32(), + (end - start2).as_secs_f32(), ); - }); + + Ok(res) + } } -/// Check if the provided string is a valid url -pub fn is_valid_url(s: &str) -> bool { - s.ends_with(".com") - || s.ends_with(".net") - || s.ends_with(".org") - || s.ends_with(".edu") - || s.ends_with(".gov") - || s.ends_with(".io") - || s.ends_with(".co") - || s.ends_with(".me") - || s.ends_with(".app") - || s.ends_with(".dev") +/// Check if the provided string looks like a valid url +pub fn is_url_like(s: &str) -> bool { + if s.starts_with("http://") || s.starts_with("https://") { + return true; + } + if !s.contains('.') { + return false; + } + let mut parts = s.split('.'); + + let tld = match parts.next_back() { + Some(p) => p, + None => return false, + }; + + if tld.is_empty() || tld.len() > 63 || !tld.chars().all(|c| c.is_ascii_alphabetic()) { + return false; + } + + parts.all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + && !label.starts_with('-') + && !label.ends_with('-') + }) +} + +/// Converts a slice of BGRA data to RGBA using SIMD +/// +/// Stolen from https://stackoverflow.com/a/78190249/ +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +pub fn bgra_to_rgba(data: &mut [u8]) { + use std::arch::x86_64::__m128i; + use std::arch::x86_64::_mm_loadu_si128; + use std::arch::x86_64::_mm_setr_epi8; + use std::arch::x86_64::_mm_storeu_si128; + + #[cfg(target_arch = "x86")] + use std::arch::x86::_mm_shuffle_epi8; + #[cfg(target_arch = "x86_64")] + use std::arch::x86_64::_mm_shuffle_epi8; + // + // The shuffle mask for converting BGRA -> RGBA + let mask: __m128i = unsafe { + _mm_setr_epi8( + 2, 1, 0, 3, // First pixel + 6, 5, 4, 7, // Second pixel + 10, 9, 8, 11, // Third pixel + 14, 13, 12, 15, // Fourth pixel + ) + }; + // For each 16-byte chunk in your data + for chunk in data.chunks_exact_mut(16) { + let mut vector = unsafe { _mm_loadu_si128(chunk.as_ptr() as *const __m128i) }; + vector = unsafe { _mm_shuffle_epi8(vector, mask) }; + unsafe { _mm_storeu_si128(chunk.as_mut_ptr() as *mut __m128i, vector) }; + } +} + +// Fallback for non x86/x86_64 devices (not like that'll ever be used, but why not) +/// Converts a slice of BGRA data to RGBA +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +pub fn bgra_to_rgba(data: &mut [u8]) { + for i in (0..data.len()).step_by(4) { + let r = data[i + 2]; + + data[i + 2] = data[i]; + data[i] = r; + } }