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
5 changes: 5 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ jobs:
TAURI_CONF_VERSION=$(node -p 'require("./src-tauri/tauri.conf.json").version')
CARGO_VERSION=$(awk -F'"' '/^version =/ { print $2; exit }' ./src-tauri/Cargo.toml)
PKG_VERSION=$(node -p 'require("./package.json").version')
PKGBUILD_VERSION=$(awk -F= '/^pkgver=/ { print $2; exit }' ./packaging/PKGBUILD)

if [[ "$TAURI_CONF_VERSION" != "$TAG_VERSION" ]]; then
echo "src-tauri/tauri.conf.json version ($TAURI_CONF_VERSION) != tag version ($TAG_VERSION)"
Expand All @@ -101,6 +102,10 @@ jobs:
echo "package.json version ($PKG_VERSION) != tag version ($TAG_VERSION)"
exit 1
fi
if [[ "$PKGBUILD_VERSION" != "$TAG_VERSION" ]]; then
echo "packaging/PKGBUILD pkgver ($PKGBUILD_VERSION) != tag version ($TAG_VERSION)"
exit 1
fi

- uses: tauri-apps/tauri-action@v0
env:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ openusage-cli_*.tar.gz
src-tauri/binaries/
src-tauri/resources/bundled_plugins/*
!src-tauri/resources/bundled_plugins/.gitkeep
/packaging/*.zst
/packaging/pkg/
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# OpenUsage Community

_Track all your AI coding subscriptions in one place_
_Track all your AI coding subscriptions in one place_

</div>

Expand Down Expand Up @@ -130,7 +130,19 @@ chmod +x OpenUsage.AppImage
./OpenUsage.AppImage
```

After installing via `.rpm` or `.deb`, launch **OpenUsage Community** from your app menu. The app starts in the system tray.
### Arch (and other Arch-based rolling-release distros)

On rolling-release distros with a very recent Mesa, the portable AppImage may fail to open its window with `Could not create default EGL display: EGL_BAD_PARAMETER`. The AppImage bundles an older GTK/WebKit/GL stack that no longer matches a bleeding-edge host GPU driver. Build a native package instead — it links against your system's own `webkit2gtk-4.1` and Mesa, so it always matches:

```sh
git clone https://github.com/openusage-community/openusage.git
cd openusage/packaging
makepkg -si
```

This builds and installs the `openusage` package; launch **OpenUsage** from your app menu.

After installing via `.rpm`, `.deb` or `PKGBUILD`, launch **OpenUsage Community** from your app menu. The app starts in the system tray.

If your desktop environment has no tray support, install an AppIndicator or StatusNotifier extension, or open the panel with the global shortcut.

Expand Down
64 changes: 64 additions & 0 deletions packaging/PKGBUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Maintainer: OpenUsage community <https://github.com/openusage-community/openusage>
pkgname=openusage
# Tauri's resource_dir() resolves to /usr/lib/<productName>; productName is "OpenUsage".
_appname=OpenUsage
pkgver=0.6.37
pkgrel=1
pkgdesc="Open source AI subscription limit tracker (native build for Arch-based systems)"
arch=('x86_64')
url="https://github.com/openusage-community/openusage"
license=('MIT')
# Runtime: linked against the host's WebKitGTK/GTK stack, so it always matches the
# host Mesa/Wayland/EGL.
depends=('webkit2gtk-4.1' 'gtk3' 'libayatana-appindicator' 'libsecret' 'openssl'
'hicolor-icon-theme')
# rust>=1.85 is required (Cargo edition 2024). bun builds and embeds the frontend.
makedepends=('git' 'rust' 'cargo' 'bun')
options=('!lto')
source=("$pkgname-$pkgver::git+$url.git#tag=v$pkgver")
sha256sums=('SKIP')

prepare() {
cd "$srcdir/$pkgname-$pkgver"
bun install --frozen-lockfile
}

build() {
cd "$srcdir/$pkgname-$pkgver"
bun run tauri build --no-bundle
}

package() {
cd "$srcdir/$pkgname-$pkgver"

# Cargo workspace: the app binary lands in the repo-root target dir. The openusage-cli
# sidecar (externalBin) is built by the beforeBuildCommand into src-tauri/binaries.
install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
install -Dm755 "src-tauri/binaries/openusage-cli-x86_64-unknown-linux-gnu" "$pkgdir/usr/bin/openusage-cli"

install -d "$pkgdir/usr/lib/$_appname/resources"
cp -r src-tauri/resources/bundled_plugins "$pkgdir/usr/lib/$_appname/resources/"
install -Dm644 src-tauri/icons/tray-icon.png "$pkgdir/usr/lib/$_appname/icons/tray-icon.png"

# Application icons.
for size in 32 64 128; do
install -Dm644 "src-tauri/icons/${size}x${size}.png" \
"$pkgdir/usr/share/icons/hicolor/${size}x${size}/apps/$pkgname.png"
done
install -Dm644 "src-tauri/icons/128x128@2x.png" \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/$pkgname.png"

install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/$_appname.desktop" <<'DESKTOP'
[Desktop Entry]
Type=Application
Name=OpenUsage
Comment=OpenUsage is an open source AI subscription limit tracker
Exec=openusage
Icon=openusage
Terminal=false
Categories=Utility;
StartupWMClass=openusage
DESKTOP

install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
2 changes: 2 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ rust_xlsxwriter = "0.95.0"
tauri-plugin-dialog = "2.7.1"

[target.'cfg(target_os = "linux")'.dependencies]
ashpd = { version = "0.13", default-features = false, features = ["tokio", "global_shortcuts"] }
futures-util = { version = "0.3", default-features = false, features = ["std"] }
gtk = "0.18"

[target.'cfg(target_os = "windows")'.dependencies]
Expand Down
67 changes: 45 additions & 22 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ mod panel_windows;
#[cfg(any(target_os = "linux", target_os = "windows"))]
mod popover_platform;
mod tray;
#[cfg(target_os = "linux")]
mod wayland_global_shortcut;
#[cfg(target_os = "macos")]
mod webkit_config;

Expand Down Expand Up @@ -478,6 +480,15 @@ fn update_global_shortcut(
Some(trimmed)
}
});

// On Wayland, X11 key grabs never fire; register through the XDG GlobalShortcuts
// portal instead
#[cfg(target_os = "linux")]
if wayland_global_shortcut::is_wayland() {
wayland_global_shortcut::configure(&app_handle, normalized_shortcut);
return Ok(());
}

let mut managed_shortcut = managed_shortcut_slot()
.lock()
.map_err(|e| format!("failed to lock managed shortcut state: {}", e))?;
Expand Down Expand Up @@ -686,28 +697,40 @@ pub fn run() {
{
use tauri_plugin_store::StoreExt;

if let Ok(store) = app.handle().store("settings.json") {
if let Some(shortcut_value) = store.get(GLOBAL_SHORTCUT_STORE_KEY) {
if let Some(shortcut) = shortcut_value.as_str() {
let shortcut = shortcut.trim();
if !shortcut.is_empty() {
let handle = app.handle().clone();
log::info!("Registering initial global shortcut: {}", shortcut);
if let Err(e) = handle.global_shortcut().on_shortcut(
shortcut,
|app, _shortcut, event| {
handle_global_shortcut(app, event);
},
) {
log::warn!("Failed to register initial global shortcut: {}", e);
} else if let Ok(mut managed_shortcut) =
managed_shortcut_slot().lock()
{
*managed_shortcut = Some(shortcut.to_string());
} else {
log::warn!("Failed to store managed shortcut in memory");
}
}
let stored_shortcut = app
.handle()
.store("settings.json")
.ok()
.and_then(|store| store.get(GLOBAL_SHORTCUT_STORE_KEY))
.and_then(|value| value.as_str().map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty());

// On Wayland, register through the XDG portal (X11 grabs don't fire).
#[cfg(target_os = "linux")]
let handled_by_portal = if wayland_global_shortcut::is_wayland() {
log::info!("Registering global shortcut via Wayland portal");
wayland_global_shortcut::configure(app.handle(), stored_shortcut.clone());
true
} else {
false
};
#[cfg(not(target_os = "linux"))]
let handled_by_portal = false;

if !handled_by_portal {
if let Some(shortcut) = stored_shortcut {
log::info!("Registering initial global shortcut: {}", shortcut);
if let Err(e) = app.handle().global_shortcut().on_shortcut(
shortcut.as_str(),
|app, _shortcut, event| {
handle_global_shortcut(app, event);
},
) {
log::warn!("Failed to register initial global shortcut: {}", e);
} else if let Ok(mut managed_shortcut) = managed_shortcut_slot().lock() {
*managed_shortcut = Some(shortcut);
} else {
log::warn!("Failed to store managed shortcut in memory");
}
}
}
Expand Down
67 changes: 57 additions & 10 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
fn prefer_x11_backend_for_panel_positioning() {
if should_force_x11_backend(
std::env::var_os("GDK_BACKEND").as_deref(),
std::env::var_os("WAYLAND_DISPLAY").as_deref(),
std::env::var_os("XDG_SESSION_TYPE").as_deref(),
std::env::var_os("DISPLAY").as_deref(),
) {
// Wayland compositors usually ignore absolute window positioning.
// The tray panel needs X11/XWayland so it can open next to the tray icon.
// Wayland ignores client window positioning; route through XWayland so the tray
// panel can be placed under the tray icon.
unsafe {
std::env::set_var("GDK_BACKEND", "x11");
}
Expand All @@ -18,9 +20,21 @@ fn prefer_x11_backend_for_panel_positioning() {
#[cfg(target_os = "linux")]
fn should_force_x11_backend(
current_backend: Option<&std::ffi::OsStr>,
wayland_display: Option<&std::ffi::OsStr>,
session_type: Option<&std::ffi::OsStr>,
display: Option<&std::ffi::OsStr>,
) -> bool {
display.is_some() && current_backend.and_then(|value| value.to_str()) != Some("x11")
// Never override an explicit GDK_BACKEND.
if current_backend.is_some() {
return false;
}
// The x11 backend needs a reachable X server.
if display.is_none() {
return false;
}
// Wayland detection: DISPLAY is also set under XWayland, so it can't distinguish X11
// from Wayland. Key off WAYLAND_DISPLAY (GDK's own signal), then XDG_SESSION_TYPE.
wayland_display.is_some() || session_type.and_then(|value| value.to_str()) == Some("wayland")
}

fn main() {
Expand All @@ -35,24 +49,57 @@ mod tests {
use super::*;
use std::ffi::OsStr;

fn os(value: &str) -> Option<&OsStr> {
Some(OsStr::new(value))
}

#[test]
fn forces_x11_when_wayland_backend_is_inherited() {
fn forces_x11_on_wayland_session_with_xwayland() {
// unset backend, WAYLAND_DISPLAY + DISPLAY set.
assert!(should_force_x11_backend(
Some(OsStr::new("wayland")),
Some(OsStr::new(":0")),
None,
os("wayland-0"),
os("wayland"),
os(":0"),
));
}

#[test]
fn does_not_force_x11_without_display() {
assert!(!should_force_x11_backend(Some(OsStr::new("wayland")), None,));
fn forces_x11_when_only_session_type_signals_wayland() {
// no WAYLAND_DISPLAY, session_type=wayland, DISPLAY set.
assert!(should_force_x11_backend(None, None, os("wayland"), os(":0")));
}

#[test]
fn does_not_force_on_pure_x11_session() {
// no Wayland signals, DISPLAY set: already X11.
assert!(!should_force_x11_backend(None, None, os("x11"), os(":0")));
}

#[test]
fn does_not_force_without_xwayland() {
// Wayland session, no DISPLAY: stay on Wayland.
assert!(!should_force_x11_backend(None, os("wayland-0"), os("wayland"), None));
}

#[test]
fn respects_explicit_wayland_backend() {
// explicit GDK_BACKEND=wayland.
assert!(!should_force_x11_backend(
os("wayland"),
os("wayland-0"),
os("wayland"),
os(":0"),
));
}

#[test]
fn leaves_existing_x11_backend_alone() {
assert!(!should_force_x11_backend(
Some(OsStr::new("x11")),
Some(OsStr::new(":0")),
os("x11"),
os("wayland-0"),
os("wayland"),
os(":0"),
));
}
}
Loading
Loading