Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
945 changes: 937 additions & 8 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ udev = { version = "^0.9.3", features = ["send", "sync"] }
# cardwire doesn't support X11 and will never support it unless someone maintains it
iced = { version = "0.14.0", default-features = false, features = [
"crisp",
"image",
"linux-theme-detection",
"svg",
"tokio",
"tiny-skia",
"wayland",
Expand Down
69 changes: 69 additions & 0 deletions crates/cardwire-daemon/src/analyzer/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@ pub fn parse_cmdline_name(cmdline_bytes: &[u8]) -> Option<String> {
}
}

// Electron apps, the real app name is in the .asar path argument
if base_name == "electron" || base_name.ends_with("-electron") {
for arg in args.iter().skip(1) {
if arg.starts_with('-') {
continue;
}
if arg.ends_with(".asar") || arg.contains("resources/app") {
let path = Path::new(arg);
for component in path.components().rev() {
let part = component.as_os_str().to_string_lossy();
if part == "app.asar" || part == "resources" || part == "app" || part == "share"
{
continue;
}
return Some(part.to_string());
}
}
}
}

// Fix for discord or other apps:
if base_name.contains("--") {
return base_name.split_whitespace().next().map(|s| s.to_string());
Expand All @@ -84,6 +104,18 @@ pub fn is_proc_still_alive(pid: u32) -> bool {
Path::new(&format!("/proc/{}", pid)).exists()
}

/// Unwrap NixOS-style wrapper names into lookups, eg:
/// ".discord-wrapped" -> ["discord-wrapped", "discord"]
/// "steamwebhelper" -> ["steamwebhelper"]
pub fn normalized_candidates(name: &str) -> Vec<String> {
let trimmed = name.trim_start_matches('.');
let mut candidates = vec![trimmed.to_string()];
if let Some(rest) = trimmed.strip_suffix("-wrapped") {
candidates.push(rest.to_string());
}
candidates
}

/// Decode the 16-byte kernel comm into a String, trimming trailing NULs
pub fn comm_to_string(comm: [u8; 16]) -> String {
match String::from_utf8(comm.to_vec()) {
Expand Down Expand Up @@ -167,4 +199,41 @@ mod tests {
assert!(is_proc_still_alive(std::process::id()));
assert!(!is_proc_still_alive(0));
}

#[test]
fn test_parse_cmdline_name_extracts_electron_app_from_asar() {
// NixOS-style Obsidian wrapped in electron
let cmdline_bytes = b"/nix/store/abc-electron-41.10.3/libexec/electron/electron\0/nix/store/xyz-obsidian-1.12.7/share/obsidian/app.asar\0--ozone-platform=wayland";
assert_eq!(
parse_cmdline_name(cmdline_bytes),
Some("obsidian".to_string())
);
}

#[test]
fn test_parse_cmdline_name_electron_without_asar_falls_back() {
// Plain electron without .asar argument
let cmdline_bytes = b"/usr/bin/electron\0--no-sandbox";
assert_eq!(
parse_cmdline_name(cmdline_bytes),
Some("electron".to_string())
);
}

#[test]
fn test_normalized_candidates_unwraps_nix_wrapper() {
assert_eq!(
normalized_candidates(".discord-wrapped"),
vec!["discord-wrapped".to_string(), "discord".to_string()]
);
}

#[test]
fn test_normalized_candidates_plain_name_unchanged() {
assert_eq!(
normalized_candidates("steamwebhelper"),
vec!["steamwebhelper".to_string()]
);
assert_eq!(normalized_candidates("steam"), vec!["steam".to_string()]);
}
}
37 changes: 27 additions & 10 deletions crates/cardwire-daemon/src/analyzer/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ use zbus::object_server::SignalEmitter;

use crate::{
analyzer::{
dynamic_analysis::{check_env, get_app_id_wayland_with_retry, get_steam_app_id}, helpers::{comm_to_string, get_real_process_name, is_proc_still_alive}, static_analysis::{self, AppMetadata, watch_fdo_folders}
dynamic_analysis::{check_env, get_app_id_wayland_with_retry, get_steam_app_id}, helpers::{
comm_to_string, get_real_process_name, is_proc_still_alive, normalized_candidates
}, static_analysis::{self, AppMetadata, watch_fdo_folders}
}, file::{DbusAppMetadata, GpuPolicy}, interface::{LogEntry, LoggerInterfaceSignals, SmartPolicyInterface}
};
#[repr(C)]
Expand Down Expand Up @@ -304,6 +306,9 @@ impl CardwireAnalyzer {
// Check the database now, we can take our time since if we reached it, the app would've
// been blocked
let mut lookup_name = comm.to_lowercase();
if lookup_name.contains("xdg-desktop-portal") {
return None;
}
if let Some(steam_app) = get_steam_app_id(&environ) {
lookup_name = steam_app;
}
Expand All @@ -321,10 +326,22 @@ impl CardwireAnalyzer {

{
let xdg_list = self.xdg_list.read().await;
if let Some(meta) = xdg_list.get(&lookup_name) {
for candidate in normalized_candidates(&lookup_name) {
if let Some(meta) = xdg_list.get(&candidate) {
let meta = meta.clone();
drop(xdg_list);
self.discover_app(&lookup_name, meta).await;
return Some((false, PidType::Allowed, 0));
}
}
if let Some((_key, meta)) = xdg_list
.iter()
.find(|(key, _)| key.len() >= 3 && lookup_name.starts_with(key.as_str()))
{
let meta = meta.clone();
drop(xdg_list);
self.discover_app(&lookup_name, meta).await;
return Some((false, PidType::Allowed, 0));
Comment thread
luytan marked this conversation as resolved.
}
}
// Fallback for steam games
Expand Down Expand Up @@ -354,6 +371,12 @@ impl CardwireAnalyzer {
}

let dbus_meta = DbusAppMetadata::from_app_metadata(&meta, GpuPolicy::Blocked as u32);
// Mirror in the cache regardless of the outcome, the app is blocked by default
// and this prevents re-discovering it on every new process spawn
self.db_cache
.write()
.await
.insert(lookup_name.to_string(), GpuPolicy::Blocked);
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
let res = self
.db_tx
Expand All @@ -362,11 +385,6 @@ impl CardwireAnalyzer {
match res {
Ok(_) => match reply_rx.await {
Ok(true) => {
// Mirror in the cache
self.db_cache
.write()
.await
.insert(lookup_name.to_string(), GpuPolicy::Blocked);
if let Some(emitter) = self.new_app_signal.get()
&& let Err(e) = SmartPolicyInterface::new_app_added(
emitter,
Expand All @@ -381,9 +399,8 @@ impl CardwireAnalyzer {
lookup_name
);
}
Ok(false) => {
error!("Couldn't write {} to the database", lookup_name)
}
// Duplicate entry or write failure, nothing to do
Ok(false) => {}
Err(err) => {
error!("DB worker dropped the reply for {}: {}", lookup_name, err)
}
Expand Down
6 changes: 6 additions & 0 deletions crates/cardwire-daemon/src/analyzer/static_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ pub async fn watch_fdo_folders(
fn parse_fdo_app(app_fdo: &DesktopEntry, name: &str, path: &Path) -> HashMap<String, AppMetadata> {
let mut app_list: HashMap<String, AppMetadata> = HashMap::new();

if let Some(file_name) = path.file_name().and_then(|s| s.to_str())
&& file_name.contains("xdg-desktop-portal")
{
return app_list;
}

let display_name = name.to_string();
let icon_name = app_fdo.icon().map(|icon| icon.to_string());

Expand Down
20 changes: 12 additions & 8 deletions crates/cardwire-daemon/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,6 @@ async fn spawn_dbus_api(
let path = "/org/opengamingcollective/cardwire";

let gpu_interfaces = daemon.inner.gpu_list.read().await;
// cardwire.Mode
object_server
.at(path, daemon.mode_interface.clone())
.await?;
// cardwire.Config
object_server
.at(path, daemon.config_interface.clone())
Expand All @@ -147,15 +143,23 @@ async fn spawn_dbus_api(
object_server
.at(path.clone(), gpu_interface.as_ref().clone())
.await?;
let gpu_ref = object_server
.interface::<_, crate::interface::GpuInterface>(path)
.await?;
gpu_interface
.signal_emitter
.get_or_init(|| gpu_ref.signal_emitter().to_owned());
// spawn power state watcher only for available GPUs
if gpu_interface.device.is_available() {
let handle = task::spawn(watch_power_state(
Arc::clone(gpu_interface),
object_server.interface(path).await?,
));
let handle = task::spawn(watch_power_state(Arc::clone(gpu_interface), gpu_ref));
Comment thread
luytan marked this conversation as resolved.
power_tasks.insert(*id, handle);
}
}

// cardwire.Mode
object_server
.at(path, daemon.mode_interface.clone())
.await?;
Comment thread
luytan marked this conversation as resolved.
// Cardwire logger
object_server
.at(path, daemon.logger_interface.clone())
Expand Down
20 changes: 19 additions & 1 deletion crates/cardwire-daemon/src/file/sql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc};

use crate::{STATE_PATH, analyzer::AppMetadata};
use log::error;
use rusqlite::{Connection, Result};
use rusqlite::{Connection, OptionalExtension, Result};
use tokio::sync::{RwLock, mpsc, oneshot};
use zbus::zvariant;

Expand Down Expand Up @@ -95,6 +95,24 @@ impl CardwireDatabase {
tokio::task::spawn_blocking(move || {
let conn = conn;
while let Some((binary_name, meta, reply)) = rx.blocking_recv() {
if let Some(desktop_file_id) = meta.desktop_file_id.as_deref()
&& conn
.query_row(
"SELECT 1 FROM app_policies
WHERE desktop_file_id IS NOT NULL
AND desktop_file_id = ?1
AND binary_name != ?2
LIMIT 1",
rusqlite::params![desktop_file_id, binary_name],
|_| Ok(()),
)
.optional()
.unwrap_or(None)
.is_some()
{
let _ = reply.send(false);
continue;
}
let res = conn.execute(
"INSERT INTO app_policies (binary_name, display_name, desktop_file_id, icon_name, policy)
VALUES (?1, ?2, ?3, ?4, 0)
Expand Down
15 changes: 8 additions & 7 deletions crates/cardwire-daemon/src/interface/debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,16 @@ impl DebugInterface {
object_server
.at(path.clone(), gpu_interface.as_ref().clone())
.await?;
let gpu_ref = object_server
.interface::<_, GpuInterface>(path)
.await
.map_err(|err| fdo::Error::Failed(err.to_string()))?;
gpu_interface
.signal_emitter
.get_or_init(|| gpu_ref.signal_emitter().to_owned());
// spawn power state tasks only for available GPUs
if gpu_interface.device.is_available() {
let handle = task::spawn(watch_power_state(
Arc::clone(gpu_interface),
object_server
.interface(path)
.await
.map_err(|err| fdo::Error::Failed(err.to_string()))?,
));
let handle = task::spawn(watch_power_state(Arc::clone(gpu_interface), gpu_ref));
power_tasks.insert(*id, handle);
}
}
Expand Down
6 changes: 6 additions & 0 deletions crates/cardwire-daemon/src/interface/gpu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub struct GpuInterface {
pci_list: Arc<RwLock<BTreeMap<String, PciDevice>>>,
gpu_state: Arc<RwLock<CardwireGpuState>>,
mode_state: Arc<RwLock<CardwireModeState>>,
pub signal_emitter: Arc<OnceLock<SignalEmitter<'static>>>,
}

impl GpuInterface {
Expand All @@ -54,6 +55,7 @@ impl GpuInterface {
pci_list,
gpu_state,
mode_state,
signal_emitter: Arc::new(OnceLock::new()),
})
}
}
Expand Down Expand Up @@ -232,6 +234,10 @@ impl GpuInterface {

#[zbus(property)]
pub async fn block(&self) -> fdo::Result<bool> {
let mode = self.mode_state.read().await.mode();
if mode == Modes::Smart && (self.device.is_default() && !self.device.is_discrete()) {
return Ok(false);
}
Comment thread
luytan marked this conversation as resolved.
self.gpu_blocked().await
}

Expand Down
20 changes: 18 additions & 2 deletions crates/cardwire-daemon/src/interface/mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,24 @@ impl ModeInterface {
}
}

if let Some(emitter) = self.signal_emitter.get() {
self.mode_changed(emitter).await?;
if let Some(emitter) = self.signal_emitter.get()
&& let Err(err) = self.mode_changed(emitter).await
{
warn!("failed to emit mode change signal: {err}");
};

// Emit block_changed signal after the mode has been applied
let gpu_list = self.gpu_list.read().await;
for gpu in gpu_list.values().filter(|gpu| gpu.device.is_available()) {
let Some(emitter) = gpu.signal_emitter.get() else {
continue;
};
if let Err(err) = gpu.block_changed(emitter).await {
warn!(
"failed to emit Block property change for {}: {err}",
gpu.device.name()
);
}
}
Comment thread
luytan marked this conversation as resolved.
Ok(())
}
Expand Down
Loading