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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,54 @@ jobs:
name: deb
path: dist/*.deb

rpm:
name: Build .rpm (Fedora)
needs: [prepare, build]
runs-on: ubuntu-latest
container: fedora:latest
permissions:
contents: read
steps:
- name: Install git (required by actions/checkout)
run: dnf install -y git tar

- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.sha }}

- name: Install packaging dependencies
run: |
dnf install -y rpm-build rpmdevtools gtk4-devel vte291-gtk4-devel \
libssh2-devel openssl-devel zlib-devel ImageMagick

- name: Download prebuilt generic binary
uses: actions/download-artifact@v8
with:
name: binary-x86_64
path: prebuilt

- name: Stage binary for packaging
run: |
mkdir -p target/release
install -m755 prebuilt/rustmius-x86_64 target/release/rustmius

- name: Build .rpm package
env:
SKIP_BUILD: "1"
run: ./packages/rpm/build-rpm.sh

- name: Upload .rpm artifact
uses: actions/upload-artifact@v7
with:
name: rpm
path: |
dist/*.rpm
!dist/*.src.rpm

release:
name: Create Release
needs: [prepare, build, deb]
needs: [prepare, build, deb, rpm]
runs-on: ubuntu-latest
permissions:
contents: write
Expand All @@ -169,6 +214,12 @@ jobs:
name: deb
path: artifacts

- name: Download .rpm artifact
uses: actions/download-artifact@v8
with:
name: rpm
path: artifacts

- name: Release
uses: softprops/action-gh-release@v3
with:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ packages/**/src/
packages/**/*.tar.gz
packages/**/*.tar.zst
packages/**/*.pkg.tar.*
packages/**/*.rpm
rpmbuild/
59 changes: 59 additions & 0 deletions packages/rpm/build-rpm.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
#
# Build a .rpm package for Rustmius (Fedora / RHEL / openSUSE family).
#
# Usage:
# packages/rpm/build-rpm.sh # builds release binary if missing, then packages
# SKIP_BUILD=1 packages/rpm/build-rpm.sh # reuse an existing target/release/rustmius
#
# Output: dist/rustmius-<version>-<release>.<arch>.rpm (in the repo root)
#
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$REPO_ROOT"

PKG_NAME="rustmius"
VERSION="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"

command -v rpmbuild >/dev/null 2>&1 || {
echo "!! rpmbuild not found. Install it with: sudo dnf install rpm-build rpmdevtools" >&2
exit 1
}

echo ">> Packaging $PKG_NAME $VERSION (.rpm)"

# Private rpmbuild tree so this never touches ~/rpmbuild.
TOPDIR="$(mktemp -d)"
STAGE="$(mktemp -d)"
trap 'rm -rf "$TOPDIR" "$STAGE"' EXIT
mkdir -p "$TOPDIR"/{BUILD,RPMS,SOURCES,SPECS,SRPMS}

# Snapshot the working tree (including uncommitted edits, like build-deb.sh
# building straight from target/release) into a source tarball. Name matches
# the spec's %autosetup -n rustmius-$VERSION.
SRC_DIR="$STAGE/$PKG_NAME-$VERSION"
mkdir -p "$SRC_DIR"
tar --exclude=.git --exclude=target --exclude=dist -cf - -- * | tar -xf - -C "$SRC_DIR"

# With SKIP_BUILD=1, carry the already-built binary into the tarball so the
# spec's %build step can skip its own `cargo build` (mirrors build-deb.sh).
if [[ "${SKIP_BUILD:-0}" == "1" && -x "target/release/$PKG_NAME" ]]; then
echo ">> Reusing prebuilt target/release/$PKG_NAME (SKIP_BUILD=1)"
install -Dm755 "target/release/$PKG_NAME" "$SRC_DIR/target/release/$PKG_NAME"
fi

tar -czf "$TOPDIR/SOURCES/$PKG_NAME-$VERSION.tar.gz" -C "$STAGE" "$PKG_NAME-$VERSION"

cp "packages/rpm/$PKG_NAME.spec" "$TOPDIR/SPECS/"

SKIP_BUILD="${SKIP_BUILD:-0}" rpmbuild --define "_topdir $TOPDIR" -ba "$TOPDIR/SPECS/$PKG_NAME.spec"

mkdir -p dist
find "$TOPDIR/RPMS" -name '*.rpm' -exec cp -v {} dist/ \;
find "$TOPDIR/SRPMS" -name '*.rpm' -exec cp -v {} dist/ \;

