7 Commits
Author SHA1 Message Date
bod09 e580577ab1 README: corrections (network wording, fixed modes, panel after a SteamVR update, tools/); panel: correct crash-guard resume instructions 2026-10-06 21:27:02 +01:00
bod09 6f63f950bd README: icon next to the title 2026-10-06 21:17:53 +01:00
bod09 2b1cf0bca4 Release notes: fetch the annotated tag before creating the release 2026-10-06 21:16:56 +01:00
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
9 changed files with 642 additions and 53 deletions

No files matched your search

+7 -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,8 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes \
# actions/checkout turns annotated tags into lightweight ones; fetch
# the real tag so --notes-from-tag finds its message.
git fetch --force origin "refs/tags/$GITHUB_REF_NAME:refs/tags/$GITHUB_REF_NAME"
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
+42 -33
View File
@@ -1,4 +1,4 @@
# frame-autopass
# <img src="tools/autopass.svg" alt="" width="44" align="top"> frame-autopass
> **AI disclaimer:** built with the help of AI and tested on a single
> headset. Treat it as experimental.
@@ -9,8 +9,9 @@ infrared (IR) passthrough in the dark. Turn the lights off and you can see
in IR about 1.5 s later; turn them on and colour is back in about a second.
There is nothing to press and nothing to configure.
It runs entirely on the headset, never talks to the network, and does
nothing at all while passthrough is not showing.
It runs entirely on the headset, never goes online by itself (only when you
check for updates), and does nothing at all while passthrough is not
showing.
## Requirements
@@ -23,27 +24,23 @@ 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
```
Or the short version, easier to type in VR:
```sh
curl -L bod09.github.io/frame-autopass/i | bash
```
That's it. It starts automatically whenever SteamVR runs, from now on.
Tip: the code blocks on GitHub have a copy button; paste into Konsole with
Ctrl+Shift+V.
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
survives SteamOS updates and needs no root access.
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
`frame-autopass-aarch64.tar.gz` from the
@@ -52,20 +49,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:
@@ -93,6 +97,8 @@ From these:
the moment a switch is allowed, as a flashing light would.
- Losing tracking (which happens in the dark if you stand still) does not
stop it working.
- **Always colour / Always night vision:** it switches to that camera once
and then does nothing until you choose Automatic again.
Known limits: in a dimly lit room, or with your face right up against a
wall, the IR camera may not see enough light, so colour comes back when the
@@ -109,14 +115,16 @@ There is no public way to switch the passthrough camera, so frame-autopass
uses a private SteamVR interface. Before every switch it checks that
SteamVR's code still matches what it was built against. If an update
changes it, frame-autopass stops switching (passthrough keeps working
normally, just without automatic switching) and `autopass status` says so.
Run `autopass update`; if no fixed release exists yet, please open an issue
normally, just without automatic switching), and the settings panel and
`autopass status` say so. Check for updates in the panel (or run
`autopass update`); if no fixed release exists yet, please open an issue
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.
@@ -149,9 +157,10 @@ make dist # cross-compiled release package in dist/
GitHub Actions builds and tests every push and publishes a release for every
`v*` tag.
`tools/` holds the research tools used to find all of this: a passive
shared-memory recorder and sampler, and an annotated ARM64 disassembler for
SteamVR's binaries.
`tools/` holds the `autopass` control tool, the settings panel
(`autopass-settings`) and its icon, plus the research tools used to find
all of this: a passive shared-memory recorder and sampler, and an annotated
ARM64 disassembler for SteamVR's binaries.
## Credits
+3 -2
View File
@@ -57,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
@@ -68,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);
+401
View File
@@ -0,0 +1,401 @@
#!/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. To resume, run "
"`autopass guard reset` in Konsole, then restart SteamVR.",
"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())
+106 -6
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");
@@ -110,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;
}
@@ -296,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