6 Commits
Author SHA1 Message Date
bod09 1ba313b9b7 Settings panel in the app launcher, with automatic, always colour and always night vision modes
autopass-settings (GTK4/libadwaita, sized for VR): passthrough mode,
current state, update check and one-press update, full uninstall. The
daemon reads the mode file via inotify; fixed modes switch once and never
poll. New CLI commands: mode, uninstall --purge. Install adds the launcher
entry and the icon (in the icon theme, named after the app ID).
2026-10-06 21:12:37 +01:00
bod09 796a0145b3 README: drop the short install address 2026-10-06 18:19:52 +01:00
bod09andClaude Opus 5.5 85c733eb29 Release notes from the tag message instead of a generated commit list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:53:51 +01:00
bod09andClaude Opus 5.5 9641c757ac README: install via Konsole's Paste button; drop the keyboard tip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:40:33 +01:00
bod09andClaude Opus 5.5 e9be271e9f Reach the real user service manager from Desktop Mode's nested session
Konsole on the Frame runs with XDG_RUNTIME_DIR=/run/user/1000/nested_plasma
and a private D-Bus, so systemctl --user failed in the installer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:36:38 +01:00
bod09andClaude Opus 5.5 30d05c25d4 README: short install address and copy tip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:26:11 +01:00
9 changed files with 636 additions and 39 deletions

No files matched your search

