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