Two additions to the virtual launcher's real-process mode: - Auto-restart watchdog. Replaces the poll-on-query PruneExitedProcesses with a per-process watcher thread (StartWatcher): when a real-launched app exits on its own -- not via a Kill*/Uninstall, which untrack it first -- it is dropped from the running list and, if its LaunchData has AutoRestart and the "Auto-restart after the app exits (watchdog)" toggle is on, relaunched after the Agent's 2 s delay. A watchdog generation counter cancels pending restarts when the pod goes dark (power off / reboot / reprovision / WipeApps); the console's KillAllApps leaves them pending, matching the real Agent's race. - postinstall.bat toggle. A "Run postinstall.bat after install" checkbox (above "Actually launch apps", off by default) makes an install execute a packaged postinstall.bat via cmd /c (waited up to 5 min) before deleting it, like the real service. Off, it is logged and removed unrun as before -- it runs package script code on the host. Both are opt-in from the vPOD window. Verified against the real LauncherRpcServer over a loopback socket: the watchdog test relaunches an exited ping.exe with a new PID and stops once toggled off; a crafted package's postinstall.bat runs (and is removed) only when enabled. Full differential suite 103/103. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1033 lines
31 KiB
C#
1033 lines
31 KiB
C#
using System;
|
|
using System.Drawing;
|
|
using System.Text;
|
|
using System.Windows.Forms;
|
|
using Munga.Net;
|
|
using Tesla;
|
|
using Tesla.Net;
|
|
|
|
namespace VPod;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 mRunPostInstallCheckbox;
|
|
private CheckBox mRealLaunchCheckbox;
|
|
private CheckBox mRealAutoRestartCheckbox;
|
|
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 = 278 };
|
|
|
|
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 (default) = a packaged postinstall.bat is logged and removed unrun;
|
|
// on = it is executed at the end of an install, like the real Agent.
|
|
mRunPostInstallCheckbox = new CheckBox
|
|
{
|
|
Text = "Run postinstall.bat after install",
|
|
Location = new Point(4, 206),
|
|
AutoSize = true,
|
|
Checked = false
|
|
};
|
|
mRunPostInstallCheckbox.CheckedChanged += (s, e) =>
|
|
{
|
|
mLauncher.RunPostInstall = mRunPostInstallCheckbox.Checked;
|
|
OnSiteLog(mRunPostInstallCheckbox.Checked
|
|
? "Installs now EXECUTE a packaged postinstall.bat."
|
|
: "Installs skip (and remove) a packaged postinstall.bat.");
|
|
};
|
|
|
|
// 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, 228),
|
|
AutoSize = true,
|
|
Checked = false
|
|
};
|
|
mRealLaunchCheckbox.CheckedChanged += (s, e) =>
|
|
{
|
|
mLauncher.RealLaunch = mRealLaunchCheckbox.Checked;
|
|
mRealAutoRestartCheckbox.Enabled = mRealLaunchCheckbox.Checked;
|
|
OnSiteLog(mRealLaunchCheckbox.Checked
|
|
? "Launch/Kill now start and terminate REAL processes."
|
|
: "Launch/Kill now simulate PIDs (no real processes).");
|
|
};
|
|
|
|
// The Agent's autoRestart watchdog for real-launched apps (applies only
|
|
// to entries registered with autoRestart, like the real pod).
|
|
mRealAutoRestartCheckbox = new CheckBox
|
|
{
|
|
Text = "Auto-restart after the app exits (watchdog)",
|
|
Location = new Point(22, 250),
|
|
AutoSize = true,
|
|
Checked = true,
|
|
Enabled = false // meaningful only in real-launch mode
|
|
};
|
|
mRealAutoRestartCheckbox.CheckedChanged += (s, e) =>
|
|
{
|
|
mLauncher.RealAutoRestart = mRealAutoRestartCheckbox.Checked;
|
|
OnSiteLog(mRealAutoRestartCheckbox.Checked
|
|
? "Watchdog ON: autoRestart apps relaunch 2 s after exiting."
|
|
: "Watchdog OFF: exited apps stay down.");
|
|
};
|
|
|
|
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(mRunPostInstallCheckbox);
|
|
siteTop.Controls.Add(mRealLaunchCheckbox);
|
|
siteTop.Controls.Add(mRealAutoRestartCheckbox);
|
|
|
|
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(cancelPendingRestarts: true); // don't orphan real launched processes when the tool exits
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void PowerOnClicked(object sender, EventArgs e)
|
|
{
|
|
mRebootTimer.Stop();
|
|
SetPoweredState(on: true);
|
|
StartManagement();
|
|
StartGameClicked(sender, e);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.)
|
|
/// </summary>
|
|
private void PowerOffClicked(object sender, EventArgs e)
|
|
{
|
|
mRebootTimer.Stop();
|
|
StopGame();
|
|
StopManagement();
|
|
mLauncher.KillAllApps(cancelPendingRestarts: true); // the machine is "off": launched apps (real or simulated) die with it
|
|
SetPoweredState(on: false);
|
|
}
|
|
|
|
/// <summary>Starts the emulated game client (Munga listener) on a powered-on pod.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void StopGameClicked(object sender, EventArgs e)
|
|
{
|
|
StopGame();
|
|
}
|
|
|
|
private void StopGame()
|
|
{
|
|
mRestartTimer.Stop();
|
|
mServer.Stop();
|
|
SetGameState(running: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Starts the listener and updates the status label. Returns false (with a message) if the port is unavailable.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Reflects the pod's (machine-level) on/off state in the buttons and,
|
|
/// when off, the status labels. Game-level state is <see cref="SetGameState" />.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Reflects whether the emulated game client is running; the pod
|
|
/// itself (launcher/site management) may still be up with the game stopped.</summary>
|
|
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 ----
|
|
|
|
/// <summary>Brings the launcher side up for a powered-on pod: the RPC server
|
|
/// when a session key is stored, otherwise the provisioning beacons.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>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.)</summary>
|
|
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}%)";
|
|
}));
|
|
}
|
|
|
|
/// <summary>The console asked the pod to shut down or restart: go fully dark
|
|
/// (Munga + launcher), and come back after a "reboot" pause when restarting.</summary>
|
|
private void OnShutdownRequested(bool restart)
|
|
{
|
|
if (IsDisposed) return;
|
|
BeginInvoke((Action)(() =>
|
|
{
|
|
mRebootTimer.Stop();
|
|
StopGame();
|
|
StopManagement();
|
|
mLauncher.KillAllApps(cancelPendingRestarts: true); // 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);
|
|
}
|
|
|
|
/// <summary>The console's Reconfigure cleared our store — drop the key and go
|
|
/// back to beaconing so the "Configure" button reappears in Manage Site.</summary>
|
|
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();
|
|
}
|
|
}
|