Unify shell opacity and add native authentication and system controls
No files matched your search
@@ -1721,6 +1721,22 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ferese-polkit-agent"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ferese-config",
|
||||
"glib",
|
||||
"libc",
|
||||
"libcosmic",
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"zbus",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ferese-protocols"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -12,6 +12,7 @@ members = [
|
||||
"shell/ferese-shell",
|
||||
"shell/ferese-settings",
|
||||
"shell/ferese-lock",
|
||||
"shell/ferese-polkit-agent",
|
||||
"portal",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
@@ -23,7 +23,7 @@ cohesive experience—beautiful by default and unmistakably yours.
|
||||
- **Flexible layouts:** scrolling columns, tree tiling, floating windows and a
|
||||
workspace overview.
|
||||
- **A native shell:** menu bar, control center, settings, notifications and
|
||||
desktop widgets designed to belong together.
|
||||
desktop widgets designed to belong together, including themed authentication prompts.
|
||||
- **Personal by design:** themes, wallpapers, accent colors, blur, borders and
|
||||
motion that update without restarting your session.
|
||||
- **Ready for real setups:** gestures, multi-monitor support, fractional
|
||||
@@ -84,6 +84,9 @@ These are the built-in defaults; every shortcut and gesture can be changed.
|
||||
| Super + M | Switch scrolling / tree layout |
|
||||
| Super + Shift + S | Select a screenshot area |
|
||||
|
||||
Click the bar date for a calendar. Click the battery for power modes and
|
||||
confirmed Restart, Power off and Suspend actions.
|
||||
|
||||
Three-finger swipes move between workspaces and windows. Open **Control Center →
|
||||
Settings → Shortcuts** to change them or assign gestures to other actions.
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ pub struct ThemeSettings {
|
||||
pub focus_ring_gradient: Option<BorderGradient>,
|
||||
pub shadow_color: RgbaColor,
|
||||
pub surface_base_color: RgbaColor,
|
||||
pub panel_opacity: f64,
|
||||
pub shell_opacity: f64,
|
||||
pub inactive_dim: InactiveDimSettings,
|
||||
pub window_radius: f64,
|
||||
pub shadow_offset_y: f64,
|
||||
@@ -253,8 +253,6 @@ struct ThemeConfig {
|
||||
#[serde(default)]
|
||||
background: crate::wallpaper::WallpaperConfig,
|
||||
#[serde(default)]
|
||||
surface: ThemeSurfaceConfig,
|
||||
#[serde(default)]
|
||||
colors: ThemeColorsConfig,
|
||||
#[serde(default)]
|
||||
geometry: ThemeGeometryConfig,
|
||||
@@ -331,19 +329,10 @@ struct OverviewTypographyConfig {
|
||||
font_family: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ThemeSurfaceConfig {
|
||||
#[serde(default)]
|
||||
bar: ThemeBarConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ThemeBarConfig {
|
||||
opacity: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThemeMaterialConfig {
|
||||
#[serde(default = "default_shell_opacity")]
|
||||
opacity: f64,
|
||||
#[serde(default)]
|
||||
style: MaterialStyle,
|
||||
#[serde(default = "default_backdrop_blur")]
|
||||
@@ -354,11 +343,16 @@ impl Default for ThemeMaterialConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
style: MaterialStyle::Solid,
|
||||
opacity: default_shell_opacity(),
|
||||
blur_radius: default_backdrop_blur(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_shell_opacity() -> f64 {
|
||||
ferese_config::DEFAULT_MATERIAL_OPACITY
|
||||
}
|
||||
|
||||
fn default_backdrop_blur() -> f64 {
|
||||
12.0
|
||||
}
|
||||
@@ -1095,10 +1089,7 @@ impl Config {
|
||||
&self.theme.colors.surface_base,
|
||||
"colors.surface_base",
|
||||
)?,
|
||||
panel_opacity: unit_theme_value(
|
||||
self.theme.surface.bar.opacity.unwrap_or(0.78),
|
||||
"surface.bar.opacity",
|
||||
)?,
|
||||
shell_opacity: unit_theme_value(self.theme.material.opacity, "material.opacity")?,
|
||||
inactive_dim: InactiveDimSettings {
|
||||
enabled: self.appearance.inactive_dim.enabled,
|
||||
amount: unit_theme_value(
|
||||
@@ -2034,7 +2025,7 @@ mod tests {
|
||||
focus_ring_gradient: None,
|
||||
shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]),
|
||||
surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
|
||||
panel_opacity: 0.78,
|
||||
shell_opacity: 0.78,
|
||||
inactive_dim: InactiveDimSettings {
|
||||
enabled: false,
|
||||
amount: 0.15,
|
||||
@@ -2125,26 +2116,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_opacity_uses_config_and_rejects_invalid_values() {
|
||||
assert_eq!(parse("").theme_settings().unwrap().panel_opacity, 0.78);
|
||||
fn shell_opacity_uses_config_and_rejects_invalid_values() {
|
||||
assert_eq!(parse("").theme_settings().unwrap().shell_opacity, 0.78);
|
||||
for opacity in [0.0, 0.65, 1.0] {
|
||||
assert_eq!(
|
||||
parse(&format!(
|
||||
"theme {{ surface {{ bar {{ opacity {opacity}; }} }} }}"
|
||||
))
|
||||
.theme_settings()
|
||||
.unwrap()
|
||||
.panel_opacity,
|
||||
parse(&format!("theme {{ material {{ opacity {opacity}; }} }}"))
|
||||
.theme_settings()
|
||||
.unwrap()
|
||||
.shell_opacity,
|
||||
opacity
|
||||
);
|
||||
}
|
||||
for opacity in ["-0.1", "1.1", "#nan", "#inf"] {
|
||||
assert!(
|
||||
Config::parse_source(&format!(
|
||||
"theme {{ surface {{ bar {{ opacity {opacity}; }} }} }}"
|
||||
))
|
||||
.and_then(|config| config.theme_settings())
|
||||
.is_err()
|
||||
Config::parse_source(&format!("theme {{ material {{ opacity {opacity}; }} }}"))
|
||||
.and_then(|config| config.theme_settings())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,11 @@ pub(crate) struct ResolvedMaterial {
|
||||
pub shadow: [f64; 3],
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle) -> ResolvedMaterial {
|
||||
pub(crate) fn resolve_material(
|
||||
role: SemanticRole,
|
||||
style: MaterialStyle,
|
||||
opacity: f32,
|
||||
) -> ResolvedMaterial {
|
||||
let shadow = match role {
|
||||
SemanticRole::Panel => [1.0, 5.0, 0.04],
|
||||
SemanticRole::PanelElevated | SemanticRole::Popover | SemanticRole::Menu => {
|
||||
@@ -46,11 +50,9 @@ pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle) -> Reso
|
||||
SemanticRole::Hud => [1.0, 4.0, 0.04],
|
||||
SemanticRole::Notification | SemanticRole::Modal => [3.0, 10.0, 0.09],
|
||||
};
|
||||
let opacity = match (style, role) {
|
||||
(MaterialStyle::Solid, _) => 1.0,
|
||||
(_, SemanticRole::Panel) => 0.78,
|
||||
(_, SemanticRole::Hud | SemanticRole::Modal) => 0.88,
|
||||
_ => 0.84,
|
||||
let opacity = match style {
|
||||
MaterialStyle::Solid => 1.0,
|
||||
MaterialStyle::Translucent => opacity.clamp(0.0, 1.0),
|
||||
};
|
||||
ResolvedMaterial {
|
||||
style,
|
||||
@@ -430,10 +432,10 @@ mod tests {
|
||||
SemanticRole::Notification,
|
||||
SemanticRole::Modal,
|
||||
] {
|
||||
let solid = resolve_material(role, MaterialStyle::Solid);
|
||||
let translucent = resolve_material(role, MaterialStyle::Translucent);
|
||||
let solid = resolve_material(role, MaterialStyle::Solid, 0.6);
|
||||
let translucent = resolve_material(role, MaterialStyle::Translucent, 0.6);
|
||||
assert_eq!(solid.opacity, 1.0);
|
||||
assert!(translucent.opacity < 1.0);
|
||||
assert_eq!(translucent.opacity, 0.6);
|
||||
assert_eq!(solid.shadow, translucent.shadow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ pub fn resolve(
|
||||
) -> WindowRuleResult {
|
||||
let app_id = app_id.map(normalize_app_id);
|
||||
let mut result = WindowRuleResult::default();
|
||||
if app_id.as_deref() == Some("dev.ferese.Authentication") {
|
||||
result.floating = Some(true);
|
||||
}
|
||||
|
||||
for rule in rules {
|
||||
let app_id_matches = rule
|
||||
|
||||
@@ -1532,7 +1532,11 @@ fn overview_strip_elements(
|
||||
}
|
||||
}
|
||||
let mut color = state.theme_settings.surface_base_color.0;
|
||||
color[3] = alpha * state.theme_settings.panel_opacity as f32;
|
||||
color[3] = alpha * crate::effects::resolve_material(
|
||||
crate::effects::SemanticRole::Panel,
|
||||
state.theme_settings.material_style,
|
||||
state.theme_settings.shell_opacity as f32,
|
||||
).opacity;
|
||||
if let Some(strip) = overview_chrome_element(
|
||||
state,
|
||||
renderer,
|
||||
@@ -2306,13 +2310,12 @@ fn material_element(
|
||||
capture_geometry: Rectangle<i32, Logical>,
|
||||
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
|
||||
let (role, generation) = crate::effects::surface_role(surface)?;
|
||||
let mut material = crate::effects::resolve_material(role, state.theme_settings.material_style);
|
||||
let material = crate::effects::resolve_material(
|
||||
role,
|
||||
state.theme_settings.material_style,
|
||||
state.theme_settings.shell_opacity as f32,
|
||||
);
|
||||
let presentation_alpha = crate::effects::surface_opacity(surface);
|
||||
if role == crate::effects::SemanticRole::Panel
|
||||
&& material.style == crate::config::MaterialStyle::Translucent
|
||||
{
|
||||
material.opacity = state.theme_settings.panel_opacity as f32;
|
||||
}
|
||||
let mode = output.current_mode()?;
|
||||
let scale = output.current_scale().fractional_scale();
|
||||
let transform = output.current_transform().invert();
|
||||
@@ -2330,11 +2333,7 @@ fn material_element(
|
||||
(geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(),
|
||||
geometry.size,
|
||||
);
|
||||
let background_opacity = if role == crate::effects::SemanticRole::Panel {
|
||||
material.opacity
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let background_opacity = material.opacity;
|
||||
let blur = material_blur_radius(
|
||||
material.style,
|
||||
material.opacity,
|
||||
|
||||
@@ -3,6 +3,8 @@ use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value};
|
||||
use std::{fmt, path::PathBuf};
|
||||
|
||||
pub const DEFAULT_MATERIAL_OPACITY: f64 = 0.78;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Error(String);
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ these six visually distinct.
|
||||
| `theme.background.path` | string | bundled Ferese wallpaper | Wallpaper image path; an existing selection overrides the default |
|
||||
| `theme.background.mode` | `"fill"`, `"fit"` | `"fill"` | Crop or letterbox |
|
||||
| `theme.material.style` | `"solid"`, `"translucent"` | `"solid"` | Shell background material |
|
||||
| `theme.material.opacity` | number 0–1 | `0.78` | Shared shell background opacity for bars, menus, popovers, notifications and the sharing dialog. Text/icons stay opaque; 0 and 1 skip blur. Solid mode is always opaque |
|
||||
| `theme.material.blur_radius` | number ≥ 0 | `12` | Translucent backdrop blur, capped at 32; 0 disables |
|
||||
|
||||
| `theme.colors` key | Default | Meaning |
|
||||
@@ -172,10 +173,8 @@ The target follows [normal-text contrast guidance](https://www.w3.org/WAI/WCAG22
|
||||
| Section / key | Type | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `theme.surface.bar.background` | color | `"#1C202EF2"` | Shell fallback background; compositor materials use `surface_base` |
|
||||
| `theme.surface.bar.opacity` | number 0–1 | omitted | Translucent bar background opacity, including blur; defaults to `0.78`. Text/icons stay opaque; 0 and 1 skip blur. Fallback retains background alpha |
|
||||
| `theme.surface.bar.text_primary` | color | `"#F0F3FA"` | Bar text/icons |
|
||||
| `theme.surface.bar.text_muted` | color | `"#AAB4C7"` | Inactive bar foreground |
|
||||
| `theme.surface.popover.opacity` | number 0–1 | `0.96` | Shell popover fill alpha |
|
||||
| `theme.shadow.soft.offset_y` | number | `4` | Window shadow vertical offset |
|
||||
| `theme.shadow.soft.blur` | number ≥ 0 | `18` | Window shadow softness |
|
||||
| `theme.shadow.soft.opacity` | number 0–1 | `0.20` | Window shadow strength |
|
||||
|
||||
@@ -13,7 +13,7 @@ desktop instead of direct access to the graphics device.
|
||||
|
||||
Build with **Rust 1.95 or newer**, a C/C++ toolchain, CMake, pkg-config, and the
|
||||
native development libraries below. The installer builds Ferese, its shell,
|
||||
Settings, command-line tools, locker, and ScreenCast portal together. It does not install system
|
||||
Settings, command-line tools, locker, polkit agent, and ScreenCast portal together. It does not install system
|
||||
packages or enable services for you.
|
||||
|
||||
### Fedora
|
||||
@@ -23,7 +23,8 @@ sudo dnf install git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
|
||||
wayland-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel \
|
||||
mesa-libgbm-devel mesa-libEGL-devel libdrm-devel fontconfig-devel \
|
||||
freetype-devel expat-devel dbus-daemon dbus-tools desktop-file-utils foot pam \
|
||||
clang clang-devel pipewire-devel pipewire wireplumber xdg-desktop-portal xdg-desktop-portal-gtk \
|
||||
clang clang-devel pipewire-devel pipewire wireplumber polkit polkit-libs \
|
||||
xdg-desktop-portal xdg-desktop-portal-gtk \
|
||||
gstreamer1-devel gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer
|
||||
```
|
||||
|
||||
@@ -32,7 +33,7 @@ sudo dnf install git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
|
||||
```sh
|
||||
sudo pacman -S --needed base-devel git curl cmake pkgconf wayland libxkbcommon \
|
||||
libinput systemd seatd mesa libdrm fontconfig freetype2 expat dbus \
|
||||
desktop-file-utils foot pam clang pipewire libpipewire wireplumber \
|
||||
desktop-file-utils foot pam clang pipewire libpipewire wireplumber polkit \
|
||||
xdg-desktop-portal xdg-desktop-portal-gtk \
|
||||
gstreamer gst-plugins-base gst-plugins-good gst-plugin-pipewire
|
||||
```
|
||||
@@ -45,7 +46,7 @@ sudo apt install build-essential git curl cmake pkg-config libwayland-dev \
|
||||
libxkbcommon-dev libinput-dev libudev-dev libseat-dev libgbm-dev libegl-dev \
|
||||
libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev dbus-bin \
|
||||
dbus-user-session desktop-file-utils foot libpam0g clang libclang-dev \
|
||||
libpipewire-0.3-dev libspa-0.2-dev pipewire wireplumber \
|
||||
libpipewire-0.3-dev libspa-0.2-dev pipewire wireplumber polkitd libpolkit-agent-1-0 \
|
||||
xdg-desktop-portal xdg-desktop-portal-gtk \
|
||||
libgstreamer1.0-dev gstreamer1.0-pipewire gstreamer1.0-plugins-base \
|
||||
gstreamer1.0-plugins-good
|
||||
@@ -94,7 +95,7 @@ The installer adds:
|
||||
- Versioned releases under `/usr/local/lib/ferese/releases/`, with `current` and
|
||||
`previous` links for upgrades and rollback.
|
||||
- Commands under `/usr/local/bin/`, including `ferese-session`, `ferese-settings`,
|
||||
`feresectl`, `ferese-lock`, `ferese-screenshot`,
|
||||
`feresectl`, `ferese-lock`, `ferese-polkit-agent`, `ferese-screenshot`, and
|
||||
`xdg-desktop-portal-ferese`, and `ferese-record`.
|
||||
- D-Bus activation and Ferese-specific ScreenCast portal registration.
|
||||
- A Wayland session entry at `/usr/share/wayland-sessions/ferese.desktop`.
|
||||
@@ -105,6 +106,12 @@ The installer adds:
|
||||
Your existing configuration and other desktop sessions are preserved. The
|
||||
installer does not change your default session or login manager.
|
||||
|
||||
Ferese starts its own polkit authentication agent with the shell. Password
|
||||
prompts follow the configured font, accent, surface color, and corner radius.
|
||||
Authentication still uses polkit's system helper and policy. The agent starts
|
||||
only inside a Ferese session; logging into another desktop leaves its agent
|
||||
unchanged. Restart your Ferese session after upgrading to use the new prompt.
|
||||
|
||||
### Initial configuration
|
||||
|
||||
Ferese has built-in defaults. To start from the example without replacing an
|
||||
@@ -255,7 +262,7 @@ Installer options:
|
||||
|
||||
`--dry-run` prints the planned commands. `--offline` requires dependencies to be
|
||||
cached already. `--skip-build` installs the binaries already in `target/release/`;
|
||||
use it only after building all session components from the intended revision.
|
||||
use it only after building all six components from the intended revision.
|
||||
Run `./scripts/install.sh --help` for all options.
|
||||
|
||||
## Logout and recovery
|
||||
|
||||
@@ -29,7 +29,7 @@ opens a display picker. Select a display and press **Share**. Nothing is selecte
|
||||
by default. Applications that request multiple displays can share up to four.
|
||||
The picker uses your Ferese theme colors, font, and shell corner radius. Its
|
||||
background follows `theme.material.style`: opaque in solid mode, or translucent
|
||||
using `theme.surface.popover.opacity`. Text and icons remain opaque.
|
||||
using the shared **Appearance → Shell opacity** setting (`theme.material.opacity`). Text and icons remain opaque.
|
||||
|
||||
Other applications keep a **screen sharing** window with a **Stop sharing** button.
|
||||
Ferese’s built-in recorder uses the top-bar stop control instead, leaving your
|
||||
|
||||
@@ -18,10 +18,12 @@ layout {
|
||||
}
|
||||
|
||||
theme {
|
||||
material {
|
||||
opacity 0.95
|
||||
}
|
||||
surface {
|
||||
bar {
|
||||
background "#111821"
|
||||
opacity 0.95
|
||||
text-primary "#F4F7FB"
|
||||
text-muted "#8793A2"
|
||||
}
|
||||
|
||||
@@ -9,4 +9,17 @@ if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then
|
||||
WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || true
|
||||
fi
|
||||
release_dir=$(dirname -- "$(readlink -f -- "$0")")
|
||||
exec "$release_dir/ferese-shell"
|
||||
env -u WAYLAND_SOCKET WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \
|
||||
"$release_dir/ferese-polkit-agent" &
|
||||
agent_pid=$!
|
||||
"$release_dir/ferese-shell" &
|
||||
shell_pid=$!
|
||||
trap 'kill "$shell_pid" "$agent_pid" 2>/dev/null || true' EXIT INT TERM
|
||||
if wait "$shell_pid"; then
|
||||
status=0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
kill "$agent_pid" 2>/dev/null || true
|
||||
wait "$agent_pid" 2>/dev/null || true
|
||||
exit "$status"
|
||||
@@ -322,9 +322,9 @@ fn appearance() -> (
|
||||
&string("theme.material.style", "solid"),
|
||||
document
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("theme.surface.popover.opacity"))
|
||||
.and_then(|d| d.get("theme.material.opacity"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.96),
|
||||
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY),
|
||||
);
|
||||
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
|
||||
let family: &'static str =
|
||||
|
||||
@@ -19,7 +19,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop
|
||||
[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; }
|
||||
|
||||
# Refuse to overwrite unrelated installations.
|
||||
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese ferese-record; do
|
||||
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-polkit-agent ferese-screenshot xdg-desktop-portal-ferese ferese-record; do
|
||||
link=/usr/local/bin/$name
|
||||
if [[ -e $link || -L $link ]]; then
|
||||
[[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || {
|
||||
@@ -39,9 +39,10 @@ if [[ -e $session_entry ]]; then
|
||||
echo "Different session entry exists: $session_entry" >&2; exit 1;
|
||||
}
|
||||
fi
|
||||
for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese ferese-record; do
|
||||
for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent xdg-desktop-portal-ferese ferese-record; do
|
||||
[[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; }
|
||||
done
|
||||
"$repo_dir/target/release/ferese-polkit-agent" --check
|
||||
for name in ferese.png ferese.svg; do
|
||||
[[ -f $repo_dir/assets/wallpapers/$name ]] || { echo "Missing wallpaper asset: $name" >&2; exit 1; }
|
||||
done
|
||||
@@ -79,7 +80,7 @@ install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions
|
||||
if [[ -e $session_entry ]]; then
|
||||
install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop"
|
||||
fi
|
||||
for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese ferese-record; do
|
||||
for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent xdg-desktop-portal-ferese ferese-record; do
|
||||
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
|
||||
done
|
||||
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session"
|
||||
@@ -101,7 +102,7 @@ if [[ -L $install_root/current ]]; then
|
||||
fi
|
||||
ln -s -- "releases/$release_id" "$install_root/current-$release_id"
|
||||
mv -Tf -- "$install_root/current-$release_id" "$install_root/current"
|
||||
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese ferese-record; do
|
||||
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-polkit-agent ferese-screenshot xdg-desktop-portal-ferese ferese-record; do
|
||||
ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name"
|
||||
done
|
||||
install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry"
|
||||
|
||||
@@ -49,7 +49,7 @@ desktop-file-validate "$repo_dir/packaging/ferese.desktop"
|
||||
|
||||
build=(cargo build --manifest-path "$repo_dir/Cargo.toml"
|
||||
--target-dir "$repo_dir/target" --release --locked
|
||||
-p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p xdg-desktop-portal-ferese)
|
||||
-p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p ferese-polkit-agent -p xdg-desktop-portal-ferese)
|
||||
if $offline; then build+=(--offline); fi
|
||||
if ! $skip_build; then
|
||||
((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)'
|
||||
@@ -75,7 +75,7 @@ fi
|
||||
|
||||
cd -- "$repo_dir"
|
||||
if ! $skip_build; then "${build[@]}"; fi
|
||||
for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do
|
||||
for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent; do
|
||||
[[ -x $repo_dir/target/release/$name ]] || fail "missing release binary: $name"
|
||||
done
|
||||
"${installer[@]}"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "ferese-polkit-agent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.95"
|
||||
|
||||
[dependencies]
|
||||
ferese-config = { path = "../../crates/ferese-config" }
|
||||
glib = "0.21.5"
|
||||
libc.workspace = true
|
||||
libcosmic = { workspace = true, features = ["wgpu"] }
|
||||
libloading = "0.8.9"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||
zbus = "5.19.0"
|
||||
zeroize = "1.9"
|
||||
@@ -0,0 +1,172 @@
|
||||
mod prompt;
|
||||
mod session;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
const AGENT_PATH: &str = "/dev/ferese/PolkitAgent";
|
||||
const AUTHORITY: &str = "org.freedesktop.PolicyKit1";
|
||||
const AUTHORITY_PATH: &str = "/org/freedesktop/PolicyKit1/Authority";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum PromptEvent {
|
||||
Start { message: String, user: String },
|
||||
Request { prompt: String, echo: bool },
|
||||
Info { text: String },
|
||||
Error { text: String },
|
||||
Response { value: String },
|
||||
Cancel,
|
||||
}
|
||||
|
||||
type Identity = (String, HashMap<String, OwnedValue>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Agent {
|
||||
active: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||
}
|
||||
|
||||
#[zbus::interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
|
||||
impl Agent {
|
||||
async fn begin_authentication(
|
||||
&self,
|
||||
_action_id: String,
|
||||
message: String,
|
||||
_icon_name: String,
|
||||
_details: HashMap<String, String>,
|
||||
cookie: String,
|
||||
identities: Vec<Identity>,
|
||||
) -> zbus::fdo::Result<()> {
|
||||
let uid = choose_identity(&identities).ok_or_else(|| {
|
||||
zbus::fdo::Error::Failed("No supported authentication identity".into())
|
||||
})?;
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
|
||||
{
|
||||
let mut active = self.active.lock().unwrap();
|
||||
if active.contains_key(&cookie) {
|
||||
return Err(zbus::fdo::Error::Failed("Duplicate request".into()));
|
||||
}
|
||||
active.insert(cookie.clone(), cancelled.clone());
|
||||
}
|
||||
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let cookie = cookie.clone();
|
||||
move || {
|
||||
session::authenticate(uid, message.chars().take(240).collect(), cookie, cancelled)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?;
|
||||
|
||||
self.active.lock().unwrap().remove(&cookie);
|
||||
match result {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(zbus::fdo::Error::Failed("Authentication cancelled".into())),
|
||||
Err(error) => Err(zbus::fdo::Error::Failed(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_authentication(&self, cookie: String) {
|
||||
if let Some(cancelled) = self.active.lock().unwrap().get(&cookie) {
|
||||
cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_identity(identities: &[Identity]) -> Option<u32> {
|
||||
let available: Vec<_> = identities
|
||||
.iter()
|
||||
.filter(|(kind, _)| kind == "unix-user")
|
||||
.filter_map(|(_, details)| {
|
||||
details
|
||||
.get("uid")
|
||||
.and_then(|uid| u32::try_from(uid.clone()).ok())
|
||||
.filter(|uid| *uid <= i32::MAX as u32)
|
||||
})
|
||||
.collect();
|
||||
let current = unsafe { libc::geteuid() };
|
||||
|
||||
available
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|uid| *uid == current)
|
||||
.or_else(|| available.first().copied())
|
||||
}
|
||||
|
||||
fn session_subject() -> Result<(String, HashMap<String, Value<'static>>), String> {
|
||||
let id = std::env::var("XDG_SESSION_ID")
|
||||
.map_err(|_| "XDG_SESSION_ID is required for polkit registration".to_owned())?;
|
||||
|
||||
if id.is_empty() {
|
||||
return Err("XDG_SESSION_ID is empty".into());
|
||||
}
|
||||
|
||||
let mut details = HashMap::new();
|
||||
details.insert("session-id".to_owned(), Value::from(id));
|
||||
|
||||
Ok(("unix-session".into(), details))
|
||||
}
|
||||
|
||||
async fn run_agent() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let subject = session_subject()?;
|
||||
let connection = zbus::connection::Builder::system()?
|
||||
.serve_at(AGENT_PATH, Agent::default())?
|
||||
.build()
|
||||
.await?;
|
||||
let authority = zbus::Proxy::new(&connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY).await?;
|
||||
let locale = std::env::var("LANG").unwrap_or_default();
|
||||
|
||||
authority
|
||||
.call::<_, _, ()>(
|
||||
"RegisterAuthenticationAgent",
|
||||
&(subject, locale, AGENT_PATH),
|
||||
)
|
||||
.await?;
|
||||
std::future::pending::<()>().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
unsafe {
|
||||
libc::prctl(libc::PR_SET_DUMPABLE, 0);
|
||||
}
|
||||
|
||||
match std::env::args().nth(1).as_deref() {
|
||||
None => tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(run_agent()),
|
||||
Some("--prompt") => prompt::run(),
|
||||
Some("--check") => session::check().map_err(Into::into),
|
||||
_ => Err("Usage: ferese-polkit-agent [--check]".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chooses_current_identity_when_available() {
|
||||
let current = unsafe { libc::geteuid() };
|
||||
let identity = |uid: u32| {
|
||||
(
|
||||
"unix-user".to_owned(),
|
||||
HashMap::from([("uid".to_owned(), OwnedValue::from(uid))]),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
choose_identity(&[identity(0), identity(current)]),
|
||||
Some(current)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
use crate::PromptEvent;
|
||||
use cosmic::{
|
||||
Element,
|
||||
app::{Core, Settings, Task},
|
||||
iced::{Alignment, Background, Border, Color, Length, Size, Subscription, window},
|
||||
theme,
|
||||
widget::{self, button, column, container, row, text},
|
||||
};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::{BufRead, BufReader, Write},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Appearance {
|
||||
surface: Color,
|
||||
translucent: bool,
|
||||
text: Color,
|
||||
muted: Color,
|
||||
accent: Color,
|
||||
radius: f32,
|
||||
font: cosmic::font::Font,
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
fn load() -> Self {
|
||||
let document = ferese_config::config_path()
|
||||
.and_then(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|source| ferese_config::Document::parse(&source).ok());
|
||||
let setting = |key: &str, fallback: &str| {
|
||||
document
|
||||
.as_ref()
|
||||
.and_then(|doc| doc.get(key))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or(fallback)
|
||||
.to_owned()
|
||||
};
|
||||
let color = |key, fallback| parse_color(&setting(key, fallback), fallback);
|
||||
let family: &'static str =
|
||||
Box::leak(setting("theme.typography.font_family", "Inter").into_boxed_str());
|
||||
let translucent = setting("theme.material.style", "solid") == "translucent";
|
||||
let shell_opacity = document
|
||||
.as_ref()
|
||||
.and_then(|doc| doc.get("theme.material.opacity"))
|
||||
.and_then(|value| value.as_f64())
|
||||
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY)
|
||||
.clamp(0., 1.) as f32;
|
||||
let mut surface = color("theme.colors.surface_base", "#111821");
|
||||
surface.a = if translucent { shell_opacity } else { 1. };
|
||||
|
||||
Self {
|
||||
surface,
|
||||
translucent,
|
||||
text: color("theme.colors.text_primary", "#F4F7FB"),
|
||||
muted: color("theme.colors.text_muted", "#8793A2"),
|
||||
accent: color("theme.colors.accent", "#3D7BE6"),
|
||||
radius: document
|
||||
.as_ref()
|
||||
.and_then(|doc| doc.get("theme.geometry.shell_radius"))
|
||||
.and_then(|value| value.as_f64())
|
||||
.unwrap_or(14.)
|
||||
.clamp(8., 36.) as f32,
|
||||
font: cosmic::font::Font {
|
||||
family: cosmic::iced::font::Family::Name(family),
|
||||
..cosmic::font::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn theme(&self) -> cosmic::Theme {
|
||||
use cosmic::cosmic_theme::{ThemeBuilder, palette::Srgba};
|
||||
|
||||
let rgba = |color: Color| Srgba::new(color.r, color.g, color.b, color.a);
|
||||
let builder = if self.surface.r + self.surface.g + self.surface.b > 1.5 {
|
||||
ThemeBuilder::light()
|
||||
} else {
|
||||
ThemeBuilder::dark()
|
||||
};
|
||||
|
||||
let mut corners = cosmic::cosmic_theme::CornerRadii::default();
|
||||
corners.radius_xs = [self.radius.min(4.); 4];
|
||||
corners.radius_s = [self.radius.min(8.); 4];
|
||||
corners.radius_m = [self.radius; 4];
|
||||
corners.radius_l = [self.radius; 4];
|
||||
corners.radius_xl = [self.radius; 4];
|
||||
|
||||
cosmic::Theme::custom(Arc::new(
|
||||
builder
|
||||
.corner_radii(corners)
|
||||
.bg_color(rgba(Color {
|
||||
a: if self.translucent { 0. } else { 1. },
|
||||
..self.surface
|
||||
}))
|
||||
.primary_container_bg(rgba(self.surface))
|
||||
.text_tint(rgba(self.text).color)
|
||||
.accent(rgba(self.accent).color)
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_color(input: &str, fallback: &str) -> Color {
|
||||
let parse = |source: &str| {
|
||||
let value = u32::from_str_radix(source.strip_prefix('#')?, 16).ok()?;
|
||||
(source.len() == 7)
|
||||
.then(|| Color::from_rgb8((value >> 16) as u8, (value >> 8) as u8, value as u8))
|
||||
};
|
||||
parse(input)
|
||||
.or_else(|| parse(fallback))
|
||||
.unwrap_or(Color::BLACK)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Message {
|
||||
Tick,
|
||||
Input(String),
|
||||
Submit,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Message {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("AuthenticationPromptMessage")
|
||||
}
|
||||
}
|
||||
|
||||
struct Prompt {
|
||||
core: Core,
|
||||
appearance: Appearance,
|
||||
incoming: Arc<Mutex<VecDeque<PromptEvent>>>,
|
||||
description: String,
|
||||
user: String,
|
||||
question: String,
|
||||
echo: bool,
|
||||
answer: Zeroizing<String>,
|
||||
waiting: bool,
|
||||
error: Option<String>,
|
||||
info: Option<String>,
|
||||
height: f32,
|
||||
}
|
||||
|
||||
fn content_height(description: &str, status: Option<&str>) -> f32 {
|
||||
let lines: usize = description
|
||||
.lines()
|
||||
.map(|line| line.chars().count().div_ceil(48).max(1))
|
||||
.sum();
|
||||
let status_height = status.map_or(0, |message| {
|
||||
14 + 18 * message.chars().count().div_ceil(48).clamp(1, 3)
|
||||
});
|
||||
(214 + 18 * lines.saturating_sub(1) + status_height).min(360) as f32
|
||||
}
|
||||
|
||||
fn input_style(palette: &Appearance) -> theme::TextInput {
|
||||
let text = palette.text;
|
||||
let muted = palette.muted;
|
||||
let accent = palette.accent;
|
||||
let radius = palette.radius.min(10.);
|
||||
let appearance = move |focused: bool| widget::text_input::Appearance {
|
||||
background: Color::from_rgba(text.r, text.g, text.b, 0.045).into(),
|
||||
border_radius: radius.into(),
|
||||
border_width: 1.,
|
||||
border_offset: None,
|
||||
border_color: if focused {
|
||||
accent.scale_alpha(0.82)
|
||||
} else {
|
||||
muted.scale_alpha(0.26)
|
||||
},
|
||||
icon_color: Some(muted),
|
||||
text_color: Some(text),
|
||||
placeholder_color: muted,
|
||||
selected_text_color: text,
|
||||
selected_fill: accent.scale_alpha(0.35),
|
||||
label_color: text,
|
||||
};
|
||||
theme::TextInput::Custom {
|
||||
active: Box::new(move |_| appearance(false)),
|
||||
hovered: Box::new(move |_| appearance(true)),
|
||||
focused: Box::new(move |_| appearance(true)),
|
||||
error: Box::new(move |_| appearance(false)),
|
||||
disabled: Box::new(move |_| appearance(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let stdin = std::io::stdin();
|
||||
let mut lines = BufReader::new(stdin).lines();
|
||||
let Some(Ok(first)) = lines.next() else {
|
||||
return Err("Missing authentication request".into());
|
||||
};
|
||||
let PromptEvent::Start { message, user } = serde_json::from_str(&first)? else {
|
||||
return Err("Invalid authentication request".into());
|
||||
};
|
||||
let incoming = Arc::new(Mutex::new(VecDeque::new()));
|
||||
let queue = incoming.clone();
|
||||
std::thread::spawn(move || {
|
||||
for line in lines {
|
||||
let Ok(line) = line else { break };
|
||||
if let Ok(event) = serde_json::from_str(&line) {
|
||||
queue.lock().unwrap().push_back(event);
|
||||
}
|
||||
}
|
||||
queue.lock().unwrap().push_back(PromptEvent::Cancel);
|
||||
});
|
||||
let appearance = Appearance::load();
|
||||
let height = content_height(&message, None);
|
||||
cosmic::app::run::<Prompt>(
|
||||
Settings::default()
|
||||
.size(Size::new(434., height))
|
||||
.client_decorations(false)
|
||||
.transparent(true)
|
||||
.theme(appearance.theme())
|
||||
.default_font(appearance.font)
|
||||
.default_text_size(14.)
|
||||
.antialiasing(true),
|
||||
(incoming, message, user, appearance),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reply(mut event: PromptEvent) {
|
||||
if let Ok(mut line) = serde_json::to_string(&event) {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let _ = writeln!(stdout, "{line}");
|
||||
let _ = stdout.flush();
|
||||
line.zeroize();
|
||||
}
|
||||
if let PromptEvent::Response { value } = &mut event {
|
||||
value.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
fn focus() -> Task<Message> {
|
||||
widget::text_input::focus(widget::Id::new("auth-response"))
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
fn resize_to_content(&mut self) -> Task<Message> {
|
||||
let height = content_height(
|
||||
&self.description,
|
||||
self.error.as_deref().or(self.info.as_deref()),
|
||||
);
|
||||
if (height - self.height).abs() < 1.0 {
|
||||
return Task::none();
|
||||
}
|
||||
self.height = height;
|
||||
self.core
|
||||
.main_window_id()
|
||||
.map(|id| window::resize(id, Size::new(434., height)))
|
||||
.unwrap_or_else(Task::none)
|
||||
}
|
||||
}
|
||||
|
||||
impl cosmic::Application for Prompt {
|
||||
type Executor = cosmic::executor::Default;
|
||||
type Flags = (
|
||||
Arc<Mutex<VecDeque<PromptEvent>>>,
|
||||
String,
|
||||
String,
|
||||
Appearance,
|
||||
);
|
||||
type Message = Message;
|
||||
const APP_ID: &'static str = "dev.ferese.Authentication";
|
||||
|
||||
fn core(&self) -> &Core {
|
||||
&self.core
|
||||
}
|
||||
|
||||
fn core_mut(&mut self) -> &mut Core {
|
||||
&mut self.core
|
||||
}
|
||||
|
||||
fn init(
|
||||
mut core: Core,
|
||||
(incoming, description, user, appearance): Self::Flags,
|
||||
) -> (Self, Task<Message>) {
|
||||
core.window.show_headerbar = false;
|
||||
let height = content_height(&description, None);
|
||||
let bounds = core
|
||||
.main_window_id()
|
||||
.map(|id| window::set_max_size(id, Some(Size::new(434., 360.))))
|
||||
.unwrap_or_else(Task::none);
|
||||
(
|
||||
Self {
|
||||
core,
|
||||
appearance,
|
||||
incoming,
|
||||
description,
|
||||
user,
|
||||
question: "Waiting for authentication…".into(),
|
||||
echo: false,
|
||||
answer: Zeroizing::new(String::new()),
|
||||
waiting: true,
|
||||
error: None,
|
||||
info: None,
|
||||
height,
|
||||
},
|
||||
Task::batch([focus(), bounds]),
|
||||
)
|
||||
}
|
||||
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
cosmic::iced::time::every(Duration::from_millis(30)).map(|_| Message::Tick)
|
||||
}
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Tick => {
|
||||
let events: Vec<_> = self.incoming.lock().unwrap().drain(..).collect();
|
||||
let mut refocus = false;
|
||||
for event in events {
|
||||
match event {
|
||||
PromptEvent::Request { prompt, echo } => {
|
||||
self.answer.zeroize();
|
||||
self.question = prompt;
|
||||
self.echo = echo;
|
||||
self.waiting = false;
|
||||
refocus = true;
|
||||
}
|
||||
PromptEvent::Info { text } => self.info = Some(text),
|
||||
PromptEvent::Error { text } => self.error = Some(text),
|
||||
PromptEvent::Cancel => return cosmic::iced::exit(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let resize = self.resize_to_content();
|
||||
if refocus {
|
||||
return Task::batch([resize, focus()]);
|
||||
}
|
||||
return resize;
|
||||
}
|
||||
Message::Input(mut value) => {
|
||||
if !self.waiting && value.len() <= 4096 {
|
||||
self.answer.zeroize();
|
||||
*self.answer = std::mem::take(&mut value);
|
||||
self.error = None;
|
||||
}
|
||||
value.zeroize();
|
||||
}
|
||||
Message::Submit if !self.waiting && !self.answer.is_empty() => {
|
||||
let value = std::mem::take(&mut *self.answer);
|
||||
reply(PromptEvent::Response { value });
|
||||
self.waiting = true;
|
||||
}
|
||||
Message::Cancel => {
|
||||
self.answer.zeroize();
|
||||
reply(PromptEvent::Cancel);
|
||||
return cosmic::iced::exit();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> Task<Message> {
|
||||
self.update(Message::Cancel)
|
||||
}
|
||||
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
let palette = &self.appearance;
|
||||
let mut input = widget::text_input(&self.question, self.answer.as_str())
|
||||
.id(widget::Id::new("auth-response"))
|
||||
.font(palette.font)
|
||||
.padding([7, 12])
|
||||
.style(input_style(palette));
|
||||
if !self.echo {
|
||||
input = input.password();
|
||||
}
|
||||
if !self.waiting {
|
||||
input = input.on_input(Message::Input).on_submit(|mut value| {
|
||||
value.zeroize();
|
||||
Message::Submit
|
||||
});
|
||||
}
|
||||
let action = button::custom(text("Authenticate").font(palette.font).size(13))
|
||||
.class(theme::Button::Suggested)
|
||||
.height(Length::Fixed(36.))
|
||||
.padding([8, 16])
|
||||
.on_press_maybe((!self.waiting && !self.answer.is_empty()).then_some(Message::Submit));
|
||||
let lock_svg = std::str::from_utf8(include_bytes!(
|
||||
"../../ferese-shell/assets/icons/status/lock.svg"
|
||||
))
|
||||
.expect("embedded lock icon is valid UTF-8")
|
||||
.replace(
|
||||
"currentColor",
|
||||
&format!(
|
||||
"#{:02x}{:02x}{:02x}",
|
||||
(palette.accent.r * 255.) as u8,
|
||||
(palette.accent.g * 255.) as u8,
|
||||
(palette.accent.b * 255.) as u8,
|
||||
),
|
||||
);
|
||||
let lock_icon = widget::icon::from_svg_bytes(lock_svg.into_bytes())
|
||||
.symbolic(false)
|
||||
.icon()
|
||||
.size(20);
|
||||
let heading = row![
|
||||
container(lock_icon)
|
||||
.width(40)
|
||||
.height(40)
|
||||
.center_x(40)
|
||||
.center_y(40)
|
||||
.class(theme::Container::custom(move |_| {
|
||||
widget::container::Style {
|
||||
background: Some(Background::Color(palette.accent.scale_alpha(0.13))),
|
||||
border: Border {
|
||||
radius: 12.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
})),
|
||||
column![
|
||||
text("Authentication required").font(palette.font).size(18),
|
||||
text(format!("Confirm as {}", self.user))
|
||||
.font(palette.font)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(palette.muted)),
|
||||
]
|
||||
.spacing(2),
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center);
|
||||
let mut body = column![
|
||||
heading,
|
||||
text(&self.description).font(palette.font).size(13),
|
||||
input
|
||||
]
|
||||
.spacing(14)
|
||||
.width(Length::Fill);
|
||||
if let Some(error) = &self.error {
|
||||
body = body.push(
|
||||
text(error)
|
||||
.font(palette.font)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(Color::from_rgb8(235, 98, 98))),
|
||||
);
|
||||
} else if let Some(info) = &self.info {
|
||||
body = body.push(
|
||||
text(info)
|
||||
.font(palette.font)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(palette.muted)),
|
||||
);
|
||||
}
|
||||
body = body.push(
|
||||
row![
|
||||
button::custom(text("Cancel").font(palette.font).size(13))
|
||||
.class(theme::Button::Text)
|
||||
.padding([8, 14])
|
||||
.on_press(Message::Cancel),
|
||||
action
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
container(body)
|
||||
.padding(20)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.class(theme::Container::custom(move |_| {
|
||||
widget::container::Style {
|
||||
background: Some(Background::Color(palette.surface)),
|
||||
text_color: Some(palette.text),
|
||||
border: Border {
|
||||
radius: palette.radius.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::content_height;
|
||||
|
||||
#[test]
|
||||
fn dialog_grows_for_long_messages_and_caps_its_height() {
|
||||
assert_eq!(content_height("Short request", None), 214.);
|
||||
assert!(content_height(&"Long message ".repeat(20), None) > 214.);
|
||||
assert_eq!(content_height(&"Long message ".repeat(100), None), 360.);
|
||||
assert!(content_height("Short request", Some("Password not accepted")) > 214.);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
use crate::PromptEvent;
|
||||
use glib::gobject_ffi;
|
||||
use libloading::Library;
|
||||
use std::{
|
||||
ffi::{CStr, CString, c_char, c_int, c_void},
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
process::{Child, ChildStdin, Command, Stdio},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{self, Sender},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
enum SessionEvent {
|
||||
Request(String, bool),
|
||||
Info(String),
|
||||
Error(String),
|
||||
Completed(bool),
|
||||
}
|
||||
|
||||
type Object = *mut c_void;
|
||||
|
||||
struct Api {
|
||||
_agent: Library,
|
||||
_polkit: Library,
|
||||
user_new: unsafe extern "C" fn(c_int) -> Object,
|
||||
session_new: unsafe extern "C" fn(Object, *const c_char) -> Object,
|
||||
initiate: unsafe extern "C" fn(Object),
|
||||
response: unsafe extern "C" fn(Object, *const c_char),
|
||||
cancel: unsafe extern "C" fn(Object),
|
||||
}
|
||||
|
||||
impl Api {
|
||||
fn load() -> Result<Self, String> {
|
||||
unsafe {
|
||||
let agent = Library::new("libpolkit-agent-1.so.0").map_err(|e| e.to_string())?;
|
||||
let polkit = Library::new("libpolkit-gobject-1.so.0").map_err(|e| e.to_string())?;
|
||||
let user_new = *polkit
|
||||
.get(b"polkit_unix_user_new\0")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let session_new = *agent
|
||||
.get(b"polkit_agent_session_new\0")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let initiate = *agent
|
||||
.get(b"polkit_agent_session_initiate\0")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let response = *agent
|
||||
.get(b"polkit_agent_session_response\0")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let cancel = *agent
|
||||
.get(b"polkit_agent_session_cancel\0")
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
_agent: agent,
|
||||
_polkit: polkit,
|
||||
user_new,
|
||||
session_new,
|
||||
initiate,
|
||||
response,
|
||||
cancel,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check() -> Result<(), String> {
|
||||
Api::load().map(|_| ())
|
||||
}
|
||||
|
||||
fn username(uid: u32) -> String {
|
||||
let output = Command::new("getent")
|
||||
.args(["passwd", &uid.to_string()])
|
||||
.output();
|
||||
output
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.and_then(|output| String::from_utf8(output.stdout).ok())
|
||||
.and_then(|entry| entry.split(':').next().map(str::to_owned))
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| uid.to_string())
|
||||
}
|
||||
|
||||
fn send(writer: &mut BufWriter<ChildStdin>, event: &PromptEvent) -> Result<(), String> {
|
||||
serde_json::to_writer(&mut *writer, event).map_err(|e| e.to_string())?;
|
||||
writer.write_all(b"\n").map_err(|e| e.to_string())?;
|
||||
writer.flush().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn prompt_process(
|
||||
message: String,
|
||||
user: String,
|
||||
) -> Result<(Child, BufWriter<ChildStdin>, mpsc::Receiver<PromptEvent>), String> {
|
||||
let mut child = Command::new(std::env::current_exe().map_err(|e| e.to_string())?)
|
||||
.arg("--prompt")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let stdin = child.stdin.take().ok_or("Prompt has no input")?;
|
||||
let stdout = child.stdout.take().ok_or("Prompt has no output")?;
|
||||
let mut writer = BufWriter::new(stdin);
|
||||
send(&mut writer, &PromptEvent::Start { message, user })?;
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
let Ok(mut line) = line else { break };
|
||||
if let Ok(event) = serde_json::from_str(&line) {
|
||||
line.zeroize();
|
||||
if tx.send(event).is_err() {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
line.zeroize();
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok((child, writer, rx))
|
||||
}
|
||||
|
||||
unsafe extern "C" fn request(
|
||||
_session: Object,
|
||||
prompt: *const c_char,
|
||||
echo: c_int,
|
||||
data: *mut c_void,
|
||||
) {
|
||||
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
|
||||
let prompt = unsafe { CStr::from_ptr(prompt) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let _ = tx.send(SessionEvent::Request(prompt, echo != 0));
|
||||
}
|
||||
|
||||
unsafe extern "C" fn info(_session: Object, text: *const c_char, data: *mut c_void) {
|
||||
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
|
||||
let text = unsafe { CStr::from_ptr(text) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let _ = tx.send(SessionEvent::Info(text));
|
||||
}
|
||||
|
||||
unsafe extern "C" fn error(_session: Object, text: *const c_char, data: *mut c_void) {
|
||||
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
|
||||
let text = unsafe { CStr::from_ptr(text) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let _ = tx.send(SessionEvent::Error(text));
|
||||
}
|
||||
|
||||
unsafe extern "C" fn completed(_session: Object, success: c_int, data: *mut c_void) {
|
||||
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
|
||||
let _ = tx.send(SessionEvent::Completed(success != 0));
|
||||
}
|
||||
|
||||
unsafe fn connect(
|
||||
session: Object,
|
||||
name: &'static CStr,
|
||||
callback: gobject_ffi::GCallback,
|
||||
data: *mut c_void,
|
||||
) {
|
||||
unsafe {
|
||||
gobject_ffi::g_signal_connect_data(
|
||||
session.cast(),
|
||||
name.as_ptr(),
|
||||
callback,
|
||||
data,
|
||||
None,
|
||||
gobject_ffi::G_CONNECT_DEFAULT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_session(
|
||||
api: &Api,
|
||||
uid: u32,
|
||||
cookie: &CString,
|
||||
context: &glib::MainContext,
|
||||
writer: &mut BufWriter<ChildStdin>,
|
||||
input: &mpsc::Receiver<PromptEvent>,
|
||||
cancelled: &AtomicBool,
|
||||
child: &mut Child,
|
||||
) -> Result<Option<bool>, String> {
|
||||
let identity = unsafe { (api.user_new)(uid as c_int) };
|
||||
if identity.is_null() {
|
||||
return Err("Could not create polkit identity".into());
|
||||
}
|
||||
let session = unsafe { (api.session_new)(identity, cookie.as_ptr()) };
|
||||
unsafe { gobject_ffi::g_object_unref(identity.cast()) };
|
||||
if session.is_null() {
|
||||
return Err("Could not create polkit session".into());
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let signal_data = Box::into_raw(Box::new(tx)).cast();
|
||||
unsafe {
|
||||
connect(
|
||||
session,
|
||||
c"request",
|
||||
Some(std::mem::transmute(
|
||||
request as unsafe extern "C" fn(Object, *const c_char, c_int, *mut c_void),
|
||||
)),
|
||||
signal_data,
|
||||
);
|
||||
connect(
|
||||
session,
|
||||
c"show-info",
|
||||
Some(std::mem::transmute(
|
||||
info as unsafe extern "C" fn(Object, *const c_char, *mut c_void),
|
||||
)),
|
||||
signal_data,
|
||||
);
|
||||
connect(
|
||||
session,
|
||||
c"show-error",
|
||||
Some(std::mem::transmute(
|
||||
error as unsafe extern "C" fn(Object, *const c_char, *mut c_void),
|
||||
)),
|
||||
signal_data,
|
||||
);
|
||||
connect(
|
||||
session,
|
||||
c"completed",
|
||||
Some(std::mem::transmute(
|
||||
completed as unsafe extern "C" fn(Object, c_int, *mut c_void),
|
||||
)),
|
||||
signal_data,
|
||||
);
|
||||
(api.initiate)(session);
|
||||
}
|
||||
|
||||
let mut outcome = None;
|
||||
let mut stopped = false;
|
||||
while outcome.is_none() {
|
||||
while context.pending() {
|
||||
context.iteration(false);
|
||||
}
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
match event {
|
||||
SessionEvent::Request(prompt, echo) => {
|
||||
send(writer, &PromptEvent::Request { prompt, echo })?;
|
||||
}
|
||||
SessionEvent::Info(text) => send(writer, &PromptEvent::Info { text })?,
|
||||
SessionEvent::Error(text) => send(writer, &PromptEvent::Error { text })?,
|
||||
SessionEvent::Completed(success) => outcome = Some(success),
|
||||
}
|
||||
}
|
||||
if outcome.is_some() {
|
||||
break;
|
||||
}
|
||||
while let Ok(event) = input.try_recv() {
|
||||
match event {
|
||||
PromptEvent::Response { mut value } => {
|
||||
if let Ok(response) = CString::new(value.as_str()) {
|
||||
unsafe { (api.response)(session, response.as_ptr()) };
|
||||
}
|
||||
value.zeroize();
|
||||
}
|
||||
PromptEvent::Cancel => cancelled.store(true, Ordering::Release),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !stopped
|
||||
&& (cancelled.load(Ordering::Acquire)
|
||||
|| child.try_wait().map_err(|e| e.to_string())?.is_some())
|
||||
{
|
||||
unsafe { (api.cancel)(session) };
|
||||
stopped = true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
unsafe {
|
||||
gobject_ffi::g_object_unref(session.cast());
|
||||
drop(Box::from_raw(signal_data as *mut Sender<SessionEvent>));
|
||||
}
|
||||
Ok(outcome.filter(|_| !stopped))
|
||||
}
|
||||
|
||||
pub fn authenticate(
|
||||
uid: u32,
|
||||
message: String,
|
||||
cookie: String,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
) -> Result<bool, String> {
|
||||
let api = Api::load()?;
|
||||
let cookie = CString::new(cookie).map_err(|_| "Invalid authentication cookie")?;
|
||||
let context = glib::MainContext::new();
|
||||
context
|
||||
.with_thread_default(|| {
|
||||
let (mut child, mut writer, input) = prompt_process(message, username(uid))?;
|
||||
let result = loop {
|
||||
match run_session(
|
||||
&api,
|
||||
uid,
|
||||
&cookie,
|
||||
&context,
|
||||
&mut writer,
|
||||
&input,
|
||||
&cancelled,
|
||||
&mut child,
|
||||
) {
|
||||
Ok(Some(true)) => break Ok(true),
|
||||
Ok(Some(false)) if !cancelled.load(Ordering::Acquire) => {
|
||||
if let Err(error) = send(
|
||||
&mut writer,
|
||||
&PromptEvent::Error {
|
||||
text: "Password not accepted. Try again.".into(),
|
||||
},
|
||||
) {
|
||||
break Err(error);
|
||||
}
|
||||
}
|
||||
Ok(_) => break Ok(false),
|
||||
Err(error) => break Err(error),
|
||||
}
|
||||
};
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
result
|
||||
})
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
@@ -623,6 +623,17 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
"solid",
|
||||
&[("solid", "Solid"), ("translucent", "Translucent")],
|
||||
),
|
||||
range(
|
||||
"theme.material.opacity",
|
||||
"Shell opacity",
|
||||
"Background opacity for the bar, popovers, menus, notifications and sharing dialog. Text and icons stay opaque.",
|
||||
ferese_config::DEFAULT_MATERIAL_OPACITY,
|
||||
0.0,
|
||||
1.0,
|
||||
0.01,
|
||||
"",
|
||||
false,
|
||||
),
|
||||
range(
|
||||
"theme.material.blur_radius",
|
||||
"Background blur",
|
||||
@@ -727,17 +738,6 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
" px",
|
||||
true,
|
||||
),
|
||||
range(
|
||||
"theme.surface.bar.opacity",
|
||||
"Opacity",
|
||||
"Background opacity for translucent bars, with or without blur. Text and icons stay opaque.",
|
||||
0.78,
|
||||
0.0,
|
||||
1.0,
|
||||
0.01,
|
||||
"",
|
||||
false,
|
||||
),
|
||||
range(
|
||||
"theme.geometry.top_bar_window_gap",
|
||||
"Window clearance",
|
||||
|
||||
@@ -151,6 +151,7 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
|
||||
} else {
|
||||
(background, p.text)
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background: Some(Background::Color(background)),
|
||||
text_color: Some(on),
|
||||
@@ -196,6 +197,7 @@ pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
|
||||
.text_tint(cosmic_color(palette.text).color)
|
||||
.accent(ferese_theme::accent_color(palette.accent, palette.card))
|
||||
.build();
|
||||
|
||||
ferese_theme::apply(&mut theme, palette.text);
|
||||
cosmic::Theme::custom(std::sync::Arc::new(theme))
|
||||
}
|
||||
@@ -204,16 +206,20 @@ pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
|
||||
static FONTS: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
let family = snapshot.string("theme.typography.font_family", "Inter");
|
||||
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
|
||||
if let Some(font) = fonts.get(&family) {
|
||||
return *font;
|
||||
}
|
||||
|
||||
if fonts.len() >= 32 {
|
||||
return cosmic::font::default();
|
||||
}
|
||||
|
||||
let font = cosmic::font::Font::with_name(Box::leak(family.clone().into_boxed_str()));
|
||||
fonts.insert(family, font);
|
||||
|
||||
font
|
||||
}
|
||||
|
||||
@@ -331,6 +337,7 @@ pub fn preset(index: usize) -> Vec<Edit> {
|
||||
let Some(preset) = PRESETS.get(index) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
vec![
|
||||
set("theme.colors.accent", preset.accent),
|
||||
set("theme.colors.surface_base", preset.base),
|
||||
@@ -351,6 +358,7 @@ pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
|
||||
let Some(preset) = PRESETS.get(index) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
[
|
||||
("theme.colors.accent", preset.accent, "#3D7BE6"),
|
||||
("theme.colors.surface_base", preset.base, "#111821"),
|
||||
@@ -442,7 +450,11 @@ pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
|
||||
let bar_margin = snapshot.number("theme.geometry.top_bar_margin_horizontal", 0.) * 0.5 + 14.;
|
||||
let bar_height = snapshot.number("theme.geometry.top_bar_height", 30.) * 0.65;
|
||||
let bar_radius = snapshot.number("theme.geometry.shell_radius", 14.) * 0.65;
|
||||
let opacity = snapshot.number("theme.surface.bar.opacity", 0.78);
|
||||
let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" {
|
||||
snapshot.number("theme.material.opacity", ferese_config::DEFAULT_MATERIAL_OPACITY).clamp(0., 1.)
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
let right = 340. + gap / 2.;
|
||||
let left_width = 288. - gap / 2.;
|
||||
let svg = format!(
|
||||
@@ -465,6 +477,7 @@ pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
|
||||
bar_y + bar_height / 2.,
|
||||
right + 16.
|
||||
);
|
||||
|
||||
svg_icon::from_svg_bytes(svg.into_bytes())
|
||||
.symbolic(false)
|
||||
.icon()
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::{path::PathBuf, time::Duration};
|
||||
|
||||
pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
|
||||
let path = path.clone();
|
||||
|
||||
cosmic::iced::stream::channel(1, async move |mut output| {
|
||||
let (send, mut events) = tokio::sync::mpsc::channel(1);
|
||||
let target = path.clone();
|
||||
@@ -26,6 +27,7 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(error) = watcher.watch(
|
||||
path.parent().unwrap_or(std::path::Path::new(".")),
|
||||
RecursiveMode::NonRecursive,
|
||||
@@ -35,6 +37,7 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Read once after installing the watch to cover the startup race.
|
||||
loop {
|
||||
let file = path.clone();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4m10-4v4M3 10h18"/><path d="M8 14h2m4 0h2m-8 4h2m4 0h2"/></svg>
|
||||
|
After Width: | Height: | Size: 287 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M7 12h10"/></svg>
|
||||
|
After Width: | Height: | Size: 226 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v9"/><path d="M7 5.5a9 9 0 1 0 10 0"/></svg>
|
||||
|
After Width: | Height: | Size: 219 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 1 0 9 9"/><path d="M12 12 18 6"/><circle cx="12" cy="12" r="1"/></svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 4c-8 0-14 3-14 10a6 6 0 0 0 6 6c7 0 10-6 8-16Z"/><path d="M4 21c2-5 6-8 12-11"/></svg>
|
||||
|
After Width: | Height: | Size: 260 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 1 1-2.3-5.7"/><path d="M20 4v5h-5"/></svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 230 B |
@@ -62,11 +62,11 @@ pub(crate) struct ShellTheme {
|
||||
impl Default for ShellTheme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bar_background: [28, 32, 46, 242],
|
||||
bar_background: [28, 32, 46, 255],
|
||||
bar_text_primary: [240, 243, 250, 255],
|
||||
bar_text_muted: [170, 180, 199, 255],
|
||||
surface_base: [17, 24, 33, 255],
|
||||
surface_popover: [17, 24, 33, 245],
|
||||
surface_popover: [17, 24, 33, 255],
|
||||
text_primary: [244, 247, 251, 255],
|
||||
text_muted: [135, 147, 162, 255],
|
||||
accent: [61, 123, 230, 255],
|
||||
@@ -148,6 +148,8 @@ struct AppearanceConfig {
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ThemeConfig {
|
||||
#[serde(default)]
|
||||
material: MaterialConfig,
|
||||
#[serde(default)]
|
||||
surface: SurfaceConfig,
|
||||
#[serde(default)]
|
||||
@@ -166,16 +168,12 @@ struct ThemeConfig {
|
||||
struct SurfaceConfig {
|
||||
#[serde(default)]
|
||||
bar: BarConfig,
|
||||
#[serde(default)]
|
||||
popover: PopoverConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct BarConfig {
|
||||
background: String,
|
||||
// If omitted, preserve the alpha supplied in `background`.
|
||||
opacity: Option<f32>,
|
||||
text_primary: String,
|
||||
text_muted: String,
|
||||
}
|
||||
@@ -184,29 +182,17 @@ impl Default for BarConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
background: "#1C202EF2".to_owned(),
|
||||
opacity: None,
|
||||
text_primary: "#F0F3FA".to_owned(),
|
||||
text_muted: "#AAB4C7".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PopoverConfig {
|
||||
#[serde(default = "default_popover_opacity")]
|
||||
opacity: f32,
|
||||
}
|
||||
|
||||
impl Default for PopoverConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
opacity: default_popover_opacity(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_popover_opacity() -> f32 {
|
||||
0.96
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct MaterialConfig {
|
||||
#[serde(default)]
|
||||
style: String,
|
||||
opacity: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -364,17 +350,26 @@ pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::E
|
||||
|
||||
fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
let defaults = ShellTheme::default();
|
||||
let opacity = if theme.material.style == "translucent" {
|
||||
finite_or(
|
||||
theme
|
||||
.material
|
||||
.opacity
|
||||
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY as f32),
|
||||
ferese_config::DEFAULT_MATERIAL_OPACITY as f32,
|
||||
)
|
||||
.clamp(0.0, 1.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let alpha = (opacity * 255.0).round() as u8;
|
||||
|
||||
ShellTheme {
|
||||
material_radius: defaults.material_radius,
|
||||
bar_background: {
|
||||
let mut background =
|
||||
parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
|
||||
if let Some(opacity) = theme.surface.bar.opacity {
|
||||
background[3] =
|
||||
(finite_or(opacity, f32::from(background[3]) / 255.0).clamp(0.0, 1.0) * 255.0)
|
||||
.round() as u8;
|
||||
}
|
||||
background[3] = alpha;
|
||||
background
|
||||
},
|
||||
bar_text_primary: parse_color(&theme.surface.bar.text_primary)
|
||||
@@ -385,10 +380,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
surface_popover: {
|
||||
let mut color =
|
||||
parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
|
||||
color[3] = (finite_or(theme.surface.popover.opacity, default_popover_opacity())
|
||||
.clamp(0.0, 1.0)
|
||||
* 255.0)
|
||||
.round() as u8;
|
||||
color[3] = alpha;
|
||||
color
|
||||
},
|
||||
text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary),
|
||||
@@ -617,7 +609,7 @@ theme {
|
||||
assert_eq!(theme.bar_height, 28.0);
|
||||
assert_eq!(theme.bar_margin_horizontal, 0);
|
||||
assert_eq!(theme.bar_radius, 14.0);
|
||||
assert_eq!(theme.bar_background, ShellTheme::default().bar_background);
|
||||
assert_eq!(theme.bar_background[3], 255);
|
||||
assert_eq!(theme.for_bar().text_primary, theme.bar_text_primary);
|
||||
assert_ne!(theme.for_bar().text_primary, theme.text_primary);
|
||||
}
|
||||
@@ -638,21 +630,17 @@ theme {
|
||||
)
|
||||
.unwrap();
|
||||
let theme = shell_theme(&config.theme);
|
||||
assert_eq!(theme.bar_background, [234, 236, 238, 221]);
|
||||
assert_eq!(theme.bar_background, [234, 236, 238, 255]);
|
||||
assert_eq!(theme.for_bar().text_primary, [34, 34, 34, 255]);
|
||||
assert_eq!(theme.for_bar().text_muted, [102, 102, 102, 255]);
|
||||
assert_eq!(theme.text_primary, ShellTheme::default().text_primary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_transparency_and_shadow_opacity_are_independent() {
|
||||
fn shell_opacity_is_shared_and_solid_remains_opaque() {
|
||||
let config: FereseConfig = ferese_config::from_str(
|
||||
r#"theme {
|
||||
surface {
|
||||
bar {
|
||||
opacity 0.6
|
||||
}
|
||||
}
|
||||
material { style "translucent"; opacity 0.6; }
|
||||
shadow {
|
||||
soft {
|
||||
opacity 0.07
|
||||
@@ -664,6 +652,12 @@ theme {
|
||||
.unwrap();
|
||||
let theme = shell_theme(&config.theme);
|
||||
assert_eq!(theme.bar_background[3], 153);
|
||||
assert_eq!(theme.surface_popover[3], 153);
|
||||
assert_eq!(theme.shadow_opacity, 0.07);
|
||||
let mut config = config;
|
||||
config.theme.material.style = "solid".into();
|
||||
let solid = shell_theme(&config.theme);
|
||||
assert_eq!(solid.bar_background[3], 255);
|
||||
assert_eq!(solid.surface_popover[3], 255);
|
||||
}
|
||||
}
|
||||
@@ -59,12 +59,15 @@ fn configured_font(family: Option<&str>) -> cosmic::font::Font {
|
||||
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
|
||||
|
||||
if let Some(font) = fonts.get(family) {
|
||||
return *font;
|
||||
}
|
||||
|
||||
if fonts.len() >= 64 {
|
||||
return cosmic::font::default();
|
||||
}
|
||||
|
||||
let family = Box::leak(family.to_owned().into_boxed_str());
|
||||
let font = cosmic::font::Font::with_name(family);
|
||||
fonts.insert(family.to_owned(), font);
|
||||
@@ -113,14 +116,17 @@ impl From<ShellTheme> for BarMetrics {
|
||||
fn stacked_clock_digits(label: &str) -> Option<[String; 2]> {
|
||||
let mut parts = label.split_whitespace();
|
||||
let time = parts.next()?;
|
||||
|
||||
if let Some(period) = parts.next() {
|
||||
if !period.eq_ignore_ascii_case("am") && !period.eq_ignore_ascii_case("pm") {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (hour, minute) = time.split_once(':')?;
|
||||
if !(1..=2).contains(&hour.len())
|
||||
|| minute.len() != 2
|
||||
@@ -131,6 +137,7 @@ fn stacked_clock_digits(label: &str) -> Option<[String; 2]> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some([format!("{hour:0>2}"), minute.to_owned()])
|
||||
}
|
||||
|
||||
@@ -142,6 +149,7 @@ fn main() -> cosmic::iced::Result {
|
||||
// toolkit threads are started.
|
||||
unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") };
|
||||
}
|
||||
|
||||
cosmic::iced::advanced::graphics::text::font_system()
|
||||
.write()
|
||||
.unwrap()
|
||||
@@ -154,6 +162,7 @@ fn main() -> cosmic::iced::Result {
|
||||
.load_font(std::borrow::Cow::Borrowed(include_bytes!(
|
||||
"../../../assets/fonts/Cantarell-ExtraBold.otf"
|
||||
)));
|
||||
|
||||
let mut config = config::load();
|
||||
motion::configure(config.animations, config.theme.material_radius);
|
||||
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
|
||||
@@ -207,6 +216,7 @@ struct FereseShell {
|
||||
status_service: status::Service,
|
||||
status: status::Snapshot,
|
||||
recorder: recording::Recorder,
|
||||
calendar_offset: i32,
|
||||
status_error: Option<String>,
|
||||
menu: Option<status_ui::OpenMenu>,
|
||||
note_editor: Option<DesktopNoteEditor>,
|
||||
@@ -284,6 +294,8 @@ enum Message {
|
||||
Control(status::Action),
|
||||
ConfirmPower(status::Action),
|
||||
CancelPower,
|
||||
CalendarMonth(i32),
|
||||
CalendarToday,
|
||||
}
|
||||
|
||||
impl cosmic::Application for FereseShell {
|
||||
@@ -316,6 +328,7 @@ impl cosmic::Application for FereseShell {
|
||||
status_service: status::Service::start(config.status.settings_command.clone()),
|
||||
status: status::Snapshot::default(),
|
||||
recorder: recording::Recorder::default(),
|
||||
calendar_offset: 0,
|
||||
status_error: None,
|
||||
menu: None,
|
||||
note_editor: None,
|
||||
@@ -348,6 +361,7 @@ impl cosmic::Application for FereseShell {
|
||||
}),
|
||||
None => Task::none(),
|
||||
};
|
||||
|
||||
(app, wallpaper_task)
|
||||
}
|
||||
|
||||
@@ -420,22 +434,28 @@ impl cosmic::Application for FereseShell {
|
||||
if let Err(error) = effects.set_opacity(menu.progress()) {
|
||||
eprintln!("ferese-shell: could not update popup opacity: {error}");
|
||||
}
|
||||
|
||||
let regions = menu.regions.lock().unwrap().clone();
|
||||
|
||||
if let Err(error) = effects.set_regions(®ions) {
|
||||
eprintln!("ferese-shell: could not update card materials: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(surface) = &self.notification_surface
|
||||
&& let Some(effects) = &surface.effects
|
||||
{
|
||||
let regions = surface.regions.lock().unwrap().clone();
|
||||
|
||||
if let Err(error) = effects.set_regions(®ions) {
|
||||
eprintln!("ferese-shell: could not update notification materials: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
match message {
|
||||
Message::BeginNoteEdit(id) => {
|
||||
let mut tasks = vec![self.finish_note_edit()];
|
||||
|
||||
if let Some(note) = self
|
||||
.config
|
||||
.desktop_widgets
|
||||
@@ -459,6 +479,7 @@ impl cosmic::Application for FereseShell {
|
||||
{
|
||||
let edited = action.is_edit();
|
||||
editor.content.perform(action);
|
||||
|
||||
if edited {
|
||||
editor.revision = editor.revision.wrapping_add(1);
|
||||
let revision = editor.revision;
|
||||
@@ -525,6 +546,7 @@ impl cosmic::Application for FereseShell {
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(menu) = &mut self.menu
|
||||
&& menu.id == id
|
||||
&& menu.effects.is_none()
|
||||
@@ -539,6 +561,7 @@ impl cosmic::Application for FereseShell {
|
||||
eprintln!("ferese-shell: popover material unavailable: {error}")
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(effects) = &menu.effects {
|
||||
let _ = effects.set_opacity(menu.progress());
|
||||
}
|
||||
@@ -566,6 +589,7 @@ impl cosmic::Application for FereseShell {
|
||||
self.status_error = update.error;
|
||||
}
|
||||
self.notifications.tick();
|
||||
|
||||
if self.notifications.ready {
|
||||
self.status.notifications = Some(status::Notifications {
|
||||
count: self.notifications.unread(),
|
||||
@@ -636,6 +660,14 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::CalendarMonth(delta) => {
|
||||
self.calendar_offset = (self.calendar_offset + delta).clamp(-1200, 1200);
|
||||
Task::none()
|
||||
}
|
||||
Message::CalendarToday => {
|
||||
self.calendar_offset = 0;
|
||||
Task::none()
|
||||
}
|
||||
Message::CancelPower => {
|
||||
if let Some(menu) = &mut self.menu {
|
||||
menu.confirm = None;
|
||||
@@ -643,6 +675,18 @@ impl cosmic::Application for FereseShell {
|
||||
Task::none()
|
||||
}
|
||||
Message::Control(action) => {
|
||||
if let status::Action::PowerProfile(profile) = action {
|
||||
self.status_error = self
|
||||
.status_service
|
||||
.send(status::Action::PowerProfile(profile))
|
||||
.err();
|
||||
if self.status_error.is_none() {
|
||||
if let Some(profiles) = &mut self.status.power_profiles {
|
||||
profiles.active = profile.to_owned();
|
||||
}
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.notifications.ready {
|
||||
match action {
|
||||
status::Action::Dnd(value) => {
|
||||
@@ -774,9 +818,11 @@ impl FereseShell {
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
|
||||
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
|
||||
config.wallpaper.path = None;
|
||||
}
|
||||
|
||||
if self.config.font_family != config.font_family {
|
||||
*SHELL_FONT
|
||||
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
|
||||
@@ -784,6 +830,7 @@ impl FereseShell {
|
||||
.unwrap() = configured_font(config.font_family.as_deref());
|
||||
}
|
||||
motion::configure(config.animations, config.theme.material_radius);
|
||||
|
||||
if self.config.animations != config.animations
|
||||
&& let Some(menu) = &mut self.menu
|
||||
{
|
||||
@@ -791,6 +838,7 @@ impl FereseShell {
|
||||
}
|
||||
self.status_service
|
||||
.update_settings(config.status.settings_command.clone());
|
||||
|
||||
let old = self.config.theme;
|
||||
let old_clock = &self.config.desktop_widgets.clock;
|
||||
let old_notes = &self.config.desktop_widgets.notes;
|
||||
@@ -826,6 +874,7 @@ impl FereseShell {
|
||||
} else {
|
||||
Task::none()
|
||||
}];
|
||||
|
||||
if clock_changed
|
||||
&& self
|
||||
.note_drag
|
||||
@@ -834,6 +883,7 @@ impl FereseShell {
|
||||
{
|
||||
tasks.push(self.finish_note_drag(false));
|
||||
}
|
||||
|
||||
if notes_changed {
|
||||
tasks.push(self.finish_note_drag(false));
|
||||
if self.note_editor.as_ref().is_some_and(|editor| {
|
||||
@@ -848,6 +898,7 @@ impl FereseShell {
|
||||
}
|
||||
tasks.push(self.rebuild_notes(true));
|
||||
}
|
||||
|
||||
for entry in self.outputs.iter().filter(|_| geometry_changed) {
|
||||
tasks.push(set_size(
|
||||
entry.bar,
|
||||
@@ -866,6 +917,7 @@ impl FereseShell {
|
||||
(theme.bar_height.round() as i32).saturating_add(theme.bar_window_gap),
|
||||
));
|
||||
}
|
||||
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
@@ -882,6 +934,7 @@ impl FereseShell {
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
|
||||
let mut tasks = vec![destroy_layer_surface(entry.bar), menu];
|
||||
if self.note_drag.as_ref().is_some_and(|drag| {
|
||||
entry.clock == Some(drag.source)
|
||||
@@ -889,20 +942,26 @@ impl FereseShell {
|
||||
}) {
|
||||
tasks.push(self.finish_note_drag(false));
|
||||
}
|
||||
|
||||
if let Some(wallpaper) = entry.wallpaper {
|
||||
tasks.push(destroy_layer_surface(wallpaper));
|
||||
}
|
||||
|
||||
if let Some(clock) = entry.clock {
|
||||
tasks.push(destroy_layer_surface(clock));
|
||||
}
|
||||
|
||||
for (_, id) in entry.notes {
|
||||
self.note_pointer.remove(&id);
|
||||
tasks.push(destroy_layer_surface(id));
|
||||
}
|
||||
|
||||
return Task::batch(tasks);
|
||||
}
|
||||
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let size = match &event {
|
||||
wayland::OutputEvent::Created(info) => info.as_ref().and_then(|info| info.logical_size),
|
||||
wayland::OutputEvent::InfoUpdate(info) => info.logical_size,
|
||||
@@ -913,20 +972,24 @@ impl FereseShell {
|
||||
wayland::OutputEvent::InfoUpdate(info) => info.name,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) {
|
||||
if size.is_some() {
|
||||
entry.size = size;
|
||||
}
|
||||
|
||||
let changed = name.is_some() && entry.name != name;
|
||||
if name.is_some() {
|
||||
entry.name = name;
|
||||
}
|
||||
|
||||
return if changed {
|
||||
Task::batch([self.rebuild_clocks(true), self.rebuild_notes(true)])
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
}
|
||||
|
||||
let bar_surface_id = window::Id::unique();
|
||||
let wallpaper_surface_id = window::Id::unique();
|
||||
let shell_theme = self.config.theme;
|
||||
@@ -1005,6 +1068,7 @@ impl FereseShell {
|
||||
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_none() {
|
||||
surfaces.push(wallpaper_action);
|
||||
}
|
||||
|
||||
let tasks = surfaces
|
||||
.into_iter()
|
||||
.map(cosmic::Action::Surface)
|
||||
@@ -1021,6 +1085,7 @@ impl FereseShell {
|
||||
if self.note_saving || self.note_pending.is_empty() {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let Some(path) = config::config_path() else {
|
||||
self.note_error = Some("Configuration path unavailable.".into());
|
||||
return Task::none();
|
||||
@@ -1109,6 +1174,7 @@ impl FereseShell {
|
||||
let output = entry.output.clone();
|
||||
let mut obstacles = Vec::new();
|
||||
let clock = &self.config.desktop_widgets.clock;
|
||||
|
||||
if id.is_some() && clock.on_output(entry.name.as_deref()) {
|
||||
let position = widget_origin(
|
||||
clock.anchor,
|
||||
@@ -1122,6 +1188,7 @@ impl FereseShell {
|
||||
cosmic::iced::Size::new(clock.width as f32, clock.height as f32),
|
||||
));
|
||||
}
|
||||
|
||||
for note in &self.config.desktop_widgets.notes {
|
||||
if Some(¬e.id) != id.as_ref() && note.on_output(entry.name.as_deref()) {
|
||||
obstacles.push(cosmic::iced::Rectangle::new(
|
||||
@@ -1130,6 +1197,7 @@ impl FereseShell {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let overlay = window::Id::unique();
|
||||
self.note_drag = Some(NoteDrag {
|
||||
id,
|
||||
@@ -1161,8 +1229,10 @@ impl FereseShell {
|
||||
size_limits: Limits::NONE,
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
Some(Box::new(Self::view_note_drag)),
|
||||
);
|
||||
|
||||
Task::batch([
|
||||
self.finish_note_edit(),
|
||||
cosmic::task::message(cosmic::Action::Surface(action)),
|
||||
@@ -1173,6 +1243,7 @@ impl FereseShell {
|
||||
let Some(drag) = &self.note_drag else {
|
||||
return text("").into();
|
||||
};
|
||||
|
||||
if let Some(id) = &drag.id {
|
||||
self.view_note_content(id, drag.source)
|
||||
} else {
|
||||
@@ -1185,6 +1256,7 @@ impl FereseShell {
|
||||
return Task::none();
|
||||
};
|
||||
let mut tasks = vec![destroy_layer_surface(drag.overlay)];
|
||||
|
||||
if save {
|
||||
if drag.id.is_none() {
|
||||
let clock = &mut self.config.desktop_widgets.clock;
|
||||
@@ -1202,6 +1274,7 @@ impl FereseShell {
|
||||
clock.margin_y,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(note) = self
|
||||
.config
|
||||
.desktop_widgets
|
||||
@@ -1212,6 +1285,7 @@ impl FereseShell {
|
||||
note.anchor = ferese_core::desktop::Anchor::TopLeft;
|
||||
note.margin_x = drag.position.x.round() as i32;
|
||||
note.margin_y = drag.position.y.round() as i32;
|
||||
|
||||
for entry in &self.outputs {
|
||||
if let Some((_, id)) = entry
|
||||
.notes
|
||||
@@ -1222,12 +1296,14 @@ impl FereseShell {
|
||||
tasks.push(set_margin(*id, note.margin_y, 0, 0, note.margin_x));
|
||||
}
|
||||
}
|
||||
|
||||
self.note_pending.push(note_store::Edit::Position(
|
||||
note.id.clone(),
|
||||
note.margin_x,
|
||||
note.margin_y,
|
||||
));
|
||||
}
|
||||
|
||||
tasks.push(self.flush_note_changes());
|
||||
}
|
||||
Task::batch(tasks)
|
||||
@@ -1235,6 +1311,7 @@ impl FereseShell {
|
||||
|
||||
fn rebuild_notes(&mut self, reset: bool) -> Task<Message> {
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for entry in &mut self.outputs {
|
||||
if reset {
|
||||
for (_, id) in entry.notes.drain(..) {
|
||||
@@ -1242,12 +1319,14 @@ impl FereseShell {
|
||||
tasks.push(destroy_layer_surface(id));
|
||||
}
|
||||
}
|
||||
|
||||
for note in &self.config.desktop_widgets.notes {
|
||||
if !note.on_output(entry.name.as_deref())
|
||||
|| entry.notes.iter().any(|(id, _)| id == ¬e.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = window::Id::unique();
|
||||
entry.notes.push((note.id.clone(), id));
|
||||
let note = note.clone();
|
||||
@@ -1281,11 +1360,14 @@ impl FereseShell {
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
|
||||
Some(Box::new(move |app| app.view_note(¬e_id, id))),
|
||||
);
|
||||
|
||||
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
|
||||
}
|
||||
}
|
||||
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
@@ -1300,6 +1382,7 @@ impl FereseShell {
|
||||
.height(Length::Fill)
|
||||
.into();
|
||||
}
|
||||
|
||||
self.view_note_content(id, surface)
|
||||
}
|
||||
|
||||
@@ -1334,7 +1417,9 @@ impl FereseShell {
|
||||
.and_then(config::parse_color)
|
||||
.unwrap_or(self.config.theme.text_primary),
|
||||
);
|
||||
|
||||
foreground.a *= note.opacity;
|
||||
|
||||
let background = if note.background.as_deref() == Some("") {
|
||||
None
|
||||
} else {
|
||||
@@ -1350,6 +1435,7 @@ impl FereseShell {
|
||||
let mut body = cosmic::widget::column([])
|
||||
.spacing(note.gap)
|
||||
.align_x(alignment);
|
||||
|
||||
if note.interactive {
|
||||
let title = cosmic::widget::text(if note.title.is_empty() {
|
||||
"⋮⋮"
|
||||
@@ -1369,6 +1455,7 @@ impl FereseShell {
|
||||
.note_editor
|
||||
.as_ref()
|
||||
.is_some_and(|editor| editor.id == id);
|
||||
|
||||
body = body.push(
|
||||
row([]).push(header).push(motion::button(
|
||||
button::custom(text(if editing { "Done" } else { "Edit" }))
|
||||
@@ -1396,6 +1483,7 @@ impl FereseShell {
|
||||
.class(theme::Text::Color(foreground)),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(editor) = &self.note_editor
|
||||
&& editor.id == id
|
||||
{
|
||||
@@ -1425,6 +1513,7 @@ impl FereseShell {
|
||||
.class(theme::Text::Color(foreground)),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(error) = &self.note_error {
|
||||
body = body.push(
|
||||
text(error.clone())
|
||||
@@ -1432,7 +1521,9 @@ impl FereseShell {
|
||||
.class(theme::Text::Color(foreground)),
|
||||
);
|
||||
}
|
||||
|
||||
let radius = self.config.theme.material_radius;
|
||||
|
||||
container(body)
|
||||
.padding(note.padding)
|
||||
.width(Length::Fill)
|
||||
@@ -1456,18 +1547,23 @@ impl FereseShell {
|
||||
.labels(&Zoned::now())
|
||||
.unwrap_or_default();
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for entry in &mut self.outputs {
|
||||
if !reset && entry.clock.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(id) = entry.clock.take() {
|
||||
self.note_pointer.remove(&id);
|
||||
tasks.push(destroy_layer_surface(id));
|
||||
}
|
||||
|
||||
let clock = self.config.desktop_widgets.clock.clone();
|
||||
|
||||
if !clock.on_output(entry.name.as_deref()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = window::Id::unique();
|
||||
entry.clock = Some(id);
|
||||
let output = entry.output.clone();
|
||||
@@ -1490,10 +1586,13 @@ impl FereseShell {
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
|
||||
Some(Box::new(move |app| app.view_desktop_clock(id))),
|
||||
);
|
||||
|
||||
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
|
||||
}
|
||||
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
@@ -1508,6 +1607,7 @@ impl FereseShell {
|
||||
.height(Length::Fill)
|
||||
.into();
|
||||
}
|
||||
|
||||
cosmic::widget::mouse_area(self.view_clock_content())
|
||||
.on_press(cosmic::Action::App(Message::BeginClockDrag(id)))
|
||||
.interaction(cosmic::iced::mouse::Interaction::Grab)
|
||||
@@ -1602,6 +1702,7 @@ impl FereseShell {
|
||||
|
||||
for (line, digits) in digits.into_iter().enumerate() {
|
||||
let mut digit_row = row([]).spacing(0);
|
||||
|
||||
for (position, digit) in digits.chars().enumerate() {
|
||||
digit_row = digit_row.push(
|
||||
cosmic::widget::text(digit.to_string())
|
||||
@@ -1611,8 +1712,10 @@ impl FereseShell {
|
||||
.class(theme::Text::Color(digit_colors[(line + position) % 2])),
|
||||
);
|
||||
}
|
||||
|
||||
stack = stack.push(digit_row);
|
||||
}
|
||||
|
||||
labels = labels.push(stack);
|
||||
} else {
|
||||
let characters = self.desktop_clock.0.chars().count().max(1) as f32;
|
||||
@@ -1629,6 +1732,7 @@ impl FereseShell {
|
||||
.time_size
|
||||
.min(available_width / (characters * 0.75))
|
||||
.min(available_height / 1.3);
|
||||
|
||||
labels = labels.push(
|
||||
cosmic::widget::text(self.desktop_clock.0.clone())
|
||||
.font(font)
|
||||
@@ -1662,6 +1766,7 @@ impl FereseShell {
|
||||
Background::Color(tint)
|
||||
});
|
||||
let radius = self.config.theme.material_radius;
|
||||
|
||||
container(labels)
|
||||
.padding(clock.padding)
|
||||
.width(Length::Fill)
|
||||
@@ -1706,9 +1811,11 @@ impl FereseShell {
|
||||
}) {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match mouse {
|
||||
cosmic::iced::mouse::Event::CursorMoved { position } => {
|
||||
self.note_pointer.insert(id, *position);
|
||||
|
||||
if let Some(drag) = &mut self.note_drag
|
||||
&& drag.source == id
|
||||
{
|
||||
@@ -1724,6 +1831,7 @@ impl FereseShell {
|
||||
let clock = &self.config.desktop_widgets.clock;
|
||||
Some((clock.width, clock.height))
|
||||
};
|
||||
|
||||
if let Some(dimensions) = dimensions {
|
||||
let requested = clamp_note_position(
|
||||
drag.origin + delta,
|
||||
@@ -1748,6 +1856,7 @@ impl FereseShell {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left) => {
|
||||
if self
|
||||
.note_drag
|
||||
@@ -1770,13 +1879,16 @@ impl FereseShell {
|
||||
if self.note_drag.is_some() {
|
||||
return self.finish_note_drag(false);
|
||||
}
|
||||
|
||||
if self.note_editor.is_some() {
|
||||
return self.finish_note_edit();
|
||||
}
|
||||
|
||||
if self.notifications.history_open {
|
||||
return self.close_notification_history();
|
||||
}
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(
|
||||
event,
|
||||
@@ -1800,6 +1912,7 @@ impl FereseShell {
|
||||
let _ = effects.set_opacity(menu.progress());
|
||||
}
|
||||
}
|
||||
|
||||
window::run(id, native_wayland_surface)
|
||||
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface)))
|
||||
}
|
||||
@@ -1978,6 +2091,7 @@ impl FereseShell {
|
||||
let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len())
|
||||
.spacing(1)
|
||||
.align_y(cosmic::iced::Alignment::Center);
|
||||
|
||||
for workspace in &self.snapshot.workspaces {
|
||||
let active = workspace_active_on_bar(workspace.id, focused_output);
|
||||
let owner = workspace
|
||||
@@ -2040,9 +2154,28 @@ impl FereseShell {
|
||||
let (date, time) = self.clock.split_once(", ").unwrap_or(("", &self.clock));
|
||||
let clock = container(
|
||||
row![
|
||||
text(date)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(color(shell_theme.text_muted))),
|
||||
motion::button(
|
||||
button::custom(text(date).size(12))
|
||||
.padding([4, 2])
|
||||
.name("Open calendar")
|
||||
.on_press_with_rectangle(move |offset, bounds| {
|
||||
cosmic::Action::App(Message::OpenMenuOn(
|
||||
id,
|
||||
status_ui::Menu::Calendar,
|
||||
cosmic::iced::Rectangle {
|
||||
x: (bounds.x - offset.x).round() as i32,
|
||||
y: (bounds.y - offset.y).round() as i32,
|
||||
width: bounds.width.round() as i32,
|
||||
height: bounds.height.round() as i32,
|
||||
},
|
||||
))
|
||||
}),
|
||||
color(shell_theme.text_muted),
|
||||
self.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.kind == status_ui::Menu::Calendar),
|
||||
1.0
|
||||
),
|
||||
text(time)
|
||||
.size(bar.text_size)
|
||||
.class(theme::Text::Color(foreground)),
|
||||
|
||||
@@ -48,6 +48,7 @@ impl Recorder {
|
||||
pub fn busy(&self) -> bool {
|
||||
self.updates.is_some()
|
||||
}
|
||||
|
||||
pub fn elapsed(&self) -> Option<String> {
|
||||
let State::Recording(start) = self.state else {
|
||||
return None;
|
||||
@@ -55,15 +56,18 @@ impl Recorder {
|
||||
let seconds = start.elapsed().as_secs();
|
||||
Some(format!("{:02}:{:02}", seconds / 60, seconds % 60))
|
||||
}
|
||||
|
||||
pub fn start(&mut self) {
|
||||
if self.busy() {
|
||||
return;
|
||||
}
|
||||
|
||||
let program = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|p| p.join("ferese-record")))
|
||||
.filter(|p| p.is_file())
|
||||
.unwrap_or_else(|| PathBuf::from("ferese-record"));
|
||||
|
||||
match Command::new(program)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -92,6 +96,7 @@ impl Recorder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = child.wait();
|
||||
let _ = send.send(Event::Exited);
|
||||
});
|
||||
@@ -103,6 +108,7 @@ impl Recorder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
if self.busy() && !matches!(self.state, State::Saving) {
|
||||
// EOF requests EOS/finalization. Never terminate the encoder abruptly.
|
||||
@@ -110,6 +116,7 @@ impl Recorder {
|
||||
self.state = State::Saving;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll(&mut self) {
|
||||
let mut done = false;
|
||||
if let Some(updates) = &self.updates {
|
||||
@@ -146,6 +153,7 @@ impl Recorder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if done {
|
||||
match &self.state {
|
||||
State::Saved(path) => notify("Recording saved", path.display().to_string()),
|
||||
|
||||
@@ -21,6 +21,7 @@ pub struct Snapshot {
|
||||
pub bluetooth: Option<Bluetooth>,
|
||||
pub audio: Option<Audio>,
|
||||
pub battery: Option<Battery>,
|
||||
pub power_profiles: Option<PowerProfiles>,
|
||||
pub brightness: Option<u8>,
|
||||
pub notifications: Option<Notifications>,
|
||||
pub poweroff: bool,
|
||||
@@ -54,6 +55,12 @@ pub struct Battery {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PowerProfiles {
|
||||
pub active: String,
|
||||
pub available: [bool; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Notifications {
|
||||
pub count: u32,
|
||||
@@ -73,6 +80,7 @@ pub enum Action {
|
||||
Poweroff,
|
||||
Reboot,
|
||||
Suspend,
|
||||
PowerProfile(&'static str),
|
||||
}
|
||||
|
||||
impl Action {
|
||||
@@ -143,11 +151,13 @@ impl Service {
|
||||
Condvar::new(),
|
||||
));
|
||||
let polling = shared.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let mut system_bus = StatusBus::new(true);
|
||||
let mut session_bus = StatusBus::new(false);
|
||||
let mut network = dbus_cache::Cache::new("org.freedesktop.NetworkManager");
|
||||
let mut bluetooth = dbus_cache::Cache::new("org.bluez");
|
||||
|
||||
loop {
|
||||
// A poll overlapping a write is discarded below. Wait for the
|
||||
// write to finish before retrying instead of launching commands
|
||||
@@ -159,9 +169,11 @@ impl Service {
|
||||
snapshot.network = network.read(before, network::read);
|
||||
snapshot.bluetooth = bluetooth.read(before, bluetooth::read);
|
||||
let state = polling.0.lock().unwrap();
|
||||
|
||||
if state.3 {
|
||||
break;
|
||||
}
|
||||
|
||||
if state.0 == before && !state.1 {
|
||||
updates.send_replace(Some(Update {
|
||||
snapshot,
|
||||
@@ -172,6 +184,7 @@ impl Service {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
thread::spawn(move || {
|
||||
loop {
|
||||
let first = match commands.recv() {
|
||||
@@ -200,6 +213,7 @@ impl Service {
|
||||
shared.0.lock().unwrap().3 = true;
|
||||
shared.1.notify_one();
|
||||
});
|
||||
|
||||
Self {
|
||||
settings: live_settings,
|
||||
tx,
|
||||
@@ -214,6 +228,7 @@ impl Service {
|
||||
.try_send((generation, action))
|
||||
.map_err(|_| "Controls are busy; please try again".to_owned())?;
|
||||
self.generation = generation;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -228,13 +243,16 @@ impl Service {
|
||||
|
||||
pub fn available(program: &str) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let executable = |p: &Path| {
|
||||
p.metadata()
|
||||
.is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||
};
|
||||
|
||||
if program.contains('/') {
|
||||
return executable(Path::new(program));
|
||||
}
|
||||
|
||||
env::var_os("PATH")
|
||||
.is_some_and(|paths| env::split_paths(&paths).any(|path| executable(&path.join(program))))
|
||||
}
|
||||
@@ -243,6 +261,7 @@ fn run(program: &str, args: &[&str]) -> Result<String, String> {
|
||||
if !available(program) {
|
||||
return Err(format!("{program} is not installed"));
|
||||
}
|
||||
|
||||
// Coreutils timeout bounds disconnected D-Bus services, too. Never invoke a shell.
|
||||
let output = Command::new("timeout")
|
||||
.args(["--kill-after=1s", "2s", program])
|
||||
@@ -251,6 +270,7 @@ fn run(program: &str, args: &[&str]) -> Result<String, String> {
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stderr)
|
||||
.trim()
|
||||
@@ -263,6 +283,7 @@ fn run(program: &str, args: &[&str]) -> Result<String, String> {
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
@@ -291,12 +312,14 @@ impl StatusBus {
|
||||
} else {
|
||||
zbus::blocking::connection::Builder::session()
|
||||
};
|
||||
|
||||
self.connection = builder
|
||||
.ok()?
|
||||
.method_timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.ok();
|
||||
}
|
||||
|
||||
match query(self.connection.as_ref()?) {
|
||||
Ok(value) => Some(value),
|
||||
Err(_) => {
|
||||
@@ -346,6 +369,7 @@ fn poll(system_bus: &mut StatusBus, session_bus: &mut StatusBus) -> Snapshot {
|
||||
bluetooth: None,
|
||||
audio: audio(),
|
||||
battery: battery(),
|
||||
power_profiles: power_profiles(system_bus),
|
||||
brightness: brightness(),
|
||||
notifications: notifications(session_bus),
|
||||
poweroff: system_bus.can_power("CanPowerOff"),
|
||||
@@ -361,9 +385,11 @@ pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
|
||||
.next()?
|
||||
.parse::<f32>()
|
||||
.ok()?;
|
||||
|
||||
if !volume.is_finite() || volume < 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((
|
||||
(volume * 100.0).round().clamp(0.0, 100.0) as u8,
|
||||
value.contains("[MUTED]"),
|
||||
@@ -379,6 +405,7 @@ fn audio() -> Option<Audio> {
|
||||
.find_map(|line| line.trim().strip_prefix("node.description = "))
|
||||
.map(|s| s.trim_matches('"').to_owned())
|
||||
.unwrap_or_else(|| "Default output".into());
|
||||
|
||||
Some(Audio {
|
||||
volume,
|
||||
muted,
|
||||
@@ -403,6 +430,7 @@ fn battery() -> Option<Battery> {
|
||||
&& read(p, "scope").as_deref() != Some("Device")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
batteries.sort();
|
||||
batteries.iter().find_map(|path| {
|
||||
Some(Battery {
|
||||
@@ -412,6 +440,46 @@ fn battery() -> Option<Battery> {
|
||||
})
|
||||
}
|
||||
|
||||
fn power_profiles(bus: &mut StatusBus) -> Option<PowerProfiles> {
|
||||
bus.query(|connection| {
|
||||
read_power_profiles(
|
||||
connection,
|
||||
"org.freedesktop.UPower.PowerProfiles",
|
||||
"/org/freedesktop/UPower/PowerProfiles",
|
||||
)
|
||||
.or_else(|_| {
|
||||
read_power_profiles(
|
||||
connection,
|
||||
"net.hadess.PowerProfiles",
|
||||
"/net/hadess/PowerProfiles",
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn read_power_profiles(
|
||||
connection: &zbus::blocking::Connection,
|
||||
destination: &str,
|
||||
path: &str,
|
||||
) -> zbus::Result<PowerProfiles> {
|
||||
let proxy = zbus::blocking::Proxy::new(connection, destination, path, destination)?;
|
||||
let active = proxy.get_property::<String>("ActiveProfile")?;
|
||||
let profiles = proxy
|
||||
.get_property::<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>(
|
||||
"Profiles",
|
||||
)?;
|
||||
let names = ["power-saver", "balanced", "performance"];
|
||||
let available = names.map(|name| {
|
||||
profiles.iter().any(|profile| {
|
||||
profile
|
||||
.get("Profile")
|
||||
.and_then(|value| String::try_from(value.clone()).ok())
|
||||
.is_some_and(|value| value == name)
|
||||
})
|
||||
});
|
||||
Ok(PowerProfiles { active, available })
|
||||
}
|
||||
|
||||
fn brightness() -> Option<u8> {
|
||||
parse_brightness(
|
||||
&run(
|
||||
@@ -490,6 +558,12 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
|
||||
&[if *on { "--dnd-on" } else { "--dnd-off" }],
|
||||
),
|
||||
Action::Notifications => run("swaync-client", &["--open-panel"]),
|
||||
Action::PowerProfile(profile)
|
||||
if matches!(*profile, "power-saver" | "balanced" | "performance") =>
|
||||
{
|
||||
run("powerprofilesctl", &["set", profile])
|
||||
}
|
||||
Action::PowerProfile(_) => return Err("Invalid power profile".to_owned()),
|
||||
Action::Poweroff => run("systemctl", &["poweroff"]),
|
||||
Action::Reboot => run("systemctl", &["reboot"]),
|
||||
Action::Suspend => run("systemctl", &["suspend"]),
|
||||
@@ -523,8 +597,10 @@ mod tests {
|
||||
|
||||
pub(super) struct TestBus {
|
||||
daemon: std::process::Child,
|
||||
|
||||
pub(super) address: String,
|
||||
}
|
||||
|
||||
impl TestBus {
|
||||
pub(super) fn new() -> Self {
|
||||
use std::io::{BufRead, BufReader};
|
||||
@@ -534,14 +610,17 @@ mod tests {
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut address = String::new();
|
||||
|
||||
BufReader::new(daemon.stdout.take().unwrap())
|
||||
.read_line(&mut address)
|
||||
.unwrap();
|
||||
|
||||
Self {
|
||||
daemon,
|
||||
address: address.trim().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn connect(&self) -> zbus::blocking::Connection {
|
||||
zbus::blocking::connection::Builder::address(self.address.as_str())
|
||||
.unwrap()
|
||||
@@ -550,6 +629,7 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestBus {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.daemon.kill();
|
||||
@@ -557,6 +637,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a running Power Profiles D-Bus service"]
|
||||
fn reads_available_power_profiles_from_system_bus() {
|
||||
let mut bus = StatusBus::new(true);
|
||||
let profiles = power_profiles(&mut bus).expect("Power Profiles service is available");
|
||||
let names = ["power-saver", "balanced", "performance"];
|
||||
assert!(
|
||||
names
|
||||
.iter()
|
||||
.zip(profiles.available)
|
||||
.any(|(name, available)| { available && profiles.active == *name })
|
||||
);
|
||||
}
|
||||
|
||||
struct MockLogin1(Arc<std::sync::atomic::AtomicU8>);
|
||||
|
||||
#[zbus::interface(name = "org.freedesktop.login1.Manager")]
|
||||
|
||||
@@ -16,6 +16,7 @@ pub enum Menu {
|
||||
Bluetooth,
|
||||
Audio,
|
||||
Battery,
|
||||
Calendar,
|
||||
Recording,
|
||||
Notifications,
|
||||
System,
|
||||
@@ -25,7 +26,8 @@ impl Menu {
|
||||
fn width(self) -> f32 {
|
||||
match self {
|
||||
Self::System => 360.0,
|
||||
Self::Battery => 280.0,
|
||||
Self::Battery => 328.0,
|
||||
Self::Calendar => 310.0,
|
||||
Self::Notifications => 368.0,
|
||||
_ => 300.0,
|
||||
}
|
||||
@@ -42,6 +44,7 @@ impl Menu {
|
||||
Self::Bluetooth => "Bluetooth",
|
||||
Self::Audio => "Sound",
|
||||
Self::Battery => "Battery",
|
||||
Self::Calendar => "Calendar",
|
||||
Self::Recording => "Screen recording",
|
||||
Self::Notifications => "Notifications",
|
||||
}
|
||||
@@ -53,8 +56,9 @@ impl Menu {
|
||||
Self::Bluetooth => status.bluetooth.is_some(),
|
||||
Self::Audio => status.audio.is_some(),
|
||||
Self::Battery => status.battery.is_some(),
|
||||
Self::Calendar | Self::Recording => true,
|
||||
Self::Notifications => status.notifications.is_some(),
|
||||
Self::System | Self::Recording => true,
|
||||
Self::System => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +93,9 @@ impl FereseShell {
|
||||
}
|
||||
return self.close_menu();
|
||||
}
|
||||
if kind == Menu::Calendar {
|
||||
self.calendar_offset = 0;
|
||||
}
|
||||
let destroy = self.destroy_menu();
|
||||
if !kind.available(&self.status) {
|
||||
return destroy;
|
||||
@@ -451,6 +458,8 @@ impl FereseShell {
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
} else if kind == Menu::Calendar {
|
||||
rows = rows.push(calendar_grid(self.calendar_offset, theme, p));
|
||||
} else {
|
||||
let combined = menu.kind == Menu::System;
|
||||
if combined {
|
||||
@@ -717,6 +726,80 @@ impl FereseShell {
|
||||
);
|
||||
}
|
||||
}
|
||||
if menu.kind == Menu::Battery {
|
||||
rows = rows.push(text("Power mode").size(13).class(theme::Text::Color(muted)));
|
||||
if let Some(profiles) = &self.status.power_profiles {
|
||||
let mut modes = row::with_capacity(3).spacing(6).width(Length::Fill);
|
||||
for (index, (label, icon, profile)) in [
|
||||
(
|
||||
"Battery saver",
|
||||
include_bytes!("../assets/icons/status/power-saver.svg").as_slice(),
|
||||
"power-saver",
|
||||
),
|
||||
(
|
||||
"Balanced",
|
||||
include_bytes!("../assets/icons/status/power-balanced.svg").as_slice(),
|
||||
"balanced",
|
||||
),
|
||||
(
|
||||
"Performance",
|
||||
include_bytes!("../assets/icons/status/power-performance.svg")
|
||||
.as_slice(),
|
||||
"performance",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if profiles.available[index] {
|
||||
modes = modes.push(power_tile(
|
||||
label,
|
||||
icon,
|
||||
profile,
|
||||
profiles.active == profile,
|
||||
theme,
|
||||
p,
|
||||
));
|
||||
}
|
||||
}
|
||||
rows = rows.push(modes);
|
||||
} else {
|
||||
rows = rows.push(
|
||||
text("Power Profiles service unavailable")
|
||||
.size(12)
|
||||
.class(theme::Text::Color(muted)),
|
||||
);
|
||||
}
|
||||
let mut power = row::with_capacity(3).spacing(6).width(Length::Fill);
|
||||
for (enabled, label, icon, action) in [
|
||||
(
|
||||
self.status.reboot,
|
||||
"Restart",
|
||||
include_bytes!("../assets/icons/status/restart.svg").as_slice(),
|
||||
Action::Reboot,
|
||||
),
|
||||
(
|
||||
self.status.poweroff,
|
||||
"Power off",
|
||||
include_bytes!("../assets/icons/status/power-off.svg").as_slice(),
|
||||
Action::Poweroff,
|
||||
),
|
||||
(
|
||||
self.status.suspend,
|
||||
"Suspend",
|
||||
include_bytes!("../assets/icons/status/suspend.svg").as_slice(),
|
||||
Action::Suspend,
|
||||
),
|
||||
] {
|
||||
if enabled {
|
||||
power = power.push(power_tile_action(label, icon, action, theme, p));
|
||||
}
|
||||
}
|
||||
if self.status.reboot || self.status.poweroff || self.status.suspend {
|
||||
rows = rows.push(text("Power").size(13).class(theme::Text::Color(muted)));
|
||||
rows = rows.push(power);
|
||||
}
|
||||
}
|
||||
if combined {
|
||||
let mut actions = column::with_capacity(2).spacing(8);
|
||||
let mut power = row::with_capacity(3).spacing(6).width(Length::Fill);
|
||||
@@ -1002,6 +1085,155 @@ fn connection_control<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
fn power_tile<'a>(
|
||||
label: &'static str,
|
||||
icon: &'static [u8],
|
||||
profile: &'static str,
|
||||
selected: bool,
|
||||
theme: ShellTheme,
|
||||
opacity: f32,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
let foreground = color_with_opacity(
|
||||
if selected {
|
||||
theme.accent
|
||||
} else {
|
||||
theme.text_primary
|
||||
},
|
||||
opacity,
|
||||
);
|
||||
let tile = column![bar_icon(icon, 19, foreground), text(label).size(11)]
|
||||
.spacing(3)
|
||||
.align_x(Alignment::Center);
|
||||
motion::button(
|
||||
button::custom(tile)
|
||||
.width(Length::FillPortion(1))
|
||||
.padding([8, 2])
|
||||
.on_press(cosmic::Action::App(Message::Control(Action::PowerProfile(
|
||||
profile,
|
||||
)))),
|
||||
foreground,
|
||||
selected,
|
||||
opacity,
|
||||
)
|
||||
}
|
||||
|
||||
fn power_tile_action<'a>(
|
||||
label: &'static str,
|
||||
icon: &'static [u8],
|
||||
action: Action,
|
||||
theme: ShellTheme,
|
||||
opacity: f32,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
let foreground = color_with_opacity(theme.text_primary, opacity);
|
||||
motion::button(
|
||||
button::custom(
|
||||
column![bar_icon(icon, 19, foreground), text(label).size(11)]
|
||||
.spacing(3)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.width(Length::FillPortion(1))
|
||||
.padding([8, 2])
|
||||
.on_press(cosmic::Action::App(Message::ConfirmPower(action))),
|
||||
foreground,
|
||||
false,
|
||||
opacity,
|
||||
)
|
||||
}
|
||||
|
||||
fn calendar_month(today: jiff::civil::Date, offset: i32) -> (jiff::civil::Date, usize, usize) {
|
||||
let month_index = i32::from(today.year()) * 12 + i32::from(today.month()) - 1 + offset;
|
||||
let year = month_index.div_euclid(12) as i16;
|
||||
let month = (month_index.rem_euclid(12) + 1) as i8;
|
||||
let first = jiff::civil::Date::new(year, month, 1).expect("calendar month is in range");
|
||||
let start = usize::try_from(first.weekday().since(jiff::civil::Weekday::Monday)).unwrap_or(0);
|
||||
let days = usize::try_from(first.days_in_month()).unwrap_or(31);
|
||||
(first, start, days)
|
||||
}
|
||||
|
||||
fn calendar_grid<'a>(
|
||||
offset: i32,
|
||||
theme: ShellTheme,
|
||||
opacity: f32,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
let today = Zoned::now().date();
|
||||
let months = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
let (first, start, days) = calendar_month(today, offset);
|
||||
let year = first.year();
|
||||
let month = first.month();
|
||||
let primary = color_with_opacity(theme.text_primary, opacity);
|
||||
let muted = color_with_opacity(theme.text_muted, opacity);
|
||||
let header = row![
|
||||
text(format!("{} {year}", months[(month - 1) as usize]))
|
||||
.size(15)
|
||||
.width(Length::Fill),
|
||||
menu_button("‹", Message::CalendarMonth(-1), primary, opacity),
|
||||
menu_button("Today", Message::CalendarToday, primary, opacity),
|
||||
menu_button("›", Message::CalendarMonth(1), primary, opacity),
|
||||
]
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(2);
|
||||
let mut grid = column::with_capacity(7).spacing(2).push(header);
|
||||
let mut weekdays = row::with_capacity(7).spacing(2);
|
||||
for label in ["M", "T", "W", "T", "F", "S", "S"] {
|
||||
weekdays = weekdays.push(
|
||||
container(text(label).size(11).class(theme::Text::Color(muted)))
|
||||
.width(Length::FillPortion(1))
|
||||
.center_x(Length::Fill),
|
||||
);
|
||||
}
|
||||
grid = grid.push(weekdays);
|
||||
for week in 0..(start + days).div_ceil(7) {
|
||||
let mut dates = row::with_capacity(7).spacing(2);
|
||||
for weekday in 0..7 {
|
||||
let cell = week * 7 + weekday;
|
||||
let day = cell
|
||||
.checked_sub(start)
|
||||
.map(|day| day + 1)
|
||||
.filter(|day| *day <= days);
|
||||
let is_today =
|
||||
year == today.year() && month == today.month() && day == Some(today.day() as usize);
|
||||
let label = day.map_or(String::new(), |day| day.to_string());
|
||||
dates = dates.push(
|
||||
container(text(label).size(12).class(theme::Text::Color(if is_today {
|
||||
primary
|
||||
} else {
|
||||
muted
|
||||
})))
|
||||
.width(Length::FillPortion(1))
|
||||
.height(27)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: is_today.then_some(Background::Color(color_with_opacity(
|
||||
theme.accent,
|
||||
0.2 * opacity,
|
||||
))),
|
||||
border: Border {
|
||||
radius: motion::radius(12.0).into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})),
|
||||
);
|
||||
}
|
||||
grid = grid.push(dates);
|
||||
}
|
||||
grid.into()
|
||||
}
|
||||
|
||||
fn menu_button<'a>(
|
||||
label: &'a str,
|
||||
message: Message,
|
||||
@@ -1146,11 +1378,12 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
|
||||
Some(n) => format!("Notifications: {} unread", n.count),
|
||||
_ => "Notifications: unavailable".into(),
|
||||
},
|
||||
Menu::Calendar => "Calendar".into(),
|
||||
Menu::Recording => "Screen recording".into(),
|
||||
Menu::Battery => s.battery.as_ref().map_or_else(
|
||||
|| "Battery: unavailable".into(),
|
||||
|b| format!("Battery: {}%, {}", b.percent, b.status),
|
||||
),
|
||||
Menu::Recording => "Screen recording".into(),
|
||||
Menu::System => "Control Center".into(),
|
||||
}
|
||||
}
|
||||
@@ -1203,6 +1436,8 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
|
||||
Menu::Audio => s.audio.as_ref().map_or((audio_icon(0, true), false), |a| {
|
||||
(audio_icon(a.volume, a.muted), !a.muted && a.volume > 0)
|
||||
}),
|
||||
Menu::Calendar => (include_bytes!("../assets/icons/status/calendar.svg"), true),
|
||||
Menu::Recording => (include_bytes!("../assets/icons/status/record.svg"), true),
|
||||
Menu::Battery => {
|
||||
let Some(b) = &s.battery else {
|
||||
return (
|
||||
@@ -1238,7 +1473,6 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
|
||||
)
|
||||
}
|
||||
}
|
||||
Menu::Recording => (include_bytes!("../assets/icons/status/record.svg"), true),
|
||||
Menu::System => (
|
||||
include_bytes!("../assets/icons/status/control-center.svg"),
|
||||
true,
|
||||
@@ -1248,12 +1482,28 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn calendar_aligns_leap_months_and_year_boundaries() {
|
||||
let february = jiff::civil::Date::new(2024, 2, 15).unwrap();
|
||||
let (first, start, days) = calendar_month(february, 0);
|
||||
assert_eq!((first.year(), first.month(), start, days), (2024, 2, 3, 29));
|
||||
let (first, start, days) = calendar_month(february, -2);
|
||||
assert_eq!(
|
||||
(first.year(), first.month(), start, days),
|
||||
(2023, 12, 4, 31)
|
||||
);
|
||||
let june = jiff::civil::Date::new(2025, 6, 1).unwrap();
|
||||
let (_, start, days) = calendar_month(june, 0);
|
||||
assert_eq!((start, days), (6, 30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smaller_popups_have_content_specific_widths() {
|
||||
assert_eq!(Menu::System.width(), 360.0);
|
||||
assert_eq!(Menu::Network.width(), 300.0);
|
||||
assert_eq!(Menu::Bluetooth.width(), 300.0);
|
||||
assert_eq!(Menu::Battery.width(), 280.0);
|
||||
assert_eq!(Menu::Battery.width(), 328.0);
|
||||
assert_eq!(Menu::Calendar.width(), 310.0);
|
||||
assert_eq!(Menu::Audio.width(), 300.0);
|
||||
assert_eq!(Menu::Notifications.width(), 368.0);
|
||||
}
|
||||
|
||||