using System.Collections.Generic; using System.IO; using UnityEngine; using VoltstroStudios.UnityWebBrowser.Communication; using VoltstroStudios.UnityWebBrowser.Core; using VoltstroStudios.UnityWebBrowser.Core.Engines; using VoltstroStudios.UnityWebBrowser.Input; using VoltstroStudios.UnityWebBrowser.Shared; using VoltstroStudios.UnityWebBrowser.Shared.Events; #if ENABLE_INPUT_SYSTEM using UnityEngine.InputSystem; using UnityEngine.InputSystem.Controls; #endif /// /// An embedded Chromium browser (UnityWebBrowser) shown on the monitor's screen quad, used for the PCjs /// Windows 95 emulator. Added at runtime by ; while the lobby camera is in /// the Computer close-up, the mouse over the screen and the keyboard are forwarded to the page. F1 stops the /// browser and returns the monitor to whatever it showed before. /// public class MonitorBrowser : BaseUwbClientManager { [Tooltip("Which OS this instance is running; set by MonitorBrowserLauncher so the on-screen label and the swap-vs-same-OS check know what's loaded.")] public string osName = "Windows 95"; public string url = "http://78.110.173.123/VM/win95/blog/2015/09/21/"; [Tooltip("Falls back here (a local mirror of the page, its scripts, the ROM/BIOS and the Windows 95 disk image, ~80 MB) if the live site doesn't load in time — e.g. pcjs.org is down or unreachable.")] public string offlineUrl = "http://78.110.173.123/VM/win95/blog/2015/09/21/"; [Tooltip("Seconds to wait for the live page to finish loading before switching to offlineUrl.")] public float offlineFallbackAfter = 10f; [Tooltip("Player id the machine's state is saved under on the server (AfxPlayer.Id, set by MonitorBrowserLauncher). Empty = never save.")] public string userId; [Tooltip("Seconds between automatic snapshots of the running machine to the server (each is ~10 MB and pauses the machine for a second or two). 0 = only when the PC is switched off or the OS swapped.")] public float autoSaveEvery = 120f; public Renderer screen; public int width = 1280, height = 720; [Tooltip("JavaScript run when the page finishes loading: hides the blog (including the site-wide footer) and makes the PCjs machine fill the window.")] public string fullscreenJs = "(function(){if(window.__afxNative){window.__afxNative();return;}" + // our own v86 page: it lays itself out, drives the cursor and reports back itself "window.alert=function(m){console.log('alert: '+m);};window.prompt=function(){return null;};window.confirm=function(){return true;};" + // PCjs moves its mouse by the difference in event.screenX/screenY between events (it only uses movementX when // Pointer Lock is active, which offscreen CEF never grants, hence the WrongDocumentError in the console). The // synthetic events UWB feeds CEF carry good clientX/clientY but zero screenX/screenY, so the emulated cursor // never moved. Alias screenX/screenY to clientX/clientY and swallow the pointer lock request. "try{Object.defineProperty(MouseEvent.prototype,'screenX',{get:function(){return this.clientX;},configurable:true});" + "Object.defineProperty(MouseEvent.prototype,'screenY',{get:function(){return this.clientY;},configurable:true});}catch(e){console.log('screenXY alias failed: '+e.message);}" + "try{Element.prototype.requestPointerLock=function(){return Promise.resolve();};}catch(e){}" + "function findCanvas(){return document.querySelector('.pcx86-machine canvas,.pcjs-machine canvas,canvas.pcjs-canvas');}" + // Reports the emulator picture's own on-screen box back to Unity, in the same page-pixel space Unity's mouse // events use, so Unity knows exactly where the black letterbox bars are (see MonitorBrowser.contentRect): // that is what keeps the emulated cursor lined up with the real one instead of reacting early, over a bar, // and lets right click/Esc reach the lobby again once the player is off the actual picture. "function reportRect(c){" + "if(!c)return;var r=c.getBoundingClientRect();" + "var s=Math.round(r.left)+','+Math.round(r.top)+','+Math.round(r.width)+','+Math.round(r.height);" + "if(s!==window.__afxLastRect){window.__afxLastRect=s;if(window.uwb)uwb.ExecuteJsMethod('AfxContentRect',s);}}" + // Keeps the screen's own shape (4:3 for VGA) as large as fits, centred on the 16:9 monitor with black around // it. Re-run on a timer, not just once on load: the boot sequence often starts in a different text/graphics // mode than the eventual desktop, so the canvas' own resolution (and so its correct aspect) can change after // this first ran, and a one-shot fit left stale against the real picture (part of it cropped or squashed). // window.__afxInset (fractions of the height kept clear at the top and bottom, sent by Unity, see // pictureInsetTop/Bottom) keeps the picture inside the monitor bezel's opening: the screen quad runs a little // way under the bezel's bottom lip, which hid the bottom rows (the Start bar) when the picture used the full height. "function fit(c,m){" + "var it=window.__afxInset||{t:0,b:0},H0=window.innerHeight,pt=Math.round(H0*it.t),pb=Math.round(H0*it.b);" + "var h=document.getElementById('uwb-host');if(h){h.style.setProperty('padding-top',pt+'px','important');h.style.setProperty('padding-bottom',pb+'px','important');}" + "var a=(c.width||4)/(c.height||3),W=window.innerWidth,H=H0-pt-pb;" + "var w=Math.floor(Math.min(W,H*a)),hh=Math.floor(Math.min(H,W/a));" + "m.style.setProperty('width',w+'px','important');m.style.setProperty('height',hh+'px','important');" + "reportRect(c);}" + "function boot(){" + "var c=findCanvas();" + "if(!c){setTimeout(boot,500);return;}" + // the machine can still be loading when OnLoadFinish fires // Host ONLY the emulator's screen (the div holding the canvas and its keyboard overlay), not the whole machine: // the machine div carries a border, a 'VGA Color Display' title bar with LEDs and buttons, and (Win 3.1) a row // of drive/CPU controls under the screen. Everything else in the page is hidden, so nothing but the screen shows. "var m=c.closest('.pcx86-video-object,.pcjs-screen')||c.parentNode;" + "var h=document.getElementById('uwb-host');if(!h){h=document.createElement('div');h.id='uwb-host';document.body.appendChild(h);}h.appendChild(m);" + "var all=document.body.querySelectorAll('*');for(var i=0;i*{position:relative!important;margin:0!important;padding:0!important;border:0!important;background:#000!important;overflow:hidden!important}" + "#uwb-host canvas{display:block!important;width:100%!important;height:100%!important;margin:0!important;border:0!important}" + "#uwb-host button,#uwb-host select,#uwb-host input{display:none!important}';document.head.appendChild(s);" + "if(window.__afxInstallMouse)window.__afxInstallMouse();" + // exact cursor tracking (AbsoluteMouseJs) "if(window.uwb)uwb.ExecuteJsMethod('AfxFullscreen','ok');" + // tell Unity it took effect (retried until it does) "}var existing=document.getElementById('uwb-host');" + "if(existing&&existing.firstChild){reportRect(findCanvas());if(window.__afxInstallMouse)window.__afxInstallMouse();if(window.uwb)uwb.ExecuteJsMethod('AfxFullscreen','already');}else boot();})();"; // Snapshot the RUNNING machine and send it to the server. This is PCjs's own "save" control (a hidden button we // added to the mirrored machine.xml): its click handler calls Computer.powerOff(save) WITHOUT shutdown, which // serialises every component (CPU, RAM, video, the disks' modified sectors...) while the machine keeps running, // then POSTs it synchronously to /api/v1/user as this user. The user id comes from localStorage "user", // which is why it is set first. The result is reported back to Unity through the uwb JS bridge (AfxSaved). string SaveJs => "(function(){if(window.__afxSave){window.__afxSave('" + userId + "');return;}" + // our own v86 page saves its disk changes itself and reports AfxSaved "var r='ok';try{localStorage.setItem('user','" + userId + "');" + "var bs=document.querySelectorAll('button.pcx86-binding,button.pcjs-binding'),b=null;for(var i=0;i=0){b=bs[i];break;}}" + "if(!b)r='no save button on this machine';else b.click();}catch(e){r='error: '+e.message;}" + "if(window.uwb)uwb.ExecuteJsMethod('AfxSaved',r);})();"; [Tooltip("Fraction of the screen's height kept clear above and below the picture so it sits inside the monitor bezel's opening (the screen quad runs a little way under the bezel's bottom lip). Editable live in Play mode.")] [Range(0f, 0.2f)] public float pictureInsetTop = 0.01f, pictureInsetBottom = 0.035f; float sentInsetTop = -1f, sentInsetBottom = -1f; string InsetJs => "window.__afxInset={t:" + pictureInsetTop.ToString(System.Globalization.CultureInfo.InvariantCulture) + ",b:" + pictureInsetBottom.ToString(System.Globalization.CultureInfo.InvariantCulture) + "};"; // Absolute mouse, installed by fullscreenJs once the emulator canvas exists (window.__afxInstallMouse). PCjs only // feeds Windows a relative serial mouse, and Windows 95 adds its own speed-dependent acceleration on top, so passing // the browser movement straight through left Windows' cursor wherever those deltas happened to add up to, never // exactly under the player's pointer. Instead the page blocks the real mouse events from reaching PCjs, keeps a target // (where the pointer is on the picture, in 640x480 guest pixels), finds where Windows actually drew its arrow on the // canvas (a full template match of the standard arrow, near its last spot first), and sends small synthetic moves // until the two match; while the arrow isn't visible (hourglass, I-beam) it carries on by dead reckoning. Clicks are // held until the cursor has arrived (or 400 ms). Verified headless on the Win95 mirror: the cursor lands on the exact // guest pixel under the pointer, no real events leak through, and a double-click opens the icon it is over. const string AbsoluteMouseJs = "window.__afxInstallMouse=function(){" + "if(window.__afxMouse)return;" + "var c=document.querySelector('.pcx86-machine canvas,.pcjs-machine canvas,canvas.pcjs-canvas');if(!c)return;" + "var A=window.__afxMouse={tx:-1,ty:-1,ex:-1,ey:-1,vx:100000,vy:100000,sent:0,clicks:[],el:null,found:false,hits:0,miss:0,moves:0,leaked:0,full:0,bl:[],lx:-1,ly:-1,push:0};" + "function overlay(){var r=c.getBoundingClientRect(),e=document.elementFromPoint(r.left+r.width/2,r.top+r.height/2);return e&&(e===c||(c.parentNode&&c.parentNode.contains(e)))?e:c;}" + "function GW(){return 640;}function GH(){return 480;}" + "function move(mx,my){if(!A.el)A.el=overlay();A.vx+=2*mx;A.vy+=2*my;A.sent=performance.now();" + "A.moves++;" + "A.el.dispatchEvent(new MouseEvent('mousemove',{clientX:A.vx,clientY:A.vy,screenX:A.vx,screenY:A.vy,movementX:2*mx,movementY:2*my,bubbles:true}));}" + "function button(b,down){if(!A.el)A.el=overlay();A.el.dispatchEvent(new MouseEvent(down?'mousedown':'mouseup',{button:b,clientX:A.vx,clientY:A.vy,screenX:A.vx,screenY:A.vy,bubbles:true}));}" + "function find(){var W=c.width,H=c.height;if(!W||!H)return null;var sx=W/GW(),sy=H/GH(),ctx=c.getContext('2d');" + "function scan(x0,y0,x1,y1){x0=Math.max(1,x0);y0=Math.max(0,y0);x1=Math.min(GW()-11,x1);y1=Math.min(GH()-11,y1);if(x1<=x0||y1<=y0)return null;" + "var bx=Math.floor(x0*sx),by=Math.floor(y0*sy),bw=Math.ceil((x1-x0+12)*sx),bh=Math.ceil((y1-y0+12)*sy);" + "bw=Math.min(bw,W-bx);bh=Math.min(bh,H-by);var g=ctx.getImageData(bx,by,bw,bh).data;" + "function k(x,y){var px=Math.floor(x*sx+sx/2)-bx,py=Math.floor(y*sy+sy/2)-by;if(px<0||py<0||px>=bw||py>=bh)return 'o';var i=(py*bw+px)*4,a=g[i],b=g[i+1],d=g[i+2];return a<40&&b<40&&d<40?'K':(a>215&&b>215&&d>215?'W':'o');}" + "for(var y=y0;y<=y1;y++)for(var x=x0;x<=x1;x++){if(k(x,y)!=='K')continue;var ok=true,r,j;" + "for(r=1;r<=9&&ok;r++){if(k(x,y+r)!=='K'||k(x+r,y+r)!=='K')ok=false;for(j=1;j=0)p=scan(A.ex-40,A.ey-40,A.ex+40,A.ey+40);if(!p&&performance.now()-A.full>500){A.full=performance.now();p=scan(0,0,GW(),GH());}return p;}" + "function bad(x,y){var n=performance.now();for(var i=A.bl.length-1;i>=0;i--){if(n>A.bl[i][2]){A.bl.splice(i,1);continue;}if(A.bl[i][0]===x&&A.bl[i][1]===y)return true;}return false;}" + "function step(d){var a=Math.abs(d);if(!a)return 0;var m=a<=3?1:Math.min(60,Math.round(a/2));return d<0?-m:m;}" + "function gain(m){var a=Math.abs(m);return a<=1?m:2*m;}" + "function tick(){if(A.tx<0)return;var now=performance.now();" + "if(now-A.sent>90||A.ex<0){var p=find();" + "if(p&&p[0]===A.lx&&p[1]===A.ly&&A.push>=8){A.bl.push([p[0],p[1],now+4000]);A.push=0;A.full=0;p=find();}" + "if(p){if(p[0]!==A.lx||p[1]!==A.ly)A.push=0;A.lx=p[0];A.ly=p[1];A.ex=p[0];A.ey=p[1];A.found=true;A.hits++;}else{A.found=false;A.miss++;if(A.ex<0){A.ex=GW()/2;A.ey=GH()/2;}}}" + "var dx=A.tx-A.ex,dy=A.ty-A.ey;" + "if(dx||dy){var settled=now-A.sent>90;var far=Math.abs(dx)>6||Math.abs(dy)>6;" + "if(far||settled){var mx=step(dx),my=step(dy);move(mx,my);A.push+=Math.abs(gain(mx))+Math.abs(gain(my));A.ex+=gain(mx);A.ey+=gain(my);}}" + "if(A.clicks.length){var arrived=(!dx&&!dy&&now-A.sent>90)||!A.found;var c0=A.clicks[0];" + "if(arrived||now-c0.t>400){A.clicks.shift();button(c0.b,c0.d);}}}" + "function toGuest(e){var r=c.getBoundingClientRect();if(!r.width||!r.height)return false;" + "var gx=Math.floor((e.clientX-r.left)/r.width*GW()),gy=Math.floor((e.clientY-r.top)/r.height*GH());" + "A.tx=Math.max(0,Math.min(GW()-1,gx));A.ty=Math.max(0,Math.min(GH()-1,gy));return true;}" + "function own(e){return e.isTrusted&&(e.target===c||(c.parentNode&&c.parentNode.contains(e.target)));}" + "window.addEventListener('mousemove',function(e){if(!own(e))return;toGuest(e);e.stopImmediatePropagation();},true);" + "window.addEventListener('mousedown',function(e){if(!own(e))return;toGuest(e);A.clicks.push({b:e.button,d:true,t:performance.now()});e.stopImmediatePropagation();},true);" + "window.addEventListener('mouseup',function(e){if(!own(e))return;A.clicks.push({b:e.button,d:false,t:performance.now()});e.stopImmediatePropagation();},true);" + "move(0,0);A.sent=0;" + "setInterval(tick,30);" + "};"; void SendFullscreenJs() { fullscreenSentAt = Time.unscaledTime; fullscreenAttempts++; sentInsetTop = pictureInsetTop; sentInsetBottom = pictureInsetBottom; try { browserClient.ExecuteJs(AbsoluteMouseJs + InsetJs + fullscreenJs); } catch (System.Exception ex) { Debug.LogWarning("MonitorBrowser: fullscreen script not sent yet (" + ex.Message + "), will retry."); } } // What page is actually in the browser and what PCjs controls it has; the answer lands in the Console via AfxProbe. // Sent on F2 and automatically when a save reports no Save control. const string ProbeJs = "(function(){var bs=document.querySelectorAll('button'),n=[];for(var i=0;iLast save result for the on-screen label, e.g. "Saved 21:05:12". public string LastSaved { get; private set; } = ""; public bool IsShuttingDown => shuttingDown; volatile bool pendingSaved, fullscreenDone; float fullscreenSentAt; int fullscreenAttempts; bool fullscreenGaveUp; volatile string lastSaveResult = ""; float lastSaveTime, shutdownDeadline; bool shuttingDown; static readonly int BaseMap = Shader.PropertyToID("_BaseMap"), MainTex = Shader.PropertyToID("_MainTex"); Material mat; Texture previousTexture; Vector2 lastMouse = new Vector2(-1, -1); readonly List down = new List(), up = new List(); readonly System.Text.StringBuilder typed = new System.Text.StringBuilder(); bool textHooked, textureApplied, usingOffline, fellBack; float loadStartTime; // OnClientConnected/OnLoadFinish fire from the package's background TCP-listener thread, not Unity's main // thread (confirmed by a get_isReadable-can-only-be-called-from-the-main-thread exception in testing). // Touching Unity APIs (Resolution, ExecuteJs, GUI-visible state) from there either throws or silently // misbehaves, so those handlers only set a flag; the actual work happens in OnFixedUpdate(). volatile bool pendingConnected, pendingLoadFinished; string cachePath; public string Status { get; private set; } = "Starting browser"; /// /// Sets the page this instance boots. Call it straight after AddComponent: Awake has already run by then (inside /// AddComponent, with the field defaults), and the engine starts on the next frame (BaseUwbClientManager.Start -> /// Init) with whatever browserClient.initialUrl holds at that moment. Setting only the url field there was the bug /// that made every boot load the default page (live pcjs.org: stock Windows 95, no D: drive, no Save control). /// public void SetUrl(string pageUrl, string fallbackUrl) { url = pageUrl; offlineUrl = fallbackUrl; browserClient.initialUrl = pageUrl; } // Hands out a new even port each call (odd port = outPort, right above it), cycling through a wide private // range so a long play session can't climb forever. Wide range and always-forward cycling keeps this boot far // away from a pair only just released by a boot a few instances ago, giving Windows plenty of time to actually // free the socket before it gets reused. The start point is randomised (not a fixed 55000): this field's // initialiser reruns on every domain reload/Sync, so a fixed start would always collide with the same port on // a machine that happens to have something else permanently bound there (confirmed on Paul's own PC: Razer's // background service sits on 55000 specifically, so every first boot after a Sync failed there before this). static int nextPort = 55000 + (System.Math.Abs(System.Environment.TickCount) % 4900 / 2) * 2; static int NextPortPair() { int p = nextPort; nextPort += 2; if (nextPort > 65000) nextPort = 55000; return p; } void Awake() { // Engine and communication assets: the CEF engine config ships in the engine package's Resources browserClient.engine = Resources.Load("Cef Engine Configuration"); if (browserClient.engine == null) foreach (var e in Resources.LoadAll("")) { browserClient.engine = e; break; } var tcp = ScriptableObject.CreateInstance(); // A fresh port pair per boot, not the same fixed 5575/5576 every time: this monitor gets booted and torn // down repeatedly in one session (F1, OS swap, retries, self-heal), and Windows doesn't always release a // closed socket instantly (TIME_WAIT) - a socket bind can fail with "Only one usage of each socket address // is normally permitted" for a previous instance's not-yet-released pair, which stops the comms layer (and // so the whole engine) from ever coming up, leaving a black screen with no CEF-side error at all (this was // the actual cause behind a run of "engine did not get ready"/black-screen reports, confirmed via that // exact SocketException in the log on Paul's machine). int p = NextPortPair(); tcp.inPort = p; tcp.outPort = p + 1; browserClient.communicationLayer = tcp; browserClient.initialUrl = url; // NOTE: the .Resolution setter always calls Resize(), which throws (UwbIsNotReadyException) if the // client isn't connected yet. Awake() runs before Init(), so it must not be set here (the exception // used to abort the rest of Awake(), silently skipping the event subscriptions below). Leave the // package's default resolution (1920x1080) for the pre-init state; resize once actually connected. browserClient.backgroundColor = new Color32(0, 0, 0, 255); browserClient.windowlessFrameRate = 30; // CEF's sandboxed renderer/GPU child processes need a window station object that Remote Desktop sessions // often restrict, so under RDP the sandbox setup can silently fail and the child process never completes // its handshake with the main CEF process, self-terminating after ~15s with nothing ever composited (a // reproducible black screen with IsConnected/ReadySignalReceived stuck false, confirmed via the engine's // own log on Paul's machine, which he uses over RDP). We only ever load our own mirrored pages here (never // arbitrary sites), so the sandbox isn't protecting against untrusted content the way it would in a normal // browser; disabling it is an acceptable trade here to make the emulator actually start under RDP. browserClient.noSandbox = true; // UWB's default cache path is a single FIXED directory shared by every instance/boot ever launched // (/UWBCache). This monitor gets booted and torn down over and over (F1, OS swap, retries, // self-heal), each time as a brand new CEF child process pointed at that SAME directory; if the previous // process hasn't fully released its cache lock file by the time the next one starts, the new process can // hang trying to acquire it and never complete its handshake, self-terminating after ~15s (a known UWB // failure mode, see Voltstro-Studios/UnityWebBrowser#166: "cache was still locked by another UWB process"). // Give this specific instance its own cache directory so successive boots never contend for the same lock; // cleaned up best-effort in OnDestroyed(). cachePath = Path.Combine(Application.temporaryCachePath, "afx_uwb_" + System.Guid.NewGuid().ToString("N")); browserClient.CachePath = new FileInfo(cachePath); // The CEF engine is a separate process; a cold start (first launch after a long idle, antivirus scanning the // exe) can take well over UWB's 4 s default, after which UWB gives up and disposes the client. browserClient.engineStartupTimeout = 30000; browserClient.OnLoadFinish += _ => pendingLoadFinished = true; browserClient.OnClientConnected += () => pendingConnected = true; // Page -> Unity bridge (uwb.ExecuteJsMethod in the page). Fires on the background thread too: flags only. browserClient.jsMethodManager.jsMethodsEnable = true; browserClient.RegisterJsMethod("AfxSaved", r => { lastSaveResult = r; pendingSaved = true; }); browserClient.RegisterJsMethod("AfxFullscreen", _ => fullscreenDone = true); browserClient.RegisterJsMethod("AfxProbe", r => { probeResult = r; pendingProbe = true; }); browserClient.RegisterJsMethod("AfxContentRect", r => { contentRectRaw = r; pendingContentRect = true; }); loadStartTime = Time.unscaledTime; } protected override void OnStart() { if (screen != null) { mat = screen.material; previousTexture = mat.HasProperty(BaseMap) ? mat.GetTexture(BaseMap) : null; // Fully stop the stream (audio, playback, background fetching), not just its Update() loop, and // blank the screen so no frozen stream frame shows behind/before the browser texture arrives. var hls = screen.GetComponent(); if (hls != null) hls.Pause(); if (mat.HasProperty(BaseMap)) mat.SetTexture(BaseMap, Texture2D.blackTexture); if (mat.HasProperty(MainTex)) mat.SetTexture(MainTex, Texture2D.blackTexture); } } // IMPORTANT: this class must never declare its own Update(). BaseUwbClientManager already owns a private // Update() that drives browserClient.LoadTextureData() (pulls the rendered frame into BrowserTexture) and // calls OnFixedUpdate() at a fixed cadence — that's the intended extension point. Unity's MonoBehaviour // message dispatch resolves same-named methods by most-derived-wins, so a subclass Update() here would // silently starve out the base one: the browser keeps running (confirmed by console logs of the emulated // PC actually booting) but its frame data is never pulled and OnFixedUpdate() never runs at all, which was // exactly the "connected but permanently black screen, Status stuck" bug this fixes. LateUpdate() below is // fine (the base class doesn't declare one), and is used for input specifically so it runs every rendered // frame rather than at OnFixedUpdate's fixed-timestep cadence, which could miss brief keypresses. protected override void OnFixedUpdate() { // Runs on the main thread (BaseUwbClientManager calls this from its own Update()): safe to touch // Unity APIs here, unlike inside the OnClientConnected/OnLoadFinish handlers themselves. if (pendingConnected) { pendingConnected = false; Status = "Connected"; try { browserClient.Resolution = new VoltstroStudios.UnityWebBrowser.Shared.Resolution((uint)width, (uint)height); } catch (System.Exception ex) { Debug.LogWarning("MonitorBrowser: resize after connect failed (" + ex.Message + "); keeping the default resolution."); } } if (pendingLoadFinished) { pendingLoadFinished = false; Status = "Loading " + osName; // the monitor stays black until the emulator screen is hosted (see fullscreenDone) fullscreenDone = false; fullscreenAttempts = 0; SendFullscreenJs(); } // If the live site hasn't finished loading in time (offline, unreachable, DNS trouble), switch to the // local mirror once. !textureApplied is the simplest "hasn't actually shown anything yet" check. if (!fellBack && !usingOffline && !textureApplied && !string.IsNullOrEmpty(offlineUrl) && offlineUrl != url && Time.unscaledTime - loadStartTime > offlineFallbackAfter) { if (browserClient.IsConnected && browserClient.ReadySignalReceived) { fellBack = true; usingOffline = true; Status = "Live site unreachable, loading the local copy"; try { browserClient.LoadUrl(offlineUrl); } catch (System.Exception ex) { Debug.LogWarning("MonitorBrowser: offline fallback LoadUrl failed (" + ex.Message + ")"); } } } // Keep re-applying (rather than once): a post-connect Resize() replaces BrowserTexture with a new // Texture2D instance, so the material would otherwise keep pointing at the old, now-stale one. // IsConnected/ReadySignalReceived are required here, not just BrowserTexture != null: UWB pre-allocates a // placeholder texture (the size in Resolution, all-black) before the engine has actually connected, so a // check on BrowserTexture alone goes true, sets textureApplied and reports "Showing (WxH)" while the CEF // process is still starting (or, as happened once, permanently failed to start) -- a real black screen // reported as if it were live, and downstream code (autosave, fullscreen retries) then tries to run JS // against a browser that was never actually ready, throwing UwbIsNotReadyException. // fullscreenDone (or the fullscreen script having given up) is required too: until the page reports it has // hosted just the emulator's screen, the browser is showing the raw page (blog text, PCjs "Loading..." lines), // so the monitor stays black instead. if (browserClient.IsConnected && browserClient.ReadySignalReceived && (fullscreenDone || fullscreenGaveUp) && mat != null && browserClient.BrowserTexture != null && !ReferenceEquals(mat.HasProperty(BaseMap) ? mat.GetTexture(BaseMap) : null, browserClient.BrowserTexture)) { // The browser texture's rows come from CEF's off-screen buffer, top row first; a mesh Quad's UVs // expect bottom row first (that's why the package's own UI components, which don't hit this, work // unflipped but a raw Quad material shows the picture upside down and mirrored). Flip both axes. if (mat.HasProperty(BaseMap)) mat.SetTexture(BaseMap, browserClient.BrowserTexture); ApplyFlip(); if (mat.HasProperty(MainTex)) mat.SetTexture(MainTex, browserClient.BrowserTexture); if (!textureApplied) lastSaveTime = Time.unscaledTime; textureApplied = true; width = browserClient.BrowserTexture.width; height = browserClient.BrowserTexture.height; // keep the mouse mapping in sync with whatever size is actually live Status = "Showing (" + width + "x" + height + ")"; // visible, unambiguous proof this is the browser texture, not the HLS one } // The fullscreen script can be lost (sent before the page's scripts were ready, or the machine element not // there yet); until the page reports it took effect, send it again every few seconds, a handful of times. if (browserClient.IsConnected && !fullscreenDone && !fullscreenGaveUp && fullscreenAttempts > 0 && Time.unscaledTime - fullscreenSentAt > 4f) { // ~1 minute of retries (the machine can take a while to load); then show the page anyway rather than a // monitor that stays black with no explanation. if (fullscreenAttempts < 15) SendFullscreenJs(); else { fullscreenGaveUp = true; Debug.LogWarning("MonitorBrowser: the fullscreen script never reported back; showing the page as it is."); } } // Inset tweaked in the Inspector while running: hand the page the new values (its fit timer applies them) if (fullscreenDone && (sentInsetTop != pictureInsetTop || sentInsetBottom != pictureInsetBottom)) { sentInsetTop = pictureInsetTop; sentInsetBottom = pictureInsetBottom; try { browserClient.ExecuteJs(InsetJs); } catch (System.Exception ex) { Debug.LogWarning("MonitorBrowser: inset not sent (" + ex.Message + ")"); } } if (pendingContentRect) { pendingContentRect = false; var parts = contentRectRaw != null ? contentRectRaw.Split(',') : null; if (parts != null && parts.Length == 4 && float.TryParse(parts[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float rx) && float.TryParse(parts[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float ry) && float.TryParse(parts[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float rw) && float.TryParse(parts[3], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float rh) && rw > 1f && rh > 1f) { contentRect = new Rect(rx, ry, rw, rh); hasContentRect = true; } } if (pendingSaved) { pendingSaved = false; LastSaved = (lastSaveResult == "ok" ? "Saved " : "Save failed (" + lastSaveResult + ") ") + System.DateTime.Now.ToString("HH:mm:ss"); if (lastSaveResult != "ok") { Debug.LogWarning("MonitorBrowser: " + osName + " state save: " + lastSaveResult); SendProbe(); } if (shuttingDown) { Destroy(this); return; } } if (pendingProbe) { pendingProbe = false; Debug.Log("MonitorBrowser page probe (" + osName + "): " + probeResult); } if (shuttingDown) { if (Time.unscaledTime > shutdownDeadline) { Debug.LogWarning("MonitorBrowser: no reply from the save script, switching off anyway."); Destroy(this); } return; } if (textureApplied && autoSaveEvery > 0 && !string.IsNullOrEmpty(userId) && Time.unscaledTime - lastSaveTime > autoSaveEvery) { lastSaveTime = Time.unscaledTime; try { browserClient.ExecuteJs(SaveJs); } catch (System.Exception ex) { Debug.LogWarning("MonitorBrowser: autosave not sent (" + ex.Message + "), will try again next interval."); } } } // LateUpdate (unlike Update) isn't used by BaseUwbClientManager, so it's safe to declare here — this runs // every rendered frame (unlike OnFixedUpdate's ~50 Hz fixed-timestep cadence above), which matters because // wasPressedThisFrame/wasReleasedThisFrame are only true for exactly one frame: polling them from inside // OnFixedUpdate risked missing a brief keypress or click entirely if it fell between two fixed-update polls // (confirmed happening to F1 in testing — single presses were sometimes silently dropped). void LateUpdate() { bool focused = LobbyCamera.CurrentLabel != null && LobbyCamera.CurrentLabel.StartsWith("Computer"); if (textureApplied && mat != null && !browserClient.HasDisposed && browserClient.BrowserTexture != null && mat.HasProperty(BaseMap) && ReferenceEquals(mat.GetTexture(BaseMap), browserClient.BrowserTexture)) ApplyFlip(); // The engine can die on its own (startup timeout, crash): UWB disposes the client and nothing would ever // connect, leaving a dead black monitor that the launchers refuse to reboot (a MonitorBrowser is still // attached). Drop this component so the Mouse/Keyboard boot again and the stream comes back. if (browserClient.HasDisposed && !shuttingDown) { Debug.LogWarning("MonitorBrowser: the browser engine shut down on its own while running " + osName + " (startup timeout or crash); switching the PC off so it can be booted again."); Status = "Browser engine failed"; shuttingDown = true; Destroy(this); return; } #if ENABLE_INPUT_SYSTEM // F1 is the escape hatch and must work even while the engine is still starting up or has failed, so it // is checked before the connection gate below (ShutDown() just destroys when nothing is connected). if (focused && Keyboard.current != null && Keyboard.current.f1Key.wasPressedThisFrame) { ShutDown(); return; } if (focused && Keyboard.current != null && Keyboard.current.f2Key.wasPressedThisFrame) SendProbe(); #endif if (!browserClient.IsConnected || !browserClient.ReadySignalReceived) { LobbyCamera.CaptureKeys = false; return; } if (!focused) { LobbyCamera.CaptureKeys = false; return; } #if ENABLE_INPUT_SYSTEM var kb = Keyboard.current; var m = Mouse.current; if (kb == null || m == null) { LobbyCamera.CaptureKeys = false; return; } if (!textHooked) { kb.onTextInput += OnText; textHooked = true; } // Mouse: project onto the screen quad. Only counts as "over the emulator" -- which both forwards the event // and keeps CaptureKeys true so the lobby's own right click/Esc/scroll-out stays suppressed -- when it lands // within the actual visible picture reported by fullscreenJs (contentRect), not just anywhere on the monitor // quad: the quad is 16:9 but the picture is letterboxed to 4:3 inside it, so the black bars either side (and // anywhere off the quad entirely) need to behave like stepping off the screen, not still being "on" it -- // otherwise the emulated cursor reacts before the real one visually reaches the picture, and right // click/Esc there gets swallowed instead of returning to the lobby. bool overContent = false; var cam = Camera.main; if (cam != null && screen != null) { var ray = cam.ScreenPointToRay(m.position.ReadValue()); var plane = new Plane(-screen.transform.forward, screen.transform.position); if (plane.Raycast(ray, out float t)) { var local = screen.transform.InverseTransformPoint(ray.GetPoint(t)); // quad spans -0.5..0.5 if (Mathf.Abs(local.x) <= 0.5f && Mathf.Abs(local.y) <= 0.5f) { var pos = new Vector2((local.x + 0.5f) * width, (0.5f - local.y) * height); overContent = !hasContentRect || contentRect.Contains(pos); if (overContent) { if (pos != lastMouse) { browserClient.SendMouseMove(pos); lastMouse = pos; } if (m.leftButton.wasPressedThisFrame) browserClient.SendMouseClick(pos, 1, MouseClickType.Left, MouseEventType.Down); if (m.leftButton.wasReleasedThisFrame) browserClient.SendMouseClick(pos, 1, MouseClickType.Left, MouseEventType.Up); if (m.rightButton.wasPressedThisFrame) browserClient.SendMouseClick(pos, 1, MouseClickType.Right, MouseEventType.Down); if (m.rightButton.wasReleasedThisFrame) browserClient.SendMouseClick(pos, 1, MouseClickType.Right, MouseEventType.Up); float scroll = m.scroll.ReadValue().y; if (Mathf.Abs(scroll) > 0.01f) browserClient.SendMouseScroll(pos, (int)Mathf.Sign(scroll) * 60); } } } } LobbyCamera.CaptureKeys = overContent; // Keyboard: forwarded whenever the computer close-up is showing, regardless of exactly where the mouse // happens to be resting -- typing shouldn't require pinning the cursor over the picture. down.Clear(); up.Clear(); foreach (KeyControl key in kb.allKeys) { if (key.keyCode == Key.F1) continue; if ((key.keyCode == Key.Enter || key.keyCode == Key.NumpadEnter) && key.wasPressedThisFrame) typed.Append('\r'); try { if (key.wasPressedThisFrame) down.Add(key.keyCode.UnityKeyToWindowKey()); if (key.wasReleasedThisFrame) up.Add(key.keyCode.UnityKeyToWindowKey()); } catch (System.ArgumentOutOfRangeException) { } } if (down.Count > 0 || up.Count > 0 || typed.Length > 0) { browserClient.SendKeyboardControls(down.ToArray(), up.ToArray(), typed.ToString().ToCharArray()); typed.Clear(); } #endif } /// /// Switch the PC off: saves the running machine to the server first (when the page is up and a user id is /// set), then removes this component once the page reports back (or after a few seconds if it never does). /// MonitorBrowserLauncher waits for the component to be gone before booting the other OS. /// public void ShutDown() { if (shuttingDown) return; shuttingDown = true; if (textureApplied && !browserClient.HasDisposed && browserClient.IsConnected && browserClient.ReadySignalReceived && !string.IsNullOrEmpty(userId)) { Status = "Saving " + osName + " before switching off"; shutdownDeadline = Time.unscaledTime + 8f; try { browserClient.ExecuteJs(SaveJs); } catch (System.Exception ex) { Debug.LogWarning("MonitorBrowser: shutdown save not sent (" + ex.Message + "), switching off without saving."); Destroy(this); } } else Destroy(this); } // UWB's texture arrives upside down for our quad (rows top to bottom), so the material samples it with a vertical // flip: scale (1,-1), offset (0,1). Re-asserted every frame while the browser texture is on screen (LateUpdate) // rather than only when it is first bound: anything else that touches the material's tiling (the stream player's // letterbox fit, a swap between the two machines) must never leave the picture mirrored or upside down. [Tooltip("Texture tiling/offset used while the browser picture is on the screen. UWB delivers its texture with the rows top to bottom, so the quad samples it with a vertical flip only: (1,-1)/(0,1), confirmed on the monitor. (-1,-1)/(1,1) would also mirror it left to right. Editable live in Play mode.")] public Vector2 flipScale = new Vector2(1f, -1f), flipOffset = new Vector2(0f, 1f); void ApplyFlip() { foreach (var prop in new[] { BaseMap, MainTex }) { if (!mat.HasProperty(prop)) continue; if (mat.GetTextureScale(prop) != flipScale) mat.SetTextureScale(prop, flipScale); if (mat.GetTextureOffset(prop) != flipOffset) mat.SetTextureOffset(prop, flipOffset); } } protected override void OnDestroyed() { if (mat != null && previousTexture != null && mat.HasProperty(BaseMap)) { // Hand the material back the way the stream expects it: its texture, plain tiling (no 180 degree flip) mat.SetTexture(BaseMap, previousTexture); foreach (var prop in new[] { BaseMap, MainTex }) if (mat.HasProperty(prop)) { mat.SetTextureScale(prop, Vector2.one); mat.SetTextureOffset(prop, Vector2.zero); } } var hls = screen != null ? screen.GetComponent() : null; if (hls != null) hls.Resume(); #if ENABLE_INPUT_SYSTEM if (textHooked && Keyboard.current != null) Keyboard.current.onTextInput -= OnText; #endif LobbyCamera.CaptureKeys = false; // Best-effort: the CEF process may still be releasing its lock on this the moment Dispose() returns, so a // failed delete here (still in use) isn't a problem, just a few stray MB in the OS temp folder over time. if (!string.IsNullOrEmpty(cachePath)) { try { if (Directory.Exists(cachePath)) Directory.Delete(cachePath, true); } catch { } } } void OnText(char c) { if (!char.IsControl(c)) typed.Append(c); } void OnGUI() { var style = new GUIStyle(GUI.skin.label) { fontSize = 14 }; style.normal.textColor = new Color(1f, 1f, 1f, 0.8f); GUI.Label(new Rect(14, Screen.height - 30, 900, 24), osName + ": " + Status + (textureApplied && !fullscreenDone ? " (fitting the screen, attempt " + fullscreenAttempts + ")" : "") + (LastSaved.Length > 0 ? ". " + LastSaved : "") + ". F1 to switch the PC off.", style); } }