@bitefoundry/retrieviewer 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESKTOP-PLATFORMS.md +70 -0
- package/INSTALLING.md +28 -6
- package/README.md +33 -5
- package/RELEASING.md +15 -4
- package/WINDOWS-COMPANION.md +113 -0
- package/integrations/desktop/platform.mjs +27 -0
- package/integrations/linux-overlay/README.md +83 -0
- package/integrations/linux-overlay/overlay.py +1094 -0
- package/integrations/macos-overlay/README.md +83 -0
- package/integrations/macos-overlay/overlay.jxa +637 -0
- package/integrations/windows-overlay/Overlay.cs +259 -0
- package/integrations/windows-overlay/desktop.mjs +119 -0
- package/integrations/windows-overlay/protocol.mjs +39 -0
- package/integrations/windows-overlay/worker.mjs +66 -0
- package/package.json +15 -3
- package/retrieviewer-cli.mjs +25 -5
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Native Windows painter for the existing Braille engine. No injection, keyboard
|
|
2
|
+
// hooks, terminal output, screen capture, network, registry or agent config access.
|
|
3
|
+
// Built locally with the Windows .NET Framework compiler; no downloaded binaries.
|
|
4
|
+
using System;
|
|
5
|
+
using System.Collections.Generic;
|
|
6
|
+
using System.Diagnostics;
|
|
7
|
+
using System.Drawing;
|
|
8
|
+
using System.Drawing.Imaging;
|
|
9
|
+
using System.IO;
|
|
10
|
+
using System.Runtime.InteropServices;
|
|
11
|
+
using System.Text;
|
|
12
|
+
using System.Threading;
|
|
13
|
+
using System.Web.Script.Serialization;
|
|
14
|
+
using System.Windows.Forms;
|
|
15
|
+
|
|
16
|
+
static class Native {
|
|
17
|
+
[StructLayout(LayoutKind.Sequential)] internal struct POINT { internal int X,Y; internal POINT(int x,int y){X=x;Y=y;} }
|
|
18
|
+
[StructLayout(LayoutKind.Sequential)] internal struct SIZE { internal int Width,Height; internal SIZE(int w,int h){Width=w;Height=h;} }
|
|
19
|
+
[StructLayout(LayoutKind.Sequential)] internal struct RECT { internal int Left,Top,Right,Bottom; }
|
|
20
|
+
[StructLayout(LayoutKind.Sequential,Pack=1)] internal struct BLEND { internal byte Op,Flags,Alpha,Format; }
|
|
21
|
+
internal delegate bool EnumProc(IntPtr hwnd,IntPtr data);
|
|
22
|
+
[DllImport("user32.dll")] internal static extern bool EnumWindows(EnumProc proc,IntPtr data);
|
|
23
|
+
[DllImport("user32.dll")] internal static extern bool IsWindowVisible(IntPtr hwnd);
|
|
24
|
+
[DllImport("user32.dll")] internal static extern bool IsWindow(IntPtr hwnd);
|
|
25
|
+
[DllImport("user32.dll")] internal static extern bool IsIconic(IntPtr hwnd);
|
|
26
|
+
[DllImport("user32.dll")] internal static extern IntPtr GetForegroundWindow();
|
|
27
|
+
[DllImport("user32.dll",CharSet=CharSet.Unicode)] internal static extern int GetWindowText(IntPtr hwnd,StringBuilder text,int length);
|
|
28
|
+
[DllImport("user32.dll")] internal static extern uint GetWindowThreadProcessId(IntPtr hwnd,out uint pid);
|
|
29
|
+
[DllImport("user32.dll")] internal static extern bool GetClientRect(IntPtr hwnd,out RECT rect);
|
|
30
|
+
[DllImport("user32.dll")] internal static extern bool GetWindowRect(IntPtr hwnd,out RECT rect);
|
|
31
|
+
[DllImport("user32.dll")] internal static extern bool ClientToScreen(IntPtr hwnd,ref POINT point);
|
|
32
|
+
[DllImport("user32.dll")] internal static extern bool SetWindowPos(IntPtr hwnd,IntPtr after,int x,int y,int w,int h,uint flags);
|
|
33
|
+
[DllImport("user32.dll")] internal static extern int GetWindowLong(IntPtr hwnd,int index);
|
|
34
|
+
[DllImport("user32.dll")] internal static extern bool ShowWindow(IntPtr hwnd,int command);
|
|
35
|
+
[DllImport("user32.dll")] internal static extern bool SetProcessDPIAware();
|
|
36
|
+
[DllImport("user32.dll")] internal static extern bool SetProcessDpiAwarenessContext(IntPtr context);
|
|
37
|
+
[DllImport("user32.dll")] internal static extern IntPtr GetDC(IntPtr hwnd);
|
|
38
|
+
[DllImport("user32.dll")] internal static extern int ReleaseDC(IntPtr hwnd,IntPtr dc);
|
|
39
|
+
[DllImport("gdi32.dll")] internal static extern IntPtr CreateCompatibleDC(IntPtr dc);
|
|
40
|
+
[DllImport("gdi32.dll")] internal static extern IntPtr SelectObject(IntPtr dc,IntPtr bitmap);
|
|
41
|
+
[DllImport("gdi32.dll")] internal static extern bool DeleteDC(IntPtr dc);
|
|
42
|
+
[DllImport("gdi32.dll")] internal static extern bool DeleteObject(IntPtr obj);
|
|
43
|
+
[DllImport("user32.dll",SetLastError=true)] internal static extern bool UpdateLayeredWindow(IntPtr hwnd,IntPtr dc,ref POINT dst,ref SIZE size,IntPtr src,ref POINT point,int key,ref BLEND blend,int flags);
|
|
44
|
+
internal static Rectangle ClientBounds(IntPtr hwnd){
|
|
45
|
+
RECT r;POINT p=new POINT(0,0);
|
|
46
|
+
if(!GetClientRect(hwnd,out r)||!ClientToScreen(hwnd,ref p))return Rectangle.Empty;
|
|
47
|
+
return new Rectangle(p.X,p.Y,r.Right-r.Left,r.Bottom-r.Top);
|
|
48
|
+
}
|
|
49
|
+
internal static void Place(IntPtr hwnd,Rectangle rect){
|
|
50
|
+
SetWindowPos(hwnd,new IntPtr(-1),rect.X,rect.Y,rect.Width,rect.Height,0x10|0x40); // TOPMOST, NOACTIVATE, SHOWWINDOW
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class QuietWindow : Form {
|
|
55
|
+
internal QuietWindow(){FormBorderStyle=FormBorderStyle.None;ShowInTaskbar=false;AutoScaleMode=AutoScaleMode.None;StartPosition=FormStartPosition.Manual;}
|
|
56
|
+
protected override bool ShowWithoutActivation {get{return true;}}
|
|
57
|
+
protected override CreateParams CreateParams {get{CreateParams p=base.CreateParams;p.ExStyle|=0x08000000|0x80;return p;}} // NOACTIVATE, TOOLWINDOW
|
|
58
|
+
protected override void WndProc(ref Message m){if(m.Msg==0x21){m.Result=new IntPtr(3);return;}base.WndProc(ref m);} // MA_NOACTIVATE
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
sealed class PetWindow : QuietWindow {
|
|
62
|
+
protected override CreateParams CreateParams {get{CreateParams p=base.CreateParams;p.ExStyle|=0x80000|0x20;return p;}} // LAYERED + TRANSPARENT
|
|
63
|
+
internal int PaintedDots;
|
|
64
|
+
internal Bitmap PaintFrame(Dictionary<string,object> m,int cell,Rectangle canvas){
|
|
65
|
+
int cols=Program.Int(m,"columns"),rows=Program.Int(m,"rows"),cx=Program.Int(m,"x"),cy=Program.Int(m,"y");
|
|
66
|
+
string text=Program.Str(m,"text");
|
|
67
|
+
if(cols==0&&rows==0&&text.Length==0){Hide();return null;}
|
|
68
|
+
if(cols<1||rows<1||cols*rows>125000||cx<0||cy<0||(cx+cols)*cell>canvas.Width||(cy+rows)*cell*2>canvas.Height)throw new Exception("Out-of-bounds overlay frame");
|
|
69
|
+
string[] lines=text.Split('\n');if(lines.Length!=rows)throw new Exception("Invalid overlay row count");
|
|
70
|
+
Bitmap image=new Bitmap(cols*cell,rows*cell*2,PixelFormat.Format32bppPArgb);
|
|
71
|
+
try{
|
|
72
|
+
using(Graphics g=Graphics.FromImage(image))using(Brush ink=new SolidBrush(Color.FromArgb(245,248,242,232))){
|
|
73
|
+
g.Clear(Color.Transparent);g.SmoothingMode=System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
|
74
|
+
int[] dx={0,0,0,1,1,1,0,1},dy={0,1,2,0,1,2,3,3};float step=cell/2f,diameter=Math.Max(1.1f,step*.48f);PaintedDots=0;
|
|
75
|
+
for(int y=0;y<rows;y++){
|
|
76
|
+
if(lines[y].Length!=cols)throw new Exception("Invalid overlay column count");
|
|
77
|
+
for(int x=0;x<cols;x++){
|
|
78
|
+
int mask=lines[y][x]-0x2800;if(mask<0||mask>255)throw new Exception("Not a Braille frame");
|
|
79
|
+
for(int bit=0;bit<8;bit++)if((mask&(1<<bit))!=0){
|
|
80
|
+
float px=x*cell+(dx[bit]+.5f)*step,py=y*cell*2+(dy[bit]+.5f)*step;
|
|
81
|
+
g.FillEllipse(ink,px-diameter/2,py-diameter/2,diameter,diameter);PaintedDots++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
Rectangle destination=new Rectangle(canvas.X+cx*cell,canvas.Y+cy*cell*2,image.Width,image.Height);
|
|
87
|
+
if(!Visible){Bounds=destination;Show();}
|
|
88
|
+
IntPtr screen=Native.GetDC(IntPtr.Zero),memory=Native.CreateCompatibleDC(screen),bitmap=IntPtr.Zero,old=IntPtr.Zero;
|
|
89
|
+
try{
|
|
90
|
+
bitmap=image.GetHbitmap(Color.FromArgb(0));old=Native.SelectObject(memory,bitmap);
|
|
91
|
+
Native.POINT target=new Native.POINT(canvas.X+cx*cell,canvas.Y+cy*cell*2),source=new Native.POINT(0,0);
|
|
92
|
+
Native.SIZE size=new Native.SIZE(image.Width,image.Height);Native.BLEND blend=new Native.BLEND();blend.Alpha=255;blend.Format=1;
|
|
93
|
+
if(!Native.UpdateLayeredWindow(Handle,screen,ref target,ref size,memory,ref source,0,ref blend,2))throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
|
94
|
+
Native.Place(Handle,new Rectangle(target.X,target.Y,size.Width,size.Height));
|
|
95
|
+
}finally{if(old!=IntPtr.Zero)Native.SelectObject(memory,old);if(bitmap!=IntPtr.Zero)Native.DeleteObject(bitmap);Native.DeleteDC(memory);Native.ReleaseDC(IntPtr.Zero,screen);}
|
|
96
|
+
return image;
|
|
97
|
+
}catch{image.Dispose();throw;}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
sealed class TargetItem {
|
|
102
|
+
internal IntPtr Window;internal uint Process;internal string Title;internal bool Terminal;
|
|
103
|
+
public override string ToString(){return Title;}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
sealed class Companion : ApplicationContext {
|
|
107
|
+
readonly PetWindow pet=new PetWindow();readonly QuietWindow bar=new QuietWindow();
|
|
108
|
+
readonly NotifyIcon tray=new NotifyIcon();readonly System.Windows.Forms.Timer clock=new System.Windows.Forms.Timer();
|
|
109
|
+
readonly JavaScriptSerializer json=new JavaScriptSerializer();readonly object inputLock=new object();
|
|
110
|
+
readonly Queue<Dictionary<string,object>> messages=new Queue<Dictionary<string,object>>();
|
|
111
|
+
TargetItem target;Form pickerWindow;QuietWindow testTarget;readonly bool testMode;Rectangle canvas;int revision=0,cell=8;bool visible=false,paused=false,loaded=false,quitting=false,picking=false,announced=false;
|
|
112
|
+
Label status=new Label();Mutex mutex;
|
|
113
|
+
internal Companion(Mutex single,bool diagnostic){
|
|
114
|
+
mutex=single;testMode=diagnostic;bar.Text="Retrieviewer controls";bar.Size=new Size(354,40);bar.BackColor=Color.FromArgb(28,30,34);bar.ForeColor=Color.White;
|
|
115
|
+
bar.Font=new Font("Segoe UI",9);bar.Controls.Add(MakeButton("공",8,()=>Interact("ball")));bar.Controls.Add(MakeButton("간식",52,()=>Interact("treat")));
|
|
116
|
+
bar.Controls.Add(MakeButton("창",102,ChooseTarget));bar.Controls.Add(MakeButton("쉼",146,TogglePause));bar.Controls.Add(MakeButton("종료",190,Quit));
|
|
117
|
+
status.AutoSize=false;status.Location=new Point(240,12);status.Size=new Size(110,22);status.Text="준비 중";bar.Controls.Add(status);
|
|
118
|
+
ContextMenuStrip menu=new ContextMenuStrip();menu.Items.Add("다른 창 선택",null,(s,e)=>ChooseTarget());menu.Items.Add("숨기기 / 다시 표시",null,(s,e)=>TogglePause());menu.Items.Add("종료",null,(s,e)=>Quit());
|
|
119
|
+
tray.Icon=SystemIcons.Application;tray.Text="Retrieviewer · 창 선택";tray.ContextMenuStrip=menu;tray.Visible=true;tray.DoubleClick+=(s,e)=>ChooseTarget();
|
|
120
|
+
// Handles are created on the UI thread before the reader can signal EOF.
|
|
121
|
+
IntPtr unused=bar.Handle;unused=pet.Handle;
|
|
122
|
+
Thread reader=new Thread(ReadInput);reader.IsBackground=true;reader.Start();
|
|
123
|
+
clock.Interval=16;clock.Tick+=(s,e)=>Tick();clock.Start();
|
|
124
|
+
if(testMode){
|
|
125
|
+
testTarget=new QuietWindow();testTarget.Bounds=new Rectangle(-12000,-12000,1280,800);testTarget.Show();
|
|
126
|
+
target=new TargetItem{Window=testTarget.Handle,Process=(uint)Process.GetCurrentProcess().Id,Title="owned diagnostic window"};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
Button MakeButton(string title,int x,Action action){
|
|
130
|
+
Button b=new Button();b.Text=title;b.TabStop=false;b.Size=new Size(title=="간식"?46:40,28);b.Location=new Point(x,6);b.FlatStyle=FlatStyle.Flat;b.FlatAppearance.BorderSize=0;b.BackColor=Color.FromArgb(48,51,58);b.ForeColor=Color.White;b.Click+=(s,e)=>action();return b;
|
|
131
|
+
}
|
|
132
|
+
void ReadInput(){
|
|
133
|
+
try{
|
|
134
|
+
string line;while((line=Program.Input.ReadLine())!=null){
|
|
135
|
+
if(line.Length>800000)break;
|
|
136
|
+
Dictionary<string,object> value=json.Deserialize<Dictionary<string,object>>(line);
|
|
137
|
+
lock(inputLock){if(messages.Count>4)break;messages.Enqueue(value);}
|
|
138
|
+
}
|
|
139
|
+
}catch{}
|
|
140
|
+
try{bar.BeginInvoke(new Action(Quit));}catch{}
|
|
141
|
+
}
|
|
142
|
+
void Interact(string kind){if(!loaded||paused)return;Program.Emit(new {type="interact",kind=kind});}
|
|
143
|
+
void TogglePause(){paused=!paused;Program.Emit(new {type="pause",paused=paused});Track(true);}
|
|
144
|
+
void Tick(){
|
|
145
|
+
if(quitting)return;
|
|
146
|
+
try{
|
|
147
|
+
if(!announced){announced=true;if(!testMode)ChooseTarget();Program.Emit(new {type="ready",pickerVisible=pickerWindow!=null&&pickerWindow.Visible});}
|
|
148
|
+
Track(false);
|
|
149
|
+
for(;;){Dictionary<string,object> m;lock(inputLock){if(messages.Count==0)break;m=messages.Dequeue();}
|
|
150
|
+
string type=Program.Str(m,"type");
|
|
151
|
+
if(type=="quit"){Quit();return;}
|
|
152
|
+
if(type=="loaded"){loaded=true;status.Text="함께 노는 중";if(testMode)((Button)bar.Controls[0]).PerformClick();continue;}
|
|
153
|
+
if(type=="interaction"){status.Text=Program.Bool(m,"accepted")?"좋아!":"잠깐 기다려";continue;}
|
|
154
|
+
if(type=="error"){status.Text="엔진 오류";Quit();return;}
|
|
155
|
+
if(type=="frame"){
|
|
156
|
+
// Always acknowledge discarded frames too, otherwise resizing can deadlock the sender.
|
|
157
|
+
bool painted=visible&&!paused&&Program.Int(m,"revision")==revision;
|
|
158
|
+
if(painted){using(Bitmap image=pet.PaintFrame(m,cell,canvas)){} }
|
|
159
|
+
Program.Emit(new {type="ack",painted=painted});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}catch(Exception error){Program.Emit(new {type="error",message=error.Message});Quit();}
|
|
163
|
+
}
|
|
164
|
+
void Track(bool force){
|
|
165
|
+
Rectangle next=Rectangle.Empty;bool show=false;uint pid;
|
|
166
|
+
if(target!=null&&(!Native.IsWindow(target.Window)||Native.GetWindowThreadProcessId(target.Window,out pid)==0||pid!=target.Process)){target=null;}
|
|
167
|
+
if(target!=null&&!Native.IsIconic(target.Window)&&Native.IsWindowVisible(target.Window)){
|
|
168
|
+
next=Native.ClientBounds(target.Window);IntPtr foreground=Native.GetForegroundWindow();show=!picking&&(testMode||foreground==target.Window||foreground==bar.Handle)&&next.Width>=160&&next.Height>=120;
|
|
169
|
+
}
|
|
170
|
+
int nextCell=8;while(next.Width/nextCell*(next.Height/(nextCell*2))>125000&&nextCell<16)nextCell+=2;
|
|
171
|
+
if(nextCell==14)nextCell=16;
|
|
172
|
+
int columns=next.Width/nextCell,rows=next.Height/(nextCell*2);
|
|
173
|
+
if(force||show!=visible||next!=canvas||nextCell!=cell){
|
|
174
|
+
visible=show;canvas=next;cell=nextCell;revision++;pet.Hide();
|
|
175
|
+
Program.Emit(new {type="viewport",revision=revision,visible=show&&!paused,selected=target!=null,columns=columns,rows=rows,cellWidth=cell,cellHeight=cell*2});
|
|
176
|
+
}
|
|
177
|
+
if(visible){
|
|
178
|
+
if(!bar.Visible)bar.Show();Native.Place(bar.Handle,new Rectangle(canvas.Right-bar.Width-10,canvas.Top+10,bar.Width,bar.Height));
|
|
179
|
+
if(paused)pet.Hide();
|
|
180
|
+
}else{pet.Hide();bar.Hide();}
|
|
181
|
+
}
|
|
182
|
+
List<TargetItem> Targets(){
|
|
183
|
+
List<TargetItem> result=new List<TargetItem>();uint mine=(uint)Process.GetCurrentProcess().Id;
|
|
184
|
+
Native.EnumWindows((hwnd,data)=>{
|
|
185
|
+
uint pid;Native.GetWindowThreadProcessId(hwnd,out pid);if(pid==mine||!Native.IsWindowVisible(hwnd))return true;
|
|
186
|
+
StringBuilder name=new StringBuilder(512);Native.GetWindowText(hwnd,name,name.Capacity);if(name.Length==0)return true;
|
|
187
|
+
string process="";try{using(Process p=Process.GetProcessById((int)pid)){process=p.ProcessName;}}catch{}
|
|
188
|
+
bool terminal=process.Equals("WindowsTerminal",StringComparison.OrdinalIgnoreCase)||process.IndexOf("wezterm",StringComparison.OrdinalIgnoreCase)>=0||process=="OpenConsole"||process=="conhost"||process=="mintty";
|
|
189
|
+
result.Add(new TargetItem{Window=hwnd,Process=pid,Title=(terminal?"[터미널] ":"")+name.ToString()+" · "+process,Terminal=terminal});return true;
|
|
190
|
+
},IntPtr.Zero);
|
|
191
|
+
result.Sort((a,b)=>a.Terminal==b.Terminal?String.Compare(a.Title,b.Title,StringComparison.CurrentCulture):a.Terminal?-1:1);return result;
|
|
192
|
+
}
|
|
193
|
+
void ChooseTarget(){
|
|
194
|
+
if(picking||quitting)return;picking=true;Track(true);
|
|
195
|
+
Form picker=new Form();pickerWindow=picker;
|
|
196
|
+
{
|
|
197
|
+
picker.Text="Retrieviewer · 함께 있을 창";picker.Size=new Size(650,230);picker.StartPosition=FormStartPosition.CenterScreen;picker.FormBorderStyle=FormBorderStyle.FixedDialog;picker.MaximizeBox=false;picker.MinimizeBox=false;picker.Font=new Font("Segoe UI",10);
|
|
198
|
+
picker.ShowInTaskbar=true;picker.TopMost=true;
|
|
199
|
+
Label hint=new Label();hint.Text="Codex를 사용하는 터미널 창을 선택하세요.\n내용을 읽지 않고 창의 위치·크기만 따라갑니다. 탭은 구별하지 않습니다.";hint.SetBounds(18,16,610,50);
|
|
200
|
+
ComboBox list=new ComboBox();list.DropDownStyle=ComboBoxStyle.DropDownList;list.SetBounds(18,76,600,28);foreach(TargetItem item in Targets())list.Items.Add(item);if(list.Items.Count>0)list.SelectedIndex=0;
|
|
201
|
+
Button refresh=new Button();refresh.Text="새로 고침";refresh.SetBounds(18,124,100,34);refresh.Click+=(s,e)=>{list.Items.Clear();foreach(TargetItem item in Targets())list.Items.Add(item);if(list.Items.Count>0)list.SelectedIndex=0;};
|
|
202
|
+
Button attach=new Button();attach.Text="이 창에 붙이기";attach.SetBounds(378,124,140,34);attach.Click+=(s,e)=>{if(list.SelectedItem!=null){target=(TargetItem)list.SelectedItem;paused=false;Program.Emit(new {type="pause",paused=false});picker.Close();}};
|
|
203
|
+
Button cancel=new Button();cancel.Text="취소";cancel.SetBounds(528,124,90,34);cancel.Click+=(s,e)=>picker.Close();
|
|
204
|
+
picker.Controls.AddRange(new Control[]{hint,list,refresh,attach,cancel});
|
|
205
|
+
picker.FormClosed+=(s,e)=>{pickerWindow=null;picking=false;Program.Emit(new {type="picker",visible=false});if(!quitting){if(target==null)status.Text="트레이에서 창 선택";tray.Text=target==null?"Retrieviewer · 창 선택":"Retrieviewer · 창에 붙어 있음";Track(true);}picker.Dispose();};
|
|
206
|
+
picker.Show();Native.ShowWindow(picker.Handle,5);Program.Emit(new {type="picker",visible=Native.IsWindowVisible(picker.Handle)});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
void Quit(){if(quitting)return;quitting=true;clock.Stop();if(pickerWindow!=null)pickerWindow.Close();pet.Close();bar.Close();tray.Visible=false;tray.Dispose();Program.Emit(new {type="quit"});ExitThread();}
|
|
210
|
+
protected override void Dispose(bool disposing){if(disposing){clock.Dispose();pet.Dispose();bar.Dispose();tray.Dispose();if(testTarget!=null)testTarget.Dispose();if(mutex!=null){mutex.ReleaseMutex();mutex.Dispose();mutex=null;}}base.Dispose(disposing);}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
static class Program {
|
|
214
|
+
static readonly object outputLock=new object();
|
|
215
|
+
internal static TextReader Input;static TextWriter Output;
|
|
216
|
+
internal static void Emit(object value){lock(outputLock){try{Output.WriteLine(new JavaScriptSerializer().Serialize(value));Output.Flush();}catch{Environment.Exit(0);}}}
|
|
217
|
+
internal static int Int(Dictionary<string,object> m,string key){return Convert.ToInt32(m[key]);}
|
|
218
|
+
internal static string Str(Dictionary<string,object> m,string key){return Convert.ToString(m[key]);}
|
|
219
|
+
internal static bool Bool(Dictionary<string,object> m,string key){return Convert.ToBoolean(m[key]);}
|
|
220
|
+
[STAThread] static int Main(string[] args){
|
|
221
|
+
try{
|
|
222
|
+
// GUI-subsystem processes have redirected handles but no console code
|
|
223
|
+
// page. Setting Console.*Encoding can throw before startup on Windows.
|
|
224
|
+
Input=new StreamReader(Console.OpenStandardInput(),new UTF8Encoding(false),false);
|
|
225
|
+
Output=new StreamWriter(Console.OpenStandardOutput(),new UTF8Encoding(false));
|
|
226
|
+
try{Native.SetProcessDpiAwarenessContext(new IntPtr(-4));}catch(EntryPointNotFoundException){Native.SetProcessDPIAware();}
|
|
227
|
+
Application.EnableVisualStyles();Application.SetCompatibleTextRenderingDefault(false);
|
|
228
|
+
if(args.Length==2&&args[0]=="--self-test")return SelfTest(args[1]);
|
|
229
|
+
bool ipcTest=args.Length==1&&args[0]=="--ipc-test";
|
|
230
|
+
if(args.Length!=0&&!ipcTest)throw new Exception("Unsupported native painter argument");
|
|
231
|
+
// Diagnostics own only off-screen windows and must not stop or contend
|
|
232
|
+
// with a user's running companion during a release verification.
|
|
233
|
+
string mutexName="Local\\BiteFoundry.Retrieviewer.Overlay."+System.Security.Principal.WindowsIdentity.GetCurrent().User.Value+(ipcTest?".Diagnostics."+Process.GetCurrentProcess().Id:"");
|
|
234
|
+
bool first;Mutex single=new Mutex(true,mutexName,out first);
|
|
235
|
+
if(!first){single.Dispose();Emit(new {type="error",message="A Retrieviewer companion is already running"});return 2;}
|
|
236
|
+
using(Companion context=new Companion(single,ipcTest))Application.Run(context);return 0;
|
|
237
|
+
}catch(Exception error){Emit(new {type="error",message=error.Message});return 1;}
|
|
238
|
+
}
|
|
239
|
+
static int SelfTest(string proof){
|
|
240
|
+
// Uses only our own off-screen windows. Does NOT click/type in or capture a user application.
|
|
241
|
+
IntPtr foreground=Native.GetForegroundWindow();
|
|
242
|
+
Dictionary<string,object> frame=new JavaScriptSerializer().Deserialize<Dictionary<string,object>>(Input.ReadLine());
|
|
243
|
+
using(QuietWindow target=new QuietWindow())using(PetWindow pet=new PetWindow()){
|
|
244
|
+
target.Bounds=new Rectangle(-12000,-12000,1600,1000);target.Show();Application.DoEvents();
|
|
245
|
+
Rectangle first=Native.ClientBounds(target.Handle);using(Bitmap bitmap=pet.PaintFrame(frame,8,first)){bitmap.Save(proof,ImageFormat.Png);}
|
|
246
|
+
int styles=Native.GetWindowLong(pet.Handle,-20);bool clickThrough=(styles&0x20)!=0,noActivate=(styles&0x08000000)!=0,layered=(styles&0x80000)!=0;
|
|
247
|
+
target.Bounds=new Rectangle(-11000,-11500,1800,1100);Application.DoEvents();Rectangle moved=Native.ClientBounds(target.Handle);
|
|
248
|
+
using(Bitmap bitmap=pet.PaintFrame(frame,8,moved)){}
|
|
249
|
+
Native.RECT actual;Native.GetWindowRect(pet.Handle,out actual);
|
|
250
|
+
int expectedX=moved.Left+Int(frame,"x")*8,expectedY=moved.Top+Int(frame,"y")*16;
|
|
251
|
+
bool follows=actual.Left==expectedX&&actual.Top==expectedY;
|
|
252
|
+
Native.ShowWindow(target.Handle,7);Application.DoEvents();bool minimized=Native.IsIconic(target.Handle);pet.Hide();bool hidden=!pet.Visible;
|
|
253
|
+
Native.ShowWindow(target.Handle,4);Application.DoEvents();bool restored=!Native.IsIconic(target.Handle);
|
|
254
|
+
bool focus=Native.GetForegroundWindow()==foreground;
|
|
255
|
+
bool passed=clickThrough&&noActivate&&layered&&follows&&minimized&&hidden&&restored&&focus&&pet.PaintedDots>0;
|
|
256
|
+
Emit(new {type="self-test",passed=passed,clickThroughStyle=clickThrough,noActivateStyle=noActivate,layered=layered,followGeometry=follows,expectedX=expectedX,expectedY=expectedY,actualX=actual.Left,actualY=actual.Top,minimize=minimized,hide=hidden,restore=restored,foregroundPreserved=focus,dots=pet.PaintedDots,proof=proof});return passed?0:1;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Explicit native companion launcher. Never imports the pet on help/status/stop.
|
|
2
|
+
import {spawn,execFile} from 'node:child_process';
|
|
3
|
+
import {promisify} from 'node:util';
|
|
4
|
+
import {readFile,mkdir,lstat,unlink,open,rename,readdir} from 'node:fs/promises';
|
|
5
|
+
import {createHash,randomUUID} from 'node:crypto';
|
|
6
|
+
import {join,dirname} from 'node:path';
|
|
7
|
+
import {fileURLToPath} from 'node:url';
|
|
8
|
+
import {createConnection} from 'node:net';
|
|
9
|
+
import {getConfigPaths} from '../../retrieviewer-config.mjs';
|
|
10
|
+
import {desktopPlatform,validControlAddress} from '../desktop/platform.mjs';
|
|
11
|
+
const exec=promisify(execFile);
|
|
12
|
+
const SOURCE=new URL('./Overlay.cs',import.meta.url);
|
|
13
|
+
export function desktopPaths(options={}){const root=join(getConfigPaths(options).root,'desktop');return {root,session:join(root,'session.json')};}
|
|
14
|
+
export async function noLink(path){try{if((await lstat(path)).isSymbolicLink())throw Error('Refusing desktop symlink: '+path);}catch(e){if(e.code!=='ENOENT')throw e;}}
|
|
15
|
+
export async function prepareRoot(options={}){
|
|
16
|
+
const parent=getConfigPaths(options).root,{root}=desktopPaths(options);await noLink(dirname(parent));await noLink(parent);await noLink(root);await mkdir(root,{recursive:true,mode:0o700});return desktopPaths(options);
|
|
17
|
+
}
|
|
18
|
+
export async function readSession(options={}){
|
|
19
|
+
const {root,session}=desktopPaths(options);await noLink(dirname(root));await noLink(root);await noLink(session);
|
|
20
|
+
let data;try{data=JSON.parse(await readFile(session,'utf8'));}catch(e){if(e.code==='ENOENT')return null;throw e;}
|
|
21
|
+
if(data?.owner!=='bitefoundry-retrieviewer-overlay-v1'||!Number.isInteger(data.pid)||data.pid<=0||!validControlAddress(data.pipe)||!/^[a-f0-9]{64}$/.test(data.token))throw Error('Unrecognized desktop session; not modifying it');
|
|
22
|
+
return data;
|
|
23
|
+
}
|
|
24
|
+
export function requestSession(session,command){
|
|
25
|
+
return new Promise((resolve,reject)=>{
|
|
26
|
+
const socket=createConnection(session.pipe);let text='',done=false;
|
|
27
|
+
const finish=(error,value)=>{if(done)return;done=true;socket.destroy();error?reject(error):resolve(value);};
|
|
28
|
+
socket.setTimeout(1800,()=>finish(Error('Desktop control timed out')));
|
|
29
|
+
socket.on('connect',()=>socket.write(JSON.stringify({token:session.token,command})+'\n'));
|
|
30
|
+
socket.on('data',bytes=>{text+=bytes.toString('utf8');if(text.length>65536)return finish(Error('Oversized desktop response'));if(text.includes('\n'))try{finish(null,JSON.parse(text.split('\n')[0]));}catch(e){finish(e);}});
|
|
31
|
+
socket.on('error',e=>finish(e));socket.on('end',()=>{if(!done)finish(Error('Desktop control closed without a response'));});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function processAlive(pid){
|
|
35
|
+
try{process.kill(pid,0);}catch(error){if(error.code==='ESRCH')return false;throw error;}
|
|
36
|
+
if(process.platform==='linux')try{
|
|
37
|
+
// A container PID1 without a reaper can retain a dead detached worker as Z.
|
|
38
|
+
// kill(pid,0) alone treats it as alive, falsely making a successful stop fail.
|
|
39
|
+
const stat=await readFile('/proc/'+pid+'/stat','utf8'),state=stat.slice(stat.lastIndexOf(')')+2).charAt(0);
|
|
40
|
+
if(state==='Z'||state==='X')return false;
|
|
41
|
+
}catch(error){if(error.code==='ENOENT')return false;throw error;}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
export async function desktopStatus(options={}){
|
|
45
|
+
const session=await readSession(options);if(!session)return {running:false};
|
|
46
|
+
try{return await requestSession(session,'status');}catch(error){const alive=await processAlive(session.pid);
|
|
47
|
+
return {running:alive,reachable:false,stale:!alive,pid:session.pid,reason:error.code??error.message};}
|
|
48
|
+
}
|
|
49
|
+
export async function stopDesktop(options={}){
|
|
50
|
+
const session=await readSession(options);if(!session)return {running:false,stopped:true};
|
|
51
|
+
const result=await desktopStatus(options);
|
|
52
|
+
if(result.stale){
|
|
53
|
+
if(session.pipe.startsWith('/tmp/')){
|
|
54
|
+
await noLink(session.pipe);
|
|
55
|
+
try{const entry=await lstat(session.pipe);if(!entry.isSocket()||(process.getuid&&entry.uid!==process.getuid()))throw Error('Refusing unowned/non-socket desktop endpoint');await unlink(session.pipe);}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
56
|
+
}
|
|
57
|
+
await unlink(desktopPaths(options).session);return {running:false,stopped:true,staleSessionRemoved:true};
|
|
58
|
+
}
|
|
59
|
+
if(result.reachable===false)throw Error('Desktop process is not responding; close it from its tray icon. No PID was killed.');
|
|
60
|
+
await requestSession(session,'stop');
|
|
61
|
+
// The stop response acknowledges the request, not native process termination.
|
|
62
|
+
// Wait before advertising success so a following cleanup cannot race an in-use exe.
|
|
63
|
+
for(let i=0;i<60;i++){
|
|
64
|
+
if(!await processAlive(session.pid))return {running:false,stopped:true};
|
|
65
|
+
await new Promise(resolve=>setTimeout(resolve,100));
|
|
66
|
+
}
|
|
67
|
+
throw Error('Desktop accepted stop but has not exited; cache preserved. Retry after it closes.');
|
|
68
|
+
}
|
|
69
|
+
export async function buildHelper(options={}){
|
|
70
|
+
if(process.platform!=='win32')throw Error('The transparent companion currently supports Windows only');
|
|
71
|
+
const {root}=await prepareRoot(options),source=await readFile(SOURCE),hash=createHash('sha256').update(source).digest('hex').slice(0,20);
|
|
72
|
+
const executable=join(root,`overlay-${hash}.exe`);await noLink(executable);
|
|
73
|
+
try{if(!(await lstat(executable)).isFile())throw Error('Desktop helper cache is not a regular file');return executable;}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
74
|
+
const compiler=join(process.env.SystemRoot??'C:\\Windows','Microsoft.NET','Framework64','v4.0.30319','csc.exe');
|
|
75
|
+
const temporary=join(root,`build-${randomUUID()}.exe`);
|
|
76
|
+
try{
|
|
77
|
+
await exec(compiler,['/nologo','/target:winexe','/platform:anycpu','/optimize+','/codepage:65001','/reference:System.Windows.Forms.dll','/reference:System.Drawing.dll','/reference:System.Web.Extensions.dll','/out:'+temporary,fileURLToPath(SOURCE)],{windowsHide:true,timeout:30000,maxBuffer:1024*1024});
|
|
78
|
+
await rename(temporary,executable);
|
|
79
|
+
}catch(error){await unlink(temporary).catch(()=>{});throw Error('Windows overlay build failed: '+(error.stdout??error.message));}
|
|
80
|
+
return executable;
|
|
81
|
+
}
|
|
82
|
+
export async function desktopHelper(options={}){
|
|
83
|
+
const platform=desktopPlatform(options);
|
|
84
|
+
if(platform.helper==='windows')return {command:await buildHelper(options),args:[],mode:platform.id,fps:platform.fps};
|
|
85
|
+
if(platform.helper==='macos')return {command:'/usr/bin/osascript',args:['-l','JavaScript',fileURLToPath(new URL('../macos-overlay/overlay.jxa',import.meta.url))],mode:platform.id,fps:platform.fps};
|
|
86
|
+
// Use an existing system Python, including AlmaLinux8's3.6 platform interpreter.
|
|
87
|
+
// No Python package, compiled addon, native bundle, compiler, or package manager.
|
|
88
|
+
const candidates=[...(process.env.RETRIEVIEWER_PYTHON?[process.env.RETRIEVIEWER_PYTHON]:[]),'python3','/usr/libexec/platform-python'];
|
|
89
|
+
for(const command of candidates)try{
|
|
90
|
+
await exec(command,['-I','-c','import sys;sys.exit(0 if sys.version_info >= (3,6) else 1)'],{windowsHide:true,timeout:5000,maxBuffer:4096});
|
|
91
|
+
return {command,args:['-I',fileURLToPath(new URL('../linux-overlay/overlay.py',import.meta.url))],mode:platform.id,fps:platform.fps};
|
|
92
|
+
}catch{}
|
|
93
|
+
throw Error('Linux desktop needs system Python3.6+ (AlmaLinux8 platform-python is supported). No packages or system settings were installed.');
|
|
94
|
+
}
|
|
95
|
+
export async function startDesktop(options={}){
|
|
96
|
+
// Fail before creating any cache/session on unsupported desktops (e.g. Wayland).
|
|
97
|
+
desktopPlatform(options);
|
|
98
|
+
const status=await desktopStatus(options);if(status.running)return {...status,alreadyRunning:true};
|
|
99
|
+
if(status.stale)await stopDesktop(options);
|
|
100
|
+
const helper=await desktopHelper(options);
|
|
101
|
+
return new Promise((resolve,reject)=>{
|
|
102
|
+
const child=spawn(process.execPath,[fileURLToPath(new URL('./worker.mjs',import.meta.url)),'--helper-spec',JSON.stringify(helper)],{detached:true,windowsHide:true,stdio:['ignore','ignore','ignore','ipc'],env:{...process.env,RETRIEVIEWER_HOME:getConfigPaths(options).root}});
|
|
103
|
+
let settled=false;const finish=(error,message)=>{if(settled)return;settled=true;clearTimeout(timer);if(child.connected)child.disconnect();child.unref();error?reject(error):resolve(message);};
|
|
104
|
+
const timer=setTimeout(()=>{if(child.connected)child.send({command:'abort'});finish(Error('Desktop startup timed out; no agent settings changed'));},20000);
|
|
105
|
+
child.on('error',error=>finish(error));child.on('message',message=>message.error?finish(Error(message.error)):finish(null,message));child.on('exit',code=>finish(Error('Desktop exited during startup ('+code+')')));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export async function cleanDesktop(options={}){
|
|
109
|
+
const status=await desktopStatus(options);if(status.running)throw Error('Stop the desktop companion before removing its cache');
|
|
110
|
+
const {root,session}=desktopPaths(options);if(status.stale)await unlink(session);
|
|
111
|
+
let files;try{await noLink(root);files=await readdir(root);}catch(e){if(e.code==='ENOENT')return {removed:[]};throw e;}
|
|
112
|
+
const removed=[];
|
|
113
|
+
for(const name of files)if(/^overlay-[a-f0-9]{20}\.exe$/.test(name)||/^build-[a-f0-9-]{36}\.exe$/.test(name)){
|
|
114
|
+
const path=join(root,name);await noLink(path);if(!(await lstat(path)).isFile())throw Error('Refusing non-file desktop cache entry');await unlink(path);removed.push(name);
|
|
115
|
+
}
|
|
116
|
+
// Unknown files and all other Retrieviewer/agent settings are deliberately kept.
|
|
117
|
+
return {removed,preservedOtherFiles:true};
|
|
118
|
+
}
|
|
119
|
+
export async function removeDesktop(options={}){await stopDesktop(options);return {stopped:true,...await cleanDesktop(options)};}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Pure transport helpers: no art, timers, processes or I/O on import.
|
|
2
|
+
export function viewportOf(message){
|
|
3
|
+
if(message?.type!=='viewport'||!Number.isSafeInteger(message.revision)||message.revision<1||typeof message.visible!=='boolean')throw Error('Invalid overlay viewport');
|
|
4
|
+
if(!message.visible)return null;
|
|
5
|
+
const {columns,rows,cellWidth,cellHeight}=message;
|
|
6
|
+
if(!Number.isInteger(columns)||!Number.isInteger(rows)||columns<1||rows<1||columns*rows>125000||![6,8,10,12,16].includes(cellWidth)||cellHeight!==cellWidth*2)throw Error('Invalid overlay grid');
|
|
7
|
+
return {columns,rows,cellAspect:.5,revision:message.revision,cellWidth,cellHeight};
|
|
8
|
+
}
|
|
9
|
+
export function cropFrame(frame,revision){
|
|
10
|
+
const lines=frame.text.split('\n');
|
|
11
|
+
if(lines.length!==frame.rows||lines.some(s=>s.length!==frame.columns))throw Error('Invalid frame dimensions');
|
|
12
|
+
let left=frame.columns,top=frame.rows,right=-1,bottom=-1;
|
|
13
|
+
for(let y=0;y<lines.length;y++)for(let x=0;x<lines[y].length;x++){
|
|
14
|
+
const c=lines[y].charCodeAt(x);if(c<0x2800||c>0x28ff)throw Error('Only Braille cells may cross the overlay bridge');
|
|
15
|
+
if(c!==0x2800){left=Math.min(left,x);right=Math.max(right,x);top=Math.min(top,y);bottom=Math.max(bottom,y);}
|
|
16
|
+
}
|
|
17
|
+
return {type:'frame',revision,x:right<0?0:left,y:right<0?0:top,columns:right<0?0:right-left+1,rows:right<0?0:bottom-top+1,
|
|
18
|
+
text:right<0?'':lines.slice(top,bottom+1).map(s=>s.slice(left,right+1)).join('\n')};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Request/ack rendering: never queue obsolete frames behind a slow native painter.
|
|
22
|
+
export function createFramePump({pet,send,now=()=>performance.now()}){
|
|
23
|
+
let viewport=null,inFlight=false,last=null,revision=0,paused=false,disposed=false;
|
|
24
|
+
const alive=()=>{if(disposed)throw Error('Overlay pump is disposed');};
|
|
25
|
+
return {
|
|
26
|
+
viewport(message){alive();const next=viewportOf(message);revision=message.revision;viewport=next;last=null;},
|
|
27
|
+
pause(value){alive();paused=!!value;last=null;},
|
|
28
|
+
ack(){alive();inFlight=false;},
|
|
29
|
+
tick(){
|
|
30
|
+
alive();if(inFlight||!viewport||paused)return false;
|
|
31
|
+
const t=now(),dt=last===null?0:Math.max(0,(t-last)/1000);last=t;
|
|
32
|
+
pet.step(dt);const frame=pet.frame(viewport);inFlight=true;
|
|
33
|
+
try{send(cropFrame(frame,revision));}catch(error){inFlight=false;throw error;}return true;
|
|
34
|
+
},
|
|
35
|
+
interact(kind){alive();if(!['ball','treat'].includes(kind))throw Error('Invalid overlay interaction');return pet.interact(kind);},
|
|
36
|
+
get hidden(){return !viewport||paused;},
|
|
37
|
+
dispose(){if(!disposed){disposed=true;viewport=null;pet.dispose();}},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Private detached worker, started only by the explicit `desktop` command.
|
|
2
|
+
import {spawn} from 'node:child_process';
|
|
3
|
+
import {createServer} from 'node:net';
|
|
4
|
+
import {createInterface} from 'node:readline';
|
|
5
|
+
import {open,unlink,readFile,chmod} from 'node:fs/promises';
|
|
6
|
+
import {randomBytes,randomUUID} from 'node:crypto';
|
|
7
|
+
import {prepareRoot,noLink} from './desktop.mjs';
|
|
8
|
+
import {createFramePump} from './protocol.mjs';
|
|
9
|
+
import {controlAddress,parseHelperSpec} from '../desktop/platform.mjs';
|
|
10
|
+
|
|
11
|
+
let helper,pump,timer,server,session,paths,owned=false,stopping=false,frames=0,paintedFrames=0,visible=false,selected=false,pickerVisible=false,pendingViewport=null,lastInteraction=null;
|
|
12
|
+
let nativeError=null;
|
|
13
|
+
let nativeStderr='';
|
|
14
|
+
const sendParent=message=>{if(process.connected)process.send(message);};
|
|
15
|
+
async function stop(){
|
|
16
|
+
if(stopping)return;stopping=true;clearInterval(timer);pump?.dispose();
|
|
17
|
+
if(helper&&!helper.killed){try{helper.stdin.end(JSON.stringify({type:'quit'})+'\n');}catch{}}
|
|
18
|
+
if(server)server.close();
|
|
19
|
+
if(owned)try{const current=JSON.parse(await readFile(paths.session,'utf8'));if(current.token===session.token)await unlink(paths.session);}catch{}
|
|
20
|
+
// A wedged painter is our own child only, never the selected terminal.
|
|
21
|
+
const watchdog=setTimeout(()=>{helper?.kill();process.exit(0);},2500);watchdog.unref();
|
|
22
|
+
if(!helper||helper.exitCode!==null)process.exitCode=0;
|
|
23
|
+
}
|
|
24
|
+
process.on('SIGTERM',stop);process.on('SIGINT',stop);process.on('message',m=>{if(m?.command==='abort')stop();});
|
|
25
|
+
try{
|
|
26
|
+
const spec=parseHelperSpec(process.argv.slice(2));
|
|
27
|
+
paths=await prepareRoot();await noLink(paths.session);
|
|
28
|
+
session={owner:'bitefoundry-retrieviewer-overlay-v1',pid:process.pid,pipe:controlAddress({uuid:randomUUID()}),token:randomBytes(32).toString('hex')};
|
|
29
|
+
const record=await open(paths.session,'wx',0o600);owned=true;try{await record.writeFile(JSON.stringify(session)+'\n');}finally{await record.close();}
|
|
30
|
+
server=createServer(socket=>{
|
|
31
|
+
socket.setTimeout(1500,()=>socket.destroy());let body='';
|
|
32
|
+
socket.on('error',()=>{});socket.on('data',bytes=>{
|
|
33
|
+
body+=bytes.toString('utf8');if(body.length>2048)return socket.destroy();if(!body.includes('\n'))return;
|
|
34
|
+
let command;try{command=JSON.parse(body.split('\n')[0]);}catch{return socket.destroy();}
|
|
35
|
+
if(command.token!==session.token||!['status','stop'].includes(command.command))return socket.destroy();
|
|
36
|
+
socket.end(JSON.stringify(command.command==='stop'?{stopped:true}:{running:true,reachable:true,selected,visible,pickerVisible,frames,paintedFrames,lastInteraction,pid:process.pid,painterPid:helper?.pid,rssBytes:process.memoryUsage().rss})+'\n');
|
|
37
|
+
if(command.command==='stop')stop();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(session.pipe,resolve);});
|
|
41
|
+
if(process.platform!=='win32')await chmod(session.pipe,0o600);
|
|
42
|
+
// GUI-subsystem exe: no console to hide. SW_HIDE here also hides its first UI.
|
|
43
|
+
helper=spawn(spec.command,spec.args,{windowsHide:false,stdio:['pipe','pipe','pipe']});
|
|
44
|
+
helper.stdin.on('error',()=>stop());helper.stderr.setEncoding('utf8');helper.stderr.on('data',s=>{nativeStderr=(nativeStderr+s).slice(-4096);});helper.once('error',error=>{sendParent({error:error.message});stop();});
|
|
45
|
+
helper.once('exit',code=>{if(!stopping)sendParent({error:nativeError??('Desktop painter exited ('+code+')'+(nativeStderr?' — '+nativeStderr.trim():''))});stop();});
|
|
46
|
+
const write=message=>{if(!stopping&&!helper.stdin.destroyed)helper.stdin.write(JSON.stringify(message)+'\n');};
|
|
47
|
+
const reader=createInterface({input:helper.stdout});
|
|
48
|
+
reader.on('line',line=>{
|
|
49
|
+
if(line.length>100000)return stop();let m;try{m=JSON.parse(line);}catch{return stop();}
|
|
50
|
+
try{
|
|
51
|
+
if(m.type==='ready'){pickerVisible=m.pickerVisible===true;sendParent({running:true,windowPicker:pickerVisible,mode:spec.mode,pid:process.pid});}
|
|
52
|
+
else if(m.type==='error'){nativeError=String(m.message??'Desktop painter failed');sendParent({error:nativeError});stop();}
|
|
53
|
+
else if(m.type==='picker')pickerVisible=m.visible===true;
|
|
54
|
+
else if(m.type==='viewport'){pendingViewport=m;visible=m.visible;selected=m.selected===true;pump?.viewport(m);}
|
|
55
|
+
else if(m.type==='ack'){frames++;if(m.painted)paintedFrames++;pump?.ack();}
|
|
56
|
+
else if(m.type==='pause')pump?.pause(m.paused);
|
|
57
|
+
else if(m.type==='interact'){const accepted=pump?.interact(m.kind)??false;lastInteraction={kind:m.kind,accepted};write({type:'interaction',kind:m.kind,accepted});}
|
|
58
|
+
else if(m.type==='quit')stop();
|
|
59
|
+
}catch(error){write({type:'error',message:error.message});stop();}
|
|
60
|
+
});
|
|
61
|
+
const {createPet}=await import('../../retrieviewer.mjs');const pet=await createPet();
|
|
62
|
+
if(stopping)pet.dispose();else{
|
|
63
|
+
pump=createFramePump({pet,send:write});if(pendingViewport)pump.viewport(pendingViewport);
|
|
64
|
+
write({type:'loaded'});timer=setInterval(()=>{try{pump.tick();}catch(error){write({type:'error',message:error.message});stop();}},1000/spec.fps);
|
|
65
|
+
}
|
|
66
|
+
}catch(error){sendParent({error:error.message});await stop();}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitefoundry/retrieviewer",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "A fluffy Braille desktop pet for Windows and Linux X11, with experimental macOS support and an embeddable TUI engine.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://github.com/BiteFoundry",
|
|
7
|
-
"keywords": ["tui", "braille", "pet", "retriever", "opencode"],
|
|
7
|
+
"keywords": ["tui", "braille", "pet", "retriever", "opencode", "desktop-pet", "windows", "linux", "macos", "overlay"],
|
|
8
8
|
"publishConfig": {
|
|
9
9
|
"access": "public",
|
|
10
10
|
"registry": "https://registry.npmjs.org/",
|
|
@@ -52,6 +52,17 @@
|
|
|
52
52
|
"retrieviewer-config.mjs",
|
|
53
53
|
"integrations/opencode/tui.tsx",
|
|
54
54
|
"integrations/opencode/runtime.mjs",
|
|
55
|
+
"integrations/windows-overlay/protocol.mjs",
|
|
56
|
+
"integrations/windows-overlay/desktop.mjs",
|
|
57
|
+
"integrations/windows-overlay/worker.mjs",
|
|
58
|
+
"integrations/windows-overlay/Overlay.cs",
|
|
59
|
+
"integrations/desktop/platform.mjs",
|
|
60
|
+
"integrations/linux-overlay/overlay.py",
|
|
61
|
+
"integrations/linux-overlay/README.md",
|
|
62
|
+
"integrations/macos-overlay/overlay.jxa",
|
|
63
|
+
"integrations/macos-overlay/README.md",
|
|
64
|
+
"DESKTOP-PLATFORMS.md",
|
|
65
|
+
"WINDOWS-COMPANION.md",
|
|
55
66
|
"INSTALLING.md",
|
|
56
67
|
"PLUGIN.md",
|
|
57
68
|
"EMBEDDING.md",
|
|
@@ -114,6 +125,7 @@
|
|
|
114
125
|
"terminal": "node terminal.mjs",
|
|
115
126
|
"pet": "node puppy-terminal.mjs",
|
|
116
127
|
"pet:companion": "node examples/live.mjs",
|
|
128
|
+
"pet:desktop": "node retrieviewer-cli.mjs desktop",
|
|
117
129
|
"test": "node --test \"*.test.mjs\"",
|
|
118
130
|
"test:locomotion": "node check-locomotion.mjs",
|
|
119
131
|
"test:package": "node check-pet-package.mjs",
|
package/retrieviewer-cli.mjs
CHANGED
|
@@ -13,11 +13,20 @@ Usage:
|
|
|
13
13
|
retrieviewer doctor [--agent ID] [--json]
|
|
14
14
|
retrieviewer agents [--json]
|
|
15
15
|
retrieviewer preview [--original]
|
|
16
|
+
retrieviewer desktop
|
|
17
|
+
retrieviewer desktop-status [--json]
|
|
18
|
+
retrieviewer desktop-stop
|
|
19
|
+
retrieviewer desktop-clean --yes
|
|
20
|
+
retrieviewer desktop-remove --yes
|
|
16
21
|
|
|
17
22
|
Enable opens a first-run selection/confirmation wizard on a real terminal.
|
|
18
23
|
Non-interactive changes require --agent and --yes. Unsupported native renderers
|
|
19
24
|
are refused: MCP connectivity alone is never treated as in-screen rendering.
|
|
20
25
|
No command changes credentials, shell profiles or agent executables.
|
|
26
|
+
Desktop is a separate native overlay, NOT a Codex plugin. Choose a window once;
|
|
27
|
+
use Codex normally. Exit from its tray icon or desktop-stop. No auto-start is added.
|
|
28
|
+
Windows is verified; macOS and Linux X11 adapters are experimental. Native Wayland
|
|
29
|
+
app tracking is not supported. Linux targets AlmaLinux8/GLIBC2.28, system Python3.6+.
|
|
21
30
|
`;
|
|
22
31
|
export function parseArguments(args){
|
|
23
32
|
const [command='help',...rest]=args,options={};
|
|
@@ -30,14 +39,20 @@ export function parseArguments(args){
|
|
|
30
39
|
else throw Error('Unknown argument: '+token);
|
|
31
40
|
}
|
|
32
41
|
if(options.agent&&!AGENTS.some(a=>a.id===options.agent))throw Error('Unknown agent: '+options.agent);
|
|
33
|
-
if(!['help','--help','-h','enable','disable','disconnect','status','doctor','agents','preview'].includes(command))throw Error('Unknown command: '+command);
|
|
34
|
-
const allowed={enable:['agent','yes','json','dry-run'],disconnect:['agent','yes','json','dry-run'],disable:['agent','json'],status:['agent','json'],doctor:['agent','json'],agents:['json'],preview:['original']};
|
|
42
|
+
if(!['help','--help','-h','enable','disable','disconnect','status','doctor','agents','preview','desktop','desktop-status','desktop-stop','desktop-clean','desktop-remove'].includes(command))throw Error('Unknown command: '+command);
|
|
43
|
+
const allowed={enable:['agent','yes','json','dry-run'],disconnect:['agent','yes','json','dry-run'],disable:['agent','json'],status:['agent','json'],doctor:['agent','json'],agents:['json'],preview:['original'],'desktop-status':['json'],'desktop-clean':['yes'],'desktop-remove':['yes']};
|
|
35
44
|
for(const key of Object.keys(options))if(!(allowed[command]??[]).includes(key))throw Error(`--${key} is not supported by ${command}`);
|
|
36
45
|
return {command,options};
|
|
37
46
|
}
|
|
38
|
-
export async function runCli(args,{input=process.stdin,output=process.stdout,error=process.stderr,installer=createInstaller()}={}){
|
|
47
|
+
export async function runCli(args,{input=process.stdin,output=process.stdout,error=process.stderr,installer=createInstaller(),desktop}={}){
|
|
39
48
|
const {command,options}=parseArguments(args),emit=value=>output.write(JSON.stringify(value,null,2)+'\n');
|
|
40
49
|
if(['help','--help','-h'].includes(command)){output.write(HELP);return 0;}
|
|
50
|
+
if(command.startsWith('desktop')){
|
|
51
|
+
if(['desktop-clean','desktop-remove'].includes(command)&&!options.yes)throw Error('Use '+command+' --yes to remove only the companion cache.');
|
|
52
|
+
const api=desktop??await import('./integrations/windows-overlay/desktop.mjs');
|
|
53
|
+
const action={'desktop':'startDesktop','desktop-status':'desktopStatus','desktop-stop':'stopDesktop','desktop-clean':'cleanDesktop','desktop-remove':'removeDesktop'}[command];
|
|
54
|
+
emit(await api[action]());return 0;
|
|
55
|
+
}
|
|
41
56
|
if(command==='preview'){await import('./examples/live.mjs');return 0;}
|
|
42
57
|
if(['status','doctor','agents'].includes(command)){
|
|
43
58
|
const result=command==='agents'?await installer.list():await installer[command](options.agent);
|
|
@@ -75,7 +90,12 @@ export async function runCli(args,{input=process.stdin,output=process.stdout,err
|
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
// CLI is deliberately a separate module from the side-effect-free library.
|
|
78
|
-
import {
|
|
79
|
-
|
|
93
|
+
import {fileURLToPath} from 'node:url';
|
|
94
|
+
import {realpathSync} from 'node:fs';
|
|
95
|
+
// npm global bins are symlinks on Unix. import.meta resolves the real module,
|
|
96
|
+
// whereas argv[1] keeps the bin link; comparing those raw URLs silently did nothing.
|
|
97
|
+
let main=false;
|
|
98
|
+
try{main=!!process.argv[1]&&realpathSync(process.argv[1])===realpathSync(fileURLToPath(import.meta.url));}catch{}
|
|
99
|
+
if(main){
|
|
80
100
|
try{process.exitCode=await runCli(process.argv.slice(2));}catch(e){process.stderr.write('Retrieviewer: '+e.message+'\n');process.exitCode=1;}
|
|
81
101
|
}
|