From e40a8ec3c5f65e3871837fef8fc7901c10bd882b Mon Sep 17 00:00:00 2001 From: edibotopic Date: Wed, 13 May 2026 15:39:13 +0200 Subject: [PATCH] failed GNOME search integration --- Cargo.lock | 907 ++++++++++++++++++ Cargo.toml | 4 + ...com.canonical.UbuntuDesktopHelp.desktop.in | 9 + ...ical.UbuntuDesktopHelp.search-provider.ini | 5 + ...com.canonical.UbuntuDesktopHelp.service.in | 3 + search-setup.sh | 86 ++ src/gui.rs | 206 ++++ src/llm.rs | 58 +- src/main.rs | 45 +- src/search_provider.rs | 139 +++ 10 files changed, 1430 insertions(+), 32 deletions(-) create mode 100644 data/com.canonical.UbuntuDesktopHelp.desktop.in create mode 100644 data/com.canonical.UbuntuDesktopHelp.search-provider.ini create mode 100644 data/com.canonical.UbuntuDesktopHelp.service.in create mode 100755 search-setup.sh create mode 100644 src/gui.rs create mode 100644 src/search_provider.rs diff --git a/Cargo.lock b/Cargo.lock index 13a6700..f2e68ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,137 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -98,6 +229,12 @@ dependencies = [ "syn", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -110,6 +247,19 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -122,6 +272,29 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cairo-rs" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "cc" version = "1.2.62" @@ -132,6 +305,16 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -184,6 +367,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -223,6 +415,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "derive_utils" version = "0.15.1" @@ -266,6 +464,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -282,12 +507,43 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -345,12 +601,36 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -390,6 +670,63 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk-pixbuf" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + [[package]] name = "getopts" version = "0.2.24" @@ -435,6 +772,197 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gio" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", +] + +[[package]] +name = "glib" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + [[package]] name = "h2" version = "0.4.14" @@ -475,6 +1003,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -770,6 +1310,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "libadwaita" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500135d29c16aabf67baafd3e7741d48e8b8978ca98bac39e589165c8dc78191" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6680988058c2558baf3f548a370e4e78da3bf7f08469daa822ac414842c912db" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + [[package]] name = "libc" version = "0.2.186" @@ -809,6 +1380,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -904,6 +1484,46 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -939,12 +1559,37 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -970,6 +1615,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1114,6 +1768,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1259,6 +1922,26 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1401,6 +2084,25 @@ dependencies = [ "libc", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + [[package]] name = "tempfile" version = "3.27.0" @@ -1523,6 +2225,57 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -1575,9 +2328,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1598,16 +2363,31 @@ name = "ubuntu-desktop-help" version = "0.1.0" dependencies = [ "anyhow", + "async-channel", "bytes", "clap", "futures-util", + "gtk4", "indicatif", + "libadwaita", "pulldown-cmark", "reqwest", "serde", "serde_json", "text-splitter", "tokio", + "zbus", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", ] [[package]] @@ -1670,12 +2450,29 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -1963,6 +2760,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2086,6 +2892,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -2171,3 +3038,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 41d0d43..7d282c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,13 +5,17 @@ edition = "2024" [dependencies] anyhow = "1" +async-channel = "2" bytes = "1.11.1" clap = { version = "4.5", features = ["derive", "env"] } futures-util = "0.3" +gtk4 = "0.9" indicatif = "0.17" +libadwaita = { version = "0.7", features = ["v1_4"] } pulldown-cmark = "0.11" reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" text-splitter = { version = "0.14", features = ["markdown"] } tokio = { version = "1", features = ["full"] } +zbus = "5" diff --git a/data/com.canonical.UbuntuDesktopHelp.desktop.in b/data/com.canonical.UbuntuDesktopHelp.desktop.in new file mode 100644 index 0000000..93f3766 --- /dev/null +++ b/data/com.canonical.UbuntuDesktopHelp.desktop.in @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Ubuntu Desktop Help +Comment=Ask questions about using Ubuntu +Exec=@BINARY@ --copilot gui +Icon=help-browser +Terminal=false +Categories=GNOME;GTK;System; +StartupNotify=true diff --git a/data/com.canonical.UbuntuDesktopHelp.search-provider.ini b/data/com.canonical.UbuntuDesktopHelp.search-provider.ini new file mode 100644 index 0000000..f027a14 --- /dev/null +++ b/data/com.canonical.UbuntuDesktopHelp.search-provider.ini @@ -0,0 +1,5 @@ +[Shell Search Provider] +DesktopId=com.canonical.UbuntuDesktopHelp.desktop +BusName=com.canonical.UbuntuDesktopHelp +ObjectPath=/com/canonical/UbuntuDesktopHelp/SearchProvider +Version=2 diff --git a/data/com.canonical.UbuntuDesktopHelp.service.in b/data/com.canonical.UbuntuDesktopHelp.service.in new file mode 100644 index 0000000..41ada9f --- /dev/null +++ b/data/com.canonical.UbuntuDesktopHelp.service.in @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=com.canonical.UbuntuDesktopHelp +Exec=@BINARY@ --copilot dbus diff --git a/search-setup.sh b/search-setup.sh new file mode 100755 index 0000000..72e106f --- /dev/null +++ b/search-setup.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Installs (or uninstalls) the GNOME Shell search provider for the current user. +# +# Drops three files under ~/.local with the path to the locally-built binary +# substituted in. Run after `cargo build --release`. +# +# Requires a GNOME Shell session restart (logout / login on Wayland) for +# GNOME to pick up changes to the search-provider .ini. +# +# Usage: +# ./search-setup.sh install +# ./search-setup.sh --uninstall remove the three files and kill any running daemon +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BINARY="${REPO_ROOT}/target/release/ubuntu-desktop-help" + +DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}" +SP_FILE="${DATA_HOME}/gnome-shell/search-providers/com.canonical.UbuntuDesktopHelp.search-provider.ini" +DBUS_FILE="${DATA_HOME}/dbus-1/services/com.canonical.UbuntuDesktopHelp.service" +DESKTOP_FILE="${DATA_HOME}/applications/com.canonical.UbuntuDesktopHelp.desktop" + +uninstall() { + # Ask any running daemon to drop its bus name so the next overview search + # re-activates the binary we're about to remove. Best-effort. + pkill -f "ubuntu-desktop-help.*dbus" 2>/dev/null || true + + rm -f "${SP_FILE}" "${DBUS_FILE}" "${DESKTOP_FILE}" + + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$(dirname "${DESKTOP_FILE}")" >/dev/null 2>&1 || true + fi + + cat <&2 + exit 1 + fi + + mkdir -p "$(dirname "${SP_FILE}")" "$(dirname "${DBUS_FILE}")" "$(dirname "${DESKTOP_FILE}")" + + # Kill any previously-installed daemon so the new .service file is honoured. + pkill -f "ubuntu-desktop-help.*dbus" 2>/dev/null || true + + # Substitute @BINARY@ with the absolute path to the built binary. + sed "s|@BINARY@|${BINARY}|g" \ + "${REPO_ROOT}/data/com.canonical.UbuntuDesktopHelp.service.in" \ + > "${DBUS_FILE}" + + sed "s|@BINARY@|${BINARY}|g" \ + "${REPO_ROOT}/data/com.canonical.UbuntuDesktopHelp.desktop.in" \ + > "${DESKTOP_FILE}" + + # The .ini file has no substitutions; copy as-is. + cp "${REPO_ROOT}/data/com.canonical.UbuntuDesktopHelp.search-provider.ini" "${SP_FILE}" + + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$(dirname "${DESKTOP_FILE}")" >/dev/null 2>&1 || true + fi + + cat <&2; exit 2 ;; +esac diff --git a/src/gui.rs b/src/gui.rs new file mode 100644 index 0000000..3e8d949 --- /dev/null +++ b/src/gui.rs @@ -0,0 +1,206 @@ +use anyhow::Result; +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +use crate::llm::{CopilotClient, LlmClient, Message, OllamaClient}; +use crate::{DEFAULT_OLLAMA_URL, SYSTEM_PROMPT}; + +const APP_ID: &str = "com.canonical.UbuntuDesktopHelp"; + +// Messages sent from the LLM worker thread to the GTK main loop. +enum StreamMsg { + Token(String), + Error(String), + Done, +} + +// Entry point for `ubuntu-desktop-help gui `. Starts the GTK main loop +// and presents a single window that streams the answer for `query`. +pub fn run(query: String, use_copilot: bool, model: String) -> Result<()> { + let app = adw::Application::builder().application_id(APP_ID).build(); + + // connect_activate is called when the application is told to present itself. + // We pass an empty argv to .run_with_args because clap has already consumed + // the real argv and GTK would otherwise re-parse it. + app.connect_activate(move |app| { + build_window(app, query.clone(), use_copilot, model.clone()); + }); + + let empty: [&str; 0] = []; + app.run_with_args(&empty); + Ok(()) +} + +fn build_window(app: &adw::Application, query: String, use_copilot: bool, model: String) { + let window = adw::ApplicationWindow::builder() + .application(app) + .title("Ubuntu Desktop Help") + .default_width(640) + .default_height(520) + .build(); + + // ToolbarView gives us a standard GNOME header bar above the content area. + let toolbar = adw::ToolbarView::new(); + let header = adw::HeaderBar::new(); + toolbar.add_top_bar(&header); + + // Vertical box: the user's question on top, the streamed answer below. + let vbox = gtk4::Box::builder() + .orientation(gtk4::Orientation::Vertical) + .spacing(8) + .margin_top(16) + .margin_bottom(16) + .margin_start(16) + .margin_end(16) + .build(); + + let header_text = if query.is_empty() { + "Type your question in the GNOME overview after ??".to_string() + } else { + format!("{}", glib::markup_escape_text(&query)) + }; + let question_label = gtk4::Label::builder() + .label(&header_text) + .use_markup(true) + .xalign(0.0) + .wrap(true) + .wrap_mode(gtk4::pango::WrapMode::WordChar) + .build(); + vbox.append(&question_label); + + // Spinner shown until the first token arrives. Hidden when there's nothing to ask. + let spinner = gtk4::Spinner::builder().spinning(true).build(); + let spinner_row = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(8) + .build(); + spinner_row.append(&spinner); + let thinking = gtk4::Label::new(Some("Thinking…")); + spinner_row.append(&thinking); + if query.is_empty() { + spinner_row.set_visible(false); + } + vbox.append(&spinner_row); + + // TextView holds the streamed answer. It starts empty and is filled + // token-by-token as the model responds. + let text_view = gtk4::TextView::builder() + .editable(false) + .cursor_visible(false) + .wrap_mode(gtk4::WrapMode::WordChar) + .top_margin(8) + .bottom_margin(8) + .left_margin(4) + .right_margin(4) + .build(); + let buffer = text_view.buffer(); + + let scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .hscrollbar_policy(gtk4::PolicyType::Never) + .child(&text_view) + .build(); + vbox.append(&scroll); + + toolbar.set_content(Some(&vbox)); + window.set_content(Some(&toolbar)); + window.present(); + + // Channel from the LLM worker (tokio thread) to the GTK main loop. Only + // start a worker if there's actually something to ask; an empty-query + // launch (from the app grid) just shows the placeholder above. + let (tx, rx) = async_channel::unbounded::(); + if !query.is_empty() { + spawn_worker(query, use_copilot, model, tx); + } else { + let _ = tx.send_blocking(StreamMsg::Done); + } + + // Drain the channel on the GTK main loop and update the buffer in place. + glib::spawn_future_local(async move { + let mut got_first_token = false; + while let Ok(msg) = rx.recv().await { + match msg { + StreamMsg::Token(t) => { + if !got_first_token { + spinner_row.set_visible(false); + got_first_token = true; + } + let mut end = buffer.end_iter(); + buffer.insert(&mut end, &t); + } + StreamMsg::Error(e) => { + spinner_row.set_visible(false); + let mut end = buffer.end_iter(); + buffer.insert(&mut end, &format!("Error: {e}")); + } + StreamMsg::Done => break, + } + } + }); +} + +// Spawns a thread that runs a single-threaded tokio runtime, executes one +// chat call, and forwards tokens to the GTK side via `tx`. We use a fresh +// thread because GTK and tokio each want to own the main thread. +fn spawn_worker( + query: String, + use_copilot: bool, + model: String, + tx: async_channel::Sender, +) { + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send_blocking(StreamMsg::Error(format!("runtime: {e}"))); + let _ = tx.send_blocking(StreamMsg::Done); + return; + } + }; + + rt.block_on(async move { + let client = if use_copilot { + match CopilotClient::create().await { + Ok(c) => LlmClient::Copilot(c), + Err(e) => { + let _ = tx.send(StreamMsg::Error(format!("auth: {e}"))).await; + let _ = tx.send(StreamMsg::Done).await; + return; + } + } + } else { + LlmClient::Ollama(OllamaClient::new(DEFAULT_OLLAMA_URL.to_string(), model)) + }; + + let messages = vec![ + Message { + role: "system".into(), + content: SYSTEM_PROMPT.into(), + }, + Message { + role: "user".into(), + content: query, + }, + ]; + + let tx_token = tx.clone(); + let on_first_token = || {}; + let on_token = |t: &str| { + // send_blocking on an unbounded channel only blocks if the + // receiver was dropped, in which case we just give up. + let _ = tx_token.send_blocking(StreamMsg::Token(t.to_string())); + }; + let result = client.chat(&messages, on_first_token, on_token).await; + if let Err(e) = result { + let _ = tx.send(StreamMsg::Error(e.to_string())).await; + } + let _ = tx.send(StreamMsg::Done).await; + }); + }); +} diff --git a/src/llm.rs b/src/llm.rs index 92dfbf5..eee1fef 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -1,4 +1,3 @@ -use std::io::{self, Write}; use std::process::Command as StdCommand; use anyhow::{Context, Result}; @@ -91,12 +90,18 @@ impl OllamaClient { } // Streams the reply token-by-token via Ollama's NDJSON format. - // `on_first_token` fires once just before the first character is printed. - pub async fn chat( + // `on_first_token` fires once just before the first token is delivered. + // `on_token` receives each token's text as it arrives. + pub async fn chat( &self, messages: &[Message], on_first_token: F, - ) -> Result { + on_token: T, + ) -> Result + where + F: FnOnce(), + T: FnMut(&str), + { let req = OllamaChatRequest { model: &self.model, messages, @@ -115,7 +120,7 @@ impl OllamaClient { .error_for_status()?; // Ollama streams newline-delimited JSON; each chunk is one line - stream_ndjson(response.bytes_stream(), on_first_token, |line| { + stream_ndjson(response.bytes_stream(), on_first_token, on_token, |line| { let chunk: OllamaStreamChunk = serde_json::from_str(line).context("failed to parse Ollama stream chunk")?; Ok(StreamToken { @@ -171,12 +176,18 @@ impl CopilotClient { } // Streams the reply token-by-token via the GitHub Models OpenAI-compatible SSE API. - // `on_first_token` fires once just before the first character is printed. - pub async fn chat( + // `on_first_token` fires once just before the first token is delivered. + // `on_token` receives each token's text as it arrives. + pub async fn chat( &self, messages: &[Message], on_first_token: F, - ) -> Result { + on_token: T, + ) -> Result + where + F: FnOnce(), + T: FnMut(&str), + { let req = OpenAiChatRequest { model: COPILOT_MODEL, messages, @@ -200,7 +211,7 @@ impl CopilotClient { } // Copilot streams Server-Sent Events; each line is "data: " or "data: [DONE]" - stream_ndjson(response.bytes_stream(), on_first_token, |line| { + stream_ndjson(response.bytes_stream(), on_first_token, on_token, |line| { // Strip the SSE "data: " prefix let json = line .strip_prefix("data: ") @@ -235,14 +246,19 @@ pub enum LlmClient { } impl LlmClient { - pub async fn chat( + pub async fn chat( &self, messages: &[Message], on_first_token: F, - ) -> Result { + on_token: T, + ) -> Result + where + F: FnOnce(), + T: FnMut(&str), + { match self { - LlmClient::Ollama(c) => c.chat(messages, on_first_token).await, - LlmClient::Copilot(c) => c.chat(messages, on_first_token).await, + LlmClient::Ollama(c) => c.chat(messages, on_first_token, on_token).await, + LlmClient::Copilot(c) => c.chat(messages, on_first_token, on_token).await, } } } @@ -257,11 +273,12 @@ struct StreamToken { } // Reads a byte stream line-by-line, calls `parse_line` on each non-empty line, -// prints content tokens as they arrive, and returns the full assembled reply. -// `on_first_token` is called once before the first non-empty token is printed. -async fn stream_ndjson( +// delivers tokens to `on_token` as they arrive, and returns the full assembled reply. +// `on_first_token` is called once just before the first non-empty token is delivered. +async fn stream_ndjson( mut stream: S, on_first_token: F, + mut on_token: T, parse_line: P, ) -> Result where @@ -269,11 +286,10 @@ where E: std::error::Error + Send + Sync + 'static, F: FnOnce(), P: Fn(&str) -> Result, + T: FnMut(&str), { let mut full_reply = String::new(); let mut buf = Vec::new(); - let stdout = io::stdout(); - let mut out = stdout.lock(); // Wrap in Option so the callback fires exactly once let mut on_first_token = Some(on_first_token); @@ -296,20 +312,16 @@ where if let Some(f) = on_first_token.take() { f(); } - write!(out, "{}", token.content)?; - out.flush()?; + on_token(&token.content); full_reply.push_str(&token.content); } if token.done { - // Move to a new line after the final token - writeln!(out)?; return Ok(full_reply); } } } - writeln!(out)?; Ok(full_reply) } diff --git a/src/main.rs b/src/main.rs index 76d33ce..cab7407 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,9 @@ use llm::{CopilotClient, LlmClient, Message, OllamaClient}; mod docs; use docs::load_chunks; +mod gui; +mod search_provider; + // Default address where Ollama listens when installed locally const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434"; // Default model to use; small enough to run without a GPU @@ -20,7 +23,7 @@ const DEFAULT_MODEL: &str = "deepseek-r1:1.5b"; // Default directory to load documentation markdown files from const DEFAULT_DOCS_DIR: &str = "docs"; // Instruction given to the LLM at the start of every conversation -const SYSTEM_PROMPT: &str = "You are a helpful Ubuntu Desktop assistant. Answer questions clearly and concisely. The user you are talking to is running Ubuntu. Do not offer advice on alternative operating systems. Prefer strongly the information that you receive as context within a session."; +pub(crate) const SYSTEM_PROMPT: &str = "You are a helpful Ubuntu Desktop assistant. Answer questions clearly and concisely. The user you are talking to is running Ubuntu. Do not offer advice on alternative operating systems. Prefer strongly the information that you receive as context within a session."; // Top-level CLI struct; clap uses the fields and attributes to build argument parsing #[derive(Parser)] @@ -50,19 +53,38 @@ enum Commands { #[arg(long, env = "DOCS_DIR", default_value = DEFAULT_DOCS_DIR)] docs_dir: String, }, + /// Run the GNOME Shell search provider over D-Bus (auto-activated; not normally run by hand) + Dbus, + /// Open the GTK answer window for a single query (invoked by the search provider) + Gui { + /// The question to ask; remaining args are joined with spaces. If absent, + /// the window opens with a placeholder message instead of calling the LLM. + #[arg(trailing_var_arg = true)] + query: Vec, + }, } -// Entry point; #[tokio::main] sets up the async runtime so we can use .await -#[tokio::main] -async fn main() -> Result<()> { +// Plain entry point: the gui subcommand needs to run GTK's main loop, so we +// build a tokio runtime only for the subcommands that actually need it. +fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Chat { ollama_url, docs_dir } => { - run_chat(ollama_url, cli.model, cli.copilot, docs_dir).await + tokio_runtime()?.block_on(run_chat(ollama_url, cli.model, cli.copilot, docs_dir)) + } + Commands::Dbus => { + tokio_runtime()?.block_on(search_provider::run()) } + Commands::Gui { query } => gui::run(query.join(" "), cli.copilot, cli.model), } } +fn tokio_runtime() -> Result { + Ok(tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?) +} + // Runs the interactive chat loop, sending user input to the chosen LLM backend and printing replies async fn run_chat(ollama_url: String, model: String, use_copilot: bool, docs_dir: String) -> Result<()> { // Build the appropriate backend based on whether --copilot was passed @@ -122,10 +144,16 @@ async fn run_chat(ollama_url: String, model: String, use_copilot: bool, docs_dir ); spinner.enable_steady_tick(Duration::from_millis(80)); - // Pass a callback that clears the spinner the moment the first token arrives - match client.chat(&messages, || spinner.finish_and_clear()).await { + // Pass two callbacks: one fires when the spinner should clear, + // one fires per streamed token so we can print it to stdout. + let on_first_token = || spinner.finish_and_clear(); + let on_token = |t: &str| { + print!("{t}"); + let _ = io::stdout().flush(); + }; + match client.chat(&messages, on_first_token, on_token).await { Ok(reply) => { - // Tokens were already printed by the streaming chat call; just add spacing + // Tokens were printed by the on_token callback; add a trailing newline println!(); // Store the assistant reply so future turns have full context messages.push(Message { @@ -134,7 +162,6 @@ async fn run_chat(ollama_url: String, model: String, use_copilot: bool, docs_dir }); } Err(e) => { - spinner.finish_and_clear(); eprintln!("Error: {e}"); // Remove the user message to keep history consistent with what the LLM has seen messages.pop(); diff --git a/src/search_provider.rs b/src/search_provider.rs new file mode 100644 index 0000000..576636c --- /dev/null +++ b/src/search_provider.rs @@ -0,0 +1,139 @@ +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use zbus::{connection, interface}; +use zbus::zvariant::{OwnedValue, Value}; + +// The unique D-Bus bus name and object path GNOME Shell looks up via the +// search-provider .ini file. Both must match the .ini exactly. +const BUS_NAME: &str = "com.canonical.UbuntuDesktopHelp"; +const OBJECT_PATH: &str = "/com/canonical/UbuntuDesktopHelp/SearchProvider"; + +// Only intercept overview searches that begin with this prefix, so we don't +// match every keystroke the user types into the overview. +const TRIGGER_PREFIX: &str = "??"; + +// Single fixed result identifier — the search provider always returns either +// zero results or this one "open the assistant" entry. +const RESULT_ID: &str = "ask"; + +struct UbuntuDesktopHelp; + +impl UbuntuDesktopHelp { + // Joins the search terms and, if the query starts with the trigger prefix, + // returns the question with the prefix stripped. Otherwise returns None. + fn extract_query(terms: &[String]) -> Option { + let joined = terms.join(" "); + let trimmed = joined.trim_start(); + let rest = trimmed.strip_prefix(TRIGGER_PREFIX)?; + let q = rest.trim(); + if q.is_empty() { None } else { Some(q.to_string()) } + } +} + +#[interface(name = "org.gnome.Shell.SearchProvider2")] +impl UbuntuDesktopHelp { + // GNOME calls this with the user's terms split on whitespace. We return + // a single placeholder result if the trigger prefix is present. + async fn get_initial_result_set(&self, terms: Vec) -> Vec { + if Self::extract_query(&terms).is_some() { + vec![RESULT_ID.to_string()] + } else { + Vec::new() + } + } + + // Called as the user continues typing. Same trigger check applies. + async fn get_subsearch_result_set( + &self, + _previous_results: Vec, + terms: Vec, + ) -> Vec { + if Self::extract_query(&terms).is_some() { + vec![RESULT_ID.to_string()] + } else { + Vec::new() + } + } + + // Metadata GNOME uses to render each result tile. We only ever return + // metadata for our single fixed identifier; the description shows the + // current question so the user sees what they're about to send. + async fn get_result_metas( + &self, + identifiers: Vec, + ) -> Vec> { + identifiers + .into_iter() + .filter(|id| id == RESULT_ID) + .map(|id| { + let mut meta = HashMap::new(); + meta.insert("id".to_string(), str_value(&id)); + meta.insert("name".to_string(), str_value("Ask Ubuntu Desktop Help")); + meta.insert( + "description".to_string(), + str_value("Open the assistant for an answer"), + ); + meta.insert("gicon".to_string(), str_value("help-browser")); + meta + }) + .collect() + } + + // GNOME calls this when the user clicks our result. We launch a separate + // GUI process so the search provider stays small and unblocked. + async fn activate_result( + &self, + _identifier: String, + terms: Vec, + _timestamp: u32, + ) { + if let Some(query) = Self::extract_query(&terms) { + spawn_gui(&query); + } + } + + // GNOME calls this if the user hits enter without selecting a tile. + async fn launch_search(&self, terms: Vec, _timestamp: u32) { + if let Some(query) = Self::extract_query(&terms) { + spawn_gui(&query); + } + } +} + +// Builds an OwnedValue containing a string, with the unwrap path that can't +// fail in practice — string conversion is infallible inside zvariant. +fn str_value(s: &str) -> OwnedValue { + Value::from(s).try_to_owned().expect("string OwnedValue") +} + +// Launches `ubuntu-desktop-help gui ` in the background. We use the +// path of the currently running binary so the dev build, the installed binary, +// and the snap all do the right thing without configuration. +fn spawn_gui(query: &str) { + let exe = std::env::current_exe().unwrap_or_else(|_| "ubuntu-desktop-help".into()); + if let Err(e) = std::process::Command::new(&exe) + .arg("gui") + .arg(query) + .spawn() + { + eprintln!("failed to spawn gui process: {e}"); + } +} + +// Connects to the session bus, registers our object, and blocks forever. +// D-Bus activation will start this process when GNOME first opens the overview +// after login; the connection is kept alive by holding the Connection handle. +pub async fn run() -> Result<()> { + let _conn = connection::Builder::session() + .context("failed to open session D-Bus")? + .name(BUS_NAME)? + .serve_at(OBJECT_PATH, UbuntuDesktopHelp)? + .build() + .await + .context("failed to register search provider on the bus")?; + + // Hold the connection open for the lifetime of the process. + std::future::pending::<()>().await; + Ok(()) +}