"""KDE's ordinary workspaces: owned persistent game rule and verified placement.""" import configparser import json import os from pathlib import Path import re import secrets import tempfile import time from . import support ROOT = Path(__file__).resolve().parent.parent CONFIG = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home()/'.config')))/'kwinrulesrc' RECEIPT = support.STATE_ROOT/'workspace-rule.json' GROUP = '2ce61c83-3cf9-58ce-aea0-bb420d0e3e73' GAME_CLASS = 'steam_app_292030' def script(body): """Short-lived KWin query/action with a private D-Bus reply; always unload.""" import dbus import dbus.service from dbus.mainloop.glib import DBusGMainLoop from gi.repository import GLib DBusGMainLoop(set_as_default=True) bus = dbus.SessionBus(private=True) name = 'org.pathdevkit.Workspace.p'+str(os.getpid())+'.s'+secrets.token_hex(6) owner = dbus.service.BusName(name, bus) loop = GLib.MainLoop() result = [] class Reply(dbus.service.Object): @dbus.service.method('org.pathdevkit.Workspace', in_signature='s', out_signature='') def result(self, value): result.append(json.loads(str(value))) loop.quit() receiver = Reply(bus, '/reply') def call(path, interface, method, signature='', args=()): return bus.call_blocking('org.kde.KWin', path, interface, method, signature, args, timeout=5) def timeout(): loop.quit() return False timer = None loaded = False try: with tempfile.NamedTemporaryFile(mode='w', suffix='.js') as f: code = 'var reply;try {reply={ok:true,value:(function(){'+body+'})()};} catch(e) {reply={ok:false,error:String(e)};}\n' code += 'callDBus('+json.dumps(name)+',"/reply","org.pathdevkit.Workspace","result",JSON.stringify(reply));' f.write(code);f.flush() ident = call('/Scripting','org.kde.kwin.Scripting','loadScript','ss',(f.name,name)) if ident < 0:raise RuntimeError('KWin refused the workspace query script') loaded = True call('/Scripting/Script'+str(ident),'org.kde.kwin.Script','run') timer = GLib.timeout_add_seconds(5, timeout) loop.run() if not result:raise RuntimeError('KWin workspace reply timed out; inspect workspace-status before retrying a change') if not result[0]['ok']:raise RuntimeError(result[0]['error']) return result[0]['value'] finally: if timer and result:GLib.source_remove(timer) try: if loaded:call('/Scripting','org.kde.kwin.Scripting','unloadScript','s',(name,)) finally: receiver.remove_from_connection() del owner bus.close() QUERY = ''' return { desktops:workspace.desktops.map(d=>({id:d.id,name:d.name,number:d.x11DesktopNumber})), current:{id:workspace.currentDesktop.id,name:workspace.currentDesktop.name}, windows:workspace.windowList().filter(w=>String(w.resourceClass)==="steam_app_292030").map(w=>({ id:String(w.internalId),pid:w.pid,title:w.caption,resourceClass:String(w.resourceClass), desktops:w.desktops.map(d=>({id:d.id,name:d.name})),on_all_desktops:w.onAllDesktops,active:w.active,minimized:w.minimized,full_screen:w.fullScreen,geometry:{x:w.frameGeometry.x,y:w.frameGeometry.y,width:w.frameGeometry.width,height:w.frameGeometry.height} })) };''' def config(text): c = configparser.ConfigParser(interpolation=None,strict=True) c.optionxform = str c.read_string(text) return c def rule_values(desktop_id): return dict(Description='PathDevkit: Witcher 3 on Desktop 2',wmclass=GAME_CLASS, wmclassmatch='1',wmclasscomplete='false',desktops=desktop_id,desktopsrule='2', fsplevel='4',fsplevelrule='2') def update_config(text, values): """Preserve unrelated sections byte-for-byte; edit only our group and order key.""" sections = re.split(r'(?m)(?=^\[)', text) updated = [] general = False for section in sections: if section.startswith('['+GROUP+']\n'):continue if section.startswith('[General]\n'): general = True c = config(section) order = [v for v in c['General'].get('rules','').split(',') if v and v != GROUP] if values:order.insert(0,GROUP) line = 'rules='+','.join(order)+'\n' if re.search(r'(?m)^rules=',section):section=re.sub(r'(?m)^rules=[^\n]*(?:\n|$)',lambda _:line,section) else:section=section.rstrip('\n')+'\n'+line updated.append(section) if not general:updated.insert(0,'[General]\nrules='+(GROUP if values else '')+'\n\n') result = ''.join(updated) if values:result=result.rstrip('\n')+'\n\n['+GROUP+']\n'+''.join(k+'='+v+'\n' for k,v in values.items()) return result def current_rule(text): c = config(text) return dict(c[GROUP]) if GROUP in c else None def read_config(): if CONFIG.is_symlink():raise RuntimeError('Refusing symlinked KWin rules file') return CONFIG.read_bytes().decode() if CONFIG.exists() else '' def replace_config(before, after): if read_config()!=before:raise RuntimeError('KWin rules changed concurrently; inspect workspace-status') CONFIG.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile(mode='w',dir=CONFIG.parent,delete=False) as f: try: f.write(after);f.flush() os.chmod(f.name, CONFIG.stat().st_mode & 0o777 if CONFIG.exists() else 0o600) os.replace(f.name, CONFIG) finally: Path(f.name).unlink(missing_ok=True) support.run('busctl','--user','call','org.kde.KWin','/KWin','org.kde.KWin','reconfigure') def status(): observed = script(QUERY) text = read_config();c=config(text) receipt=json.loads(RECEIPT.read_text()) if RECEIPT.exists() else None values=current_rule(text) enabled=bool(receipt and not receipt.get('removed') and values==receipt['values'] and GROUP in c.get('General','rules',fallback='').split(',')) target=receipt['desktop'] if receipt and not receipt.get('removed') else None target_exists=bool(target and any(d['id']==target['id'] for d in observed['desktops'])) return dict(ok=True,backend='KDE KWin',rule_enabled=enabled,target_exists=target_exists,target=target, placement_verified=bool(enabled and target_exists and observed['windows'] and all([d['id'] for d in w['desktops']]==[target['id']] for w in observed['windows'])), config=str(CONFIG),receipt=str(RECEIPT),**observed) def prepare_pin(): """Validate the workspace and owned rule before changing any configuration.""" observed=script(QUERY) if len(observed['desktops'])<2:raise RuntimeError('Desktop 2 does not exist; create a second workspace in KDE System Settings, then retry workspace-pin') target=observed['desktops'][1] # Target the existing workspace UUID, which survives relabeling and reordering. before=read_config();old=current_rule(before) receipt=json.loads(RECEIPT.read_text()) if RECEIPT.exists() else None values=rule_values(target['id']) if old and (not receipt or receipt.get('removed') or old!=receipt['values']):raise RuntimeError('Owned KWin rule changed externally; refusing overwrite') if old and old.get('desktops')!=values['desktops']:raise RuntimeError('Desktop order changed; workspace-unpin before choosing the new second workspace') return target,before,old,receipt,values def pin(): target,before,old,receipt,values=prepare_pin() if not old: folder=support.ARTIFACTS/('workspace-'+str(time.time_ns()));folder.mkdir() (folder/'kwinrules.before').write_text(before) receipt=dict(group=GROUP,values=values,desktop=target,backup=str(folder/'kwinrules.before'),removed=False) support.atomic_json(RECEIPT,receipt) after=update_config(before,values) replace_config(before,after) if old and old!=values: # Upgrade our unchanged legacy rule without replacing the original backup. receipt['values']=values support.atomic_json(RECEIPT,receipt) for _ in range(20): result=status() if not result['windows'] or result['placement_verified']:return result time.sleep(.1) raise RuntimeError('KWin rule saved but live placement was not verified; inspect workspace-status') def prepare_unpin(): """Check the rule and ownership receipt without changing KDE configuration.""" before=read_config() receipt=json.loads(RECEIPT.read_text()) if RECEIPT.exists() else None if not receipt or receipt.get('removed'): if current_rule(before) is not None or GROUP in config(before).get('General','rules',fallback='').split(','): raise RuntimeError('Lab KWin rule exists without an active ownership receipt; inspect workspace-rule.json before removal') return None if current_rule(before)!=receipt['values']:raise RuntimeError('Owned KWin rule changed externally; refusing removal') return receipt,before def unpin(): prepared=prepare_unpin() if prepared is None: return dict(ok=True,removed=True) if RECEIPT.exists() else dict(ok=True,removed=False,reason='no_owned_rule') receipt,before=prepared replace_config(before,update_config(before,None)) receipt['removed']=True;support.atomic_json(RECEIPT,receipt) return dict(ok=True,removed=True,window_moved=False,note='Current placement remains; future launches follow ordinary desktop behavior.') def show(): before=status() if not before['rule_enabled'] or not before['target_exists']:raise RuntimeError('No active workspace pin; use workspace-pin first') target=json.dumps(before['target']['id']) script('var d=workspace.desktops.find(d=>d.id==='+target+');if(!d)throw new Error("Pinned desktop was removed");workspace.currentDesktop=d;var w=workspace.windowList().find(w=>String(w.resourceClass)==="steam_app_292030" && w.caption==="The Witcher 3");if(w){w.minimized=false;workspace.activeWindow=w;}return true;') after=status() if after['current']['id']!=before['target']['id']:raise RuntimeError('Workspace switch was not observed; inspect workspace-status') return after