diff --git a/plugins/game-memory-mode/AGENTS.md b/plugins/game-memory-mode/AGENTS.md new file mode 100644 index 0000000..e853d34 --- /dev/null +++ b/plugins/game-memory-mode/AGENTS.md @@ -0,0 +1,84 @@ +# Agent guide for the game-memory-mode plugin + +This folder is a Ryoku shell plugin. If you are an AI agent editing it, follow +every rule below. They are the same rules `ryoku plugin validate` enforces, so +work that breaks one will not install. This file is self-contained: you do not +need any other document to work here. + +## The rules + +R1 Place. A plugin is one folder named after its id. Author it under +`$(xdg-user-dir DOCUMENTS)/ryoku-plugins//` (fallback `~/ryoku-plugins//`); +`ryoku plugin new ` creates it there. Never write into +`~/.local/share/ryoku/plugins/` (the store's install root, receipt-owned) or +`~/.config/quickshell/` (the shipped shell). + +R2 Shape. `manifest.json` at the root; `service/Main.qml` (logic, no UI); +`content/Widget.qml` (the one view); optional `content/Panel.qml` (a bar panel); +`README.md`; `LICENSE`; `assets/preview-widget.png` (a real capture); scripts +only under `bin/` with a shebang and the exec bit. Every extra file listed in +manifest `files`. + +R3 Id. Lowercase `[a-z0-9][a-z0-9-]*`, unique, not a built-in widget id. + +R4 Imports. Only `QtQuick*`, `Quickshell*`, `Ryoku.PluginKit`, +`Ryoku.PluginKit.Singletons`, and files inside the plugin folder. Never +`shell.*`, `Ryoku.Ui*` internals, or a relative import that climbs out of the +folder. + +R5 Settings. Declared in `metadata.settings` (types the bar renders: toggle, +choice, multi, int, text); read through `pluginApi.pluginSettings` behind a +default; written only through `pluginApi.saveSetting(key, value)`. Never edit +`shell.json` or `plugins.json` directly. + +R6 Commands. Every external program the plugin runs is either in its own `bin/` +or listed in `dependencies.commands`. No `sudo`, `doas`, `su`. A privileged +action is allowed only through `pkexec`, only on an explicit click, only when +listed in manifest `capabilities.privileged` (exact command strings) and +explained in README. + +R7 Network. Every host the plugin talks to is listed in +`capabilities.network`. No `curl … | sh`, no downloading and running code. + +R8 Files. Write only under `pluginApi.stateDir` +(`$XDG_STATE_HOME/ryoku/plugins/`), `$XDG_CACHE_HOME/ryoku/plugins/`, or +a temp dir. Never touch `~/.ssh`, `/etc`, shell rc files, or another plugin's +folder. + +R9 Shell. No `sh -c` with a string built from settings or program output +(injection). Pass argv arrays. + +R10 Secrets and binaries. No tokens, keys or credentials in the tree; no +compiled binaries (ELF/Mach-O/.so); scripts only. + +R11 Honesty. `official` is never true for a community plugin. `author` is +`Name `. README says what runs, what it reads, what it writes, and every +privileged/network capability. + +## The workflow + +1. `ryoku plugin new ` scaffolds this folder (already done). +2. Edit: `service/Main.qml` for logic, `content/Widget.qml` for the view, and + `content/Panel.qml` for the bar panel. Capture `assets/preview-widget.png`. +3. `ryoku plugin validate ` and fix every blocking finding (warnings are + advisory but read them). +4. `ryoku plugin add --bar --yes` to install it, then verify it on the bar + and under **QS Bar Settings > Community**. +5. `ryoku plugin share ` ONLY when the user asks to publish. Never share a + plugin unless asked. + +## What the host sets + +Every entry point receives, from the host: `pluginApi` (see below), `s` (a scale +factor), `active`, `density`, and `widthBudget`. Read them; never assign them. + +`pluginApi` exposes: + +- `mainInstance`: the live `service/Main.qml` instance every view shares. +- `pluginSettings`: the resolved settings object; read behind a default. +- `pluginDir`: this plugin's folder. +- `stateDir`: `$XDG_STATE_HOME/ryoku/plugins/`; the only place to write. +- `saveSetting(key, value)`: persist one setting. +- `panelOpen` (bool), `openPanel()`, `closePanel()`, `togglePanel()`: the bar + panel controls (present but inert off the bar). The sanctioned widget click is + `onClicked: pluginApi.togglePanel()`. diff --git a/plugins/game-memory-mode/LICENSE b/plugins/game-memory-mode/LICENSE new file mode 100644 index 0000000..ee37ec6 --- /dev/null +++ b/plugins/game-memory-mode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sannidhya Sandheer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/game-memory-mode/README.md b/plugins/game-memory-mode/README.md new file mode 100644 index 0000000..d181791 --- /dev/null +++ b/plugins/game-memory-mode/README.md @@ -0,0 +1,78 @@ +# Game Mode Optimizer + +Temporarily stop voice typing and replace animated wallpapers while gaming, then restore your desktop. + +![Game Mode Optimizer on the QS Bar](assets/preview-widget.png) + +## What it does + +The QS Bar controller adds two desktop optimizations to **Quick Settings → Gaming** (Super+Esc): stop active Voxtype and switch live wallpapers to a static frame. Existing Gaming performance and DND behavior stays with Ryoku. The independent manual control and launcher wrapper apply only these two actions. + +The previous wallpaper, audio settings and voice-typing state return after the last game/manual/Gaming hold ends. Already-off voice typing stays off. A static wallpaper stays unchanged. A wallpaper chosen during gaming is respected, as is a changed Voxtype service enablement setting. Overlapping games have independent holds; a killed wrapper retains its hold while its game process group survives. + +One tested NVIDIA desktop reclaimed 1825 MiB of GPU memory with these actions. Savings vary by wallpaper, voice model and hardware; this is game-mode optimization, not a graphics-driver fix. + +## Install + +Install **Game Mode Optimizer** from Ryostore → Plugins, or install a reviewed local copy with `ryoku plugin add --bar --yes`. Click the controller icon on the QS Bar for its panel. Preferences are also under **QS Bar Settings → Community**. + +Requires Python 3, systemd user services and Ryogami. FFmpeg is needed to extract a still frame from video; selecting an existing static image avoids extraction. Voxtype is optional: an absent or inactive service is left alone. No root access or separate service installation is required. The first client starts a helper; the bar monitors it. If the widget is unloaded and no game remains, the helper restores the desktop and exits after 15 seconds without requests. Existing service-managed deployments may still run `bin/game-memory daemon` directly. + +### Automatic game launches + +Use the installed `bin/game-memory-run` as a launcher wrapper. Launcher settings are configured by you; this plugin never edits Heroic or Steam configuration. + +**Heroic:** Settings → Advanced → Wrapper (the exact section can vary by version). Add the executable at `~/.local/share/ryoku/plugins/game-memory-mode/bin/game-memory-run`, using your full home-directory path. Leave wrapper arguments empty. Configure Game Defaults for new games, and existing games individually if they override defaults. Preserve any other wrappers already in use. + +**Steam:** Game → Properties → General → Launch Options. With the default XDG data location: + +```sh +"$HOME/.local/share/ryoku/plugins/game-memory-mode/bin/game-memory-run" %command% +``` + +Prefix existing launch options instead of replacing them. Steam requires this once per game; it has no universal per-game launch-option default. If you use a custom XDG data home, use the actual installed path. + +The wrapper waits for desktop preparation before starting the game. Turning off **Automatic on game launch** makes the wrapper run the game without acquiring a hold. Launch failure releases its own hold. Shell reloads do not interrupt an existing game hold. + +### Remove + +Close wrapped games, turn Gaming/manual mode off, and remove the wrapper from launcher settings before removing the plugin through QS Bar Settings or Ryostore. The ordinary helper restores and exits when the widget stops polling. To stop it immediately, run the installed `bin/game-memory shutdown`. For a pre-existing systemd-managed deployment, stop and disable your own helper unit first. Runtime state is kept in the plugin's own directory; the plugin does not delete it automatically. + +## How it plugs in + +- `service/Main.qml` polls the local helper and passes declared settings. The bar glyph only opens the panel; actions occur in the panel or follow the user's Gaming/launcher configuration. +- `bin/game-memory` runs an unprivileged Python helper and CLI. It executes `systemctl --user` for Voxtype and `ffmpeg` for a still frame. Games are passed as argv arrays to `bin/game-memory-run`; no shell strings are generated. +- Reads: published Ryoku `flags.json` Gaming state, the user's `/proc` process identities/groups, boot identity, Voxtype service status/enablement, selected wallpaper/image files, and Ryogami's local JSON-RPC socket. +- Writes: only `$XDG_STATE_HOME/ryoku/plugins/game-memory-mode` (private state, preferences, snapshots, still frames and controller socket). Restores wallpapers through the owning Ryogami API. Stops/starts only `voxtype.service`; never changes its enablement. No launcher configuration, shell stores, login scripts, user units or shipped Ryoku files are edited. +- No network access, downloads, privileged operations, NVIDIA dependency or compositor-specific API. Source code runs with the user's permissions and is not sandboxed by the store. + +Limitations: active wallpaper auto-rotation refuses activation; turn rotation off first. Video/static wallpapers work automatically; Wallpaper Engine needs a static image path. Failed preparation blocks a wrapped launch and reports an error. GPU memory cannot be released by merely pausing a video or suspending the voice process, so these are stopped/replaced. A game that changes its process group independently of its launcher may escape group tracking; automatic holds are intended for launchers that preserve the game process group. + +## Settings + +| Setting | Default | Effect | +| --- | --- | --- | +| Automatic on game launch | On | Wrapped launches acquire a game hold. | +| Follow the Gaming toggle | On | Existing Quick Settings Gaming adds these two actions. | +| Static image path | Blank | Use a frame of the video; supply an absolute image path for other live wallpaper types. | + +## Develop + +```text +manifest.json +service/Main.qml +content/Widget.qml +content/Panel.qml +bin/game-memory +bin/game-memory-run +bin/test-controller +bin/test-startup +bin/test-live +assets/preview-widget.png +``` + +Run `PYTHONDONTWRITEBYTECODE=1 bin/test-controller`, `bin/test-startup`, and `ryoku plugin validate .`. The startup test uses isolated temporary state and does not change your desktop. `bin/test-live` is an explicit live qualification: it temporarily changes wallpaper/voice state, launches short sleep processes, kills this helper to check recovery, and verifies restoration. Run it only with no active Gaming/manual/game hold and after reviewing it. It is never an install hook. Live tests require the wrapper/helper paths to be from the same installation. + +## Credits + +MIT © 2026 Sannidhya Sandheer. Built with Ryoku PluginKit and Ryogami. Community maintenance belongs to the contributor, not the Ryoku team. Reports and maintenance contact: [@sannidhyas](https://github.com/sannidhyas), through the [Ryostore issue tracker](https://github.com/Ryoku-dev/ryostore/issues). Store screening is not a security guarantee; review source and updates before enabling. diff --git a/plugins/game-memory-mode/assets/preview-widget.png b/plugins/game-memory-mode/assets/preview-widget.png new file mode 100644 index 0000000..da1efca Binary files /dev/null and b/plugins/game-memory-mode/assets/preview-widget.png differ diff --git a/plugins/game-memory-mode/bin/game-memory b/plugins/game-memory-mode/bin/game-memory new file mode 100755 index 0000000..a7c485e --- /dev/null +++ b/plugins/game-memory-mode/bin/game-memory @@ -0,0 +1,296 @@ +#!/usr/bin/python3 +"""Two-action gaming mode. State stays in this plugin's own state directory.""" +import fcntl +import hashlib +import json +import os +from pathlib import Path +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time +import uuid + +STATE = Path(os.environ.get('XDG_STATE_HOME', str(Path.home()/'.local/state')))/'ryoku/plugins/game-memory-mode' +RUNTIME = Path(os.environ.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')) +SOCKET = STATE/'control.sock' +FLAGS = STATE.parent.parent/'flags.json' +BOOT = Path('/proc/sys/kernel/random/boot_id').read_text().strip() + +def read(path, default): + try: return json.loads(path.read_text()) + except (OSError, ValueError): return default + +def write(path, obj): + path.parent.mkdir(parents=True, exist_ok=True) + content = json.dumps(obj, indent=2)+'\n' + try: + if path.read_text() == content: return + except OSError: pass + # A private random temp file avoids following pre-existing links; rename + # replaces the destination atomically rather than opening it for writing. + with tempfile.NamedTemporaryFile(mode='w',dir=path.parent,prefix=path.name+'.',delete=False) as stream: + tmp=Path(stream.name) + stream.write(content) + try: tmp.replace(path) + finally: tmp.unlink(missing_ok=True) + +def rpc(path, method, params=None): + with socket.socket(socket.AF_UNIX) as sock: + sock.settimeout(30); sock.connect(str(path)) + sock.sendall((json.dumps({'id':1,'method':method,'params':params or {}})+'\n').encode()) + with sock.makefile('r') as reader: + for line in reader: + reply=json.loads(line) + if reply.get('id') == 1: + if reply.get('error'): raise RuntimeError(str(reply['error'])) + return reply.get('result', reply) + raise RuntimeError('No daemon reply') + +def wall(method, params=None): return rpc(RUNTIME/'ryogami.sock', method, params) + +def unit(verb): + p=subprocess.run(['systemctl','--user',verb,'voxtype.service'],capture_output=True,text=True,timeout=15) + if verb.startswith('is-'): return p.stdout.strip() + if p.returncode: raise RuntimeError(p.stderr.strip() or 'Voxtype '+verb+' failed') + return p.stdout.strip() + +def process(pid): + try: + fields=Path(f'/proc/{pid}/stat').read_text().rsplit(')',1)[1].split() + return {'start':fields[19],'group':int(fields[2]),'state':fields[0]} + except (OSError,ValueError,IndexError): return None + +def alive(lease, groups): + if lease.get('boot') != BOOT: return False + p=process(lease['pid']) + return bool(p and p['start']==lease['start'] and p['state']!='Z') or lease.get('group') in groups + +def groups_now(): + groups=set() + for p in Path('/proc').iterdir(): + if p.name.isdigit(): + try: + if p.stat().st_uid!=os.getuid(): continue + info=process(int(p.name)) + if info and info['state']!='Z': groups.add(info['group']) + except OSError: pass + return groups + +def static_frame(entry): + source=Path(entry.get('path','')) + if entry.get('type')=='static' and source.is_file(): return str(source) + if entry.get('type')!='video' or not source.is_file(): + raise RuntimeError('This wallpaper type needs a static image selected in Game Mode Optimizer settings') + key=hashlib.sha256((str(source)+str(source.stat().st_mtime_ns)).encode()).hexdigest()[:20] + target=STATE/'frames'/f'{key}.png' + if not target.exists(): + target.parent.mkdir(parents=True,exist_ok=True) + p=subprocess.run(['ffmpeg','-hide_banner','-loglevel','error','-ss','1','-i',str(source),'-frames:v','1','-update','1','-y',str(target)],capture_output=True,text=True,timeout=30) + if p.returncode or not target.is_file(): raise RuntimeError('Could not extract wallpaper still frame') + return str(target) + +def apply_entry(output, entry): + kind=entry.get('type') + params={'type':kind} + if output!='*': params['outputs']=[output] + if kind in ('static','video'): params['path']=entry['path'] + elif kind=='we': params.update({'we_id':entry['we_id'],'screens':[] if output=='*' else [output]}) + else: raise RuntimeError('Unsupported wallpaper type '+str(kind)) + if kind in ('video','we'): + params['outputs_audio']={output:entry.get('mute',True)} + params['outputs_volume']={output:entry.get('volume',80)} + wall('wall.apply',params) + +def client(method, params=None): + try: return rpc(SOCKET,method,params) + except (FileNotFoundError,ConnectionRefusedError): + # No installation hooks or user-unit writes: the first client starts an + # inspectable, unprivileged helper. flock arbitrates simultaneous starts. + STATE.mkdir(parents=True,exist_ok=True,mode=0o700) + subprocess.Popen(['python3',str(Path(__file__).resolve()),'daemon','--idle-timeout','15'], + stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL,start_new_session=True) + for _ in range(30): + try: return rpc(SOCKET,method,params) + except (FileNotFoundError,ConnectionRefusedError): time.sleep(.2) + raise RuntimeError('Game Mode Optimizer helper did not start') + +class Controller: + def __init__(self): + self.lock=threading.RLock() + self.data=read(STATE/'state.json',{'manual':False,'leases':{},'snapshot':None}) + self.config=read(STATE/'options.json',{'automatic':True,'followGaming':True,'staticImage':''}) + self.error=''; self.stopping=False + if self.data.get('boot')!=BOOT: + self.data['leases']={}; self.data['manual']=False + self.data['boot']=BOOT + def save(self): write(STATE/'state.json',self.data) + def status(self): + gaming=bool(self.config.get('followGaming',True) and read(FLAGS,{}).get('gameMode',False)) + return {'active':bool(self.data.get('snapshot')),'manual':self.data.get('manual',False),'gaming':gaming,'games':len(self.data['leases']),'error':self.error,'automatic':self.config.get('automatic',True),'followGaming':self.config.get('followGaming',True)} + def enter(self): + outputs=wall('wall.outputs')['outputs'] + rotation=wall('wall.rotation_status') + if rotation.get('active'): + raise RuntimeError('Turn off wallpaper auto-rotation before using Game Mode Optimizer mode') + replacement={} + image=self.config.get('staticImage','') + if image and not Path(image).is_file(): raise RuntimeError('Selected static wallpaper does not exist') + for key,entry in outputs.items(): + if entry.get('type')!='static': replacement[key]={'type':'static','path':image or static_frame(entry)} + snapshot={'boot':BOOT,'outputs':outputs,'replacement':replacement,'voiceActive':unit('is-active')=='active','voiceEnabled':unit('is-enabled'),'voiceStopped':False,'changed':[]} + self.data['snapshot']=snapshot; self.save() + try: + if snapshot['voiceActive']: + # Record intent first so a crash between stop and save is recoverable. + snapshot['voiceStopped']=True; self.save(); unit('stop') + for key,entry in replacement.items(): + snapshot['changed'].append(key); self.save(); apply_entry(key,entry) + except Exception: + self.leave(); raise + def leave(self): + snapshot=self.data.get('snapshot') + if not snapshot: return + current=wall('wall.outputs')['outputs'] + for key in list(snapshot['changed']): + actual=current.get(key,{}) + expected=snapshot['replacement'][key] + # Don't overwrite a wallpaper the user chose while gaming. + if actual.get('type')==expected['type'] and actual.get('path')==expected['path']: + apply_entry(key,snapshot['outputs'][key]) + snapshot['changed'].remove(key); self.save() + if snapshot['voiceStopped'] and snapshot.get('boot')==BOOT: + if unit('is-active')!='active' and unit('is-enabled')==snapshot['voiceEnabled']: + unit('start') + self.data['snapshot']=None; self.save() + def reconcile(self): + with self.lock: + groups=groups_now() if self.data['leases'] else set() + self.data['leases']={k:v for k,v in self.data['leases'].items() if alive(v,groups)} + try: + if self.data.get('snapshot') and self.data['snapshot'].get('boot') != BOOT: + self.leave() + st=self.status(); wanted=st['manual'] or st['gaming'] or bool(self.data['leases']) + if wanted and not st['active']: self.enter() + elif not wanted and st['active']: self.leave() + self.error='' + except Exception as exc: self.error=str(exc) + self.save(); write(STATE/'status.json',self.status()) + def handle(self,method,params): + with self.lock: + if method=='configure': + for key in ('automatic','followGaming'): + if key in params and not isinstance(params[key],bool): + raise RuntimeError(key+' must be true or false') + if 'staticImage' in params and not isinstance(params['staticImage'],str): + raise RuntimeError('staticImage must be an image path') + self.config.update({k:v for k,v in params.items() if k in ('automatic','followGaming','staticImage')}) + write(STATE/'options.json',self.config) + elif method=='manual': self.data['manual']=bool(params['enabled']) + elif method=='acquire': + if self.config.get('automatic',True): + p=process(params['pid']) + if not p: raise RuntimeError('Launch owner disappeared') + self.data['leases'][params['token']]={'pid':params['pid'],'start':p['start'],'boot':BOOT} + elif method=='attach': + if params['token'] in self.data['leases']: self.data['leases'][params['token']]['group']=params['group'] + elif method=='release': + # Keep a lease if a forked child is still alive in the game's group. + lease=self.data['leases'].get(params['token']) + if lease and lease.get('group') in groups_now(): + lease['pid']=0; lease['start']='' + else: self.data['leases'].pop(params['token'],None) + elif method=='shutdown': + if self.data['leases']: raise RuntimeError('Close wrapped games before stopping the helper') + self.data['manual']=False + self.leave(); self.stopping=True; self.save() + return self.status() + elif method!='status': raise RuntimeError('Unknown request') + self.reconcile() + if method=='acquire' and self.error: + self.data['leases'].pop(params['token'],None); self.reconcile() + raise RuntimeError('Could not prepare desktop for game') + return self.status() + +def daemon(idle_timeout=0): + STATE.mkdir(parents=True,exist_ok=True,mode=0o700) + descriptor=os.open(STATE/'daemon.lock',os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600) + lock=os.fdopen(descriptor,'w'); fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB) + ctl=Controller(); SOCKET.unlink(missing_ok=True) + last_request=time.monotonic() + server=socket.socket(socket.AF_UNIX);server.bind(str(SOCKET));SOCKET.chmod(0o600);server.listen(8);server.settimeout(1) + def stop(*_): ctl.stopping=True + signal.signal(signal.SIGTERM,stop); signal.signal(signal.SIGINT,stop) + def connection(conn): + nonlocal last_request + with conn: + conn.settimeout(35) + try: + with conn.makefile('r') as f: + line=f.readline(65537) + if len(line)>65536: raise RuntimeError('Request too large') + req=json.loads(line) + last_request=time.monotonic() + with ctl.lock: + result=ctl.handle(req['method'],req.get('params',{})) + # Extraction/preparation can outlast the idle timeout. Count + # inactivity from completion, before releasing the lock. + last_request=time.monotonic() + reply={'id':1,'result':result} + except Exception as exc: reply={'id':1,'error':str(exc)} + try: conn.sendall((json.dumps(reply)+'\n').encode()) + except OSError: pass + while not ctl.stopping: + ctl.reconcile() + # A removed/unloaded widget has no polling client. Restore and exit, + # while surviving games retain their hold independently of Quickshell. + if idle_timeout and not ctl.data['leases'] and time.monotonic()-last_request>idle_timeout: + ctl.data['manual']=False; ctl.save(); break + try: + conn,_=server.accept();threading.Thread(target=connection,args=(conn,),daemon=True).start() + except socket.timeout: pass + try: ctl.leave() + except Exception as exc: print(str(exc),file=sys.stderr) + server.close(); SOCKET.unlink(missing_ok=True) + +def run(command): + if command and command[0]=='--': command=command[1:] + if not command: raise RuntimeError('No game command supplied') + token=uuid.uuid4().hex + client('acquire',{'pid':os.getpid(),'token':token}) + child=None + try: + child=subprocess.Popen(command,start_new_session=True) + client('attach',{'token':token,'group':child.pid}) + def forward(sig,_): + try: os.killpg(child.pid,sig) + except ProcessLookupError: pass + signal.signal(signal.SIGTERM,forward);signal.signal(signal.SIGINT,forward) + return child.wait() + finally: client('release',{'token':token}) + +def main(): + args=sys.argv[1:] + cmd=args[0] if args else 'status' + if cmd=='daemon': + idle_timeout=0 + if len(args)>1: + if len(args)!=3 or args[1]!='--idle-timeout': raise RuntimeError('Invalid daemon arguments') + idle_timeout=float(args[2]) + if not 0 0 + text: (root.status.gaming ? "Following Quick Settings → Gaming" : "") + (root.status.games > 0 ? " · " + root.status.games + " game session(s)" : "") + width: parent.width; wrapMode: Text.WordWrap; color: Theme.dim; font.pixelSize: 12*root.s + } + Rectangle { + width: parent.width; height: 36*root.s; radius: Theme.radius; color: Theme.accent + Text { anchors.centerIn: parent; text: root.status.manual ? "Turn manual mode off" : "Turn manual mode on"; color: Theme.cardBot; font.pixelSize: 13*root.s } + MouseArea { anchors.fill: parent; onClicked: if (root.service) root.service.toggleManual() } + } + Repeater { + model: [{key:"automatic",label:"Automatic on game launch"},{key:"followGaming",label:"Follow the Gaming toggle"}] + delegate: Rectangle { + required property var modelData + readonly property bool checked: root.pluginApi ? (root.pluginApi.pluginSettings[modelData.key] ?? true) : true + width: col.width; height: 34*root.s; radius: Theme.radius; color: Theme.cardBot + Text { anchors.left: parent.left; anchors.leftMargin: 8*root.s; anchors.verticalCenter: parent.verticalCenter; text: modelData.label; color: Theme.bright; font.pixelSize: 12*root.s } + Text { anchors.right: parent.right; anchors.rightMargin: 8*root.s; anchors.verticalCenter: parent.verticalCenter; text: parent.checked ? "On" : "Off"; color: Theme.accent; font.pixelSize: 12*root.s } + MouseArea { anchors.fill: parent; onClicked: root.pluginApi.saveSetting(parent.modelData.key,!parent.checked) } + } + } + Text { width: parent.width; wrapMode: Text.WordWrap; text: "Restores what was running before gaming. A video becomes a still frame of the same wallpaper."; color: Theme.dim; font.pixelSize: 12*root.s } + } +} diff --git a/plugins/game-memory-mode/content/Widget.qml b/plugins/game-memory-mode/content/Widget.qml new file mode 100644 index 0000000..04a42a4 --- /dev/null +++ b/plugins/game-memory-mode/content/Widget.qml @@ -0,0 +1,23 @@ +import QtQuick +import Ryoku.PluginKit.Singletons +Item { + id: root + property var pluginApi + property var screen + property bool active: false + property string density: "glyph" + property real s: 1 + property real widthBudget: 0 + readonly property var service: pluginApi ? pluginApi.mainInstance : null + implicitWidth: label.implicitWidth + 8*s + implicitHeight: 20*s + Text { + id: label + anchors.centerIn: parent + text: "🎮" + opacity: root.service?.enabled ? 1 : 0.65 + color: root.service?.enabled ? Theme.accent : Theme.dim + font.pixelSize: 16*root.s + } + MouseArea { anchors.fill: parent; onClicked: if (root.pluginApi) root.pluginApi.togglePanel() } +} diff --git a/plugins/game-memory-mode/manifest.json b/plugins/game-memory-mode/manifest.json new file mode 100644 index 0000000..1c555ae --- /dev/null +++ b/plugins/game-memory-mode/manifest.json @@ -0,0 +1,70 @@ +{ + "id": "game-memory-mode", + "name": "Game Mode Optimizer", + "version": "0.2.0", + "author": "Sannidhya Sandheer <148160409+sannidhyas@users.noreply.github.com>", + "description": "Temporarily stop voice typing and replace animated wallpapers while gaming; restore the desktop afterwards.", + "license": "MIT", + "official": false, + "hosts": [ + "topbarGlyph" + ], + "entryPoints": { + "main": "service/Main.qml", + "content": "content/Widget.qml", + "panel": "content/Panel.qml" + }, + "panel": { + "width": 320 + }, + "files": [ + "bin/game-memory", + "bin/game-memory-run", + "bin/test-controller", + "assets/preview-widget.png", + "bin/test-live", + "bin/test-startup" + ], + "capabilities": { + "densities": [ + "glyph" + ] + }, + "defaults": { + "host": "topbarGlyph", + "label": "Game Mode Optimizer" + }, + "dependencies": { + "commands": [ + "python3", + "systemctl", + "ffmpeg", + "sleep" + ] + }, + "metadata": { + "settings": [ + { + "key": "automatic", + "type": "toggle", + "label": "Automatic on game launch", + "group": "Gaming", + "default": true + }, + { + "key": "followGaming", + "type": "toggle", + "label": "Follow the Gaming toggle", + "group": "Gaming", + "default": true + }, + { + "key": "staticImage", + "type": "text", + "label": "Static image path (blank uses a video still)", + "group": "Gaming", + "default": "" + } + ] + } +} diff --git a/plugins/game-memory-mode/product-manifest.json b/plugins/game-memory-mode/product-manifest.json new file mode 100644 index 0000000..558b05d --- /dev/null +++ b/plugins/game-memory-mode/product-manifest.json @@ -0,0 +1,113 @@ +{ + "schema": 1, + "id": "game-memory-mode", + "category": "plugins", + "version": "0.2.0", + "destination": "ryoku/plugins/game-memory-mode", + "files": [ + { + "source": "AGENTS.md", + "destination": "AGENTS.md", + "sha256": "a315bad498fc30c2f2778a081a0347bedfa393d1da074992d92bfe273ca3b0a2", + "mode": "0644", + "size": 3965, + "install": true + }, + { + "source": "LICENSE", + "destination": "LICENSE", + "sha256": "7b36e508a194efd6ec42caabdbcf1a2a7ae7960b5235c395a6b4170bdd039f35", + "mode": "0644", + "size": 1075, + "install": false + }, + { + "source": "README.md", + "destination": "README.md", + "sha256": "dad3a9b5bc2a10905c628a8364b8c46d203ba30ad36bfecbd82c20d57b4d76ea", + "mode": "0644", + "size": 6940, + "install": false + }, + { + "source": "assets/preview-widget.png", + "destination": "assets/preview-widget.png", + "sha256": "6db7330a195baa0fdbda6a207c11cfaf7a2552863f560b9a10deeae319542856", + "mode": "0644", + "size": 70061, + "install": false + }, + { + "source": "bin/game-memory", + "destination": "bin/game-memory", + "sha256": "fee510853b619c8de53866b440c58ba7668dca14b4e0d23ad8e1fdbd74c35c38", + "mode": "0755", + "size": 15020, + "install": true + }, + { + "source": "bin/game-memory-run", + "destination": "bin/game-memory-run", + "sha256": "6c196fc7cef5eb9abef657f177118c763a7284b5144461ff2cb72a25e6dcca72", + "mode": "0755", + "size": 398, + "install": true + }, + { + "source": "bin/test-controller", + "destination": "bin/test-controller", + "sha256": "c7557aece5d71585ca74e1d1c11502f14b69ac984d2429d9862884451e1af928", + "mode": "0755", + "size": 6012, + "install": true + }, + { + "source": "bin/test-live", + "destination": "bin/test-live", + "sha256": "0480b40407e94c71efc494fb2c44558775bc3e7301d073875733ec30b29f5d43", + "mode": "0755", + "size": 2266, + "install": true + }, + { + "source": "bin/test-startup", + "destination": "bin/test-startup", + "sha256": "0ccca326e94b0c8132b06d02d3699a648f02d97744c5f48d93885bd283a87404", + "mode": "0755", + "size": 2431, + "install": true + }, + { + "source": "content/Panel.qml", + "destination": "content/Panel.qml", + "sha256": "eeb4bc9dbd0b112c755e7acf3c9a7d88c9cf4b81ce005064706d422de78b3818", + "mode": "0644", + "size": 3043, + "install": true + }, + { + "source": "content/Widget.qml", + "destination": "content/Widget.qml", + "sha256": "90a8f019f0063fac80a78c5520e3b81acf0d6eca2811ceebebb38ada7da5107c", + "mode": "0644", + "size": 731, + "install": true + }, + { + "source": "manifest.json", + "destination": "manifest.json", + "sha256": "13524c74aee0f0c4f4a89fc9ab390cdf1042889317e58f2d601fbf953a7b49d9", + "mode": "0644", + "size": 1534, + "install": true + }, + { + "source": "service/Main.qml", + "destination": "service/Main.qml", + "sha256": "00a80611ce4590b7b466069e1c59a2ba1ed22ef63f3b85567bbb8793c8b15e57", + "mode": "0644", + "size": 1892, + "install": true + } + ] +} diff --git a/plugins/game-memory-mode/service/Main.qml b/plugins/game-memory-mode/service/Main.qml new file mode 100644 index 0000000..38a3c6b --- /dev/null +++ b/plugins/game-memory-mode/service/Main.qml @@ -0,0 +1,36 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell.Io +Item { + id: svc + property var pluginApi + readonly property var settings: pluginApi ? pluginApi.pluginSettings : null + property var status: ({active: false, manual: false, gaming: false, games: 0, error: ""}) + property string configured: "" + readonly property bool enabled: status.active === true + function invoke(verb, args) { + if (!pluginApi || action.running) return; + action.command = [pluginApi.pluginDir + "/bin/game-memory", verb].concat(args || []); + action.running = true; + } + function toggleManual() { invoke(status.manual ? "off" : "on"); } + function refresh() { + if (!pluginApi || query.running || action.running) return; + var cfg = JSON.stringify({automatic: settings?.automatic ?? true, + followGaming: settings?.followGaming ?? true, staticImage: settings?.staticImage ?? ""}); + if (cfg !== configured) { configured = cfg; invoke("configure", [cfg]); } + else { query.command = [pluginApi.pluginDir + "/bin/game-memory", "status"]; query.running = true; } + } + Process { + id: query + stdout: StdioCollector { onStreamFinished: { try { svc.status = JSON.parse(text); } catch (e) {} } } + stderr: StdioCollector { onStreamFinished: if (text.trim()) svc.status = Object.assign({}, svc.status, {error: text.trim()}); } + } + Process { + id: action + stdout: StdioCollector { onStreamFinished: { try { svc.status = JSON.parse(text); } catch (e) {} } } + stderr: StdioCollector { onStreamFinished: if (text.trim()) svc.status = Object.assign({}, svc.status, {error: text.trim()}); } + onExited: (code, exitStatus) => { if (code !== 0) svc.configured = ""; } + } + Timer { interval: 2000; running: true; repeat: true; triggeredOnStart: true; onTriggered: svc.refresh() } +} diff --git a/plugins/registry.json b/plugins/registry.json index ee1011f..88757c0 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -1651,6 +1651,35 @@ "topbarGlyph" ], "lastUpdated": "2026-10-07" + }, + { + "id": "game-memory-mode", + "name": "Game Mode Optimizer", + "path": "plugins/game-memory-mode", + "version": "0.2.0", + "author": "Sannidhya Sandheer", + "upstream": "https://github.com/Ryoku-dev/ryostore/tree/main/plugins/game-memory-mode", + "official": false, + "tagline": "A quieter desktop while you game.", + "description": "Temporarily stop voice typing and replace animated wallpapers while gaming; restore the desktop afterwards.", + "icon": "sports_esports", + "tags": [ + "bar-widget", + "gaming" + ], + "hosts": [ + "topbarGlyph" + ], + "preview": "assets/preview-widget.png", + "screenshots": [ + "assets/preview-widget.png" + ], + "lastUpdated": "2026-10-10", + "manifestSha256": "10817b4efd9e87ce49ad4caeee2491a5917ead72306a56a7075e5bf9650f1fde", + "summary": "Temporarily stop voice typing and replace animated wallpapers while gaming; restore the desktop afterwards.", + "accent": "#8f8378", + "surface": "#101010", + "manifest": "product-manifest.json" } ] }