using System; using System.Drawing; using System.Text; using System.Windows.Forms; using Munga.Net; using Tesla; using Tesla.Net; namespace VPod; /// /// The vPOD window: a live view of the simulated pod. Top panel shows the /// listening/connection status and the current ApplicationState (colour-coded), /// with the game toggle (Red Planet / BattleTech) that changes which /// ApplicationID the pod reports. Below, the left pane is the egg viewer (the /// last egg the console streamed, one field per line) and the right pane is a /// scrolling protocol log. The right column is the virtual launcher — the pod /// side of the console's Site Management (provisioning, product installs, /// launch/kill/volume/shutdown), see LauncherRpcServer / PodProvisioning. /// internal sealed class VPodForm : Form { private readonly PodArguments mOptions; private readonly MungaPodServer mServer; private readonly PodSimulator mSimulator; private readonly VirtualLauncher mLauncher; private readonly LauncherRpcServer mRpcServer; private readonly PodProvisioning mProvisioning; private Label mListeningLabel; private Label mConnectionLabel; private Label mStateLabel; private Label mEggSummaryLabel; private RadioButton mRedPlanetRadio; private RadioButton mBattleTechRadio; private Button mPowerButton; private Button mPowerOffButton; private Button mStartGameButton; private Button mStopGameButton; private Button mResetButton; private CheckBox mRestartCheckbox; private Timer mRestartTimer; private Timer mRebootTimer; private SplitContainer mSplit; private TextBox mEggBox; private TextBox mLogBox; // Virtual launcher / site-management column. private Label mProvisionStatusLabel; private Panel mPassphrasePanel; private Label mRequestIdValueLabel; private Label mPassphraseValueLabel; private Label mNetConfigLabel; private Label mRpcStatusLabel; private Label mInstallStatusLabel; private ProgressBar mInstallProgressBar; private Button mReprovisionButton; private CheckBox mRealLaunchCheckbox; private ListView mAppsView; private bool mPoweredOn; // How long the "watchdog" waits before relaunching the exited game. private const int WatchdogRestartMs = 1500; // How long a console-requested Shutdown(restart) keeps the pod dark. private const int RebootRestartMs = 4000; public VPodForm(PodArguments options) { mOptions = options; mServer = new MungaPodServer(options.Port); mSimulator = new PodSimulator(mServer, options.Application, options.HostId); mLauncher = new VirtualLauncher(); mRpcServer = new LauncherRpcServer(mLauncher); mProvisioning = new PodProvisioning(PodProvisioning.MacForHost(options.HostId)); BuildUi(); mServer.Log += OnLog; mServer.ConnectionChanged += OnConnectionChanged; mServer.MessageReceived += OnMessageReceived; mSimulator.Log += OnLog; mSimulator.StateChanged += OnStateChanged; mSimulator.EggReceived += OnEggReceived; mSimulator.EggProgress += OnEggProgress; mSimulator.EndMissionExit += OnEndMissionExit; mLauncher.Log += OnSiteLog; mLauncher.AppsChanged += OnAppsChanged; mLauncher.InstallProgressChanged += OnInstallProgress; mLauncher.ShutdownRequested += OnShutdownRequested; mLauncher.ReprovisionRequested += OnReprovisionRequested; mRpcServer.Log += OnSiteLog; mRpcServer.ConnectionsChanged += OnRpcConnectionsChanged; mProvisioning.Log += OnSiteLog; mProvisioning.ConfigReceived += OnConfigReceived; mProvisioning.Provisioned += OnProvisioned; mRestartTimer = new Timer { Interval = WatchdogRestartMs }; mRestartTimer.Tick += OnRestartTimerTick; mRebootTimer = new Timer { Interval = RebootRestartMs }; mRebootTimer.Tick += OnRebootTimerTick; Load += OnFormLoad; FormClosing += OnFormClosing; } private void BuildUi() { Text = $"vPOD - virtual pod (port {mOptions.Port}, host {mOptions.HostId})"; ClientSize = new Size(1280, 560); MinimumSize = new Size(1100, 500); Font = new Font("Segoe UI", 9f); // ---- status panel ---- GroupBox statusGroup = new GroupBox { Text = "Pod Status", Dock = DockStyle.Top, Height = 176, Padding = new Padding(10) }; mListeningLabel = new Label { AutoSize = true, Location = new Point(16, 26) }; mConnectionLabel = new Label { AutoSize = true, Location = new Point(16, 50) }; Label roleLabel = new Label { AutoSize = true, Location = new Point(16, 74), Text = "Role: " + RoleText(mOptions.HostType) }; Label stateCaption = new Label { AutoSize = true, Location = new Point(16, 106), Text = "Application State:" }; mStateLabel = new Label { AutoSize = true, Location = new Point(130, 100), Font = new Font("Segoe UI", 15f, FontStyle.Bold), Text = "—" }; // pod power: the whole virtual machine (launcher/site management included), // distinct from starting/stopping the emulated game client below. GroupBox powerGroup = new GroupBox { Text = "Pod Power", Location = new Point(740, 20), Size = new Size(150, 130) }; mPowerButton = new Button { Text = "Power On", Location = new Point(12, 28), Size = new Size(126, 30) }; mPowerOffButton = new Button { Text = "Power Off", Location = new Point(12, 66), Size = new Size(126, 30) }; mPowerButton.Click += PowerOnClicked; mPowerOffButton.Click += PowerOffClicked; powerGroup.Controls.Add(mPowerButton); powerGroup.Controls.Add(mPowerOffButton); // game toggle + game-process controls (the "exe", not the machine) GroupBox gameGroup = new GroupBox { Text = "Mimicking Game", Location = new Point(400, 20), Size = new Size(330, 130) }; mRedPlanetRadio = new RadioButton { Text = "Red Planet (RPL4)", Location = new Point(18, 28), AutoSize = true, Checked = mOptions.Application == ApplicationID.RPL4 }; mBattleTechRadio = new RadioButton { Text = "BattleTech (BTL4)", Location = new Point(18, 56), AutoSize = true, Checked = mOptions.Application == ApplicationID.BTL4 }; mRedPlanetRadio.CheckedChanged += GameToggleChanged; mBattleTechRadio.CheckedChanged += GameToggleChanged; mStartGameButton = new Button { Text = "Start Game", Location = new Point(12, 90), Size = new Size(94, 28) }; mStopGameButton = new Button { Text = "Stop Game", Location = new Point(112, 90), Size = new Size(94, 28) }; mResetButton = new Button { Text = "Reset", Location = new Point(212, 90), Size = new Size(94, 28) }; mStartGameButton.Click += StartGameClicked; mStopGameButton.Click += StopGameClicked; mResetButton.Click += (s, e) => mSimulator.Reset(); gameGroup.Controls.Add(mRedPlanetRadio); gameGroup.Controls.Add(mBattleTechRadio); gameGroup.Controls.Add(mStartGameButton); gameGroup.Controls.Add(mStopGameButton); gameGroup.Controls.Add(mResetButton); mRestartCheckbox = new CheckBox { Text = "Restart game after mission ends (watchdog)", Location = new Point(16, 146), AutoSize = true, Checked = true }; mRestartCheckbox.CheckedChanged += (s, e) => mSimulator.RestartOnEndMission = mRestartCheckbox.Checked; statusGroup.Controls.Add(mListeningLabel); statusGroup.Controls.Add(mConnectionLabel); statusGroup.Controls.Add(roleLabel); statusGroup.Controls.Add(stateCaption); statusGroup.Controls.Add(mStateLabel); statusGroup.Controls.Add(mRestartCheckbox); statusGroup.Controls.Add(powerGroup); statusGroup.Controls.Add(gameGroup); // ---- egg viewer + log ---- // Split the egg viewer (left) and protocol log (right). The 50/50 default // is applied in OnFormLoad once the control has its real width (setting it // here would clamp to the control's default size). mSplit = new SplitContainer { Dock = DockStyle.Fill, Orientation = Orientation.Vertical }; SplitContainer split = mSplit; GroupBox eggGroup = new GroupBox { Text = "Current Egg", Dock = DockStyle.Fill, Padding = new Padding(8) }; // The egg is kept after a mission/restart (not auto-cleared) so it can be // copied for dev use; the Clear button empties the viewer on demand. Panel eggHeader = new Panel { Dock = DockStyle.Top, Height = 30 }; Button clearEggButton = new Button { Text = "Clear", Dock = DockStyle.Right, Width = 70 }; mEggSummaryLabel = new Label { Dock = DockStyle.Fill, Text = "No egg loaded.", TextAlign = ContentAlignment.MiddleLeft }; clearEggButton.Click += (s, e) => { mEggBox.Clear(); mEggSummaryLabel.Text = "No egg loaded."; }; eggHeader.Controls.Add(mEggSummaryLabel); eggHeader.Controls.Add(clearEggButton); mEggBox = new TextBox { Dock = DockStyle.Fill, Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, WordWrap = false, Font = new Font("Consolas", 9f), BackColor = Color.White }; eggGroup.Controls.Add(mEggBox); eggGroup.Controls.Add(eggHeader); GroupBox logGroup = new GroupBox { Text = "Protocol Log", Dock = DockStyle.Fill, Padding = new Padding(8) }; mLogBox = new TextBox { Dock = DockStyle.Fill, Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, WordWrap = false, Font = new Font("Consolas", 9f), BackColor = Color.FromArgb(24, 24, 24), ForeColor = Color.Gainsboro }; logGroup.Controls.Add(mLogBox); split.Panel1.Controls.Add(eggGroup); split.Panel2.Controls.Add(logGroup); // ---- virtual launcher / site management column ---- GroupBox siteGroup = new GroupBox { Text = "Launcher / Site Management", Dock = DockStyle.Right, Width = 360, Padding = new Padding(8) }; Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 234 }; mProvisionStatusLabel = new Label { AutoSize = true, Location = new Point(4, 4), Font = new Font("Segoe UI", 9f, FontStyle.Bold), Text = "Provisioning: —" }; // Stand-in for the real pod's TeslaPasscodeDisplay: the operator reads // these two values into the console's Configure dialog. mPassphrasePanel = new Panel { Location = new Point(4, 28), Size = new Size(336, 62), Visible = false }; Label requestIdCaption = new Label { AutoSize = true, Location = new Point(0, 6), Text = "Request ID:" }; mRequestIdValueLabel = new Label { AutoSize = true, Location = new Point(90, 0), Font = new Font("Segoe UI", 15f, FontStyle.Bold), Text = "—" }; Label passphraseCaption = new Label { AutoSize = true, Location = new Point(0, 38), Text = "Passphrase:" }; mPassphraseValueLabel = new Label { AutoSize = true, Location = new Point(90, 32), Font = new Font("Segoe UI", 15f, FontStyle.Bold), ForeColor = Color.Firebrick, Text = "—" }; mPassphrasePanel.Controls.Add(requestIdCaption); mPassphrasePanel.Controls.Add(mRequestIdValueLabel); mPassphrasePanel.Controls.Add(passphraseCaption); mPassphrasePanel.Controls.Add(mPassphraseValueLabel); mNetConfigLabel = new Label { Location = new Point(4, 94), Size = new Size(336, 32), ForeColor = Color.Gray, Text = "" }; mRpcStatusLabel = new Label { AutoSize = true, Location = new Point(4, 130), Text = "Launcher RPC: not running" }; mInstallStatusLabel = new Label { Location = new Point(4, 154), Size = new Size(336, 18), Text = "No install in progress." }; mInstallProgressBar = new ProgressBar { Location = new Point(4, 176), Size = new Size(240, 22), Minimum = 0, Maximum = 100 }; mReprovisionButton = new Button { Text = "Reprovision", Location = new Point(252, 174), Size = new Size(88, 26) }; mReprovisionButton.Click += ReprovisionClicked; // Off = LaunchApp records simulated PIDs; on = start/kill real processes // (the entries point into the real C:\Games, so this runs what the // console deployed — exactly like the Agent). mRealLaunchCheckbox = new CheckBox { Text = "Actually launch apps (real processes)", Location = new Point(4, 206), AutoSize = true, Checked = false }; mRealLaunchCheckbox.CheckedChanged += (s, e) => { mLauncher.RealLaunch = mRealLaunchCheckbox.Checked; OnSiteLog(mRealLaunchCheckbox.Checked ? "Launch/Kill now start and terminate REAL processes." : "Launch/Kill now simulate PIDs (no real processes)."); }; siteTop.Controls.Add(mProvisionStatusLabel); siteTop.Controls.Add(mPassphrasePanel); siteTop.Controls.Add(mNetConfigLabel); siteTop.Controls.Add(mRpcStatusLabel); siteTop.Controls.Add(mInstallStatusLabel); siteTop.Controls.Add(mInstallProgressBar); siteTop.Controls.Add(mReprovisionButton); siteTop.Controls.Add(mRealLaunchCheckbox); mAppsView = new ListView { Dock = DockStyle.Fill, View = View.Details, FullRowSelect = true, HeaderStyle = ColumnHeaderStyle.Nonclickable }; mAppsView.Columns.Add("Installed App", 150); mAppsView.Columns.Add("PID", 48); mAppsView.Columns.Add("Auto", 42); mAppsView.Columns.Add("Command", 320); siteGroup.Controls.Add(mAppsView); siteGroup.Controls.Add(siteTop); Controls.Add(split); Controls.Add(statusGroup); Controls.Add(siteGroup); } private void OnFormLoad(object sender, EventArgs e) { // Now that the split has its real width, give the log half the window. if (mSplit.Width > mSplit.Panel1MinSize + mSplit.Panel2MinSize) { mSplit.SplitterDistance = mSplit.Width / 2; } UpdateConnectionLabel(null); UpdateStateLabel(mSimulator.State); RefreshAppsList(); PowerOnClicked(this, EventArgs.Empty); } private void OnFormClosing(object sender, FormClosingEventArgs e) { mServer.Stop(); StopManagement(); mLauncher.KillAllApps(); // don't orphan real launched processes when the tool exits } /// /// Pod power = the whole virtual machine. Powering on boots the /// launcher/site-management side and auto-starts the game client (a real /// pod's boot-time autoRestart launch); the game can then be stopped and /// started on its own while the pod stays up. /// private void PowerOnClicked(object sender, EventArgs e) { mRebootTimer.Stop(); SetPoweredState(on: true); StartManagement(); StartGameClicked(sender, e); } /// /// Powers the whole pod off: the game listener AND the launcher/provisioning /// side go dark, like unplugging the machine. (To mimic just the game exe /// exiting while the pod stays up, use Stop Game.) /// private void PowerOffClicked(object sender, EventArgs e) { mRebootTimer.Stop(); StopGame(); StopManagement(); mLauncher.KillAllApps(); // the machine is "off": launched apps (real or simulated) die with it SetPoweredState(on: false); } /// Starts the emulated game client (Munga listener) on a powered-on pod. private void StartGameClicked(object sender, EventArgs e) { if (!mPoweredOn) { return; } mRestartTimer.Stop(); // cancel any pending watchdog restart if (!mServer.IsListening && !StartServer()) { SetGameState(running: false); return; // couldn't bind the port; pod stays up, game stays stopped } mSimulator.PowerOn(); SetGameState(running: true); } /// /// Stops just the emulated game "exe": the Munga listener closes (the /// console's game connection drops) but the pod — and its launcher / /// site-management side — stays up, like killing the game process on a /// real pod. /// private void StopGameClicked(object sender, EventArgs e) { StopGame(); } private void StopGame() { mRestartTimer.Stop(); mServer.Stop(); SetGameState(running: false); } /// /// The game exited gracefully on the console's end-mission command. Simulate /// the process exit by closing the listener (the console's connection drops), /// then let the watchdog timer relaunch it. /// private void OnEndMissionExit() { if (IsDisposed) return; BeginInvoke((Action)(() => { OnLog("Game exited gracefully (end mission); watchdog will restart it..."); mServer.Stop(); UpdateListeningLabel(false); UpdateConnectionLabel(null); mStateLabel.Text = "Restarting..."; mStateLabel.ForeColor = Color.DarkOrange; mStartGameButton.Enabled = true; mStopGameButton.Enabled = false; mResetButton.Enabled = false; mRestartTimer.Stop(); mRestartTimer.Start(); })); } private void OnRestartTimerTick(object sender, EventArgs e) { mRestartTimer.Stop(); if (!mPoweredOn) { return; // pod was powered off while the watchdog was pending } if (StartServer()) { OnLog("Watchdog restarted the game."); mSimulator.PowerOn(); SetGameState(running: true); } else { SetGameState(running: false); } } /// Starts the listener and updates the status label. Returns false (with a message) if the port is unavailable. private bool StartServer() { try { mServer.Start(); UpdateListeningLabel(true); return true; } catch (Exception ex) { UpdateListeningLabel(false); OnLog("FAILED to listen on port " + mOptions.Port + ": " + ex.Message); MessageBox.Show(this, "Could not listen on TCP port " + mOptions.Port + ".\n\n" + ex.Message + "\n\nIs another pod or vPOD already using it?", "vPOD", MessageBoxButtons.OK, MessageBoxIcon.Warning); return false; } } /// Reflects the pod's (machine-level) on/off state in the buttons and, /// when off, the status labels. Game-level state is . private void SetPoweredState(bool on) { mPoweredOn = on; mPowerButton.Enabled = !on; mPowerOffButton.Enabled = on; mReprovisionButton.Enabled = on && !mOptions.NoManage; if (!on) { mStartGameButton.Enabled = false; mStopGameButton.Enabled = false; mResetButton.Enabled = false; UpdateListeningLabel(false); UpdateConnectionLabel(null); mStateLabel.Text = "Powered Off"; mStateLabel.ForeColor = Color.Gray; mProvisionStatusLabel.Text = "Provisioning: pod powered off"; mProvisionStatusLabel.ForeColor = Color.Gray; mPassphrasePanel.Visible = false; mRpcStatusLabel.Text = "Launcher RPC: pod powered off"; mRpcStatusLabel.ForeColor = Color.Gray; } } /// Reflects whether the emulated game client is running; the pod /// itself (launcher/site management) may still be up with the game stopped. private void SetGameState(bool running) { mStartGameButton.Enabled = mPoweredOn && !running; mStopGameButton.Enabled = mPoweredOn && running; mResetButton.Enabled = mPoweredOn && running; if (!running) { UpdateListeningLabel(false); UpdateConnectionLabel(null); if (mPoweredOn) { mStateLabel.Text = "Game Not Running"; mStateLabel.ForeColor = Color.Gray; } } } // ---- virtual launcher / site management lifecycle ---- /// Brings the launcher side up for a powered-on pod: the RPC server /// when a session key is stored, otherwise the provisioning beacons. private void StartManagement() { if (mOptions.NoManage) { mProvisionStatusLabel.Text = "Provisioning: disabled (-nomanage)"; mProvisionStatusLabel.ForeColor = Color.Gray; mRpcStatusLabel.Text = "Launcher RPC: disabled (-nomanage)"; mRpcStatusLabel.ForeColor = Color.Gray; return; } byte[] key = mLauncher.LoadKey(); if (key != null) { StartRpcServer(key); } else { StartProvisioning(); } } private void StopManagement() { mProvisioning.Stop(); mRpcServer.Stop(); } private void StartRpcServer(byte[] key) { try { mRpcServer.Start(key); } catch (Exception ex) { OnSiteLog($"FAILED to listen on TCP {LauncherRpcServer.ManagePort}: {ex.Message}"); mRpcStatusLabel.Text = $"Launcher RPC: TCP {LauncherRpcServer.ManagePort} unavailable"; mRpcStatusLabel.ForeColor = Color.Firebrick; return; } mProvisionStatusLabel.Text = "Provisioned — key " + KeyFingerprint(key); mProvisionStatusLabel.ForeColor = Color.ForestGreen; mPassphrasePanel.Visible = false; mRpcStatusLabel.Text = $"Launcher RPC: listening on TCP {LauncherRpcServer.ManagePort}"; mRpcStatusLabel.ForeColor = Color.ForestGreen; } private void StartProvisioning() { mProvisioning.Start(); mProvisionStatusLabel.Text = "Unprovisioned — beaconing to console..."; mProvisionStatusLabel.ForeColor = Color.DarkGoldenrod; mRequestIdValueLabel.Text = mProvisioning.RequestId; mPassphraseValueLabel.Text = mProvisioning.Passphrase; mPassphrasePanel.Visible = true; mNetConfigLabel.Text = "Use Manage Site's \"Configure\" button and enter the passphrase above."; mRpcStatusLabel.Text = "Launcher RPC: waiting for provisioning"; mRpcStatusLabel.ForeColor = Color.Gray; } /// Drops the session key, clears the installed-apps store and /// re-enters provisioning — from the UI button, or when the console's /// Reconfigure clears our store. (Fresh-pod state; extracted C:\Games /// files are left on disk.) private void DropKeyAndReprovision() { mLauncher.WipeApps(); mLauncher.DeleteKey(); mRpcServer.Stop(); mProvisioning.Stop(); mNetConfigLabel.Text = ""; if (mPoweredOn && !mOptions.NoManage) { StartProvisioning(); } } private void ReprovisionClicked(object sender, EventArgs e) { OnSiteLog("Reprovision requested from the vPOD window."); DropKeyAndReprovision(); } private static string KeyFingerprint(byte[] key) { return key.Length >= 4 ? $"{key[0]:X2}{key[1]:X2}{key[2]:X2}{key[3]:X2}…" : "?"; } private void GameToggleChanged(object sender, EventArgs e) { if (sender is RadioButton rb && !rb.Checked) { return; // only act on the newly-checked one } mSimulator.ApplicationId = mBattleTechRadio.Checked ? ApplicationID.BTL4 : ApplicationID.RPL4; } // ---- event handlers (marshal to UI thread) ---- private const int MaxLogLines = 500; private void OnLog(string message) { if (IsDisposed) return; BeginInvoke((Action)(() => { // Newest first: prepend, and cap the buffer so it can't grow without bound. string line = $"[{DateTime.Now:HH:mm:ss}] {message}"; string existing = mLogBox.Text; string combined = existing.Length > 0 ? line + "\r\n" + existing : line; string[] lines = combined.Split(new[] { "\r\n" }, StringSplitOptions.None); if (lines.Length > MaxLogLines) { combined = string.Join("\r\n", lines, 0, MaxLogLines); } mLogBox.Text = combined; })); } private void OnConnectionChanged(string remote) { if (IsDisposed) return; BeginInvoke((Action)(() => UpdateConnectionLabel(remote))); } private void OnMessageReceived(MungaPodServer.Incoming incoming) { // The simulator drives all protocol behaviour; the UI only logs // non-query traffic (StateQuery is once-a-second noise). if (!(incoming.Message is StateQueryMessage) && incoming.Message != null) { OnLog("<- " + incoming.Message.GetType().Name); } mSimulator.HandleMessage(incoming); } private void OnStateChanged(ApplicationState state) { if (IsDisposed) return; // The egg viewer is intentionally NOT cleared here — the last egg stays // visible (copyable) across missions/restarts until the Clear button. BeginInvoke((Action)(() => UpdateStateLabel(state))); } private void OnEggReceived(string eggText) { if (IsDisposed) return; BeginInvoke((Action)(() => { mEggBox.Text = eggText.Replace("\n", "\r\n"); mEggSummaryLabel.Text = SummarizeEgg(eggText); })); } private void OnEggProgress() { if (IsDisposed) return; BeginInvoke((Action)(() => { int pct = mSimulator.EggPercent; if (pct < 100 && pct > 0) { mEggSummaryLabel.Text = $"Receiving egg... {pct}%"; } })); } // ---- virtual launcher event handlers (marshal to UI thread) ---- private void OnSiteLog(string message) { OnLog("[SITE] " + message); } private void OnProvisioned(byte[] key) { if (IsDisposed) return; BeginInvoke((Action)(() => { mLauncher.SaveKey(key); mProvisioning.Stop(); StartRpcServer(key); })); } private void OnConfigReceived(BasicConfigResponse config) { if (IsDisposed) return; BeginInvoke((Action)(() => { mNetConfigLabel.Text = $"Assigned (display only): IP {config.Address} / {config.Mask}" + (string.IsNullOrEmpty(config.HostName) ? "" : $"\r\nhost \"{config.HostName}\", gw {config.Gateway}, dns {config.Dns}"); })); } private void OnRpcConnectionsChanged(int count) { if (IsDisposed) return; BeginInvoke((Action)(() => { if (!mRpcServer.IsListening) { return; // Stop() already updated the label } mRpcStatusLabel.Text = count > 0 ? $"Launcher RPC: {count} console session(s) on TCP {LauncherRpcServer.ManagePort}" : $"Launcher RPC: listening on TCP {LauncherRpcServer.ManagePort}"; mRpcStatusLabel.ForeColor = Color.ForestGreen; })); } private void OnAppsChanged() { if (IsDisposed) return; BeginInvoke((Action)RefreshAppsList); } private void RefreshAppsList() { LaunchData[] installed = mLauncher.GetInstalledApps(); LaunchedAppData[] launched = mLauncher.GetLaunchedApps(); mAppsView.BeginUpdate(); mAppsView.Items.Clear(); foreach (LaunchData app in installed) { StringBuilder pids = new StringBuilder(); foreach (LaunchedAppData proc in launched) { if (proc.LaunchKey == app.LaunchPair.LaunchKey) { if (pids.Length > 0) pids.Append(", "); pids.Append(proc.ProcessId); } } ListViewItem item = new ListViewItem(new[] { app.LaunchPair.DisplayName, pids.ToString(), app.AutoRestart ? "yes" : "", string.IsNullOrEmpty(app.Arguments) ? app.ExeFile : app.ExeFile + " " + app.Arguments }); mAppsView.Items.Add(item); } mAppsView.EndUpdate(); } private void OnInstallProgress(Guid callId, OutOfBandProgress progress) { if (IsDisposed) return; BeginInvoke((Action)(() => { int percent = Math.Max(0, Math.Min(100, progress.PercentComplete)); if (progress.IsCompleted && percent >= 99) { // The wire deliberately tops out at 99 (the console retries on // 100) — but the local bar can still show a finished install. percent = 100; } mInstallProgressBar.Value = percent; mInstallStatusLabel.Text = progress.IsCompleted ? "Install: " + progress.Status : $"Installing: {progress.Status} ({progress.PercentComplete}%)"; })); } /// The console asked the pod to shut down or restart: go fully dark /// (Munga + launcher), and come back after a "reboot" pause when restarting. private void OnShutdownRequested(bool restart) { if (IsDisposed) return; BeginInvoke((Action)(() => { mRebootTimer.Stop(); StopGame(); StopManagement(); mLauncher.KillAllApps(); // launched apps die with the rebooting/halting machine SetPoweredState(on: false); if (restart) { mStateLabel.Text = "Rebooting..."; mStateLabel.ForeColor = Color.DarkOrange; mRebootTimer.Start(); } })); } private void OnRebootTimerTick(object sender, EventArgs e) { mRebootTimer.Stop(); OnLog("[SITE] Pod rebooted."); PowerOnClicked(this, EventArgs.Empty); } /// The console's Reconfigure cleared our store — drop the key and go /// back to beaconing so the "Configure" button reappears in Manage Site. private void OnReprovisionRequested() { if (IsDisposed) return; BeginInvoke((Action)DropKeyAndReprovision); } // ---- label helpers ---- private void UpdateListeningLabel(bool listening) { mListeningLabel.Text = listening ? $"Listening on TCP {mOptions.Port} ●" : $"Not listening on TCP {mOptions.Port}"; mListeningLabel.ForeColor = listening ? Color.ForestGreen : Color.Firebrick; } private void UpdateConnectionLabel(string remote) { if (string.IsNullOrEmpty(remote)) { mConnectionLabel.Text = "Console: not connected"; mConnectionLabel.ForeColor = Color.Gray; } else { mConnectionLabel.Text = "Console: connected from " + remote; mConnectionLabel.ForeColor = Color.ForestGreen; } } private void UpdateStateLabel(ApplicationState state) { mStateLabel.Text = Prettify(state); mStateLabel.ForeColor = ColorFor(state); } private static string RoleText(HostType hostType) { switch (hostType) { case HostType.MissionReviewHostType: return "Camera / Mission Review"; case HostType.ConsoleHostType: return "Console"; default: return "Game Machine (player)"; } } private static string Prettify(ApplicationState state) { switch (state) { case ApplicationState.InitializingState: return "Initializing"; case ApplicationState.WaitingForEgg: return "Waiting For Egg"; case ApplicationState.LoadingMission: return "Loading Mission"; case ApplicationState.WaitingForLaunch: return "Waiting For Launch"; case ApplicationState.LaunchingMission: return "Launching Mission"; case ApplicationState.RunningMission: return "Running Mission"; case ApplicationState.EndingMission: return "Ending Mission"; case ApplicationState.StoppingMission: return "Stopping Mission"; case ApplicationState.SuspendingMission: return "Suspended"; case ApplicationState.ResumingMission: return "Resuming Mission"; case ApplicationState.AbortingMission: return "Aborting Mission"; case ApplicationState.CreatingMission: return "Creating Mission"; default: return state.ToString(); } } private static Color ColorFor(ApplicationState state) { switch (state) { case ApplicationState.WaitingForEgg: return Color.ForestGreen; case ApplicationState.WaitingForLaunch: return Color.DarkGoldenrod; case ApplicationState.RunningMission: return Color.RoyalBlue; case ApplicationState.InitializingState: return Color.Gray; default: return Color.DarkOrange; } } private static string SummarizeEgg(string eggText) { string adventure = null, map = null, scenario = null; int pilots = 0; foreach (string raw in eggText.Split('\n')) { string line = raw.Trim(); if (line.StartsWith("adventure=")) adventure = line.Substring(10); else if (line.StartsWith("map=")) map = line.Substring(4); else if (line.StartsWith("scenario=")) scenario = line.Substring(9); else if (line.StartsWith("pilot=")) pilots++; } StringBuilder sb = new StringBuilder(); sb.Append(adventure ?? "(egg)"); if (map != null) sb.Append(" • map=").Append(map); if (scenario != null) sb.Append(" • ").Append(scenario); sb.Append(" • ").Append(pilots).Append(pilots == 1 ? " pilot" : " pilots"); return sb.ToString(); } }