← All source files

V16 / 6df58cd3ed86

MainWindow.xaml.cs

Interfaces/Gui/LilithDesktop/MainWindow.xaml.cs

Changed in this version
CS
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);}
    }
}