echo ""
echo ">> Built RPM(s) in dist/:"
ls -1 dist/*.rpm
97 changes: 97 additions & 0 deletions packages/rpm/rustmius.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Release binaries are built with `strip = "symbols"` (see [profile.release]
# in Cargo.toml) across every packaging channel (deb, AUR, GitHub releases),
# so there is no debug info here for Fedora's debuginfo/debugsource split to
# extract in the first place.
%global debug_package %{nil}

Name: rustmius
Version: 2.5.0
Release: 4%{?dist}
Summary: Local Termius alternative for Linux (GTK4)

License: AGPL-3.0-or-later
URL: https://github.com/Cleboost/Rustmius
# Generated locally from the working tree, see packages/rpm/build-rpm.sh
Source0: %{name}-%{version}.tar.gz

BuildRequires: cargo
BuildRequires: rust
BuildRequires: gcc
BuildRequires: desktop-file-utils
BuildRequires: pkgconfig(gtk4) >= 4.12
BuildRequires: pkgconfig(vte-2.91-gtk4)
BuildRequires: pkgconfig(libssh2)
BuildRequires: pkgconfig(openssl)
BuildRequires: pkgconfig(zlib)

# Runtime deps are also picked up automatically from the linked binary
# (soname-based Requires), these just make the intent explicit.
Requires: gtk4%{?_isa} >= 4.12
Requires: vte291-gtk4%{?_isa}
Requires: hicolor-icon-theme

%description
Rustmius is a modern, fast, and fully local alternative to Termius,
built with Rust and GTK4. It provides an integrated SSH terminal
(via VTE), an advanced SFTP explorer with drag & drop, a host
manager, and secure secret storage through the system keyring.

%prep
%autosetup -n %{name}-%{version}

%build
if [ "${SKIP_BUILD:-0}" = "1" ] && [ -x target/release/%{name} ]; then
echo ">> Reusing prebuilt %{name} binary (SKIP_BUILD=1)"
else
cargo build --release --locked
fi

%install
install -Dm0755 target/release/%{name} %{buildroot}%{_bindir}/%{name}

install -Dm0644 packages/org.rustmius.Rustmius.desktop \
%{buildroot}%{_datadir}/applications/org.rustmius.Rustmius.desktop

install -dm0755 %{buildroot}%{_datadir}/icons/hicolor/512x512/apps
if command -v magick >/dev/null 2>&1; then
magick packages/rustmius.png -resize 512x512 \
%{buildroot}%{_datadir}/icons/hicolor/512x512/apps/%{name}.png
elif command -v convert >/dev/null 2>&1; then
convert packages/rustmius.png -resize 512x512 \
%{buildroot}%{_datadir}/icons/hicolor/512x512/apps/%{name}.png
else
install -m0644 packages/rustmius.png \
%{buildroot}%{_datadir}/icons/hicolor/512x512/apps/%{name}.png
fi

install -dm0755 %{buildroot}%{_mandir}/man1
sed "s/@VERSION@/%{version}/g" packages/deb/rustmius.1 \
> %{buildroot}%{_mandir}/man1/%{name}.1

%check
desktop-file-validate %{buildroot}%{_datadir}/applications/org.rustmius.Rustmius.desktop

%files
%license LICENSE
%doc README.md
%{_bindir}/%{name}
%{_datadir}/applications/org.rustmius.Rustmius.desktop
%{_datadir}/icons/hicolor/512x512/apps/%{name}.png
%{_mandir}/man1/%{name}.1*

%changelog
* Wed Aug 12 2026 Subhan Gadirli <subhanqedirli@protonmail.com> - 2.5.0-4
- Support SKIP_BUILD=1 to reuse a prebuilt binary (used by CI to avoid
recompiling for each package format).

* Wed Aug 12 2026 Subhan Gadirli <subhanqedirli@protonmail.com> - 2.5.0-3
- Fix dark-mode sync: xdg-desktop-portal-gnome double-wraps the
Settings.Read/SettingChanged value in an extra GVariant "v" layer,
which needs peeling before reading the color-scheme uint32.

* Wed Aug 12 2026 Subhan Gadirli <subhanqedirli@protonmail.com> - 2.5.0-2
- Sync app light/dark mode with the system preference via the XDG
Desktop Portal (plain GTK4 does not do this on its own).

* Wed Aug 12 2026 Subhan Gadirli <subhanqedirli@protonmail.com> - 2.5.0-1
- Initial RPM packaging for Fedora.
96 changes: 96 additions & 0 deletions src/ui/style.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,101 @@
use gtk4::CssProvider;
use gtk4::gdk;
use gtk4::gio;
use gtk4::gio::prelude::*;

const PORTAL_BUS_NAME: &str = "org.freedesktop.portal.Desktop";
const PORTAL_OBJECT_PATH: &str = "/org/freedesktop/portal/desktop";
const PORTAL_SETTINGS_IFACE: &str = "org.freedesktop.portal.Settings";
const APPEARANCE_NAMESPACE: &str = "org.freedesktop.appearance";
const COLOR_SCHEME_KEY: &str = "color-scheme";

/// Peels away GVariant `v` (variant) wrapper layers until a non-variant value
/// is reached. Some portal backends (notably xdg-desktop-portal-gnome) wrap
/// the `Read`/`SettingChanged` payload in an extra variant layer on top of
/// the `v` already mandated by the portal spec, so a single unwrap isn't
/// always enough.
fn unwrap_variant(mut value: gtk4::glib::Variant) -> gtk4::glib::Variant {
while let Some(inner) = value.as_variant() {
value = inner;
}
value
}

/// Maps the portal's `color-scheme` value (0 = no preference, 1 = prefer
/// dark, 2 = prefer light) to a `prefer-dark-theme` bool, if it expresses one.
fn color_scheme_prefers_dark(value: u32) -> Option<bool> {
match value {
1 => Some(true),
2 => Some(false),
_ => None,
}
}

fn apply_color_scheme(value: u32) {
tracing::debug!(value, "portal color-scheme received");
match color_scheme_prefers_dark(value) {
Some(prefer_dark) => match gtk4::Settings::default() {
Some(settings) => {
settings.set_gtk_application_prefer_dark_theme(prefer_dark);
tracing::debug!(
prefer_dark,
now = settings.is_gtk_application_prefer_dark_theme(),
"applied gtk-application-prefer-dark-theme"
);
}
None => tracing::warn!("no default GtkSettings available (no display?)"),
},
None => tracing::debug!(value, "color-scheme value expresses no preference"),
}
}

/// Syncs the app's light/dark mode with the desktop's system-wide preference
/// via the XDG Desktop Portal, since plain GTK4 (without libadwaita) does not
/// do this on its own. Keeps following live changes for the rest of the run.
pub fn init_theme_sync() {
let proxy = match gio::DBusProxy::for_bus_sync(
gio::BusType::Session,
gio::DBusProxyFlags::NONE,
None,
PORTAL_BUS_NAME,
PORTAL_OBJECT_PATH,
PORTAL_SETTINGS_IFACE,
gio::Cancellable::NONE,
) {
Ok(proxy) => proxy,
Err(err) => {
tracing::warn!(%err, "could not connect to xdg-desktop-portal Settings interface");
return;
}
};

match proxy.call_sync(
"Read",
Some(&(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY).to_variant()),
gio::DBusCallFlags::NONE,
1000,
gio::Cancellable::NONE,
) {
Ok(reply) => match unwrap_variant(reply.child_value(0)).get::<u32>() {
Some(value) => apply_color_scheme(value),
None => tracing::warn!(reply = %reply, "unexpected reply shape from portal Read"),
},
Err(err) => tracing::warn!(%err, "portal Settings.Read call failed"),
}

proxy.connect_g_signal(move |_proxy, _sender, signal, params| {
if signal != "SettingChanged" {
return;
}
if let Some((namespace, key, value)) = params.get::<(String, String, gtk4::glib::Variant)>()
&& namespace == APPEARANCE_NAMESPACE
&& key == COLOR_SCHEME_KEY
&& let Some(value) = unwrap_variant(value).get::<u32>()
{
apply_color_scheme(value);
}
});
}

pub fn init_style() {
let provider = CssProvider::new();
Expand Down
3 changes: 2 additions & 1 deletion src/ui/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::ui::file_explorer::FileExplorer;
use crate::ui::monitor::SystemMonitor;
use crate::ui::server_list::{ServerAction, ServerList};
use crate::ui::ssh_keys::build_ssh_keys_ui;
use crate::ui::style::init_style;
use crate::ui::style::{init_style, init_theme_sync};
use gtk4::prelude::*;
use gtk4::{gdk, gio, glib};
use std::cell::RefCell;
Expand All @@ -29,6 +29,7 @@ struct AppWindowInner {

impl AppWindow {
pub fn new(app: &gtk4::Application) -> Self {
init_theme_sync();
init_style();
let window = gtk4::ApplicationWindow::builder()
.application(app)
Expand Down