mirror of
https://github.com/bod09/frame-autopass.git
synced 2026-10-17 17:00:30 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ba313b9b7 | ||
|
|
796a0145b3 | ||
|
|
85c733eb29 | ||
|
|
9641c757ac | ||
|
|
e9be271e9f | ||
|
|
30d05c25d4 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)."
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Executable
+400
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 |
Reference in new issue
Block a user