Skip to content

Commit ba8d4b3

Browse files
feat(desktop): broaden Linux system-proxy to a layered stack
Linux has no single proxy store, so apply/clear now runs every applicable layer instead of just the first match: - gsettings org.gnome.system.proxy, guarded by schema presence (covers GNOME/MATE/Cinnamon/Budgie + GLib/Chromium apps on most desktops); - KDE kioslaverc, now followed by a reparseSlaveConfiguration D-Bus signal so KIO apps actually reload (previously they kept the old proxy until restart); - environment variables — ~/.config/environment.d for persistence plus a live systemctl --user / dbus-update-activation-environment push. This is the only layer reaching CLI tools and non-GLib apps, and the only mechanism for XFCE/LXQt/LXDE (no native proxy store). Env vars can't carry a PAC, so they're cleared in pac mode and set only in system mode. All layers are unprivileged, user-scoped. clear runs all three (idempotent).
1 parent 7952766 commit ba8d4b3

1 file changed

Lines changed: 198 additions & 58 deletions

File tree

src-tauri/src/desktop/sysproxy.rs

Lines changed: 198 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
11
//! OS system-proxy integration for the `system` and `pac` proxy modes: point the OS
22
//! proxy (or PAC auto-config URL) at the core's local inbound and clear it again.
3-
//! Linux drives gsettings (GNOME-family) or kwriteconfig (KDE) by desktop; Windows
4-
//! the WinINET registry keys plus an `InternetSetOption` refresh so the change takes
5-
//! effect without a sign-out. macOS has no port yet (no-op).
63
//!
7-
//! These modes never elevate, so the process runs as the logged-in user and the
8-
//! gsettings/registry writes land in that user's session — exactly where the OS
9-
//! reads its proxy from.
4+
//! Linux has no single system-proxy store, so the apply/clear pipeline is *layered*
5+
//! and the layers are NOT mutually exclusive — each reaches a disjoint set of apps:
6+
//! 1. **gsettings `org.gnome.system.proxy`** (guarded by schema presence) — covers
7+
//! GNOME, MATE, Cinnamon, Budgie, and every GLib/GIO + Chromium-family app on
8+
//! any desktop where the schema is installed (most XFCE/LXQt/LXDE too).
9+
//! 2. **kwriteconfig `kioslaverc`** on KDE/Plasma, followed by a
10+
//! `reparseSlaveConfiguration` D-Bus signal so KIO apps reload.
11+
//! 3. **environment variables** (`~/.config/environment.d` for persistence + a live
12+
//! `systemctl --user`/`dbus-update-activation-environment` push) — the only layer
13+
//! that reaches CLI tools and non-GLib apps, and the only programmatic mechanism
14+
//! for XFCE/LXQt/LXDE. Env vars can't express a PAC URL, so they apply in `system`
15+
//! mode only.
16+
//!
17+
//! Windows uses the WinINET registry keys plus an `InternetSetOption` refresh so the
18+
//! change takes effect without a sign-out. macOS has no port yet (no-op).
19+
//!
20+
//! These modes never elevate, so the process runs as the logged-in user and every
21+
//! write lands in that user's session — exactly where the OS reads its proxy from.
1022
1123
#[cfg(target_os = "linux")]
1224
pub use linux::{clear_system_proxy, set_pac, set_system_proxy};
@@ -18,91 +30,154 @@ pub use other::{clear_system_proxy, set_pac, set_system_proxy};
1830