+4 -2
View File
@@ -1,4 +1,6 @@
# Build and test every push; publish a release for every v* tag.
# Build and test every push; publish a release for every v* tag. The
# release notes are the annotated tag's message: write them for users
# (what changed for them), not as a commit list.
# The release package is built the same way on every push, so a tag never
# ships something that has not been built before.
name: build
@@ -32,5 +34,5 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes \
gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-from-tag \
dist/frame-autopass-aarch64.tar.gz dist/frame-autopass-aarch64.tar.gz.sha256 dist/install.sh
+2 -2
View File
@@ -65,7 +65,7 @@ $(DEV)/libopenvr_loader.a: $(OPENVR_OBJ)
DAEMON_SRC := src/autopassd.cpp src/daemon_config.cpp src/app_paths.cpp src/passthrough_state.cpp \
src/light_policy.cpp src/sensors.cpp src/xrservice_log.cpp
CTL_SRC := tools/autopass.cpp src/app_paths.cpp src/passthrough_state.cpp src/private_camera.cpp
CTL_SRC := tools/autopass.cpp src/app_paths.cpp src/light_policy.cpp src/passthrough_state.cpp src/private_camera.cpp
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
RELEASE_FLAGS := -DAUTOPASS_VERSION='"$(VERSION)"' -std=c++17 -Os -Wall -Wextra -Wno-nullability-completeness -ffunction-sections -fdata-sections -Wl,--gc-sections -s
@@ -91,7 +91,7 @@ dist:
rm -rf dist $(DEV)/autopassd $(DEV)/autopass
$(MAKE) $(DEV)/autopassd $(DEV)/autopass
mkdir -p dist/frame-autopass
cp $(DEV)/autopassd $(DEV)/autopass install.sh README.md LICENSE dist/frame-autopass/
cp $(DEV)/autopassd $(DEV)/autopass tools/autopass-settings tools/autopass.svg install.sh README.md LICENSE dist/frame-autopass/
tar -C dist -czf dist/frame-autopass-aarch64.tar.gz frame-autopass
cd dist && sha256sum frame-autopass-aarch64.tar.gz > frame-autopass-aarch64.tar.gz.sha256
cp install.sh dist/install.sh
+27 -16
View File
@@ -23,18 +23,21 @@ nothing at all while passthrough is not showing.
## Install
On the headset, switch to Desktop Mode, open **Konsole** and paste:
On the headset, switch to Desktop Mode, copy the command below (copy button
on the right of the box), open **Konsole** and click **Paste** at the top
right, then press Enter:
```sh
curl -fsSL https://github.com/bod09/frame-autopass/releases/latest/download/install.sh | bash
```
That's it. It starts automatically whenever SteamVR runs, from now on.
That's it. It starts automatically whenever SteamVR runs, from now on, and
**Frame Autopass** appears in the app launcher for its settings.
The installer checks that the Arcturus module is present, downloads the
latest release, verifies its checksum, puts two programs in
`~/.local/share/frame-autopass` and registers a systemd user service that
starts and stops with SteamVR. Everything stays in your home folder, so it
latest release, verifies its checksum, puts its programs in
`~/.local/share/frame-autopass`, adds the launcher entry and registers a
systemd user service that starts and stops with SteamVR. Everything stays in your home folder, so it
survives SteamOS updates and needs no root access.
Prefer not to pipe a script into bash? Download
@@ -44,20 +47,27 @@ it, read `install.sh`, and run `./install.sh` from that folder.
## Use
Just use passthrough as normal. The commands below are optional (run them
in Konsole):
Just use passthrough as normal: it switches on its own.
To change anything, open **Frame Autopass** from the app launcher. It's only
for settings, so it doesn't need to stay open:
- **Passthrough mode:** Automatic (the default), Always colour, or Always
night vision.
- What passthrough is showing right now.
- **Check for updates**, and install them with one press.
- **Uninstall**, which removes everything and leaves passthrough on colour.
The same is available in Konsole:
```sh
~/.local/share/frame-autopass/autopass status # running? what is it showing, and why
~/.local/share/frame-autopass/autopass update # install the latest release
~/.local/share/frame-autopass/autopass report # write ~/frame-autopass-report.txt for a bug report
~/.local/share/frame-autopass/autopass uninstall # remove the service, back to plain colour passthrough
~/.local/share/frame-autopass/autopass status # running? what is it showing, and why
~/.local/share/frame-autopass/autopass mode auto # or: colour, ir
~/.local/share/frame-autopass/autopass update # install the latest release
~/.local/share/frame-autopass/autopass report # write ~/frame-autopass-report.txt for a bug report
~/.local/share/frame-autopass/autopass uninstall --purge # remove everything, back to plain colour passthrough
```
`uninstall` stops the service and removes it; delete
`~/.local/share/frame-autopass` and `~/.config/frame-autopass` as well to
remove every trace.
## How it decides
It reads three signals, none of which needs an image or a camera device:
@@ -108,7 +118,8 @@ with the output of `autopass report`.
## Safety
- It never opens a camera, never writes outside your home folder, and does
not connect to the internet (except `update`, when you run it).
not connect to the internet, except when you press "Check for updates" or
"Update" in the settings (or run `update`).
- It only reads SteamVR's shared memory and logs. To switch, it connects to
SteamVR for about 15 ms as a background client, which never wakes the
headset or starts SteamVR.
+9 -2
View File
@@ -21,6 +21,12 @@ fail() { printf 'frame-autopass: %s\n' "$*" >&2; exit 1; }
[ "$(uname -m)" = "aarch64" ] || fail "this is for the Steam Frame (aarch64); this machine is $(uname -m)."
command -v systemctl >/dev/null || fail "systemd not found."
# Desktop Mode on the Frame runs Konsole in a nested session with its own
# XDG_RUNTIME_DIR and D-Bus, where `systemctl --user` cannot reach the real
# user service manager. Always talk to the login session's one.
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
if ! grep -qs arcimx616 /sys/class/video4linux/*/name; then
fail "the Arcturus Vision colour module was not found. frame-autopass switches between that module
and the built-in IR cameras, so it needs the module attached."
@@ -51,10 +57,11 @@ old_version="$("$DEST/autopass" version 2>/dev/null || echo none)"
# unit, which starts it again if SteamVR is running.
systemctl --user stop frame-autopass.service 2>/dev/null || true
mkdir -p "$DEST"
for f in autopassd autopass; do
for f in autopassd autopass autopass-settings; do
install -m 0755 "$src/$f" "$DEST/$f.new"
mv -f "$DEST/$f.new" "$DEST/$f"
done
install -m 0644 "$src/autopass.svg" "$DEST/autopass.svg"
"$DEST/autopass" install
if [ "$old_version" = "none" ]; then
@@ -62,4 +69,4 @@ if [ "$old_version" = "none" ]; then
else
say "frame-autopass updated: $old_version -> $new_version."
fi
say "Check it any time with: $DEST/autopass status"
say "Settings: open Frame Autopass from the app launcher (only needed to change settings)."
+3
View File
@@ -18,6 +18,9 @@ inline std::string log_path() { return config_dir() + "/autopassd.log"; }
inline std::string conf_path() { return config_dir() + "/autopass.conf"; }
inline std::string lock_path() { return config_dir() + "/autopassd.lock"; }
// Crash guard: XRService restarts soon after an autopassd switch, one line each.
// Passthrough mode chosen in the settings panel or `autopass mode`: one
// word, auto (default), colour or ir. autopassd watches it with inotify.
inline std::string mode_path() { return config_dir() + "/mode"; }
inline std::string crash_guard_path() { return config_dir() + "/crash_guard"; }
// Creates the directory and its parents. Returns false on failure.
+59 -8
View File
@@ -39,6 +39,7 @@
#include <deque>
#include <optional>
#include <poll.h>
#include <sys/inotify.h>
#include <spawn.h>
#include <string>
#include <sys/file.h>
@@ -196,6 +197,26 @@ int main(int argc, char** argv) {
autopass::EvidenceBuilder evidence(cfg.sensors);
log(std::string("autopassd ") + autopass::version() + " started: showing " + autopass::to_string(initial));
// Passthrough mode (auto / always colour / always IR) from the mode
// file, re-read whenever it changes.
const auto read_mode = [] {
std::string text;
autopass::Mode m = autopass::Mode::Auto;
if (autopass::read_file(autopass::mode_path(), &text)) {
while (!text.empty() && (text.back() == '\n' || text.back() == ' ')) text.pop_back();
if (!autopass::parse_mode(text, &m)) m = autopass::Mode::Auto;
}
return m;
};
autopass::Mode mode = read_mode();
if (mode != autopass::Mode::Auto) policy.set_mode(now_ms(), mode);
log(std::string("mode: ") + autopass::to_string(mode));
const int config_watch = ::inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
if (config_watch >= 0) ::inotify_add_watch(config_watch, autopass::config_dir().c_str(), IN_CLOSE_WRITE | IN_MOVED_TO);
// A forced mode switches once when it is chosen and each time passthrough
// appears; it never polls in between.
bool force_pending = true;
double last_colour_ts = -1;
double mono_ts_at_last_light = 0;
std::uint64_t last_ir_log_ms = 0;
@@ -216,7 +237,7 @@ int main(int argc, char** argv) {
std::string last_reason, last_status;
autopass::Cause last_cause = autopass::Cause::None;
const auto apply = [&](Source target, const std::string& reason, std::uint64_t now) {
const auto apply = [&](Source target, const std::string& reason, std::uint64_t now, bool automatic = true) {
if (!blocked.empty()) {
log(std::string("would switch to ") + autopass::to_string(target) + " (" + reason + ") [" + blocked + "]");
return;
@@ -236,7 +257,7 @@ int main(int argc, char** argv) {
recent_ir.clear();
expect_source_until_ms = now + 3000;
last_switch_wall = static_cast<long long>(std::time(nullptr));
evidence.on_switched(now, target, true, last_cause);
evidence.on_switched(now, target, automatic, last_cause);
} else if (rc == kSetMismatch) {
log("SteamVR's camera interface changed (SteamVR update?); switching disabled until frame-autopass "
"is updated (`autopass update`)");
@@ -259,11 +280,30 @@ int main(int argc, char** argv) {
// means: sleep until XRService logs something.
int wait_ms = 0;
while (!g_stop) {
pollfd pfd{xr.fd(), POLLIN, 0};
if (::poll(&pfd, 1, wait_ms) < 0 && errno != EINTR) break;
pollfd pfds[2] = {{xr.fd(), POLLIN, 0}, {config_watch, POLLIN, 0}};
if (::poll(pfds, config_watch >= 0 ? 2 : 1, wait_ms) < 0 && errno != EINTR) break;
if (g_stop) break;
const std::uint64_t now = now_ms();
if (config_watch >= 0 && (pfds[1].revents & POLLIN)) {
alignas(inotify_event) char events[4096];
bool mode_touched = false;
for (ssize_t n; (n = ::read(config_watch, events, sizeof events)) > 0;)
for (char* p = events; p < events + n;) {
const auto* e = reinterpret_cast<const inotify_event*>(p);
if (e->len && std::string(e->name) == "mode") mode_touched = true;
p += sizeof(inotify_event) + e->len;
}
const autopass::Mode m = mode_touched ? read_mode() : mode;
if (m != mode) {
mode = m;
log(std::string("mode: ") + autopass::to_string(mode));
policy.set_mode(now, mode);
evidence.reset();
force_pending = true;
}
}
const bool xr_changed = xr.update();
const autopass::XrState& xs = xr.state();
if (xr.take_restarted()) {
@@ -319,12 +359,21 @@ int main(int argc, char** argv) {
last_colour_ts = -1;
dark_seen_ms.reset();
lit_seen_ms.reset();
force_pending = true;
}
Evidence ev = Evidence::Unknown;
Source shown = policy.source();
bool want_ir = false;
if (active) {
if (active && mode != autopass::Mode::Auto) {
// Always colour or always IR: switch if needed, then sleep.
const Source target = mode == autopass::Mode::ForceColor ? Source::Color : Source::Ir;
if (const auto snap = state.read()) shown = snap->config.rgb ? Source::Color : Source::Ir;
if (force_pending && shown != target)
apply(target, std::string("mode: always ") + autopass::to_string(target), now, false);
force_pending = false;
last_reason = std::string("mode: ") + autopass::to_string(mode);
} else if (active) {
if (state.path().empty() && now - last_state_retry_ms > 5000) {
last_state_retry_ms = now;
state = autopass::PassthroughState();
@@ -401,7 +450,7 @@ int main(int argc, char** argv) {
// (XRService's "emitters on" line wakes autopassd at once when it gets
// dark). IR shown: read the IR camera's light value 4 times a second
// while it matters, otherwise wait for XRService's log.
if (!active) wait_ms = -1;
if (!active || mode != autopass::Mode::Auto) wait_ms = -1;
else if (pending) wait_ms = 250;
else if (want_ir) wait_ms = 250;
else if (shown == Source::Color) wait_ms = 1000;
@@ -411,17 +460,19 @@ int main(int argc, char** argv) {
std::snprintf(buf, sizeof buf,
"{\"pid\": %d, \"passthrough\": \"%s\", \"standby\": \"%s\", \"showing\": \"%s\", "
"\"ir_emitters\": \"%s\", \"tracking\": \"%s\", \"evidence\": \"%s\", \"cooldown_s\": %.0f, "
"\"can_switch\": %s, \"blocked\": \"%s\", \"reason\": \"%s\", \"version\": \"%s\"}\n",
"\"can_switch\": %s, \"blocked\": \"%s\", \"reason\": \"%s\", \"version\": \"%s\", \"mode\": \"%s\"}\n",
static_cast<int>(::getpid()), tri(xs.passthrough, "shown", "hidden"), tri(xs.standby, "yes", "no"),
autopass::to_string(policy.source()), tri(xs.emitters, "on", "off"), tri(xs.tracking_lost, "lost", "ok"),
autopass::to_string(ev), policy.cooldown_ms() / 1000.0, blocked.empty() ? "true" : "false",
blocked.c_str(), json_escape(active ? last_reason : "").c_str(), autopass::version());
blocked.c_str(), json_escape(active ? last_reason : "").c_str(), autopass::version(),
autopass::to_string(mode));
if (buf != last_status) {
autopass::write_file_atomic(autopass::status_path(), buf);
last_status = buf;
}
}
if (config_watch >= 0) ::close(config_watch);
xr.stop();
log("autopassd stopped");
if (g_log) std::fclose(g_log);
+400
View File
@@ -0,0 +1,400 @@
#!/usr/bin/env python3
"""Frame Autopass settings panel.
Launched from the Steam Frame's app launcher and used in VR with a
controller pointer, so everything is large and needs no typing: the
passthrough mode, the current state, updates, and uninstall.
It changes nothing itself. Every action runs the `autopass` command that
sits next to this file, so the panel and the command line always agree.
The network is used only when "Check for updates" or "Update" is pressed.
Needs GTK 4 and libadwaita 1.5 through PyGObject, which SteamOS ships.
"""
import json
import os
import subprocess
import sys
import threading
import urllib.request
from pathlib import Path
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, GLib, Gtk # noqa: E402
HERE = Path(__file__).resolve().parent
AUTOPASS = HERE / "autopass"
CONFIG = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "frame-autopass"
HINT_DISMISSED = CONFIG / "hint-dismissed"
APP_ID = "io.github.bod09.FrameAutopass" # also the launcher entry and icon name
RELEASES_API = "https://api.github.com/repos/bod09/frame-autopass/releases/latest"
MODES = [
("auto", "Automatic", "Colour in the light, night vision in the dark"),
("colour", "Always colour", "Arcturus Vision colour cameras"),
("ir", "Always night vision", "The Frame's own infrared cameras"),
]
BLOCKED = {
"steamvr-changed": "A SteamVR update changed how cameras are switched. Check for updates below.",
"no-colour-module": "The Arcturus Vision module is not attached.",
"crash-guard": "Paused after XRService restarted twice. Restart SteamVR to resume.",
"observe-only": "Observe-only mode is set in autopass.conf.",
}
# Sized for a controller pointer in VR: big text, big targets.
CSS = b"""
window { font-size: 20px; }
headerbar { min-height: 72px; }
headerbar .title { font-size: 22px; font-weight: bold; }
windowcontrols { border-spacing: 16px; }
windowcontrols button { min-width: 60px; min-height: 60px; padding: 0; margin: 4px; }
windowcontrols button image { -gtk-icon-size: 30px; }
/* The minimise icon draws its dash at the bottom, like an underline; centre it. */
windowcontrols button.minimize image { -gtk-icon-transform: translateY(-5px); }
.section-inline { font-size: 18px; opacity: 0.7; }
.big-button { min-height: 64px; padding: 8px 20px; }
.mode-button { min-height: 84px; padding: 10px 22px; }
.mode-button:checked { background: @accent_bg_color; color: @accent_fg_color; }
.mode-title { font-size: 22px; font-weight: bold; }
.mode-subtitle { font-size: 17px; font-weight: normal; opacity: 0.8; }
.status-title { font-size: 28px; font-weight: bold; }
.status-detail { font-size: 18px; opacity: 0.8; }
.section-title { font-size: 18px; font-weight: bold; opacity: 0.7; }
.notes { font-size: 17px; }
.hint { padding: 16px 18px; border-radius: 12px; background: alpha(@accent_bg_color, 0.18); }
.hint-text { font-size: 22px; }
/* The uninstall confirmation, sized for a controller pointer too. */
.dialog-body { font-size: 20px; }
"""
def run_autopass(*args, timeout=180):
"""Runs the autopass command; returns (exit code, combined output)."""
try:
p = subprocess.run([str(AUTOPASS), *args], capture_output=True, text=True, timeout=timeout)
return p.returncode, (p.stdout + p.stderr).strip()
except (OSError, subprocess.TimeoutExpired) as e:
return 1, str(e)
def installed_version():
code, out = run_autopass("version", timeout=10)
return out if code == 0 and out else "unknown"
def read_status():
"""autopassd's status.json, or None when it is not running."""
try:
status = json.loads((CONFIG / "status.json").read_text())
os.kill(int(status["pid"]), 0)
return status
except (OSError, ValueError, KeyError, TypeError):
return None
def read_mode():
try:
mode = (CONFIG / "mode").read_text().strip()
return mode if mode in {m[0] for m in MODES} else "auto"
except OSError:
return "auto"
def label(text, css=None, wrap=True, xalign=0.0):
w = Gtk.Label(label=text, xalign=xalign, wrap=wrap)
if css:
w.add_css_class(css)
return w
class Panel(Adw.ApplicationWindow):
def __init__(self, app):
super().__init__(application=app, title="Frame Autopass", default_width=640, default_height=900)
self.latest = None
self.busy = False
self.removed = False
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=22,
margin_top=20, margin_bottom=28, margin_start=20, margin_end=20)
# This window is only settings: the switching runs without it. Shown
# until dismissed once.
if not HINT_DISMISSED.exists():
hint = Gtk.Box(spacing=16)
hint.add_css_class("hint")
text = label("Frame Autopass works in the background whenever SteamVR runs. "
"This window is only for settings, so you can close it at any time.", "hint-text")
text.set_hexpand(True)
got_it = Gtk.Button(label="Got it", valign=Gtk.Align.CENTER)
got_it.add_css_class("big-button")
got_it.connect("clicked", self.on_hint_dismissed, hint)
hint.append(text)
hint.append(got_it)
content.append(hint)
# Current state.
self.status_title = label("", "status-title")
self.status_detail = label("", "status-detail")
status_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
status_box.append(self.status_title)
status_box.append(self.status_detail)
content.append(status_box)
# Passthrough mode: three big buttons, one active.
content.append(label("Passthrough", "section-title"))
self.mode_buttons = {}
group = None
for key, title, subtitle in MODES:
text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, hexpand=True,
valign=Gtk.Align.CENTER)
text.append(label(title, "mode-title"))
text.append(label(subtitle, "mode-subtitle"))
button = Gtk.ToggleButton(child=text)
button.add_css_class("mode-button")
if group:
button.set_group(group)
group = group or button
button.connect("toggled", self.on_mode_toggled, key)
self.mode_buttons[key] = button
# Automatic, the point of the program, gets the full width; the two
# fixed modes share one row below it.
content.append(self.mode_buttons["auto"])
fixed = Gtk.Box(spacing=16, homogeneous=True)
fixed.append(self.mode_buttons["colour"])
fixed.append(self.mode_buttons["ir"])
content.append(fixed)
# Version and updates.
# The "Updates" heading with the version beside it; the button below,
# full width.
self.version_label = label("", "section-inline", wrap=False)
self.version_label.set_hexpand(True)
self.version_label.set_valign(Gtk.Align.CENTER)
update_title = label("Updates", "section-title", wrap=False)
update_title.set_valign(Gtk.Align.CENTER)
update_row = Gtk.Box(spacing=14)
update_row.append(update_title)
self.check_button = Gtk.Button(label="Check for updates")
self.check_button.add_css_class("big-button")
self.check_button.connect("clicked", self.on_check)
self.spinner = Gtk.Spinner()
update_row.append(self.version_label)
update_row.append(self.spinner)
content.append(update_row)
content.append(self.check_button)
self.update_info = label("")
self.update_info.set_visible(False)
self.update_notes = label("", "notes")
self.update_notes.set_visible(False)
self.update_button = Gtk.Button(label="")
self.update_button.add_css_class("big-button")
self.update_button.add_css_class("suggested-action")
self.update_button.set_visible(False)
self.update_button.connect("clicked", self.on_update)
content.append(self.update_info)
content.append(self.update_notes)
content.append(self.update_button)
# Uninstall.
content.append(label("Remove", "section-title"))
self.uninstall_button = Gtk.Button(label="Uninstall Frame Autopass")
self.uninstall_button.add_css_class("big-button")
self.uninstall_button.add_css_class("destructive-action")
self.uninstall_button.connect("clicked", self.on_uninstall)
content.append(self.uninstall_button)
self.clamp = Adw.Clamp(maximum_size=720, child=content)
scroller = Gtk.ScrolledWindow(child=self.clamp, hscrollbar_policy=Gtk.PolicyType.NEVER)
view = Adw.ToolbarView(content=scroller)
# Quick settings, opened from the launcher in VR: full screen, with
# only large minimise and close buttons.
header = Adw.HeaderBar(decoration_layout=":minimize,close")
view.add_top_bar(header)
self.set_content(view)
self.maximize()
self.syncing = True
self.mode_buttons[read_mode()].set_active(True)
self.syncing = False
self.refresh_version()
self.refresh_status()
GLib.timeout_add_seconds(1, self.refresh_status)
def on_hint_dismissed(self, _button, hint):
try:
CONFIG.mkdir(parents=True, exist_ok=True)
HINT_DISMISSED.touch()
except OSError:
pass # still hide it for now
hint.set_visible(False)
# State -----------------------------------------------------------------
def refresh_version(self):
self.version = installed_version()
self.version_label.set_text(self.version)
def refresh_status(self):
if self.removed:
return False # stop the timer
status = read_status()
mode = read_mode()
if not self.mode_buttons[mode].get_active():
self.syncing = True
self.mode_buttons[mode].set_active(True)
self.syncing = False
if status is None:
self.status_title.set_text("Not running")
self.status_detail.set_text("It starts by itself whenever SteamVR runs.")
elif status.get("blocked") in BLOCKED:
self.status_title.set_text("Not switching")
self.status_detail.set_text(BLOCKED[status["blocked"]])
elif status.get("passthrough") != "shown" or status.get("standby") == "yes":
self.status_title.set_text("Passthrough is off")
self.status_detail.set_text("Ready for when you turn it on.")
else:
showing = "Colour" if status.get("showing") == "colour" else "Night vision"
self.status_title.set_text(f"Showing: {showing}")
self.status_detail.set_text("Switching automatically with the light." if mode == "auto"
else "Fixed until you choose Automatic.")
return True # keep the timer
# Actions ---------------------------------------------------------------
def on_mode_toggled(self, button, key):
if self.syncing or not button.get_active():
return
code, out = run_autopass("mode", key, timeout=10)
if code != 0:
self.status_detail.set_text(out or "Could not change the mode.")
self.refresh_status()
def set_busy(self, busy):
self.busy = busy
self.spinner.set_spinning(busy)
for w in (self.check_button, self.update_button, self.uninstall_button):
w.set_sensitive(not busy)
def on_check(self, _button):
self.set_busy(True)
self.update_info.set_visible(True)
self.update_info.set_text("Checking...")
self.update_notes.set_visible(False)
self.update_button.set_visible(False)
threading.Thread(target=self.check_worker, daemon=True).start()
def check_worker(self):
try:
req = urllib.request.Request(RELEASES_API, headers={"User-Agent": "frame-autopass-settings",
"Accept": "application/vnd.github+json"})
with urllib.request.urlopen(req, timeout=15) as r:
release = json.load(r)
result = (release.get("tag_name", ""), (release.get("body") or "").strip(), None)
except Exception as e: # network errors, rate limits, bad JSON
result = (None, None, str(e))
GLib.idle_add(self.check_done, *result)
def check_done(self, tag, notes, error):
self.set_busy(False)
if error:
self.update_info.set_text(f"Could not check for updates: {error}")
elif tag == self.version:
self.update_info.set_text(f"You have the latest version ({tag}).")
else:
self.latest = tag
self.update_info.set_text(f"Version {tag} is available.")
if notes:
# GitHub release notes are Markdown; show list items as bullets.
lines = [("\u2022 " + l[2:]) if l.startswith(("- ", "* ")) else l for l in notes.splitlines()]
self.update_notes.set_text("\n".join(lines))
self.update_notes.set_visible(True)
self.update_button.set_label(f"Update to {tag}")
self.update_button.set_visible(True)
return False
def on_update(self, _button):
self.set_busy(True)
self.update_info.set_text("Updating...")
threading.Thread(target=self.update_worker, daemon=True).start()
def update_worker(self):
code, out = run_autopass("update")
GLib.idle_add(self.update_done, code, out)
def update_done(self, code, out):
self.set_busy(False)
self.refresh_version()
self.update_button.set_visible(False)
self.update_notes.set_visible(False)
if code == 0:
self.update_info.set_text(f"Updated to {self.version}.")
else:
last = out.splitlines()[-1] if out else "unknown error"
self.update_info.set_text(f"Update failed: {last}")
return False
def on_uninstall(self, _button):
# A custom dialog rather than Adw.AlertDialog, whose width is fixed
# and narrow: this one matches the content column.
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18,
margin_top=28, margin_bottom=24, margin_start=28, margin_end=28)
box.append(label("Uninstall?", "status-title", xalign=0.5))
box.append(label("Removes Frame Autopass completely and puts passthrough back to colour. "
"You can install it again any time.", "dialog-body", xalign=0.5))
box.get_last_child().set_justify(Gtk.Justification.CENTER)
buttons = Gtk.Box(spacing=16, homogeneous=True, margin_top=8)
cancel = Gtk.Button(label="Cancel")
confirm = Gtk.Button(label="Uninstall")
for b in (cancel, confirm):
b.add_css_class("big-button")
buttons.append(b)
confirm.add_css_class("destructive-action")
box.append(buttons)
dialog = Adw.Dialog(child=box, content_width=max(self.clamp.get_child().get_width(), 480))
cancel.connect("clicked", lambda _b: dialog.close())
confirm.connect("clicked", lambda _b: (dialog.close(), self.on_uninstall_response(dialog, "uninstall")))
dialog.set_default_widget(cancel)
dialog.set_focus(cancel)
dialog.present(self)
def on_uninstall_response(self, _dialog, response):
if response != "uninstall":
return
code, out = run_autopass("uninstall", "--purge", timeout=60)
self.removed = code == 0
for w in list(self.mode_buttons.values()) + [self.check_button, self.update_button, self.uninstall_button]:
w.set_sensitive(False)
self.status_title.set_text("Uninstalled" if code == 0 else "Uninstall failed")
self.status_detail.set_text("Frame Autopass has been removed. You can close this window."
if code == 0 else out)
def main():
app = Adw.Application(application_id=APP_ID)
def activate(app):
# Always dark: the launcher session does not pass on the desktop's
# dark preference, and dark is easier on the eyes in a headset.
Adw.StyleManager.get_default().set_color_scheme(Adw.ColorScheme.FORCE_DARK)
# The window's icon, by name from the icon theme (installed there by
# `autopass install`), for taskbars that ask the window rather than
# its launcher entry.
Gtk.Window.set_default_icon_name(APP_ID)
provider = Gtk.CssProvider()
provider.load_from_data(CSS)
Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
win = app.get_active_window() or Panel(app)
win.present()
app.connect("activate", activate)
return app.run(sys.argv[:1])
if __name__ == "__main__":
sys.exit(main())
+113 -9
View File
@@ -2,7 +2,12 @@
//
// autopass install install the systemd user unit that starts autopassd
// with SteamVR and stops it with SteamVR; start it
// autopass uninstall stop and remove the unit, switch back to colour
// autopass uninstall [--purge]
// stop and remove the unit and the launcher entry,
// switch back to colour; --purge also deletes the
// programs, settings and logs
// autopass mode [auto|colour|ir]
// show or set the passthrough mode
// autopass status autopassd's status and whether the unit is active
// autopass update download and install the latest release
// autopass report write ~/frame-autopass-report.txt for a bug report
@@ -22,6 +27,7 @@
// milliseconds; that never starts SteamVR and does not wake the headset.
#include "app_paths.hpp"
#include "light_policy.hpp"
#include "passthrough_state.hpp"
#include "private_camera.hpp"
@@ -42,11 +48,49 @@ constexpr const char* kInstallScript = "https://github.com/bod09/frame-autopass/
int usage() {
std::fprintf(stderr,
"usage: autopass install | uninstall | status | update | report | version |\n"
" guard [reset] | state [SECONDS] | get | set rgb|mono [--quiet]\n");
"usage: autopass install | uninstall [--purge] | status | mode [auto|colour|ir] |\n"
" update | report | version | guard [reset] | state [SECONDS] | get |\n"
" set rgb|mono [--quiet]\n");
return 2;
}
std::string data_home() {
const char* xdg = std::getenv("XDG_DATA_HOME");
const char* home = std::getenv("HOME");
return xdg && *xdg == '/' ? xdg : std::string(home ? home : "/tmp") + "/.local/share";
}
// The settings panel's entry in the Frame's app launcher. The launcher hides
// entries with Terminal=true, and needs an absolute Exec path. The file is
// named after the panel's application ID so KDE's taskbar can match the
// running window to it (and show its icon).
constexpr const char* kAppId = "io.github.bod09.FrameAutopass";
std::string desktop_path() { return data_home() + "/applications/" + kAppId + ".desktop"; }
// Name used before 0.2.0, removed on install and uninstall.
std::string old_desktop_path() { return data_home() + "/applications/frame-autopass.desktop"; }
// The icon, installed into the user's icon theme under the app ID: shells
// that look icons up by name (the Frame's VR taskbar) cannot use a path.
std::string theme_icon_path() { return data_home() + "/icons/hicolor/scalable/apps/" + kAppId + ".svg"; }
// Asks KDE to re-read app entries so new icons show without a restart.
void refresh_app_cache() {
std::system("command -v kbuildsycoca6 >/dev/null 2>&1 && kbuildsycoca6 >/dev/null 2>&1");
}
std::string desktop_text() {
const std::string dir = autopass::install_dir();
return "[Desktop Entry]\n"
"Type=Application\n"
"Name=Frame Autopass\n"
"Comment=Settings only: switching runs in the background with SteamVR\n"
"Exec=" + dir + "/autopass-settings\n"
"Icon=" + std::string(kAppId) + "\n"
"Terminal=false\n"
"StartupWMClass=" + std::string(kAppId) + "\n"
"Categories=Settings;Utility;\n";
}
std::string unit_path() {
const char* xdg = std::getenv("XDG_CONFIG_HOME");
const char* home = std::getenv("HOME");
@@ -54,9 +98,12 @@ std::string unit_path() {
return base + "/systemd/user/" + kUnit;
}
// Runs `systemctl --user ARGS...`, returns its exit code.
// Runs `systemctl --user ARGS...`, returns its exit code. Always against
// the login session's user manager: Desktop Mode on the Frame runs Konsole
// in a nested session whose XDG_RUNTIME_DIR and D-Bus cannot reach it.
int systemctl(std::initializer_list<const char*> args, bool quiet = false) {
std::string cmd = "systemctl --user";
const std::string run = "/run/user/" + std::to_string(::getuid());
std::string cmd = "XDG_RUNTIME_DIR=" + run + " DBUS_SESSION_BUS_ADDRESS=unix:path=" + run + "/bus systemctl --user";
for (const char* a : args) cmd += std::string(" ") + a;
if (quiet) cmd += " >/dev/null 2>&1";
const int rc = std::system(cmd.c_str());
@@ -107,18 +154,72 @@ int install() {
return 1;
}
std::printf("installed %s: autopassd now starts and stops with SteamVR\n", path.c_str());
if (::access((dir + "/autopass-settings").c_str(), X_OK) == 0) {
const std::string entry = desktop_path();
std::remove(old_desktop_path().c_str());
std::string icon;
const std::string theme_icon = theme_icon_path();
if (!autopass::read_file(dir + "/autopass.svg", &icon) ||
!autopass::make_dirs(theme_icon.substr(0, theme_icon.rfind('/'))) ||
!autopass::write_file_atomic(theme_icon, icon))
std::fprintf(stderr, "could not install the icon to %s\n", theme_icon.c_str());
if (autopass::make_dirs(entry.substr(0, entry.rfind('/'))) && autopass::write_file_atomic(entry, desktop_text())) {
refresh_app_cache();
std::printf("added Frame Autopass to the app launcher\n");
}
else
std::fprintf(stderr, "could not write %s (the service is installed anyway)\n", entry.c_str());
}
return 0;
}
int switch_source(bool want_rgb, bool quiet);
int uninstall() {
// Deletes a directory tree we created (rm -rf on a fixed path of ours).
void remove_tree(const std::string& path) {
if (path.size() < 10 || path.find("frame-autopass") == std::string::npos) return; // never anything else
const std::string cmd = "rm -rf '" + path + "'";
if (std::system(cmd.c_str()) != 0) std::fprintf(stderr, "could not remove %s\n", path.c_str());
}
int uninstall(bool purge) {
systemctl({"disable", "--now", kUnit}, true);
std::remove(unit_path().c_str());
systemctl({"daemon-reload"}, true);
std::printf("removed %s\n", kUnit);
std::remove(desktop_path().c_str());
std::remove(old_desktop_path().c_str());
std::remove(theme_icon_path().c_str());
refresh_app_cache();
std::printf("removed %s and the launcher entry\n", kUnit);
// Leave the headset showing colour, the stock behaviour with the module.
switch_source(true, true);
if (purge) {
remove_tree(autopass::config_dir());
remove_tree(autopass::install_dir());
std::printf("removed %s and %s: frame-autopass is gone\n", autopass::install_dir().c_str(),
autopass::config_dir().c_str());
}
return 0;
}
int mode(const char* value) {
std::string text;
if (!value) {
const bool set = autopass::read_file(autopass::mode_path(), &text);
std::printf("%s\n", set && !text.empty() ? text.substr(0, text.find('\n')).c_str() : "auto");
return 0;
}
autopass::Mode m;
if (!autopass::parse_mode(value, &m)) {
std::fprintf(stderr, "mode must be auto, colour or ir\n");
return 2;
}
if (!autopass::make_dirs(autopass::config_dir()) ||
!autopass::write_file_atomic(autopass::mode_path(), std::string(autopass::to_string(m)) + "\n")) {
std::fprintf(stderr, "cannot write %s\n", autopass::mode_path().c_str());
return 1;
}
std::printf("mode: %s\n", autopass::to_string(m));
return 0;
}
@@ -204,7 +305,8 @@ int report() {
section(&out, "date", "date");
section(&out, "SteamOS", "grep -E '^(VERSION_ID|BUILD_ID)=' /etc/os-release");
section(&out, "SteamVR", "cat /opt/steamvr/bin/version.txt; grep -m1 -E 'cv: version [0-9]' ~/.local/share/Steam/logs/vrserver.txt");
section(&out, "unit", std::string("systemctl --user status --no-pager ") + kUnit + " | head -5");
section(&out, "unit", "XDG_RUNTIME_DIR=/run/user/" + std::to_string(::getuid()) +
" systemctl --user status --no-pager " + kUnit + " | head -5");
section(&out, "status", "cat " + autopass::status_path());
section(&out, "config", "cat " + autopass::conf_path() + " 2>/dev/null || echo '(defaults)'");
section(&out, "crash guard", "cat " + autopass::crash_guard_path() + " 2>/dev/null || echo '(empty)'");
@@ -292,7 +394,9 @@ int main(int argc, char** argv) {
if (argc < 2) return usage();
const std::string cmd = argv[1];
if (cmd == "install" && argc == 2) return install();
if (cmd == "uninstall" && argc == 2) return uninstall();
if (cmd == "uninstall" && argc == 2) return uninstall(false);
if (cmd == "uninstall" && argc == 3 && std::string(argv[2]) == "--purge") return uninstall(true);
if (cmd == "mode" && argc <= 3) return mode(argc == 3 ? argv[2] : nullptr);
if (cmd == "status" && argc == 2) return status();
if (cmd == "update" && argc == 2) return update();
if (cmd == "report" && argc == 2) return report();
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- Frame Autopass: an eye, half warm sunlight (colour), half the blue SteamVR tints IR night vision with (camera.monochromeTintHue 0.67).
No clip paths: KDE renders icons with Qt SVG (SVG Tiny), which ignores them, so each half is drawn as its own eye-shaped path
(the outline's Bezier curves split at their midpoint, x = 64). -->
<defs>
<linearGradient id="day" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ffd877"/><stop offset="1" stop-color="#f6a23c"/>
</linearGradient>
<linearGradient id="night" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8f8df2"/><stop offset="1" stop-color="#4644b2"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="28" fill="#1e2430"/>
<path d="M8 64 C19 45 41.5 35.5 64 35.5 L64 92.5 C41.5 92.5 19 83 8 64 Z" fill="url(#day)"/>
<path d="M64 35.5 C86.5 35.5 109 45 120 64 C109 83 86.5 92.5 64 92.5 Z" fill="url(#night)"/>
<circle cx="64" cy="64" r="20" fill="#f4f6fa"/>
<circle cx="64" cy="64" r="9" fill="#1e2430"/>
<path d="M8 64 C30 26 98 26 120 64 C98 102 30 102 8 64 Z" fill="none" stroke="#f4f6fa" stroke-width="5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB