Skip to content

Commit 0b8e385

Browse files
authored
feat(plugins): add cosmic toplevel plugin
1 parent e842ba0 commit 0b8e385

File tree

13 files changed

+916
-6
lines changed

13 files changed

+916
-6
lines changed

Cargo.lock

Lines changed: 497 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bin/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ async fn main() {
2929
"scripts" => plugins::scripts::main().await,
3030
"terminal" => plugins::terminal::main().await,
3131
"web" => plugins::web::main().await,
32+
"cosmic-toplevel" => plugins::cosmic_toplevel::main().await,
3233
unknown => {
3334
eprintln!("unknown cmd: {}", unknown);
3435
}

debian/control

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ Build-Depends:
77
debhelper-compat (=10),
88
just,
99
pkgconf,
10-
rustc (>=1.47),
10+
rustc (>=1.65),
11+
libxkbcommon-dev,
12+
libegl-dev,
1113
Standards-Version: 4.1.1
1214
Homepage: https://github.com/pop-os/launcher
1315

debian/pop-launcher.links

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
/usr/bin/pop-launcher /usr/lib/pop-launcher/plugins/scripts/scripts
99
/usr/bin/pop-launcher /usr/lib/pop-launcher/plugins/terminal/terminal
1010
/usr/bin/pop-launcher /usr/lib/pop-launcher/plugins/web/web
11+
/usr/bin/pop-launcher /usr/lib/pop-launcher/plugins/cosmic_toplevel/cosmic-toplevel

justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ vendor_args := if vendor == '1' { '--frozen --offline' } else { '' }
66
debug_args := if debug == '1' { '' } else { '--release' }
77
cargo_args := vendor_args + ' ' + debug_args
88

9-
plugins := 'calc desktop_entries files find pop_shell pulse recent scripts terminal web'
9+
plugins := 'calc desktop_entries files find pop_shell pulse recent scripts terminal web cosmic_toplevel'
1010

1111
ID := 'pop-launcher'
1212

plugins/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ futures = "0.3.25"
3434
bytes = "1.2.1"
3535
recently-used-xbel = "1.0.0"
3636

37+
# dependencies cosmic toplevel
38+
cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit" }
39+
3740
[dependencies.reqwest]
3841
version = "0.11.12"
3942
default-features = false

plugins/src/cosmic_toplevel/mod.rs

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
mod toplevel_handler;
2+
3+
use cctk::wayland_client::Proxy;
4+
use cctk::{cosmic_protocols, sctk::reexports::calloop, toplevel_info::ToplevelInfo};
5+
use cosmic_protocols::toplevel_info::v1::client::zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1;
6+
7+
use crate::send;
8+
use freedesktop_desktop_entry as fde;
9+
use futures::{
10+
channel::mpsc,
11+
future::{select, Either},
12+
StreamExt,
13+
};
14+
use pop_launcher::{
15+
async_stdin, async_stdout, json_input_stream, IconSource, PluginResponse, PluginSearchResult,
16+
Request,
17+
};
18+
use std::borrow::Cow;
19+
use std::{ffi::OsString, fs, path::PathBuf};
20+
use tokio::io::{AsyncWrite, AsyncWriteExt};
21+
22+
use self::toplevel_handler::{toplevel_handler, ToplevelAction, ToplevelEvent};
23+
24+
pub async fn main() {
25+
tracing::info!("starting cosmic-toplevel");
26+
27+
let (mut app, mut toplevel_rx) = App::new(async_stdout());
28+
29+
let mut requests = json_input_stream(async_stdin());
30+
let mut next_request = requests.next();
31+
let mut next_event = toplevel_rx.next();
32+
loop {
33+
let event = select(next_request, next_event).await;
34+
match event {
35+
Either::Left((Some(request), second_to_next_event)) => {
36+
next_event = second_to_next_event;
37+
next_request = requests.next();
38+
match request {
39+
Ok(request) => match request {
40+
Request::Activate(id) => {
41+
tracing::info!("activating {id}");
42+
app.activate(id);
43+
}
44+
Request::Quit(id) => app.quit(id),
45+
Request::Search(query) => {
46+
tracing::info!("searching {query}");
47+
app.search(&query).await;
48+
// clear the ids to ignore, as all just sent are valid
49+
app.ids_to_ignore.clear();
50+
}
51+
Request::Exit => break,
52+
_ => (),
53+
},
54+
Err(why) => {
55+
tracing::error!("malformed JSON request: {}", why);
56+
}
57+
};
58+
}
59+
Either::Right((Some(event), second_to_next_request)) => {
60+
next_event = toplevel_rx.next();
61+
next_request = second_to_next_request;
62+
match event {
63+
ToplevelEvent::Add(handle, info) => {
64+
tracing::info!("{}", &info.app_id);
65+
app.toplevels.retain(|t| t.0 != handle);
66+
app.toplevels.push((handle, info));
67+
}
68+
ToplevelEvent::Remove(handle) => {
69+
app.toplevels.retain(|t| t.0 != handle);
70+
// ignore requests for this id until after the next search
71+
app.ids_to_ignore.push(handle.id().protocol_id());
72+
}
73+
ToplevelEvent::Update(handle, info) => {
74+
if let Some(t) = app.toplevels.iter_mut().find(|t| t.0 == handle) {
75+
t.1 = info;
76+
}
77+
}
78+
}
79+
}
80+
_ => break,
81+
}
82+
}
83+
}
84+
85+
struct App<W> {
86+
desktop_entries: Vec<(fde::PathSource, PathBuf)>,
87+
ids_to_ignore: Vec<u32>,
88+
toplevels: Vec<(ZcosmicToplevelHandleV1, ToplevelInfo)>,
89+
calloop_tx: calloop::channel::Sender<ToplevelAction>,
90+
tx: W,
91+
}
92+
93+
impl<W: AsyncWrite + Unpin> App<W> {
94+
fn new(tx: W) -> (Self, mpsc::UnboundedReceiver<ToplevelEvent>) {
95+
let (toplevels_tx, toplevel_rx) = mpsc::unbounded();
96+
let (calloop_tx, calloop_rx) = calloop::channel::channel();
97+
let _ = std::thread::spawn(move || toplevel_handler(toplevels_tx, calloop_rx));
98+
99+
(
100+
Self {
101+
ids_to_ignore: Vec::new(),
102+
desktop_entries: fde::Iter::new(fde::default_paths())
103+
.map(|path| (fde::PathSource::guess_from(&path), path))
104+
.collect(),
105+
toplevels: Vec::new(),
106+
calloop_tx,
107+
tx,
108+
},
109+
toplevel_rx,
110+
)
111+
}
112+
113+
fn activate(&mut self, id: u32) {
114+
tracing::info!("requested to activate: {id}");
115+
if self.ids_to_ignore.contains(&id) {
116+
return;
117+
}
118+
if let Some(handle) = self.toplevels.iter().find_map(|t| {
119+
if t.0.id().protocol_id() == id {
120+
Some(t.0.clone())
121+
} else {
122+
None
123+
}
124+
}) {
125+
tracing::info!("activating: {id}");
126+
let _ = self.calloop_tx.send(ToplevelAction::Activate(handle));
127+
}
128+
}
129+
130+
fn quit(&mut self, id: u32) {
131+
if self.ids_to_ignore.contains(&id) {
132+
return;
133+
}
134+
if let Some(handle) = self.toplevels.iter().find_map(|t| {
135+
if t.0.id().protocol_id() == id {
136+
Some(t.0.clone())
137+
} else {
138+
None
139+
}
140+
}) {
141+
let _ = self.calloop_tx.send(ToplevelAction::Close(handle));
142+
}
143+
}
144+
145+
async fn search(&mut self, query: &str) {
146+
fn contains_pattern(needle: &str, haystack: &[&str]) -> bool {
147+
let needle = needle.to_ascii_lowercase();
148+
haystack.iter().all(|h| needle.contains(h))
149+
}
150+
151+
let query = query.to_ascii_lowercase();
152+
let haystack = query.split_ascii_whitespace().collect::<Vec<&str>>();
153+
154+
for item in &self.toplevels {
155+
let retain = query.is_empty()
156+
|| contains_pattern(&item.1.app_id, &haystack)
157+
|| contains_pattern(&item.1.title, &haystack);
158+
159+
if !retain {
160+
continue;
161+
}
162+
163+
let mut icon_name = Cow::Borrowed("application-x-executable");
164+
165+
for (_, path) in &self.desktop_entries {
166+
if let Some(name) = path.file_stem() {
167+
let app_id: OsString = item.1.app_id.clone().into();
168+
if app_id == name {
169+
if let Ok(data) = fs::read_to_string(path) {
170+
if let Ok(entry) = fde::DesktopEntry::decode(path, &data) {
171+
if let Some(icon) = entry.icon() {
172+
icon_name = Cow::Owned(icon.to_owned());
173+
}
174+
}
175+
}
176+
177+
break;
178+
}
179+
}
180+
}
181+
182+
send(
183+
&mut self.tx,
184+
PluginResponse::Append(PluginSearchResult {
185+
// XXX protocol id may be re-used later
186+
id: item.0.id().protocol_id(),
187+
name: item.1.app_id.clone(),
188+
description: item.1.title.clone(),
189+
icon: Some(IconSource::Name(icon_name)),
190+
..Default::default()
191+
}),
192+
)
193+
.await;
194+
}
195+
196+
send(&mut self.tx, PluginResponse::Finished).await;
197+
let _ = self.tx.flush();
198+
}
199+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
(
2+
name: "COSMIC Windows",
3+
description: "Active windows controllable via Cosmic",
4+
query: (persistent: true),
5+
bin: (path: "cosmic-toplevel"),
6+
icon: Name("focus-windows-symbolic"),
7+
)

0 commit comments

Comments
 (0)