V16 / 6df58cd3ed86
MainWindow.xaml.cs
Interfaces/Gui/LilithDesktop/MainWindow.xaml.cs
Changed in this versionCS
using System.ComponentModel;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using System.Windows;
using System.Windows.Input;
using System.Windows.Threading;
using System.Windows.Interop;
using Lilith.Core.WebResearch;
using Lilith.Gui;
using Microsoft.Web.WebView2.Core;
using OpenQA.Selenium.Edge;
namespace Lilith.Desktop;
public partial class MainWindow : Window
{
private readonly string _repo;
private readonly GuiSession _session;
private readonly DispatcherTimer _events = new() { Interval=TimeSpan.FromMilliseconds(100) };
private long _sequence;
private bool _closing,_closed,_fullscreen;
private WindowState _previousState;
private GuiTray? _tray;
private EmbeddedResearchBrowser? _browser;
private CoreWebView2Environment? _researchEnvironment;
private EdgeDriverService? _researchService;
private readonly SemaphoreSlim _researchGate=new(1,1);
private readonly CancellationTokenSource _shutdown=new();
private ApprovalWindow? _approvalWindow;
private const string UiHost="lilith.example";
public MainWindow()
{
InitializeComponent();
_repo=Directory.GetCurrentDirectory();
while(!File.Exists(Path.Combine(_repo,"Version.props")))
_repo=Directory.GetParent(_repo)?.FullName??throw new DirectoryNotFoundException("Start inside the Lilith repository.");
string? testState=Environment.GetCommandLineArgs().Any(a=>a.EndsWith("-check"))?Path.Combine(_repo,"Admin","TestResults","Latest","Desktop","State"):null;
_session=new(_repo,testState){BrowserRequest=BrowserRequest};
_events.Tick+=PumpEvents;
ComponentDispatcher.ThreadPreprocessMessage+=DesktopKey;
}
private async void OnLoaded(object sender,RoutedEventArgs e)
{
try
{
CheckLog("Window loaded; initializing WebView2.");
var environment=await CoreWebView2Environment.CreateAsync(userDataFolder:Path.Combine(_repo,"Admin","GuiState","Desktop","Interface"));
CheckLog("WebView2 runtime: "+environment.BrowserVersionString);
await Interface.EnsureCoreWebView2Async(environment);
CheckLog("Interface initialized.");
// Serve an explicit asset allowlist from native code. WebView's sandbox
// never needs filesystem access to the user's source checkout.
Interface.CoreWebView2.AddWebResourceRequestedFilter("https://"+UiHost+"/*",CoreWebView2WebResourceContext.All);
Interface.CoreWebView2.WebResourceRequested+=(_,args)=>
{
string path=new Uri(args.Request.Uri).AbsolutePath;
string? file=path switch{"/"=>"index.html","/app.js"=>"app.js","/app.css"=>"app.css","/favicon.svg"=>"favicon.svg",_=>null};
string mime=path switch{"/app.js"=>"text/javascript","/app.css"=>"text/css","/favicon.svg"=>"image/svg+xml",_=>"text/html"};
var stream=new MemoryStream(file is null?[]:File.ReadAllBytes(Path.Combine(_repo,"Interfaces","Gui","LilithGui","wwwroot",file)));
args.Response=environment.CreateWebResourceResponse(stream,file is null?404:200,file is null?"Not Found":"OK","Content-Type: "+mime+"; charset=utf-8\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff");
};
Interface.CoreWebView2.Settings.AreDevToolsEnabled=false;
Interface.CoreWebView2.NavigationStarting+=(_,args)=>{if(!args.Uri.StartsWith("https://"+UiHost+"/",StringComparison.Ordinal))args.Cancel=true;};
Interface.CoreWebView2.NewWindowRequested+=(_,args)=>args.Handled=true;
Interface.CoreWebView2.WebMessageReceived+=OnWebMessage;
Interface.CoreWebView2.NavigationCompleted+=(_,args)=>CheckLog("Navigation: "+args.IsSuccess+" "+args.WebErrorStatus);
await Interface.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync("""
(()=>{const pending=new Map();let sequence=0;window.addEventListener("keydown",e=>{if(e.key==="F11"){e.preventDefault();chrome.webview.postMessage({id:"fullscreen",path:"/native/fullscreen",body:null});}},true);
chrome.webview.addEventListener('message',e=>{const p=pending.get(e.data.id);if(!p)return;pending.delete(e.data.id);const r=e.data.result;p.resolve(r?.image?new Response(Uint8Array.from(atob(r.image),c=>c.charCodeAt(0)),{headers:{'Content-Type':'image/png'}}):new Response(JSON.stringify(e.data.error?{error:e.data.error}:r),{status:e.data.error?409:200,headers:{'Content-Type':'application/json'}}));});
window.fetch=(path,options={})=>{const id=String(++sequence);return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});chrome.webview.postMessage({id,path:String(path),body:options.body?JSON.parse(options.body):null});});};
})();
""");
Interface.Source=new Uri("https://"+UiHost+"/");
_events.Start();
try{_tray=new GuiTray(()=>Dispatcher.BeginInvoke(()=>{Show();WindowState=WindowState.Normal;Activate();}));}
catch(Exception ex){Title="Lilith — tray unavailable: "+ex.Message;}
if(Environment.GetCommandLineArgs().Any(a=>a is "--desktop-check" or "--browser-check" or "--live-v8-check"))_=RunChecks();
}
catch(Exception ex){CheckLog(ex.ToString());if(Environment.GetCommandLineArgs().Any(a=>a.EndsWith("-check")))Close();else MessageBox.Show(this,ex.Message,"Lilith desktop could not start");}
}
private void CheckLog(string text)
{
if(!Environment.GetCommandLineArgs().Any(a=>a.EndsWith("-check")))return;
string folder=Path.Combine(_repo,"Admin","TestResults","Latest","Desktop");Directory.CreateDirectory(folder);
File.AppendAllText(Path.Combine(folder,"startup.log"),DateTimeOffset.Now+" "+text+Environment.NewLine);
}
private void DesktopKey(ref MSG message,ref bool handled)
{
if(!handled&&message.message==0x100&&message.wParam.ToInt32()==122&&(message.lParam.ToInt64()&(1L<<30))==0)
{handled=true;ToggleFullscreen();}
}
private async void OnWebMessage(object? sender,CoreWebView2WebMessageReceivedEventArgs e)
{
string? id=null;
try
{
if(!e.Source.StartsWith("https://"+UiHost+"/",StringComparison.Ordinal))return;
var request=JsonDocument.Parse(e.WebMessageAsJson).RootElement.Clone();
id=request.GetProperty("id").GetString();string path=request.GetProperty("path").GetString()!;
var body=request.GetProperty("body");object? result;
if(path=="/native/fullscreen"){ToggleFullscreen();result=new{};}
else if(path=="/native/approval")
{
if(_approvalWindow is not null){_approvalWindow.Activate();result=new{pending=true};}
else
{
var pending=await _session.SendAsync("approvals",JsonSerializer.SerializeToElement(new{}));
var proposal=pending.EnumerateArray().SingleOrDefault(p=>p.GetProperty("Id").GetString()==body.GetProperty("id").GetString()&&p.GetProperty("Service").GetString()==body.GetProperty("service").GetString());
if(proposal.ValueKind==JsonValueKind.Undefined)throw new InvalidOperationException("This approval is no longer pending.");
bool decided=false;
_approvalWindow=new ApprovalWindow(this,proposal,async approve=>
{
await _session.SendAsync("approval_decide",JsonSerializer.SerializeToElement(new{id=proposal.GetProperty("Id").GetString(),service=proposal.GetProperty("Service").GetString(),fingerprint=proposal.GetProperty("Fingerprint").GetString(),approve}));decided=true;
});
try{_approvalWindow.ShowDialog();}finally{_approvalWindow=null;}
result=new{decided};
}
}
else if(path=="/api/versions")result=await new VersionCatalog(_repo).ListAsync();
else if(path=="/api/session")result=_session.Summary();
else if(path.StartsWith("/api/image/"))result=new{image=Convert.ToBase64String(_session.ReadImage(path[11..]))};
else if(path.StartsWith("/api/events?after="))result=Array.Empty<object>(); // Native event push owns delivery.
else if(path=="/api/boot")
{
await CloseResearch();result=await _session.BootAsync(body.GetProperty("major").GetInt32(),body);await CloseResearch();
}
else if(path=="/api/action")
{
string action=body.GetProperty("action").GetString()!;
if(action is not("chat" or "command" or "panel" or "scenario" or "history" or "cancel" or "approvals" or "approval_decide"))throw new ArgumentException("Unknown action.");
try{result=await _session.SendAsync(action,body);}
finally{if(action is "chat" or "scenario" or "command")await CloseResearch();}
}
else throw new NotSupportedException("Unknown desktop request.");
Interface.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(new{id,result}));
}
catch(Exception ex){if(id is not null&&!_closed)Interface.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(new{id,error=ex.Message}));}
}
private async void PumpEvents(object? sender,EventArgs e)
{
if(_closed||Interface.CoreWebView2 is null)return;
_events.Stop();
try
{
foreach(var item in _session.Events(_sequence))
{
await Interface.ExecuteScriptAsync("consumeEvent(normalize("+JsonSerializer.Serialize(item,new JsonSerializerOptions{PropertyNamingPolicy=JsonNamingPolicy.CamelCase})+"));");
_sequence=item.Sequence;
}
}
catch(Exception ex){Title="Lilith — "+ex.Message;}
finally{if(!_closing)_events.Start();}
}
private async Task<object> BrowserRequest(JsonElement data)
{
await _researchGate.WaitAsync();
try
{
if(_browser is null)
{
var options=data.GetProperty("options").Deserialize<BrowserResearchOptions>(new JsonSerializerOptions{PropertyNameCaseInsensitive=true})!;
_browser=new EmbeddedResearchBrowser(options,reportLifecycle:s=>_session.Emit("trace",new{sender="Embedded Selenium",text=s}),createEmbedded:proxy=>Dispatcher.InvokeAsync(()=>AttachBrowser(proxy)).Task.Unwrap().GetAwaiter().GetResult());
}
string value=data.GetProperty("value").GetString()!;
using var timeout=CancellationTokenSource.CreateLinkedTokenSource(_shutdown.Token);timeout.CancelAfter(TimeSpan.FromMinutes(3));
return data.GetProperty("operation").GetString() switch
{
"SearchAsync"=>await _browser.SearchAsync(value,data.GetProperty("limit").GetInt32(),timeout.Token),
"ReadAsync"=>await _browser.ReadAsync(value,timeout.Token),
_=>throw new NotSupportedException("Unknown embedded browser operation.")
};
}
finally{_researchGate.Release();}
}
private async Task<EdgeDriver> AttachBrowser(PublicWebProxy proxy)
{
BrowserPanel.Visibility=Visibility.Visible;ArrangeBrowser();BrowserStatus.Text="Starting embedded browser…";
using var listener=new TcpListener(IPAddress.Loopback,0);listener.Start();int port=((IPEndPoint)listener.LocalEndpoint).Port;listener.Stop();
var settings=new CoreWebView2EnvironmentOptions($"--remote-debugging-port={port} --disable-quic --disable-background-networking --force-webrtc-ip-handling-policy=disable_non_proxied_udp --proxy-server=http://127.0.0.1:{proxy.Port} --proxy-bypass-list=<-loopback>");
_researchEnvironment=await CoreWebView2Environment.CreateAsync(userDataFolder:Path.Combine(_repo,"Admin","GuiState","Desktop","Research"),options:settings);
await Research.EnsureCoreWebView2Async(_researchEnvironment);
await Research.CoreWebView2.Profile.ClearBrowsingDataAsync();
Research.CoreWebView2.Settings.AreDevToolsEnabled=false;
Research.CoreWebView2.NewWindowRequested+=(_,e)=>e.Handled=true;
Research.CoreWebView2.DownloadStarting+=(_,e)=>e.Cancel=true;
Research.CoreWebView2.PermissionRequested+=(_,e)=>e.State=CoreWebView2PermissionState.Deny;
Research.CoreWebView2.SourceChanged+=(_,_)=>BrowserStatus.Text=Research.Source?.ToString()??"Research";
var options=new EdgeOptions{UseWebView=true,DebuggerAddress="127.0.0.1:"+port,PageLoadStrategy=OpenQA.Selenium.PageLoadStrategy.Eager};
string executable=await EdgeDriverLocator.ResolveAsync(_repo,_researchEnvironment.BrowserVersionString.Split(' ')[0]);
var service=EdgeDriverService.CreateDefaultService(Path.GetDirectoryName(executable));service.HideCommandPromptWindow=true;
EdgeDriver driver;
try{driver=await Task.Run(()=>new EdgeDriver(service,options,TimeSpan.FromSeconds(40)));}
catch{service.Dispose();throw;}
_researchService=service;
driver.Manage().Timeouts().PageLoad=TimeSpan.FromSeconds(25);
return driver;
}
private async Task CloseResearch()
{
await _researchGate.WaitAsync();
try
{
if(_browser is null)return;
CheckLog("Disconnecting research automation.");
await Task.Run(()=>{_researchService?.Dispose();_browser.Dispose();});_researchService=null;_browser=null;
CheckLog("Disposing research control.");
await Dispatcher.InvokeAsync(()=>{Research.Dispose();var old=Research;Research=new Microsoft.Web.WebView2.Wpf.WebView2();var panel=(System.Windows.Controls.DockPanel)BrowserPanel.Child;panel.Children.Remove(old);panel.Children.Add(Research);_researchEnvironment=null;BrowserStatus.Text="Research finished";BrowserPanel.Visibility=Visibility.Collapsed;ArrangeBrowser();});
}
finally{_researchGate.Release();}
}
private void OnSizeChanged(object sender,SizeChangedEventArgs e)=>ArrangeBrowser();
private void ArrangeBrowser()
{
if(BrowserColumn is null)return;
bool shown=BrowserPanel.Visibility==Visibility.Visible,stacked=ActualWidth<1050;
BrowserColumn.Width=shown&&!stacked?new GridLength(1,GridUnitType.Star):new GridLength(0);
Layout.ColumnDefinitions[0].Width=shown&&!stacked?new GridLength(2,GridUnitType.Star):new GridLength(1,GridUnitType.Star);
BrowserRow.Height=shown&&stacked?new GridLength(1,GridUnitType.Star):new GridLength(0);
System.Windows.Controls.Grid.SetColumn(BrowserPanel,stacked?0:1);System.Windows.Controls.Grid.SetRow(BrowserPanel,stacked?1:0);
}
private void OnKeyDown(object sender,KeyEventArgs e)
{
if(e.Key!=Key.F11)return;e.Handled=true;ToggleFullscreen();
}
private void ToggleFullscreen()
{
if(!_fullscreen){_previousState=WindowState;WindowState=WindowState.Normal;WindowStyle=WindowStyle.None;ResizeMode=ResizeMode.NoResize;WindowState=WindowState.Maximized;}
else{WindowState=WindowState.Normal;WindowStyle=WindowStyle.SingleBorderWindow;ResizeMode=ResizeMode.CanResize;WindowState=_previousState;}
_fullscreen=!_fullscreen;
}
private async Task RunChecks()
{
string output=Path.Combine(_repo,"Admin","TestResults","Latest","Desktop");Directory.CreateDirectory(output);
try
{
for(int i=0;i<100;i++){await Task.Delay(200);if(await Interface.ExecuteScriptAsync("typeof state !== 'undefined' && state.versions.length > 0")=="true")break;}
if(await Interface.ExecuteScriptAsync("typeof state !== 'undefined' && !!document.getElementById('composer')")!="true")
throw new Exception("Desktop interface did not load: "+await Interface.ExecuteScriptAsync("document.body.innerText"));
await Interface.ExecuteScriptAsync("""
activityPreference(true);
addActivity({sequence:900001,kind:'scenario',data:{runId:'layout-fixture',scenarioId:'Layout fixture',kind:'Started',step:1,totalSteps:2,text:'Fixture only; no scenario executed.'}});
addActivity({sequence:900002,kind:'tool',data:{id:'layout-tool',name:'Fixture tool',kind:'Result',text:'<img src=x onerror=alert(1)>'}});
document.querySelector('.activity-notice').dispatchEvent(new Event('mouseenter'));
""");
if(await Interface.ExecuteScriptAsync("document.querySelectorAll('.activity-card').length===2 && !!document.querySelector('.activity-card.activity-highlight') && !document.querySelector('.activity-card img')")!="true")throw new Exception("Activity cards, safe content or hover highlighting failed.");
await Interface.ExecuteScriptAsync("activityPreference(false)");
if(await Interface.ExecuteScriptAsync("$('activity-sidebar').hidden && getComputedStyle(document.querySelector('.activity-notice')).display==='none' && localStorage.getItem('lilith-show-activity')==='false'")!="true")throw new Exception("GUI activity setting did not hide or persist.");
await Interface.ExecuteScriptAsync("activityPreference(true)");
await Interface.ExecuteScriptAsync("""
state.ready=true;state.busy=false;state.booting=true;updateComposer();
""");
if(await Interface.ExecuteScriptAsync("$('prompt').disabled && $('cancel').disabled && $('cancel').textContent==='Booting'")!="true")throw new Exception("Boot controls are not disabled and labelled Booting.");
await Interface.ExecuteScriptAsync("""
state.booting=false;
applyApprovals([{id:'fixture-proposal',service:'workspace',title:'Fixture approval — no operation',review:{request:{operation:'save',path:'fixture.txt',content:'Fixture only'}},fingerprint:'fixture'}]);
""");
if(await Interface.ExecuteScriptAsync("$('prompt').disabled && $('send').disabled && !!document.querySelector('.approval-card')")!="true")throw new Exception("Pending approval did not lock chat or create a clickable receipt.");
var fixture=JsonSerializer.SerializeToElement(new{Id="fixture-proposal",Service="workspace",Title="Fixture approval — no operation",Review=new{Request=new{Operation="save",Path="fixture.txt",Content="Fixture only"}}});
var approvalCheck=new ApprovalWindow(this,fixture,_=>throw new Exception("Layout checks cannot approve actions."));
approvalCheck.Show();await Task.Delay(100);if(!approvalCheck.IsVisible)throw new Exception("Native approval window did not open.");approvalCheck.Close();
await Interface.ExecuteScriptAsync("applyApprovals([]);state.ready=false;$('gui-settings').close();updateComposer();");
foreach(var size in new[]{(1360d,900d),(800d,700d),(520d,600d)})
{
Width=size.Item1;Height=size.Item2;await Task.Delay(300);
if(await Interface.ExecuteScriptAsync("document.documentElement.scrollWidth <= innerWidth")!="true")throw new Exception("Horizontal overflow at "+size+": "+await Interface.ExecuteScriptAsync("JSON.stringify([...document.querySelectorAll(\"*\")].filter(e=>e.getBoundingClientRect().right>innerWidth+1).map(e=>e.tagName+\".\"+e.className).slice(0,15))"));
}
ToggleFullscreen();if(WindowStyle!=WindowStyle.None)throw new Exception("F11 did not enter fullscreen.");ToggleFullscreen();
if(WindowStyle!=WindowStyle.SingleBorderWindow)throw new Exception("F11 did not restore window chrome.");
Width=1360;Height=900;
using(var screenshot=File.Create(Path.Combine(output,"desktop.png")))await Interface.CoreWebView2.CapturePreviewAsync(CoreWebView2CapturePreviewImageFormat.Png,screenshot).WaitAsync(TimeSpan.FromSeconds(15));
if(Environment.GetCommandLineArgs().Contains("--browser-check"))
{
var request=JsonSerializer.SerializeToElement(new{operation="ReadAsync",value="https://www.selenium.dev/documentation/webdriver/",options=BrowserResearchOptions.Default});
var page=(WebPage)await BrowserRequest(request);
if(!page.Readable||!page.Text.Contains("WebDriver",StringComparison.OrdinalIgnoreCase))throw new Exception("Embedded Selenium could not read public documentation: "+page.Error);
File.WriteAllText(Path.Combine(output,"browser.json"),JsonSerializer.Serialize(page));
using(var screenshot=File.Create(Path.Combine(output,"browser.png")))await Research.CoreWebView2.CapturePreviewAsync(CoreWebView2CapturePreviewImageFormat.Png,screenshot).WaitAsync(TimeSpan.FromSeconds(15));
await CloseResearch();
}
if(Environment.GetCommandLineArgs().Contains("--live-v8-check"))
{
await _session.BootAsync(8,JsonSerializer.SerializeToElement(new{major=8,model="gemma4",provider="Ollama",name="Lilith",speech=false,mode="Ask"}));
var result=await _session.SendAsync("chat",JsonSerializer.SerializeToElement(new{text="When does GTA 6 come out? Use 5 sources. Call run_scenario with {\"scenario_name\":\"web_research\",\"inputs\":{\"question\":\"When does GTA 6 come out?\",\"source_count\":\"5\"}}.",clientId="desktop-check"}));
File.WriteAllText(Path.Combine(output,"live-events.json"),JsonSerializer.Serialize(_session.Events(0)));
string events=File.ReadAllText(Path.Combine(output,"live-events.json"));
if(!events.Contains("Sources read: 5 of 5")||events.Contains("Web research failed."))throw new Exception("Live V8 research did not qualify; inspect live-events.json.");
await CloseResearch();
}
File.WriteAllText(Path.Combine(output,"result.txt"),"PASS: "+string.Join(' ',Environment.GetCommandLineArgs().Skip(1)));
}
catch(Exception ex){File.WriteAllText(Path.Combine(output,"result.txt"),"FAIL: "+ex);}
finally{Close();}
}
private async void OnClosing(object? sender,CancelEventArgs e)
{
if(_closed)return;e.Cancel=true;if(_closing)return;_closing=true;_events.Stop();_shutdown.Cancel();
try{await _session.DisposeAsync();await CloseResearch();}
finally{ComponentDispatcher.ThreadPreprocessMessage-=DesktopKey;_tray?.Dispose();Interface.Dispose();Research.Dispose();_closed=true;_ = Dispatcher.BeginInvoke(Close);}
}
}