验证:补充独立光标可行性POC

This commit is contained in:
songyiming committed 2026-11-03 17:49:31 +08:00
1 parent b29b59c62f
commit 1439028833
49 files changed
+957 -1

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
# Scoped interactive cursor probe. No theme, driver, display or Sunshine changes.
$ErrorActionPreference='Stop'
$folder='C:\ProgramData\VDI\cursor-poc-20261103'
Set-Content "$folder\pid.txt" $PID
try {
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows.Forms;
public class VDICursorShape {
public string name; public int width,height,hotX,hotY,invertedPixels;
public bool monochrome; public uint[] argb;
}
public class VDICursorProbe : Form {
[StructLayout(LayoutKind.Sequential)] struct POINT { public int x,y; }
[StructLayout(LayoutKind.Sequential)] struct CI { public int size,flags; public IntPtr cursor; public POINT point; }
[StructLayout(LayoutKind.Sequential)] struct II { public bool icon; public int x,y; public IntPtr mask,color; }
[DllImport("user32.dll")] static extern bool GetCursorInfo(ref CI ci);
[DllImport("user32.dll")] static extern bool GetIconInfo(IntPtr h,out II info);
[DllImport("user32.dll")] static extern bool DrawIconEx(IntPtr dc,int x,int y,IntPtr icon,int w,int h,int step,IntPtr brush,int flags);
[DllImport("gdi32.dll")] static extern bool DeleteObject(IntPtr h);
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
public static readonly string[] Names={"arrow","ibeam","sizewe","wait"};
static readonly Cursor[] Shapes={Cursors.Default,Cursors.IBeam,Cursors.SizeWE,Cursors.WaitCursor};
static Bitmap Draw(IntPtr handle,int w,int h,Color background) {
Bitmap result=new Bitmap(w,h,System.Drawing.Imaging.PixelFormat.Format32bppArgb);
using(Graphics g=Graphics.FromImage(result)) {
g.Clear(background); IntPtr dc=g.GetHdc();
try { if(!DrawIconEx(dc,0,0,handle,w,h,0,IntPtr.Zero,3)) throw new Exception("DrawIconEx failed"); }
finally { g.ReleaseHdc(dc); }
}
return result;
}
public static VDICursorShape Export(string name,IntPtr handle) {
II info; if(!GetIconInfo(handle,out info)) throw new Exception("GetIconInfo failed");
try {
int w,h;
using(Bitmap mask=Bitmap.FromHbitmap(info.mask)) { w=mask.Width; h=mask.Height/(info.color==IntPtr.Zero?2:1); }
if(w<1 || h<1 || w>128 || h>128) throw new Exception("Unsupported cursor dimensions");
VDICursorShape s=new VDICursorShape {name=name,width=w,height=h,hotX=info.x,hotY=info.y,monochrome=info.color==IntPtr.Zero,argb=new uint[w*h]};
using(Bitmap black=Draw(handle,w,h,Color.Black))
using(Bitmap white=Draw(handle,w,h,Color.White)) {
for(int y=0;y<h;y++) for(int x=0;x<w;x++) {
Color b=black.GetPixel(x,y), z=white.GetPixel(x,y);
int dr=z.R-b.R,dg=z.G-b.G,db=z.B-b.B;
// XOR/inverted cursors need destination-dependent composition.
// Preserve the unsupported count; never silently call them alpha cursors.
if(dr<0 || dg<0 || db<0 || Math.Abs(dr-dg)>2 || Math.Abs(dr-db)>2) { ++s.invertedPixels; continue; }
int a=255-dr;
if(a==0) continue;
uint r=(uint)Math.Min(255,(b.R*255+a/2)/a),g=(uint)Math.Min(255,(b.G*255+a/2)/a),bl=(uint)Math.Min(255,(b.B*255+a/2)/a);
s.argb[y*w+x]=((uint)a<<24)|(r<<16)|(g<<8)|bl;
}
}
return s;
} finally { if(info.mask!=IntPtr.Zero) DeleteObject(info.mask); if(info.color!=IntPtr.Zero) DeleteObject(info.color); }
}
public static VDICursorShape[] ExportAll() {
List<VDICursorShape> result=new List<VDICursorShape>();
for(int i=0;i<Names.Length;i++) result.Add(Export(Names[i],Shapes[i].Handle));
return result.ToArray();
}
readonly Timer timer=new Timer(); readonly string folder; readonly DateTime started=DateTime.UtcNow;
bool hidden; string shape="arrow",lastObserved=""; int clicks;
public VDICursorProbe(string path) {
folder=path; Text="VDI Independent Cursor Probe"; FormBorderStyle=FormBorderStyle.None;
Bounds=Screen.PrimaryScreen.Bounds; TopMost=true; DoubleBuffered=true;
File.WriteAllText(Path.Combine(folder,"clicks.csv"),"utc,x,y,shape\n");
File.WriteAllText(Path.Combine(folder,"states.csv"),"utc,shape,flags,x,y\n");
timer.Interval=100;
timer.Tick+=delegate {
if((DateTime.UtcNow-started).TotalMinutes>8) { Close(); return; }
CI ci=new CI(); ci.size=Marshal.SizeOf(typeof(CI));
if(!GetCursorInfo(ref ci)) return;
string observed="unknown";
for(int i=0;i<Shapes.Length;i++) if(ci.cursor==Shapes[i].Handle) observed=Names[i];
string observedKey=observed+","+ci.flags;
if(observedKey!=lastObserved) {
File.AppendAllText(Path.Combine(folder,"states.csv"),DateTime.UtcNow.ToString("o")+","+observedKey+","+ci.point.x+","+ci.point.y+"\n");
lastObserved=observedKey;
}
string json="{\"version\":1,\"shape\":\""+observed+"\",\"visible\":"+(((ci.flags&1)!=0)?"true":"false")+",\"x\":"+ci.point.x+",\"y\":"+ci.point.y+",\"utc\":\""+DateTime.UtcNow.ToString("o")+"\"}";
string target=Path.Combine(folder,"state.json"),temp=target+".new";
File.WriteAllText(temp,json);
if(File.Exists(target)) File.Replace(temp,target,null); else File.Move(temp,target);
Invalidate(new Rectangle(0,0,Width,140));
};
timer.Start();
}
protected override void OnMouseMove(MouseEventArgs e) {
base.OnMouseMove(e);
bool nextHidden=e.Y>Height*3/4;
if(nextHidden!=hidden) { if(nextHidden) Cursor.Hide(); else Cursor.Show(); hidden=nextHidden; }
int index=Math.Min(3,e.X*4/Math.Max(1,Width)); shape=Names[index]; Cursor=Shapes[index];
}
protected override void OnMouseDown(MouseEventArgs e) {
base.OnMouseDown(e); ++clicks;
File.AppendAllText(Path.Combine(folder,"clicks.csv"),DateTime.UtcNow.ToString("o")+","+e.X+","+e.Y+","+shape+"\n"); Invalidate();
}
protected override void OnPaint(PaintEventArgs e) {
e.Graphics.Clear(Color.FromArgb(24,32,44));
using(Font f=new Font("Consolas",23)) for(int i=0;i<4;i++) {
e.Graphics.DrawRectangle(Pens.SlateGray,i*Width/4,150,Width/4-2,Height*3/4-150);
e.Graphics.DrawString(Names[i],f,Brushes.White,i*Width/4+40,200);
}
using(Font f=new Font("Consolas",24)) {
e.Graphics.DrawString("Independent cursor probe | clicks: "+clicks+" | "+DateTime.UtcNow.ToString("HH:mm:ss.fff"),f,Brushes.White,40,40);
e.Graphics.DrawString("BOTTOM AREA: cursor hidden",f,Brushes.Gold,40,Height*3/4+60);
}
}
protected override void OnFormClosed(FormClosedEventArgs e) {
timer.Stop(); timer.Dispose(); if(hidden) { Cursor.Show(); hidden=false; } base.OnFormClosed(e);
}
}
'@
[VDICursorProbe]::SetProcessDPIAware() | Out-Null
@{version=1; shapes=[VDICursorProbe]::ExportAll()} | ConvertTo-Json -Depth 6 -Compress | Set-Content -Encoding utf8 "$folder\shapes.json"
[System.Windows.Forms.Application]::Run((New-Object VDICursorProbe($folder)))
} catch { $_ | Out-String | Set-Content "$folder\error.txt"; throw }
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Temporary X11 local-cursor proof, NOT a production Moonlight cursor protocol.
Requires an already authenticated Moonlight session. Never changes Sunshine.
The caller owns remote-video-cursor handoff and must restore it in finally.
Only supports exported alpha cursors without destination-dependent XOR pixels.
"""
import argparse
import ctypes as C
import fcntl
import json
import math
import os
from pathlib import Path
import signal
import subprocess
import time
class XcursorImage(C.Structure):
_fields_ = [("version", C.c_uint32), ("size", C.c_uint32),
("width", C.c_uint32), ("height", C.c_uint32),
("xhot", C.c_uint32), ("yhot", C.c_uint32),
("delay", C.c_uint32), ("pixels", C.POINTER(C.c_uint32))]
class XfixesImage(C.Structure):
_fields_ = [("x", C.c_short), ("y", C.c_short),
("width", C.c_ushort), ("height", C.c_ushort),
("xhot", C.c_ushort), ("yhot", C.c_ushort),
("serial", C.c_ulong), ("pixels", C.POINTER(C.c_ulong)),
("atom", C.c_ulong), ("name", C.c_char_p)]
def validate_assets(data):
if not isinstance(data, dict) or type(data.get("version")) is not int or data.get("version") != 1 or not isinstance(data.get("shapes"), list) or not 1 <= len(data["shapes"]) <= 32:
raise ValueError("Invalid asset version/count")
result = {}
for s in data["shapes"]:
if not isinstance(s, dict):
raise ValueError("Invalid shape")
name = s.get("name")
if not isinstance(name, str) or not name.isascii() or not name.isalnum() or len(name) > 32 or name in result:
raise ValueError("Invalid/duplicate shape name")
for key in ("width", "height", "hotX", "hotY", "invertedPixels"):
if type(s.get(key)) is not int:
raise ValueError("Noninteger cursor metadata")
w, h = s["width"], s["height"]
if not 1 <= w <= 128 or not 1 <= h <= 128 or not 0 <= s["hotX"] < w or not 0 <= s["hotY"] < h:
raise ValueError("Invalid size/hotspot")
if s["invertedPixels"] != 0:
raise ValueError("Destination-dependent XOR cursor requires fallback")
pixels = s.get("argb")
if not isinstance(pixels, list) or len(pixels) != w*h or any(type(p) is not int or not 0 <= p <= 0xffffffff for p in pixels):
raise ValueError("Invalid ARGB pixels")
result[name] = s
return result
def validate_state(state, shapes):
if not isinstance(state, dict) or type(state.get("version")) is not int or state.get("version") != 1 or type(state.get("visible")) is not bool or not isinstance(state.get("shape"), str) or state.get("shape") not in shapes:
raise ValueError("Invalid state/unknown shape")
# A bounded wall-clock lease prevents stale local pointers after the bridge dies.
expires = state.get("expires")
if type(expires) not in (int, float) or not math.isfinite(expires) or not time.time() < expires <= time.time()+5:
raise ValueError("Expired/invalid state lease")
return state["shape"] if state["visible"] else "__hidden"
def read_json(path, limit):
with open(path, "rb") as f:
raw = f.read(limit+1)
if len(raw) > limit:
raise ValueError("Oversize JSON")
return json.loads(raw)
class LocalCursor:
def __init__(self, window):
self.x = C.CDLL("libX11.so.6")
self.c = C.CDLL("libXcursor.so.1")
self.f = C.CDLL("libXfixes.so.3")
self.x.XOpenDisplay.argtypes = [C.c_char_p]; self.x.XOpenDisplay.restype = C.c_void_p
self.x.XDefineCursor.argtypes = [C.c_void_p, C.c_ulong, C.c_ulong]
self.x.XSync.argtypes = [C.c_void_p, C.c_int]
self.x.XFree.argtypes = [C.c_void_p]
self.x.XFreeCursor.argtypes = [C.c_void_p, C.c_ulong]
self.x.XCloseDisplay.argtypes = [C.c_void_p]
self.f.XFixesGetCursorImage.argtypes = [C.c_void_p]; self.f.XFixesGetCursorImage.restype = C.POINTER(XfixesImage)
self.f.XFixesHideCursor.argtypes = [C.c_void_p, C.c_ulong]
self.f.XFixesShowCursor.argtypes = [C.c_void_p, C.c_ulong]
self.c.XcursorImageCreate.argtypes = [C.c_int, C.c_int]; self.c.XcursorImageCreate.restype = C.POINTER(XcursorImage)
self.c.XcursorImageDestroy.argtypes = [C.POINTER(XcursorImage)]
self.c.XcursorImageLoadCursor.argtypes = [C.c_void_p, C.POINTER(XcursorImage)]; self.c.XcursorImageLoadCursor.restype = C.c_ulong
self.d = self.x.XOpenDisplay(None)
if not self.d:
raise RuntimeError("Cannot open X display")
self.window = window
self.cursors = {}
self.original = self.create(self.snapshot(), premultiplied=True)
def snapshot(self):
image = self.f.XFixesGetCursorImage(self.d)
if not image:
raise RuntimeError("No XFixes cursor image")
try:
i = image.contents
if not 1 <= i.width <= 128 or not 1 <= i.height <= 128:
raise RuntimeError("Unsupported X cursor dimensions")
return dict(width=i.width, height=i.height, hotX=i.xhot, hotY=i.yhot,
x=i.x, y=i.y, argb=[int(i.pixels[p]) & 0xffffffff for p in range(i.width*i.height)])
finally:
self.x.XFree(image)
def create(self, s, premultiplied=False):
image = self.c.XcursorImageCreate(s["width"], s["height"])
if not image:
raise RuntimeError("Xcursor allocation failed")
try:
image.contents.xhot, image.contents.yhot = s["hotX"], s["hotY"]
for j, p in enumerate(s["argb"]):
if premultiplied:
image.contents.pixels[j] = p
continue
# XRender/Xcursor uses premultiplied alpha; Windows export is straight ARGB.
a = p >> 24
r, g, b = ((p >> shift) & 255 for shift in (16, 8, 0))
image.contents.pixels[j] = (a << 24) | ((r*a+127)//255 << 16) | ((g*a+127)//255 << 8) | ((b*a+127)//255)
cursor = self.c.XcursorImageLoadCursor(self.d, image)
if not cursor:
raise RuntimeError("Xcursor creation failed")
return cursor
finally:
self.c.XcursorImageDestroy(image)
def apply(self, cursor):
self.x.XDefineCursor(self.d, self.window, cursor)
self.x.XSync(self.d, 0)
def close(self, sdl_pid=None):
self.apply(self.original)
try:
if sdl_pid is not None:
# X11 cursor resources owned by this connection disappear when
# it closes. Have SDL restore its own hidden cursor first. The
# temporary XFixes hide prevents the first toggle flashing the
# default local arrow. This POC requires the same focused stream.
active = subprocess.check_output(["xdotool", "getwindowfocus", "getwindowpid"],text=True).strip()
if active != str(sdl_pid):
raise RuntimeError("Cannot return cursor ownership: stream lost focus; reconnect normal client")
self.f.XFixesHideCursor(self.d,self.window); self.x.XSync(self.d,0)
try:
for unused in range(2):
subprocess.run(["xdotool","key","ctrl+alt+shift+c"],check=True)
time.sleep(.08)
finally:
self.f.XFixesShowCursor(self.d,self.window); self.x.XSync(self.d,0)
finally:
for cursor in [self.original, *self.cursors.values()]:
self.x.XFreeCursor(self.d, cursor)
self.x.XCloseDisplay(self.d)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--window", type=int, required=True)
parser.add_argument("--pid", type=int, required=True)
parser.add_argument("--assets", type=Path, required=True)
parser.add_argument("--state", type=Path, required=True)
parser.add_argument("--seconds", type=int, default=120)
parser.add_argument("--snapshot", type=Path)
args = parser.parse_args()
if not 1 <= args.seconds <= 180 or args.window <= 0 or args.pid <= 1:
parser.error("Invalid window/pid/duration")
command = Path(f"/proc/{args.pid}/cmdline").read_bytes().replace(b"\0", b" ")
if b"/moonlight " not in command or b" stream " not in command or b" --absolute-mouse " not in command:
parser.error("Target must be the absolute-mode Moonlight stream")
actual_pid = subprocess.check_output(["xdotool", "getwindowpid", str(args.window)], text=True).strip()
focused_window = subprocess.check_output(["xdotool", "getwindowfocus"], text=True).strip()
if actual_pid != str(args.pid) or focused_window != str(args.window):
parser.error("Target must be the same focused Moonlight window")
# Keep the experiment exclusive, with owner-only local files.
lock = open(args.state.with_suffix(".lock"), "w")
os.chmod(lock.name, 0o600)
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
shapes = validate_assets(read_json(args.assets, 4*1024*1024))
cursor = LocalCursor(args.window)
if any(cursor.snapshot()["argb"]):
cursor.x.XFreeCursor(cursor.d, cursor.original)
cursor.x.XCloseDisplay(cursor.d)
parser.error("POC requires the original local cursor to be hidden")
stop = False
def stopped(*unused):
nonlocal stop
stop = True
signal.signal(signal.SIGTERM, stopped); signal.signal(signal.SIGINT, stopped)
try:
cursor.cursors = {name: cursor.create(s) for name, s in shapes.items()}
cursor.cursors["__hidden"] = cursor.create(dict(width=1, height=1, hotX=0, hotY=0, argb=[0]))
current = None
end = time.monotonic()+args.seconds
while not stop and time.monotonic() < end:
if not Path(f"/proc/{args.pid}").exists():
break
try:
desired = validate_state(read_json(args.state, 1024), shapes)
except (ValueError, OSError) as e:
print(json.dumps(dict(event="lease-ended", reason=str(e))), flush=True)
break
if desired != current:
cursor.apply(cursor.cursors[desired]); current = desired
s = cursor.snapshot()
if args.snapshot:
args.snapshot.write_text(json.dumps(s))
print(json.dumps(dict(event="applied", shape=current, x=s["x"], y=s["y"], width=s["width"], height=s["height"], hotX=s["hotX"], hotY=s["hotY"])), flush=True)
time.sleep(.01)
finally:
cursor.close(sdl_pid=args.pid)
print('{"event":"sdl-hidden-cursor-restored"}', flush=True)
if __name__ == "__main__":
main()
+73
View File
@@ -0,0 +1,73 @@
"""Scoped VM100 interactive cursor exporter. No new network listener or system cursor theme."""
import argparse
import base64
import json
from pathlib import Path
import runpy
import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["start", "status", "export", "stop"])
parser.add_argument("--output", type=Path)
args = parser.parse_args()
root = Path(__file__).resolve().parent
t = runpy.run_path(str(root / "pve_term_exec.py"))
v = runpy.run_path(str(root / "pve_vm.py"))
ticket, csrf = t["login"]()
if v["api"]("/api2/json/nodes/starhe/qemu/100/status/current", ticket=ticket).get("status") != "running":
sys.exit("VM100 is not running")
command = r"$ErrorActionPreference='Stop'; $folder='C:\ProgramData\VDI\cursor-poc-20261103'; $task='VDICursorPOC'; "
if args.action == "start":
payload = base64.b64encode((root / "cursor-poc-host.ps1").read_bytes()).decode()
command += r"""
if(Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue) { throw 'Probe already exists' }
$user=(Get-CimInstance Win32_ComputerSystem).UserName
if(-not $user) { throw 'No interactive user' }
New-Item -ItemType Directory -Force $folder | Out-Null
$acl=Get-Acl $folder
$acl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($user,'Modify','ContainerInherit,ObjectInherit','None','Allow')))
Set-Acl $folder $acl
Remove-Item "$folder\error.txt","$folder\state.json","$folder\shapes.json" -ErrorAction SilentlyContinue
[IO.File]::WriteAllBytes("$folder\probe.ps1",[Convert]::FromBase64String('__PAYLOAD__'))
$a=New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -STA -ExecutionPolicy Bypass -WindowStyle Hidden -File $folder\probe.ps1"
$p=New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
$s=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 9)
Register-ScheduledTask -TaskName $task -Action $a -Principal $p -Settings $s | Out-Null
Start-ScheduledTask -TaskName $task
Start-Sleep -Seconds 3
if(Test-Path "$folder\error.txt") { throw (Get-Content "$folder\error.txt" -Raw) }
if(-not(Test-Path "$folder\shapes.json")) { throw 'No fresh cursor export' }
'Cursor probe started'
""".replace("__PAYLOAD__", payload)
elif args.action == "export":
command += 'Get-Content "$folder\\shapes.json" -Raw'
elif args.action == "status":
command += r"""
Get-Content "$folder\state.json" -Raw
Get-Content "$folder\clicks.csv"
Get-Content "$folder\states.csv"
Get-Content "$folder\error.txt" -ErrorAction SilentlyContinue
"""
else:
command += r"""
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction SilentlyContinue
if(Test-Path "$folder\pid.txt") {
$probePid=[int](Get-Content "$folder\pid.txt")
$process=Get-CimInstance Win32_Process -Filter "ProcessId=$probePid"
if($process.CommandLine -like '*cursor-poc-20261103*') { Stop-Process -Id $probePid -ErrorAction SilentlyContinue }
}
'Cursor probe stopped'
"""
out, error = v["agent"](ticket, csrf, ["powershell.exe", "-NoProfile", "-Command", command])
if error or not out or out == "TIMEOUT":
sys.exit(error or out or "Empty guest-agent result")
if args.action == "export":
data = json.loads(out.lstrip("\ufeff"))
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
print(json.dumps([{k: s[k] for k in ("name", "width", "height", "hotX", "hotY", "monochrome", "invertedPixels")} for s in data["shapes"]]))
else:
print(out)
+50
View File
@@ -0,0 +1,50 @@
"""Cursor asset boundary/fallback tests; run on Linux (same fcntl environment as box)."""
import copy
import importlib.util
from pathlib import Path
import tempfile
import time
import unittest
spec=importlib.util.spec_from_file_location("cursor_poc",Path(__file__).with_name("cursor-poc-x11.py"))
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
class Boundaries(unittest.TestCase):
def setUp(self):
self.asset=dict(version=1,shapes=[dict(name="arrow",width=1,height=1,hotX=0,hotY=0,invertedPixels=0,argb=[0xffffffff])])
def bad(self,key,value):
data=copy.deepcopy(self.asset); data["shapes"][0][key]=value
with self.assertRaises(ValueError): m.validate_assets(data)
def test_valid(self): self.assertIn("arrow",m.validate_assets(self.asset))
def test_dimensions(self):
for v in (-1,0,129,True,"32"): self.bad("width",v)
def test_hotspot(self):
for v in (-1,1): self.bad("hotX",v)
def test_xor_requires_fallback(self): self.bad("invertedPixels",1)
def test_pixels(self):
for v in ([],[True],[-1],[0x100000000],[0,0]): self.bad("argb",v)
def test_duplicate_shapes(self):
self.asset["shapes"]*=2
with self.assertRaises(ValueError): m.validate_assets(self.asset)
def test_envelopes(self):
for v in (None,[],{},dict(version=True,shapes=self.asset["shapes"])):
with self.assertRaises(ValueError): m.validate_assets(v)
def test_lease(self):
shapes=m.validate_assets(self.asset)
for expires in (time.time()-1,time.time()+60,float("nan"),float("inf"),True):
with self.assertRaises(ValueError): m.validate_state(dict(version=1,visible=True,shape="arrow",expires=expires),shapes)
def test_state(self):
shapes=m.validate_assets(self.asset)
state=dict(version=1,visible=True,shape="arrow",expires=time.time()+1)
self.assertEqual(m.validate_state(state,shapes),"arrow")
state["visible"]=False
self.assertEqual(m.validate_state(state,shapes),"__hidden")
for key,value in (("visible",1),("shape","missing"),("shape",[]),("version",True)):
bad=dict(state); bad[key]=value
with self.assertRaises(ValueError): m.validate_state(bad,shapes)
def test_oversize_file(self):
with tempfile.TemporaryDirectory() as d:
p=Path(d)/"data.json"; p.write_text(" "*1025)
with self.assertRaises(ValueError): m.read_json(p,1024)
if __name__=="__main__": unittest.main()
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Controlled existing-stream POC; always restores the remote-video cursor toggle.
Only run with cursor-poc-host.ps1's disposable foreground window on Windows.
Shape selections here are scripted replay, NOT automatic host synchronization.
"""
import importlib.util
import json
import os
from pathlib import Path
import signal
import subprocess as S
import time
ROOT = Path(__file__).resolve().parent
os.environ.update(DISPLAY=":0", XAUTHORITY="/home/armbian/.Xauthority")
spec = importlib.util.spec_from_file_location("cursor_poc", ROOT / "cursor-poc-x11.py")
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
OUT = ROOT / "cursor-poc-evidence-v3"
OUT.mkdir(exist_ok=False) # Never overwrite earlier evidence.
def xd(*args):
return S.check_output(["xdotool", *map(str,args)], text=True).strip()
pid = int(xd("getwindowfocus", "getwindowpid"))
window = int(xd("getwindowfocus"))
cmd = Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ")
if b"/moonlight " not in cmd or b" stream 192.168.1.70 Desktop " not in cmd or b" --absolute-mouse " not in cmd:
raise RuntimeError("Unexpected foreground process")
assets = m.validate_assets(m.read_json(ROOT / "windows-shapes.json", 4*1024*1024))
cursor = m.LocalCursor(window)
if any(cursor.snapshot()["argb"]):
cursor.close(); raise RuntimeError("Original local cursor is not hidden")
hidden_remote = False
paused = False
records = []
def interrupted(signum, frame):
raise SystemExit(128+signum)
signal.signal(signal.SIGTERM, interrupted)
signal.signal(signal.SIGINT, interrupted)
def focus():
if int(xd("getwindowfocus", "getwindowpid")) != pid:
raise RuntimeError("Moonlight lost foreground; stop the POC")
def capture(name):
with (OUT / (name+".xwd")).open("wb") as f:
S.run(["xwd", "-root", "-silent"], stdout=f, check=True)
try:
cursor.cursors = {k: cursor.create(v) for k,v in assets.items()}
cursor.cursors["__hidden"] = cursor.create(dict(width=1,height=1,hotX=0,hotY=0,argb=[0]))
focus(); xd("mousemove", 320, 500); time.sleep(.8)
capture("01-remote-cursor")
focus(); xd("key", "ctrl+alt+shift+n"); hidden_remote=True
xd("mousemove",321,500); time.sleep(1.0)
capture("02-video-cursor-disabled")
for name,x in (("arrow",320),("ibeam",960),("sizewe",1600),("wait",2240),("__hidden",320)):
focus(); y=1250 if name=="__hidden" else 500
xd("mousemove", x,y); time.sleep(.25)
cursor.apply(cursor.cursors[name]); time.sleep(.05)
snap=cursor.snapshot()
(OUT/(name+".json")).write_text(json.dumps(snap))
if name=="__hidden":
assert not any(snap["argb"]), "Hidden cursor still has visible pixels"
else:
a=assets[name]
assert (snap["width"],snap["height"],snap["hotX"],snap["hotY"]) == (a["width"],a["height"],a["hotX"],a["hotY"])
assert any(snap["argb"]), "Visible cursor has no pixels"
assert (snap["x"],snap["y"])==(x,y)
records.append(dict(shape=name,x=x,y=y,visible=any(snap["argb"])))
time.sleep(.3) # Allow the host probe to observe the real shape/visibility.
focus(); xd("mousemove",320,500); cursor.apply(cursor.cursors["arrow"]); time.sleep(.4)
# Freeze only this client for < 0.5s; X11 cursor must move while its video/input
# event processing is stopped. SIGCONT is guaranteed before cursor restoration.
os.kill(pid,signal.SIGSTOP); paused=True
try:
first=cursor.snapshot()
xd("mousemove",500,650); time.sleep(.12)
second=cursor.snapshot()
assert (second["x"],second["y"])==(500,650)
assert first["argb"]==second["argb"]
records.append(dict(test="client-paused-local-motion",before=[first["x"],first["y"]],after=[second["x"],second["y"]],passed=True))
finally:
os.kill(pid,signal.SIGCONT); paused=False
time.sleep(.5)
# Click only within the disposable Windows form. Scripted shape replay is
# deliberately distinct from the not-yet-implemented live cursor protocol.
for i in range(32):
focus(); n=i%4; x=320+640*n; y=600+(i//4)*20
cursor.apply(cursor.cursors[("arrow","ibeam","sizewe","wait")[n]])
xd("mousemove",x,y); time.sleep(.06); xd("click",1); time.sleep(.06)
capture("03-local-mode-video-only")
# Verify the actual helper's lease failure path while video cursor is hidden.
cursor.apply(cursor.original)
lease=OUT/"lease.json"
lease.write_text(json.dumps(dict(version=1,shape="arrow",visible=True,expires=time.time()+.8)))
result=S.run(["python3",str(ROOT/"cursor-poc-x11.py"),"--window",str(window),"--pid",str(pid),
"--assets",str(ROOT/"windows-shapes.json"),"--state",str(lease),"--seconds","3"],
capture_output=True,text=True,timeout=5)
(OUT/"lease-expiry.log").write_text(result.stdout+result.stderr)
assert result.returncode==0 and "lease-ended" in result.stdout and "sdl-hidden-cursor-restored" in result.stdout
# Xorg can briefly have no current cursor image after another connection's
# final cursor resource is released. Trigger pointer recomputation before
# checking the *post-exit* image; do not mistake this for an alpha image.
xd("mousemove",2240,741); time.sleep(.1)
assert not any(cursor.snapshot()["argb"])
records.append(dict(test="helper-lease-expiry",passed=True))
finally:
if paused: os.kill(pid,signal.SIGCONT)
try:
cursor.close(sdl_pid=pid)
finally:
# Remote restoration must still run when local cleanup raises an error.
if hidden_remote:
xd("windowactivate","--sync",window); focus()
xd("key","ctrl+alt+shift+n"); time.sleep(.8)
capture("04-remote-cursor-restored")
(OUT/"results.json").write_text(json.dumps(records,indent=2))
print(json.dumps(dict(restored_remote_cursor=hidden_remote,records=records)),flush=True)