1931
#[cfg(target_os = "linux")]
2032
mod linux {
21-
use crate::desktop::silent;
33+
use std::path::PathBuf;
34+
35+
use crate::desktop::{run_out, silent};
2236

2337
const HOST: &str = "127.0.0.1";
2438
const IGNORE_GNOME: &str = "['localhost', '127.0.0.0/8', '::1']";
2539
const IGNORE_KDE: &str = "localhost,127.0.0.1,::1";
40+
const NO_PROXY: &str = "localhost,127.0.0.1,::1";
2641

27-
/// True on a KDE/Plasma session (uses kwriteconfig); everything else takes the
28-
/// gsettings path, which the GNOME/XFCE/Cinnamon/MATE/Budgie proxy schema covers.
29-
fn is_kde() -> bool {
30-
std::env::var("XDG_CURRENT_DESKTOP")
31-
.unwrap_or_default()
32-
.to_ascii_lowercase()
33-
.contains("kde")
42+
/// Proxy env vars, lower- and upper-case (programs disagree on which they read).
43+
const ENV_KEYS: &[&str] = &[
44+
"http_proxy",
45+
"https_proxy",
46+
"all_proxy",
47+
"no_proxy",
48+
"HTTP_PROXY",
49+
"HTTPS_PROXY",
50+
"ALL_PROXY",
51+
"NO_PROXY",
52+
];
53+
/// Persistent per-user env file (systemd user generator reads `environment.d`).
54+
const ENV_FILE_REL: &str = ".config/environment.d/50-kasumi-proxy.conf";
55+
56+
// ── apply / clear: run EVERY applicable layer, not just the first match ──
57+
58+
/// Point the OS at the local socks/http inbound. `http_port` carries http + https,
59+
/// `socks_port` the socks proxy (a sing-box mixed inbound passes the same for both).
60+
pub async fn set_system_proxy(socks_port: u16, http_port: u16) {
61+
gsettings_manual(socks_port, http_port).await;
62+
kde_manual(socks_port, http_port).await;
63+
env_apply(socks_port, http_port).await;
3464
}
3565

36-
/// The KDE config writer present on this host (Plasma 6 ships `kwriteconfig6`,
37-
/// Plasma 5 `kwriteconfig5`).
38-
fn kde_writer() -> Option<&'static str> {
39-
["kwriteconfig6", "kwriteconfig5"]
40-
.into_iter()
41-
.find(|bin| which(bin))
66+
/// Point the OS at a PAC auto-config URL (`pac` mode). Env vars can't express a PAC,
67+
/// so the env layer is cleared rather than set.
68+
pub async fn set_pac(pac_url: &str) {
69+
gsettings_auto(pac_url).await;
70+
kde_pac(pac_url).await;
71+
env_clear().await;
4272
}
4373

44-
fn which(bin: &str) -> bool {
45-
std::env::var("PATH")
46-
.unwrap_or_default()
47-
.split(':')
48-
.any(|dir| std::path::Path::new(dir).join(bin).exists())
74+
/// Disable every layer. Idempotent — safe whatever was (or wasn't) set.
75+
pub async fn clear_system_proxy() {
76+
gsettings_none().await;
77+
kde_none().await;
78+
env_clear().await;
4979
}
5080

51-
/// Point the OS proxy at the local socks/http inbound. `http_port` carries http +
52-
/// https; `socks_port` the socks proxy (for a sing-box mixed inbound the caller
53-
/// passes the same port for both).
54-
pub async fn set_system_proxy(socks_port: u16, http_port: u16) {
55-
let http = http_port.to_string();
56-
let socks = socks_port.to_string();
57-
if is_kde() {
58-
let Some(w) = kde_writer() else { return };
59-
kde(w, "ProxyType", "1").await;
60-
kde(w, "httpProxy", &format!("http://{HOST} {http}")).await;
61-
kde(w, "httpsProxy", &format!("http://{HOST} {http}")).await;
62-
kde(w, "socksProxy", &format!("socks://{HOST} {socks}")).await;
63-
kde(w, "NoProxyFor", IGNORE_KDE).await;
81+
// ── layer 1: gsettings (GNOME schema), guarded by schema presence ──
82+
83+
/// Only call gsettings when the proxy schema is installed, so minimal systems
84+
/// (no `gsettings-desktop-schemas`) don't spew errors.
85+
async fn gnome_schema_present() -> bool {
86+
let (code, out) = run_out(&["gsettings", "list-schemas"]).await;
87+
code == 0 && out.lines().any(|l| l.trim() == "org.gnome.system.proxy")
88+
}
89+
90+
async fn gsettings(args: &[&str]) {
91+
let mut argv = vec!["gsettings", "set"];
92+
argv.extend_from_slice(args);
93+
silent(&argv).await;
94+
}
95+
96+
async fn gsettings_manual(socks_port: u16, http_port: u16) {
97+
if !gnome_schema_present().await {
6498
return;
6599
}
100+
let http = http_port.to_string();
101+
let socks = socks_port.to_string();
66102
gsettings(&["org.gnome.system.proxy", "mode", "manual"]).await;
67-
for (schema, port) in [
68-
("org.gnome.system.proxy.http", &http),
69-
("org.gnome.system.proxy.https", &http),
103+
for schema in [
104+
"org.gnome.system.proxy.http",
105+
"org.gnome.system.proxy.https",
70106
] {
71107
gsettings(&[schema, "host", HOST]).await;
72-
gsettings(&[schema, "port", port]).await;
108+
gsettings(&[schema, "port", &http]).await;
73109
}
74110
gsettings(&["org.gnome.system.proxy.socks", "host", HOST]).await;
75111
gsettings(&["org.gnome.system.proxy.socks", "port", &socks]).await;
76112
gsettings(&["org.gnome.system.proxy", "ignore-hosts", IGNORE_GNOME]).await;
77113
}
78114

79-
/// Point the OS at a PAC auto-config URL (the `pac` mode).
80-
pub async fn set_pac(pac_url: &str) {
81-
if is_kde() {
82-
let Some(w) = kde_writer() else { return };
83-
kde(w, "ProxyType", "2").await;
84-
kde(w, "Proxy Config Script", pac_url).await;
115+
async fn gsettings_auto(pac_url: &str) {
116+
if !gnome_schema_present().await {
85117
return;
86118
}
87119
gsettings(&["org.gnome.system.proxy", "mode", "auto"]).await;
88120
gsettings(&["org.gnome.system.proxy", "autoconfig-url", pac_url]).await;
89121
}
90122

91-
/// Disable the OS proxy. Idempotent — safe to call when nothing was set.
92-
pub async fn clear_system_proxy() {
93-
if is_kde() {
94-
if let Some(w) = kde_writer() {
95-
kde(w, "ProxyType", "0").await;
96-
}
123+
async fn gsettings_none() {
124+
if !gnome_schema_present().await {
97125
return;
98126
}
99127
gsettings(&["org.gnome.system.proxy", "mode", "none"]).await;
100128
}
101129

102-
async fn gsettings(args: &[&str]) {
103-
let mut argv = vec!["gsettings", "set"];
104-
argv.extend_from_slice(args);
105-
silent(&argv).await;
130+
// ── layer 2: KDE kioslaverc (+ reparse so KIO apps reload) ──
131+
132+
/// True on a KDE/Plasma session.
133+
fn is_kde() -> bool {
134+
std::env::var("XDG_CURRENT_DESKTOP")
135+
.unwrap_or_default()
136+
.to_ascii_lowercase()
137+
.contains("kde")
138+
}
139+
140+
/// The KDE config writer present on this host (Plasma 6 → `kwriteconfig6`, 5 → 5).
141+
fn kde_writer() -> Option<&'static str> {
142+
["kwriteconfig6", "kwriteconfig5"]
143+
.into_iter()
144+
.find(|bin| which(bin))
145+
}
146+
147+
fn which(bin: &str) -> bool {
148+
std::env::var("PATH")
149+
.unwrap_or_default()
150+
.split(':')
151+
.any(|dir| std::path::Path::new(dir).join(bin).exists())
152+
}
153+
154+
async fn kde_manual(socks_port: u16, http_port: u16) {
155+
let Some(w) = (is_kde().then(kde_writer)).flatten() else {
156+
return;
157+
};
158+
kde(w, "ProxyType", "1").await;
159+
kde(w, "httpProxy", &format!("http://{HOST} {http_port}")).await;
160+
kde(w, "httpsProxy", &format!("http://{HOST} {http_port}")).await;
161+
kde(w, "socksProxy", &format!("socks://{HOST} {socks_port}")).await;
162+
kde(w, "NoProxyFor", IGNORE_KDE).await;
163+
kde_reparse().await;
164+
}
165+
166+
async fn kde_pac(pac_url: &str) {
167+
let Some(w) = (is_kde().then(kde_writer)).flatten() else {
168+
return;
169+
};
170+
kde(w, "ProxyType", "2").await;
171+
kde(w, "Proxy Config Script", pac_url).await;
172+
kde_reparse().await;
173+
}
174+
175+
async fn kde_none() {
176+
let Some(w) = (is_kde().then(kde_writer)).flatten() else {
177+
return;
178+
};
179+
kde(w, "ProxyType", "0").await;
180+
kde_reparse().await;
106181
}
107182

108183
async fn kde(writer: &str, key: &str, value: &str) {
@@ -118,6 +193,71 @@ mod linux {
118193
])
119194
.await;
120195
}
196+
197+
/// Without this signal KIO apps keep the old proxy until restart.
198+
async fn kde_reparse() {
199+
silent(&[
200+
"dbus-send",
201+
"--type=signal",
202+
"/KIO/Scheduler",
203+
"org.kde.KIO.Scheduler.reparseSlaveConfiguration",
204+
"string:",
205+
])
206+
.await;
207+
}
208+
209+
// ── layer 3: environment variables (CLI + non-GLib apps; the only XFCE/LXQt path) ──
210+
211+
fn env_file() -> Option<PathBuf> {
212+
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(ENV_FILE_REL))
213+
}
214+
215+
/// Persist the proxy env vars (`environment.d`, survives re-login) and push them
216+
/// live into the running session (systemd user manager + D-Bus activation env).
217+
async fn env_apply(socks_port: u16, http_port: u16) {
218+
let http_url = format!("http://{HOST}:{http_port}");
219+
let socks_url = format!("socks5://{HOST}:{socks_port}");
220+
let assigns = [
221+
format!("http_proxy={http_url}"),
222+
format!("https_proxy={http_url}"),
223+
format!("all_proxy={socks_url}"),
224+
format!("no_proxy={NO_PROXY}"),
225+
format!("HTTP_PROXY={http_url}"),
226+
format!("HTTPS_PROXY={http_url}"),
227+
format!("ALL_PROXY={socks_url}"),
228+
format!("NO_PROXY={NO_PROXY}"),
229+
];
230+
231+
if let Some(path) = env_file() {
232+
if let Some(dir) = path.parent() {
233+
let _ = std::fs::create_dir_all(dir);
234+
}
235+
let _ = std::fs::write(&path, format!("{}\n", assigns.join("\n")));
236+
}
237+
238+
let refs: Vec<&str> = assigns.iter().map(String::as_str).collect();
239+
run_with(&["systemctl", "--user", "set-environment"], &refs).await;
240+
run_with(&["dbus-update-activation-environment", "--systemd"], &refs).await;
241+
}
242+
243+
/// Remove the persistent file and unset the vars in the live session. D-Bus can't
244+
/// truly unset, so it gets the vars emptied; systemd does the real unset.
245+
async fn env_clear() {
246+
if let Some(path) = env_file() {
247+
let _ = std::fs::remove_file(path);
248+
}
249+
run_with(&["systemctl", "--user", "unset-environment"], ENV_KEYS).await;
250+
let empties: Vec<String> = ENV_KEYS.iter().map(|k| format!("{k}=")).collect();
251+
let refs: Vec<&str> = empties.iter().map(String::as_str).collect();
252+
run_with(&["dbus-update-activation-environment", "--systemd"], &refs).await;
253+
}
254+
255+
/// Run `base` with `extra` args appended (one process, args owned by the caller).
256+
async fn run_with(base: &[&str], extra: &[&str]) {
257+
let mut argv = base.to_vec();
258+
argv.extend_from_slice(extra);
259+
silent(&argv).await;
260+
}
121261
}
122262

123263
#[cfg(target_os = "windows")]

0 commit comments

Comments
 (0)