验证:补充独立光标可行性POC
This commit is contained in:
1 parent
b29b59c62f
commit
1439028833
49 files changed
+957
-1
No files matched your search
@@ -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 }
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user