mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
* Add Windows system chrome patchers (Harmony) Introduce support for toggling the system chrome on Windows using Harmony patchers. Adds Lib.Harmony.Thin to package props and project, new patcher infrastructure (ChromePatchState, PatcherEntrance) and two Harmony patches that disable FluentAvalonia's Windows chrome when configured. Program.cs now loads the chrome setting and installs patchers conditionally on Windows/x86-x64. Settings viewmodel and view updated: expose IsWindowsOs, require restart on appearance changes, migrate SettingsWindow to FAAppWindow and adapt titlebar/layout (include Windows caption placeholder and footer menu items). Also add a .gitkeep and a build log file. * Refactor settings window UI and theming Improve theming and layout for the Settings window and related services. - MaterialSurfaceService: add special material parameters for SettingsWindowBackground (lower alpha, no blur) and avoid hot-switching real backdrops for non-settings windows. - GlassEffectService: add AdaptiveSettingsWindowTintBrush + ResolveSettingsWindowTintAlpha to provide optional content tinting tied to system material mode. - SettingsWindowService: refactor theme application into ApplyThemeVariantAndResources, ensure settings window material is applied at show/activate times, and tidy theme/resource application flow. - SettingsWindow.axaml / .axaml.cs: restructure title bar (separate Grid.Row=0 border) and FANavigationView host, add pane-footer toggle button for :minimal layout, use dynamic corner radius resource, and update toggle/visibility/icon logic and responsive layout code. - SettingsPages: remove some IconText usages and adjust margins; use DesignCornerRadiusLg for update card corner radius. - Add NuGet.Config to set local globalPackagesFolder and ignore .nuget/packages in .gitignore. These changes aim to improve visuals, avoid backdrop overdraw, and make the settings window behavior consistent across themes and layouts. * Add localization and localize settings pages Add many new localization keys (en-US and zh-CN) for notifications, developer tools, about page, status bar, and video wallpaper. Update Notification, Dev, About and StatusBar view models to use LocalizationService, expose localized ObservableProperties, and refresh localized text at construction. Localize selection options and test notification texts, and fix notification severity handling. Wire up XAML to the new localized properties (About/Dev/StatusBar pages) and update the settings page title for notifications. Also adjust copyright line generation and replace hardcoded placeholders with bound Watermark properties. * Redesign settings window with fluent shell & search Rebuild the settings window as a Fluent shell: adds a custom 48-DIP titlebar with Back, pane toggle, icon/title, search box, restart/more menu, and caption-button spacer; moves compact pane toggle into the titlebar and preserves FANavigationView as the primary navigation surface. Introduces a SettingsSearchService (with UI AutoComplete integration, search indexing, navigation-by-result, and search result highlighting) plus focused tests for search filtering and theme material normalization. Adds navigation history/back stack, updates SettingsViewModels for new bindings and localization keys, and updates General/Apearance pages to expose new strings and options. Implements an "auto" system material mode: default in AppSettingsSnapshot, new MaterialAuto constants and normalization/resolution logic in ThemeAppearanceValues, WindowMaterialService and MaterialSurfaceService adjustments to prefer Mica on Win11 and Acrylic on Win10 using TransparencyLevelHint. GlassEffectService and AppearanceThemeService updated to use effective material mode and to track live theme state changes. Adds localization entries (en-US, zh-CN), spec/tasks docs, and other UI/style tweaks to support the redesign. * fix.修折叠与展开按钮 * Add OOBE startup presentation and settings merge Introduce a new OOBE step for "Startup & Presentation" that exposes startup and UI preferences in OobeWindow (toggles for taskbar, slide/fade transitions, fused popup, and autostart). Add HostAppSettingsOobeMerger to read/write Host settings.json (PascalCase fields) and MergeStartupPresentation behavior, plus LauncherWindowsStartupService to sync the current Launcher into the Windows Run key on Windows. Wire UI handlers, persist choices on Next, and load defaults when entering the step. Include unit tests for the merger, adjust SettingsWindow navigation pane/toggle handling, and update docs/LAUNCHER.md to describe the new OOBE step and implementation files. * Move whiteboard persistence to file storage Switch whiteboard note storage from legacy DB rows to per-note JSON files and add migration support. Update WhiteboardNoteSnapshot schema (version bump, viewport, canvas, expires, PathSvgData) and change IWhiteboardNotePersistenceService.SaveNote to return bool to surface write failures (e.g. read-only files). Implement file-based WhiteboardNotePersistenceService with legacy DB migration/cleanup, retention handling, and logging. Add comprehensive unit tests for persistence, stroke path builder, SVG import and viewport helper. Also add ThirdParty/DotNetCampus.InkCanvas project and reference it in the main csproj, and bump PostHog package to 2.6.0. * Introduce render gate and chart caching Replace UI DispatcherTimer polling with a StudySnapshotRenderGate across multiple widgets to queue and apply only the latest analytics snapshot; components updated include StudyDeductionReasonsWidget, StudyEnvironmentWidget, StudyInterruptDensityWidget, StudyNoiseCurveWidget. Add StudySnapshotRenderGate implementation to coordinate rendering and monitoring leases and update subscription/lease lifecycle handling (subscribe/unsubscribe, Acquire/Dispose leases, Clear/Dispose gate). Rewrite chart controls (StudyNoiseCurveChartControl and StudyNoiseDistributionScatterChartControl) to use stable logical-time origins, split series into static vs dynamic tails, add geometry/sample caching, stable jitter/coordinate mapping helpers, and expose internal helpers & counts for testing. Add unit tests (StudyComponentRenderingTests) covering the render gate and chart behaviors (layer counts, logical X mapping, stable jitter, cache rebuild). These changes improve rendering correctness and performance by avoiding redundant renders and enabling deterministic chart layout. * Use MaterialColorSnapshot in appearance flow Introduce unified material/color spec and tests, and refactor appearance plumbing to use MaterialColorSnapshot as the single source of truth. Add .trae material-color-service spec/checklist/tasks and integration/unit tests for plugin mapping and appearance VM behavior. AppearanceChangedEvent extended with new appearance change flags and HasChanged logic. ComponentEditorMaterialThemeAdapter rewritten to accept MaterialColorSnapshot and derive palette from snapshot data. Simplify AppearanceSettingsPageViewModel and related view code: remove legacy preview/custom-seed UI logic, preserve material/color fields when updating theme or corner radius, and update save calls to use with-expressions. Update ComponentEditorWindow to use adapter-provided OnPrimary brush and minor docs updates. * Add material color services, plugin DTOs, and tests Introduce IPC wire-format appearance DTOs (PluginIsolation.Contracts) and clarify they are distinct from the runtime PluginSdk snapshot. Update PluginSdk comments to document the runtime-facing snapshot shape. Change ComponentColorSchemeHelper to use the HostMaterialColorProvider and add an overload that accepts a MaterialColorSnapshot. Add new services and pipelines (MaterialColorService, MaterialSurfaceService, WindowMaterialService, WallpaperColorPipeline) and refactor AppearanceThemeService to depend on MaterialColorService while removing legacy internal implementations. Add multiple unit tests (ComponentColorSchemeHelper, PluginAppearanceBoundary, SettingsCatalogService, WallpaperSettingsPageViewModel) and update localization resources with new material_color and wallpaper keys. * Add CODE_WIKI and update localization Add a comprehensive CODE_WIKI.md documenting project architecture, modules, startup flow, plugin system, testing and developer workflows. Update localization resources (en-US.json, zh-CN.json) with new/translated keys for wallpaper controls (custom color UI), material & color settings (semantic roles, surfaces, refresh/polling state), appearance (corner radius), status bar font size options, privacy policy text, component library labels, clock settings, and new language entry (Korean). Also modify settings-related ViewModels and Settings page views to surface these new features and texts (MaterialColorSettingsPageViewModel.cs, SettingsViewModels.cs, WallpaperSettingsPageViewModel.cs, MainWindow.SettingsHardCut.Stubs.cs, ComponentsSettingsPage.axaml, WallpaperSettingsPage.axaml). * Add Data settings page and storage scanner Introduce a new "Data" settings page to visualize and manage local app storage. Adds DataStorageService (scanning, disk info, clean operations), DataSettingsPageViewModel, XAML view and code-behind, and HexToColor/HexToBrush converters; registers converters in App.axaml. Also update localization strings for the new page and add icon mapping so the settings entry uses the Database icon. Enables per-category and global cleaning workflows and formatted size display. * Add IPC backoff/retries and safer disposal Introduce exponential backoff, jitter and retry logic across IPC components to improve robustness and avoid tight retry loops; make disposal idempotent and add connection guards. Key changes: - LauncherCoordinatorIpcServer / LauncherIpcServer: add backoff constants, ComputeBackoff(), consecutive error tracking and delayed retries with jitter. - LanMountainDesktopIpcClient / LauncherIpcClient: add connect retry loops, timeouts, delayed retries, improved error logging, and use ArrayPool for buffered async writes; ensure proper cleanup on failures. - PublicIpcHostService: add disposed flag, guard OnPeerConnected and Dispose, and clear connected peers on dispose. - Add many auto-generated commit analysis docs under docs/auto_commit_md and new scripts for analyzing/generating commit docs. These changes aim to make IPC connection handling more resilient and resource-safe. * Add preview controls and settings UI tweaks Introduce GridPreviewControl and CornerRadiusPreviewControl for visual previews and wire them into the Components settings (add ScreenAspectRatio, CornerRadiusPreviewValue, and screen aspect init). Refactor ComponentsSettingsPage UI to show live previews. Improve DataSettingsPage layout and storage bar logic (use item percentages directly, include remaining segment, adjust visuals and visibility triggers). Simplify LauncherSettingsPage header/appearance layout. Add SECURITY_AUDIT_REPORT.md, analysis summary, mockup HTML, and a local .claude settings file. * Add install checkpoint/resume and DDSS workflows Introduce install checkpoint support and resume logic for updates, plus related locking and validation. Adds InstallCheckpoint model, AppJsonContext serialization, and UpdatePaths helpers for deployment lock, apply-in-progress lock and install-checkpoint path. UpdateEngineService gains checkpoint load/save/delete, incoming-state validation, resume logic for PLONDS and legacy updates, apply lock handling, and safer cleanup; ApplyPendingPlondsUpdateAsync and ApplyPendingUpdate flow updated accordingly. Add DeploymentLock contract and extend UpdateState with pause/resume/cancel helpers. Tests updated to cover stale/valid checkpoint resume and legacy/PLONDS flows. CI: enhance ddss-publish to detect release channel, validate S3 assets, prepare and atomically publish channel pointer; add ddss-rollback workflow to publish rollbacks; adjust plonds-build concurrency and release events. * changed.更了好多 * fix.消息盒子媒体播放器组件服务修复 * change.重做天气,为回到系统提供自定义功能。 * feat.airapp与融合桌面 * feat.动画优化与更新界面 * feat.数字时钟,白板功能修复 * feat.完善了时钟轻应用,为启动器提供了多语言支持 * feat.发布与打包优化 * changed.天气选项卡更新
1990 lines
86 KiB
C#
1990 lines
86 KiB
C#
using System.Diagnostics;
|
|
using Avalonia.Threading;
|
|
using LanMountainDesktop.Launcher.Models;
|
|
using LanMountainDesktop.Launcher.Resources;
|
|
using LanMountainDesktop.Launcher.Services.Ipc;
|
|
using LanMountainDesktop.Launcher.Views;
|
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
|
using LanMountainDesktop.Shared.IPC;
|
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
|
|
|
namespace LanMountainDesktop.Launcher.Services;
|
|
|
|
internal sealed class LauncherFlowCoordinator
|
|
{
|
|
private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(10);
|
|
private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(30);
|
|
private static readonly string SoftTimeoutStatusMessage = Strings.Coordinator_SlowDeviceMessage;
|
|
private static readonly string SoftTimeoutDetailsMessage = Strings.Coordinator_RunningHostMessage;
|
|
|
|
private readonly CommandContext _context;
|
|
private readonly DeploymentLocator _deploymentLocator;
|
|
private readonly OobeStateService _oobeStateService;
|
|
private readonly UpdateEngineService _updateEngine;
|
|
private readonly PluginInstallerService _pluginInstallerService;
|
|
private readonly StartupAttemptRegistry _startupAttemptRegistry;
|
|
private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer;
|
|
private readonly DataLocationResolver _dataLocationResolver;
|
|
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
|
|
|
public LauncherFlowCoordinator(
|
|
CommandContext context,
|
|
DeploymentLocator deploymentLocator,
|
|
OobeStateService oobeStateService,
|
|
UpdateEngineService updateEngine,
|
|
PluginInstallerService pluginInstallerService,
|
|
StartupAttemptRegistry? startupAttemptRegistry = null,
|
|
LauncherCoordinatorIpcServer? coordinatorIpcServer = null)
|
|
{
|
|
_context = context;
|
|
_deploymentLocator = deploymentLocator;
|
|
_oobeStateService = oobeStateService;
|
|
_updateEngine = updateEngine;
|
|
_pluginInstallerService = pluginInstallerService;
|
|
_startupAttemptRegistry = startupAttemptRegistry ?? new StartupAttemptRegistry();
|
|
_coordinatorIpcServer = coordinatorIpcServer;
|
|
_dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot());
|
|
_oobeSteps =
|
|
[
|
|
new WelcomeOobeStep(_oobeStateService, _context),
|
|
new DataLocationOobeStep(_dataLocationResolver)
|
|
];
|
|
}
|
|
|
|
public static string ResolveSuccessPolicyKey(CommandContext context)
|
|
{
|
|
return new StartupSuccessTracker(context).PolicyKey;
|
|
}
|
|
|
|
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
|
|
{
|
|
try
|
|
{
|
|
_deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
|
var oobeDecision = _oobeStateService.Evaluate(_context);
|
|
var launcherContextDetails = BuildLauncherContextDetails(_context, oobeDecision, _deploymentLocator.GetAppRoot());
|
|
|
|
if (oobeDecision.ShouldShowOobe)
|
|
{
|
|
var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation();
|
|
if (legacyInfo is not null)
|
|
{
|
|
var migrationResult = await ShowMigrationPromptAsync(legacyInfo).ConfigureAwait(false);
|
|
Logger.Info($"Migration prompt completed. Result='{migrationResult}'.");
|
|
}
|
|
}
|
|
|
|
var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() =>
|
|
{
|
|
var window = new SplashWindow();
|
|
window.Show();
|
|
return window;
|
|
});
|
|
var windowsClosingByCoordinator = false;
|
|
var versionInfo = _deploymentLocator.GetVersionInfo();
|
|
splashWindow.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
|
var reporter = (ISplashStageReporter)splashWindow;
|
|
|
|
LoadingDetailsWindow? loadingDetailsWindow = null;
|
|
if (_context.IsDebugMode || _context.GetOption("show-loading-details") == "true")
|
|
{
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
{
|
|
loadingDetailsWindow = new LoadingDetailsWindow();
|
|
loadingDetailsWindow.Show();
|
|
});
|
|
}
|
|
|
|
var successTcs = new TaskCompletionSource<StartupSuccessState>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var activationFailedTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var lastStage = StartupStage.Initializing;
|
|
var lastStageMessage = "launcher-started";
|
|
var startupSuccessTracker = new StartupSuccessTracker(_context);
|
|
var activationFailureReason = string.Empty;
|
|
var ipcConnected = false;
|
|
var softTimeoutShown = false;
|
|
var attachedToExistingAttempt = false;
|
|
StartupAttemptRecord? trackedAttempt = null;
|
|
PublicShellStatus? shellStatus = null;
|
|
|
|
void PublishCoordinatorStatus(bool? hostProcessAliveOverride = null, bool completed = false, bool succeeded = false)
|
|
{
|
|
if (_coordinatorIpcServer is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
trackedAttempt = _startupAttemptRegistry.GetOwnedAttempt() ?? trackedAttempt;
|
|
var hostPid = trackedAttempt?.HostPid ?? 0;
|
|
var hostProcessAlive = hostProcessAliveOverride ??
|
|
(hostPid > 0 && TryGetLiveProcess(hostPid, out _));
|
|
var status = new LauncherCoordinatorStatus
|
|
{
|
|
AttemptId = trackedAttempt?.AttemptId ?? string.Empty,
|
|
CoordinatorPid = Environment.ProcessId,
|
|
HostPid = hostPid,
|
|
HostProcessAlive = hostProcessAlive,
|
|
LaunchSource = trackedAttempt?.LaunchSource ?? _context.LaunchSource,
|
|
SuccessPolicy = trackedAttempt?.SuccessPolicy ?? startupSuccessTracker.PolicyKey,
|
|
LastObservedStage = lastStage,
|
|
LastObservedMessage = lastStageMessage,
|
|
PublicIpcConnected = ipcConnected,
|
|
State = trackedAttempt?.State.ToString() ?? StartupAttemptState.Pending.ToString(),
|
|
SoftTimeoutShown = softTimeoutShown,
|
|
Completed = completed,
|
|
Succeeded = succeeded,
|
|
ShellStatus = shellStatus,
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
_coordinatorIpcServer.UpdateStatus(status);
|
|
_startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat(status);
|
|
}
|
|
|
|
trackedAttempt = _startupAttemptRegistry.GetOwnedAttempt();
|
|
PublishCoordinatorStatus();
|
|
|
|
var loadingState = new LoadingStateMessage();
|
|
EventHandler? splashClosedHandler = null;
|
|
splashClosedHandler = (_, _) =>
|
|
{
|
|
if (windowsClosingByCoordinator)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_startupAttemptRegistry.MarkOwnedDetachedWaiting();
|
|
Logger.Warn("Splash window was closed manually. Launcher will continue monitoring the current startup attempt.");
|
|
};
|
|
splashWindow.Closed += splashClosedHandler;
|
|
using var ipcClient = new LanMountainDesktopIpcClient();
|
|
ipcClient.RegisterNotifyHandler<StartupProgressMessage>(IpcRoutedNotifyIds.LauncherStartupProgress, message =>
|
|
{
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
try
|
|
{
|
|
ipcConnected = true;
|
|
lastStage = message.Stage;
|
|
lastStageMessage = message.Message ?? message.Stage.ToString();
|
|
Logger.Info($"IPC stage received. Stage='{message.Stage}'; Message='{message.Message ?? string.Empty}'.");
|
|
|
|
loadingState = loadingState with
|
|
{
|
|
Stage = message.Stage,
|
|
OverallProgressPercent = message.ProgressPercent,
|
|
Message = message.Message,
|
|
Timestamp = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString());
|
|
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
|
_startupAttemptRegistry.UpdateOwnedStage(message.Stage, message.Message, ipcConnected: true);
|
|
PublishCoordinatorStatus();
|
|
|
|
if (startupSuccessTracker.TryResolve(message.Stage, out var successState))
|
|
{
|
|
successTcs.TrySetResult(successState);
|
|
}
|
|
|
|
if (message.Stage == StartupStage.ActivationFailed)
|
|
{
|
|
activationFailureReason = message.Message ?? "activation_failed";
|
|
activationFailedTcs.TrySetResult(message.Message ?? "activation_failed");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error("IPC progress callback failed.", ex);
|
|
}
|
|
});
|
|
});
|
|
ipcClient.RegisterNotifyHandler<LoadingStateMessage>(IpcRoutedNotifyIds.LauncherLoadingState, message =>
|
|
{
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
try
|
|
{
|
|
loadingState = message;
|
|
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error("IPC loading-state callback failed.", ex);
|
|
}
|
|
});
|
|
});
|
|
|
|
try
|
|
{
|
|
if (ShouldProbeExistingHostBeforeLaunch(_context))
|
|
{
|
|
var multiInstanceBehavior = LoadMultiInstanceLaunchBehavior();
|
|
var existingShellStatus = await TryGetExistingHostStatusAsync(ipcClient, TimeSpan.FromMilliseconds(900))
|
|
.ConfigureAwait(false);
|
|
if (IsExistingHostReadyForLauncherDecision(existingShellStatus))
|
|
{
|
|
ipcConnected = true;
|
|
shellStatus = existingShellStatus;
|
|
var decisionResult = await ApplyExistingHostBehaviorAsync(
|
|
ipcClient,
|
|
multiInstanceBehavior,
|
|
existingShellStatus!)
|
|
.ConfigureAwait(false);
|
|
shellStatus = decisionResult.ActivationResult?.Status ?? existingShellStatus;
|
|
var recoverableActivationFailure = decisionResult.ActivationResult is not null &&
|
|
IsRecoverableActivationFailure(decisionResult.ActivationResult);
|
|
lastStage = decisionResult.Success || recoverableActivationFailure
|
|
? StartupStage.ActivationRedirected
|
|
: StartupStage.ActivationFailed;
|
|
lastStageMessage = decisionResult.Message;
|
|
if (decisionResult.Success || recoverableActivationFailure)
|
|
{
|
|
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
|
|
}
|
|
else
|
|
{
|
|
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
|
|
}
|
|
|
|
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: decisionResult.Success);
|
|
windowsClosingByCoordinator = true;
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: decisionResult.Success,
|
|
stage: "launch",
|
|
code: decisionResult.Code,
|
|
message: decisionResult.Message,
|
|
details: MergeDetails(
|
|
launcherContextDetails,
|
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["publicIpcConnected"] = "true",
|
|
["multiInstanceBehavior"] = multiInstanceBehavior.ToString(),
|
|
["existingHostPid"] = shellStatus?.ProcessId.ToString() ?? string.Empty,
|
|
["existingShellState"] = shellStatus?.ShellState ?? string.Empty,
|
|
["existingTrayState"] = shellStatus?.Tray.State ?? string.Empty,
|
|
["existingTaskbarUsable"] = shellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty,
|
|
["activationAccepted"] = decisionResult.ActivationResult?.Accepted.ToString() ?? string.Empty
|
|
}));
|
|
}
|
|
}
|
|
|
|
reporter.Report("update", "Checking updates...");
|
|
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
|
if (!updateResult.Success)
|
|
{
|
|
Logger.Warn($"Update apply failed, will try to launch existing version. Error='{updateResult.Message}'.");
|
|
reporter.Report("update", "Update failed, launching existing version...");
|
|
// Clean up corrupted update files to prevent repeated failures
|
|
try
|
|
{
|
|
_updateEngine.CleanupIncomingArtifacts();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warn($"Failed to cleanup update artifacts after failed update: {ex.Message}");
|
|
}
|
|
// Continue to launch existing version instead of aborting
|
|
}
|
|
|
|
reporter.Report("plugins", "Applying plugin upgrades...");
|
|
var pluginsDir = _context.GetOption("plugins-dir") ?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
|
|
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
|
if (!queueResult.Success)
|
|
{
|
|
Logger.Warn($"Plugin upgrade failed, continuing startup. Error='{queueResult.Message}'.");
|
|
reporter.Report("plugins", "Plugin upgrade failed, continuing...");
|
|
}
|
|
|
|
if (oobeDecision.ShouldShowOobe)
|
|
{
|
|
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide());
|
|
foreach (var step in _oobeSteps)
|
|
{
|
|
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Show());
|
|
}
|
|
|
|
reporter.Report("launch", "Launching desktop...");
|
|
var launchOutcome = default(HostLaunchOutcome);
|
|
var attachableAttempt = _startupAttemptRegistry.TryGetAttachableAttempt(_context.LaunchSource, startupSuccessTracker.PolicyKey);
|
|
if (attachableAttempt is not null &&
|
|
_startupAttemptRegistry.AdoptAttempt(attachableAttempt.AttemptId) &&
|
|
TryGetLiveProcess(attachableAttempt.HostPid, out var attachedProcess))
|
|
{
|
|
trackedAttempt = attachableAttempt;
|
|
attachedToExistingAttempt = true;
|
|
ipcConnected = attachableAttempt.IpcConnected;
|
|
lastStage = attachableAttempt.LastObservedStage;
|
|
lastStageMessage = string.IsNullOrWhiteSpace(attachableAttempt.LastObservedMessage)
|
|
? "Attached to the existing startup attempt."
|
|
: attachableAttempt.LastObservedMessage;
|
|
reporter.Report(MapStartupStageToSplashStage(lastStage), lastStageMessage);
|
|
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
|
|
|
if (startupSuccessTracker.TryResolve(lastStage, out var attachedSuccessState))
|
|
{
|
|
windowsClosingByCoordinator = true;
|
|
_startupAttemptRegistry.MarkOwnedSucceeded(attachedSuccessState.Stage, attachedSuccessState.Message);
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: true,
|
|
stage: "launch",
|
|
code: attachedSuccessState.Code,
|
|
message: attachedSuccessState.Message,
|
|
details: MergeDetails(
|
|
launcherContextDetails,
|
|
BuildAttemptDetails(
|
|
trackedAttempt,
|
|
attachedToExistingAttempt,
|
|
ipcConnected,
|
|
hostProcessAlive: true,
|
|
lastStage,
|
|
lastStageMessage,
|
|
activationFailureReason,
|
|
softTimeoutShown: false,
|
|
recoveryActivationAttempted: false)));
|
|
}
|
|
|
|
if (attachableAttempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
|
|
{
|
|
softTimeoutShown = true;
|
|
reporter.Report("delayed", SoftTimeoutStatusMessage);
|
|
loadingState = BuildDelayedLoadingState(
|
|
loadingState,
|
|
SoftTimeoutStatusMessage,
|
|
SoftTimeoutDetailsMessage,
|
|
trackedAttempt.StartedAtUtc);
|
|
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
|
}
|
|
|
|
launchOutcome = HostLaunchOutcome.FromProcess(
|
|
attachedProcess!,
|
|
BuildResult(
|
|
true,
|
|
"launchHost",
|
|
"attached_attempt",
|
|
"Attached to an existing startup attempt.",
|
|
BuildAttemptDetails(
|
|
trackedAttempt,
|
|
attachedToExistingAttempt,
|
|
ipcConnected,
|
|
hostProcessAlive: true,
|
|
lastStage,
|
|
lastStageMessage,
|
|
activationFailureReason,
|
|
softTimeoutShown,
|
|
recoveryActivationAttempted: false)),
|
|
BuildAttemptDetails(
|
|
trackedAttempt,
|
|
attachedToExistingAttempt,
|
|
ipcConnected,
|
|
hostProcessAlive: true,
|
|
lastStage,
|
|
lastStageMessage,
|
|
activationFailureReason,
|
|
softTimeoutShown,
|
|
recoveryActivationAttempted: false));
|
|
}
|
|
else
|
|
{
|
|
launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
if (!launchOutcome.Result.Success)
|
|
{
|
|
return WithAdditionalDetails(launchOutcome.Result, launcherContextDetails);
|
|
}
|
|
|
|
if (launchOutcome.ImmediateResult is not null)
|
|
{
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return WithAdditionalDetails(launchOutcome.ImmediateResult, launcherContextDetails);
|
|
}
|
|
|
|
if (launchOutcome.Process is null)
|
|
{
|
|
return BuildResult(
|
|
success: false,
|
|
stage: "launch",
|
|
code: "host_start_failed",
|
|
message: "Host launch did not create a process.",
|
|
details: MergeDetails(
|
|
launcherContextDetails,
|
|
MergeDetails(
|
|
launchOutcome.Details,
|
|
BuildAttemptDetails(
|
|
trackedAttempt,
|
|
attachedToExistingAttempt,
|
|
ipcConnected,
|
|
hostProcessAlive: false,
|
|
lastStage,
|
|
lastStageMessage,
|
|
activationFailureReason,
|
|
softTimeoutShown,
|
|
recoveryActivationAttempted: false))));
|
|
}
|
|
|
|
if (!attachedToExistingAttempt)
|
|
{
|
|
var reservedAttempt = _startupAttemptRegistry.GetOwnedAttempt();
|
|
trackedAttempt = reservedAttempt is { ReservedBeforeHostStart: true }
|
|
? _startupAttemptRegistry.AssignOwnedHostProcess(
|
|
launchOutcome.Process.Id,
|
|
lastStage,
|
|
lastStageMessage)
|
|
: _startupAttemptRegistry.StartOwnedAttempt(
|
|
launchOutcome.Process.Id,
|
|
_context.LaunchSource,
|
|
startupSuccessTracker.PolicyKey,
|
|
lastStage,
|
|
lastStageMessage);
|
|
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
|
}
|
|
|
|
Dictionary<string, string> ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false)
|
|
{
|
|
return MergeDetails(
|
|
launcherContextDetails,
|
|
MergeDetails(
|
|
launchOutcome.Details,
|
|
BuildAttemptDetails(
|
|
trackedAttempt,
|
|
attachedToExistingAttempt,
|
|
ipcConnected,
|
|
hostProcessAlive,
|
|
lastStage,
|
|
lastStageMessage,
|
|
activationFailureReason,
|
|
softTimeoutShown,
|
|
recoveryActivationAttempted)));
|
|
}
|
|
|
|
async Task<StartupSuccessState?> RefreshShellStatusAsync(string waitingMessage)
|
|
{
|
|
if (!ipcClient.IsConnected)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
ipcConnected = true;
|
|
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
|
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
|
if (startupSuccessTracker.TryResolve(shellStatus, out var successState))
|
|
{
|
|
return successState;
|
|
}
|
|
|
|
if (shellStatus is { DesktopVisible: false })
|
|
{
|
|
_startupAttemptRegistry.MarkOwnedWaitingForShell(waitingMessage);
|
|
}
|
|
|
|
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
|
return null;
|
|
}
|
|
|
|
var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(1200)).ConfigureAwait(false);
|
|
if (!connected)
|
|
{
|
|
Logger.Info("Host public IPC is not ready yet. Launcher will keep monitoring the host process and retry.");
|
|
}
|
|
else
|
|
{
|
|
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
|
|
.ConfigureAwait(false);
|
|
if (shellSuccess is not null)
|
|
{
|
|
successTcs.TrySetResult(shellSuccess);
|
|
}
|
|
}
|
|
|
|
var processExitTask = launchOutcome.Process.WaitForExitAsync();
|
|
var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow;
|
|
var softTimeoutAt = startedAt + StartupSoftTimeout;
|
|
var hardTimeoutAt = startedAt + StartupHardTimeout;
|
|
var nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(2);
|
|
var nextShellStatusPollAt = DateTimeOffset.UtcNow.AddSeconds(1);
|
|
var activationRetryAttempted = false;
|
|
|
|
while (true)
|
|
{
|
|
if (successTcs.Task.IsCompleted)
|
|
{
|
|
var successState = await successTcs.Task.ConfigureAwait(false);
|
|
windowsClosingByCoordinator = true;
|
|
_startupAttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message);
|
|
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: true);
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: true,
|
|
stage: "launch",
|
|
code: successState.Code,
|
|
message: successState.Message,
|
|
details: ComposeLaunchDetails(!launchOutcome.Process.HasExited));
|
|
}
|
|
|
|
if (activationFailedTcs.Task.IsCompleted && !activationRetryAttempted)
|
|
{
|
|
activationRetryAttempted = true;
|
|
activationFailureReason = await activationFailedTcs.Task.ConfigureAwait(false);
|
|
Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'.");
|
|
var activationRecovery = await TryRecoverActivationThroughExistingHostAsync(
|
|
ipcClient,
|
|
startupSuccessTracker,
|
|
TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
|
if (activationRecovery is not null)
|
|
{
|
|
windowsClosingByCoordinator = true;
|
|
_startupAttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message);
|
|
PublishCoordinatorStatus(
|
|
hostProcessAliveOverride: !launchOutcome.Process.HasExited,
|
|
completed: true,
|
|
succeeded: true);
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: true,
|
|
stage: "launch",
|
|
code: activationRecovery.Code,
|
|
message: activationRecovery.Message,
|
|
details: ComposeLaunchDetails(
|
|
!launchOutcome.Process.HasExited,
|
|
recoveryActivationAttempted: true));
|
|
}
|
|
|
|
Logger.Info("Activation failure did not recover through public IPC yet. Launcher will keep monitoring the current host attempt.");
|
|
}
|
|
|
|
if (processExitTask.IsCompleted)
|
|
{
|
|
var exitCode = launchOutcome.Process.ExitCode;
|
|
Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}.");
|
|
|
|
windowsClosingByCoordinator = true;
|
|
if (IsSuccessfulActivationExitCode(exitCode))
|
|
{
|
|
_startupAttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance.");
|
|
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true);
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: true,
|
|
stage: "launch",
|
|
code: "activation_redirected",
|
|
message: "Host redirected activation to the existing desktop instance.",
|
|
details: MergeDetails(
|
|
ComposeLaunchDetails(hostProcessAlive: false),
|
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["exitCode"] = exitCode.ToString()
|
|
}));
|
|
}
|
|
|
|
if (!activationRetryAttempted &&
|
|
IsFailedActivationExitCode(exitCode))
|
|
{
|
|
activationRetryAttempted = true;
|
|
var activationRecovery = await TryRecoverActivationThroughExistingHostAsync(
|
|
ipcClient,
|
|
startupSuccessTracker,
|
|
TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
|
if (activationRecovery is not null)
|
|
{
|
|
_startupAttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message);
|
|
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: true);
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: true,
|
|
stage: "launch",
|
|
code: activationRecovery.Code,
|
|
message: activationRecovery.Message,
|
|
details: MergeDetails(
|
|
ComposeLaunchDetails(hostProcessAlive: true, recoveryActivationAttempted: true),
|
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["exitCode"] = exitCode.ToString()
|
|
}));
|
|
}
|
|
|
|
Logger.Info("Activation exit code did not recover through public IPC. Launcher will report the activation failure without launching another host.");
|
|
}
|
|
|
|
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
|
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: false,
|
|
stage: "launch",
|
|
code: IsFailedActivationExitCode(exitCode)
|
|
? "activation_failed"
|
|
: "host_exited_early",
|
|
message: IsFailedActivationExitCode(exitCode)
|
|
? $"Host activation handshake failed before the required startup state was reported. ExitCode={exitCode}."
|
|
: $"Host exited before the required startup state was reported. ExitCode={exitCode}.",
|
|
details: MergeDetails(
|
|
ComposeLaunchDetails(hostProcessAlive: false),
|
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["exitCode"] = exitCode.ToString()
|
|
}));
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
if (ipcConnected &&
|
|
!launchOutcome.Process.HasExited &&
|
|
now >= nextShellStatusPollAt)
|
|
{
|
|
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
|
|
.ConfigureAwait(false);
|
|
if (shellSuccess is not null)
|
|
{
|
|
successTcs.TrySetResult(shellSuccess);
|
|
continue;
|
|
}
|
|
|
|
nextShellStatusPollAt = DateTimeOffset.UtcNow.AddSeconds(1);
|
|
}
|
|
|
|
if (!ipcConnected &&
|
|
!launchOutcome.Process.HasExited &&
|
|
now >= nextReconnectAttemptAt)
|
|
{
|
|
connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(800)).ConfigureAwait(false);
|
|
if (connected)
|
|
{
|
|
var shellSuccess = await RefreshShellStatusAsync("Host public IPC reconnected; waiting for desktop shell.")
|
|
.ConfigureAwait(false);
|
|
if (shellSuccess is not null)
|
|
{
|
|
successTcs.TrySetResult(shellSuccess);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(2);
|
|
}
|
|
|
|
if (!softTimeoutShown &&
|
|
now >= softTimeoutAt &&
|
|
(!launchOutcome.Process.HasExited || ipcConnected))
|
|
{
|
|
softTimeoutShown = true;
|
|
_startupAttemptRegistry.MarkOwnedSoftTimeout(SoftTimeoutStatusMessage);
|
|
reporter.Report("delayed", SoftTimeoutStatusMessage);
|
|
loadingState = BuildDelayedLoadingState(
|
|
loadingState,
|
|
SoftTimeoutStatusMessage,
|
|
SoftTimeoutDetailsMessage,
|
|
trackedAttempt?.StartedAtUtc ?? startedAt);
|
|
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
|
PublishCoordinatorStatus(hostProcessAliveOverride: !launchOutcome.Process.HasExited);
|
|
}
|
|
|
|
if (now >= hardTimeoutAt)
|
|
{
|
|
break;
|
|
}
|
|
|
|
var nextCheckpointAt = hardTimeoutAt;
|
|
if (!softTimeoutShown && softTimeoutAt < nextCheckpointAt)
|
|
{
|
|
nextCheckpointAt = softTimeoutAt;
|
|
}
|
|
|
|
var delay = nextCheckpointAt - now;
|
|
if (delay > TimeSpan.FromSeconds(1))
|
|
{
|
|
delay = TimeSpan.FromSeconds(1);
|
|
}
|
|
else if (delay < TimeSpan.FromMilliseconds(100))
|
|
{
|
|
delay = TimeSpan.FromMilliseconds(100);
|
|
}
|
|
|
|
await Task.WhenAny(
|
|
successTcs.Task,
|
|
activationFailedTcs.Task,
|
|
processExitTask,
|
|
Task.Delay(delay)).ConfigureAwait(false);
|
|
}
|
|
|
|
var recoveryActivationAttempted = false;
|
|
if (!connected && !launchOutcome.Process.HasExited)
|
|
{
|
|
connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
|
if (connected)
|
|
{
|
|
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
|
|
.ConfigureAwait(false);
|
|
if (shellSuccess is not null)
|
|
{
|
|
windowsClosingByCoordinator = true;
|
|
_startupAttemptRegistry.MarkOwnedSucceeded(shellSuccess.Stage, shellSuccess.Message);
|
|
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: true);
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: true,
|
|
stage: "launch",
|
|
code: shellSuccess.Code,
|
|
message: shellSuccess.Message,
|
|
details: ComposeLaunchDetails(hostProcessAlive: true));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (connected && !launchOutcome.Process.HasExited)
|
|
{
|
|
recoveryActivationAttempted = true;
|
|
var recoveryOutcome = await TryRecoverWithPublicActivationAsync(
|
|
ipcClient,
|
|
launchOutcome.Process,
|
|
successTcs.Task,
|
|
startupSuccessTracker).ConfigureAwait(false);
|
|
if (recoveryOutcome is not null)
|
|
{
|
|
windowsClosingByCoordinator = true;
|
|
_startupAttemptRegistry.MarkOwnedSucceeded(recoveryOutcome.Stage, recoveryOutcome.Message);
|
|
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
|
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: true);
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: true,
|
|
stage: "launch",
|
|
code: recoveryOutcome.Code,
|
|
message: recoveryOutcome.Message,
|
|
details: ComposeLaunchDetails(
|
|
!launchOutcome.Process.HasExited,
|
|
recoveryActivationAttempted: true));
|
|
}
|
|
}
|
|
|
|
if (connected && !launchOutcome.Process.HasExited)
|
|
{
|
|
windowsClosingByCoordinator = true;
|
|
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host process is still running after the launcher wait window.");
|
|
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
|
if (startupSuccessTracker.TryResolve(shellStatus, out var finalShellSuccess))
|
|
{
|
|
_startupAttemptRegistry.MarkOwnedSucceeded(finalShellSuccess.Stage, finalShellSuccess.Message);
|
|
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: true);
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: true,
|
|
stage: "launch",
|
|
code: finalShellSuccess.Code,
|
|
message: finalShellSuccess.Message,
|
|
details: ComposeLaunchDetails(
|
|
hostProcessAlive: true,
|
|
recoveryActivationAttempted));
|
|
}
|
|
|
|
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: false);
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: false,
|
|
stage: "launch",
|
|
code: "shell_not_ready",
|
|
message: "Host public IPC is connected, but the desktop shell did not create or show the main window in time.",
|
|
details: ComposeLaunchDetails(
|
|
hostProcessAlive: true,
|
|
recoveryActivationAttempted));
|
|
}
|
|
|
|
if (!connected && !launchOutcome.Process.HasExited)
|
|
{
|
|
windowsClosingByCoordinator = true;
|
|
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host process is still running, but public IPC is not ready yet.");
|
|
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: false, succeeded: true);
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: true,
|
|
stage: "launch",
|
|
code: "startup_pending",
|
|
message: "Host process is still running; Launcher will not start another process while public IPC finishes startup.",
|
|
details: ComposeLaunchDetails(
|
|
hostProcessAlive: true,
|
|
recoveryActivationAttempted));
|
|
}
|
|
|
|
windowsClosingByCoordinator = true;
|
|
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
|
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: false);
|
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
return BuildResult(
|
|
success: false,
|
|
stage: "launch",
|
|
code: "desktop_not_visible",
|
|
message: $"Host process started, but it never reached the required startup state within {StartupHardTimeout.TotalSeconds:0} seconds.",
|
|
details: ComposeLaunchDetails(
|
|
!launchOutcome.Process.HasExited,
|
|
recoveryActivationAttempted));
|
|
}
|
|
finally
|
|
{
|
|
if (splashClosedHandler is not null)
|
|
{
|
|
splashWindow.Closed -= splashClosedHandler;
|
|
}
|
|
|
|
if (!windowsClosingByCoordinator)
|
|
{
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
{
|
|
try
|
|
{
|
|
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
|
{
|
|
splashWindow.Close();
|
|
Logger.Info("Splash window closed in coordinator cleanup.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error("Failed to close splash window during coordinator cleanup.", ex);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error("Launcher coordinator failed.", ex);
|
|
return BuildResult(
|
|
success: false,
|
|
stage: "launch",
|
|
code: "exception",
|
|
message: ex.Message,
|
|
details: BuildLauncherContextDetails(_context, _oobeStateService.Evaluate(_context), _deploymentLocator.GetAppRoot()),
|
|
errorMessage: ex.ToString());
|
|
}
|
|
}
|
|
|
|
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
|
|
{
|
|
try
|
|
{
|
|
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.DismissAsync());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error("Failed to dismiss splash window.", ex);
|
|
}
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
{
|
|
try
|
|
{
|
|
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
|
|
{
|
|
loadingDetailsWindow.Close();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error("Failed to close loading details window.", ex);
|
|
}
|
|
});
|
|
}
|
|
|
|
private async Task<HostLaunchOutcome> LaunchHostWithIpcAsync(bool forceDirectMode = false, string? retryTag = null)
|
|
{
|
|
var resolution = _deploymentLocator.ResolveHostExecutable(_context);
|
|
if (!resolution.Success || string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
|
|
{
|
|
var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync().ConfigureAwait(false);
|
|
if (errorResult == ErrorWindowResult.Retry)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(selectedPath) && File.Exists(selectedPath))
|
|
{
|
|
return await LaunchHostWithExplicitPathAsync(selectedPath, forceDirectMode, retryTag).ConfigureAwait(false);
|
|
}
|
|
|
|
return await LaunchHostWithIpcAsync(forceDirectMode, retryTag).ConfigureAwait(false);
|
|
}
|
|
|
|
return HostLaunchOutcome.FromResult(BuildResult(
|
|
success: false,
|
|
stage: "launchHost",
|
|
code: "host_not_found",
|
|
message: "LanMountainDesktop host executable was not found.",
|
|
details: BuildResolutionDetails(resolution, null, null, "resolve")));
|
|
}
|
|
|
|
return await LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag).ConfigureAwait(false);
|
|
}
|
|
|
|
private Task<HostLaunchOutcome> LaunchHostWithExplicitPathAsync(string hostPath, bool forceDirectMode, string? retryTag)
|
|
{
|
|
var resolution = new HostResolutionResult
|
|
{
|
|
Success = true,
|
|
ResolvedHostPath = Path.GetFullPath(hostPath),
|
|
ResolutionSource = "user_selected_path",
|
|
AppRoot = _deploymentLocator.GetAppRoot(),
|
|
ExplicitAppRoot = Path.GetDirectoryName(hostPath),
|
|
SearchedPaths = [Path.GetFullPath(hostPath)]
|
|
};
|
|
|
|
return LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag);
|
|
}
|
|
|
|
private async Task<HostLaunchOutcome> LaunchHostWithResolvedPathAsync(
|
|
HostResolutionResult resolution,
|
|
bool forceDirectMode,
|
|
string? retryTag)
|
|
{
|
|
var dataRoot = _dataLocationResolver.ResolveDataRoot();
|
|
var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot);
|
|
var hostPath = plan.HostPath;
|
|
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
|
{
|
|
EnsureExecutable(hostPath);
|
|
}
|
|
|
|
var primaryMode = HostStartMode.Direct;
|
|
var fallbackMode = !forceDirectMode && OperatingSystem.IsWindows()
|
|
? HostStartMode.ShellExecute
|
|
: (HostStartMode?)null;
|
|
|
|
var firstAttempt = await StartHostProcessAsync(plan, primaryMode, retryTag).ConfigureAwait(false);
|
|
if (firstAttempt.ProcessCreated && firstAttempt.Process is not null)
|
|
{
|
|
var firstDetails = BuildResolutionDetails(resolution, firstAttempt, null, null);
|
|
return HostLaunchOutcome.FromProcess(
|
|
firstAttempt.Process,
|
|
BuildResult(true, "launchHost", "ok", "Host launched.", firstDetails),
|
|
firstDetails);
|
|
}
|
|
|
|
if (fallbackMode is null)
|
|
{
|
|
return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
|
|
}
|
|
|
|
Logger.Warn(
|
|
$"Primary host start attempt failed. Retrying with fallback mode '{fallbackMode}'. " +
|
|
$"FailureReason='{firstAttempt.FailureReason ?? "unknown"}'; ExitCode='{firstAttempt.ExitCode?.ToString() ?? "<none>"}'.");
|
|
|
|
var secondAttempt = await StartHostProcessAsync(plan, fallbackMode.Value, retryTag).ConfigureAwait(false);
|
|
if (secondAttempt.ProcessCreated && secondAttempt.Process is not null)
|
|
{
|
|
var details = BuildResolutionDetails(resolution, firstAttempt, secondAttempt, null);
|
|
return HostLaunchOutcome.FromProcess(
|
|
secondAttempt.Process,
|
|
BuildResult(true, "launchHost", "ok", "Host launched.", details),
|
|
details);
|
|
}
|
|
|
|
return BuildOutcomeFromAttempt(resolution, secondAttempt, firstAttempt);
|
|
}
|
|
|
|
private static HostLaunchOutcome BuildOutcomeFromAttempt(
|
|
HostResolutionResult resolution,
|
|
HostStartAttempt finalAttempt,
|
|
HostStartAttempt? previousAttempt)
|
|
{
|
|
var details = BuildResolutionDetails(
|
|
resolution,
|
|
previousAttempt ?? finalAttempt,
|
|
previousAttempt is null ? null : finalAttempt,
|
|
!finalAttempt.ProcessCreated
|
|
? "start"
|
|
: finalAttempt.ExitCode is int finalExitCode && IsFailedActivationExitCode(finalExitCode)
|
|
? "activation"
|
|
: "early-exit");
|
|
|
|
if (!finalAttempt.ProcessCreated)
|
|
{
|
|
return HostLaunchOutcome.FromResult(BuildResult(
|
|
false,
|
|
"launchHost",
|
|
"host_start_failed",
|
|
$"Failed to start host using start mode '{finalAttempt.StartMode}'.",
|
|
details));
|
|
}
|
|
|
|
if (finalAttempt.ExitCode is not null && IsSuccessfulActivationExitCode(finalAttempt.ExitCode.Value))
|
|
{
|
|
return HostLaunchOutcome.FromImmediateResult(BuildResult(
|
|
true,
|
|
"launch",
|
|
"activation_redirected",
|
|
"Launcher activation was redirected to the existing desktop instance.",
|
|
details));
|
|
}
|
|
|
|
if (finalAttempt.ExitCode is not null && IsFailedActivationExitCode(finalAttempt.ExitCode.Value))
|
|
{
|
|
return HostLaunchOutcome.FromResult(BuildResult(
|
|
false,
|
|
"launch",
|
|
"activation_failed",
|
|
$"Host activation handshake failed using start mode '{finalAttempt.StartMode}'.",
|
|
details));
|
|
}
|
|
|
|
return HostLaunchOutcome.FromResult(BuildResult(
|
|
false,
|
|
"launchHost",
|
|
"host_exited_early",
|
|
$"Host exited early using start mode '{finalAttempt.StartMode}'.",
|
|
details));
|
|
}
|
|
|
|
private async Task<HostStartAttempt> StartHostProcessAsync(
|
|
HostLaunchPlan plan,
|
|
HostStartMode startMode,
|
|
string? retryTag)
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = plan.HostPath,
|
|
WorkingDirectory = plan.WorkingDirectory,
|
|
UseShellExecute = startMode == HostStartMode.ShellExecute
|
|
};
|
|
|
|
if (startMode == HostStartMode.Direct)
|
|
{
|
|
foreach (var argument in plan.Arguments)
|
|
{
|
|
startInfo.ArgumentList.Add(argument);
|
|
}
|
|
|
|
foreach (var pair in plan.EnvironmentVariables)
|
|
{
|
|
startInfo.EnvironmentVariables[pair.Key] = pair.Value;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
startInfo.Arguments = HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments);
|
|
}
|
|
|
|
try
|
|
{
|
|
var process = Process.Start(startInfo);
|
|
Logger.Info(
|
|
$"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? "<none>"}'; Path='{plan.HostPath}'; " +
|
|
$"PackageRoot='{plan.PackageRoot}'; WorkingDir='{plan.WorkingDirectory}'; Pid={(process is null ? -1 : process.Id)}; " +
|
|
$"Args='{HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)}'.");
|
|
|
|
if (process is null)
|
|
{
|
|
return HostStartAttempt.StartFailed(startMode, "process_start_returned_null", plan);
|
|
}
|
|
|
|
await Task.Yield();
|
|
return HostStartAttempt.Started(startMode, process, plan);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error($"Host start failed. Mode='{startMode}'.", ex);
|
|
return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan);
|
|
}
|
|
}
|
|
|
|
private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
|
|
{
|
|
ErrorWindow? errorWindow = null;
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
{
|
|
try
|
|
{
|
|
errorWindow = new ErrorWindow();
|
|
errorWindow.ConfigureForHostNotFound();
|
|
errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found.");
|
|
errorWindow.Show();
|
|
Logger.Warn("Host not found. Showing error window.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error("Failed to show host-not-found error window.", ex);
|
|
}
|
|
});
|
|
|
|
if (errorWindow is null)
|
|
{
|
|
return (ErrorWindowResult.Exit, null);
|
|
}
|
|
|
|
ErrorWindowResult result;
|
|
string? customPath;
|
|
try
|
|
{
|
|
result = await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
|
customPath = errorWindow.GetCustomHostPath();
|
|
Logger.Info($"Host-not-found window result='{result}'; HasCustomPath={!string.IsNullOrWhiteSpace(customPath)}.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error("Failed while waiting for host-not-found window result.", ex);
|
|
result = ErrorWindowResult.Exit;
|
|
customPath = null;
|
|
}
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
{
|
|
try
|
|
{
|
|
if (errorWindow.IsVisible && errorWindow.IsLoaded)
|
|
{
|
|
errorWindow.Close();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error("Failed to close host-not-found error window.", ex);
|
|
}
|
|
});
|
|
|
|
return (result, customPath);
|
|
}
|
|
|
|
private async Task<MigrationResult> ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo)
|
|
{
|
|
MigrationPromptWindow? migrationWindow = null;
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
{
|
|
try
|
|
{
|
|
migrationWindow = new MigrationPromptWindow();
|
|
migrationWindow.SetLegacyInfo(legacyInfo);
|
|
migrationWindow.Show();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error("Failed to show migration prompt window.", ex);
|
|
}
|
|
});
|
|
|
|
if (migrationWindow is null)
|
|
{
|
|
return MigrationResult.Skipped;
|
|
}
|
|
|
|
MigrationResult result;
|
|
try
|
|
{
|
|
result = await migrationWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error("Failed while waiting for migration prompt result.", ex);
|
|
result = MigrationResult.Skipped;
|
|
}
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
{
|
|
try
|
|
{
|
|
if (migrationWindow.IsVisible && migrationWindow.IsLoaded)
|
|
{
|
|
migrationWindow.Close();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error("Failed to close migration prompt window.", ex);
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
private static string MapStartupStageToSplashStage(StartupStage stage) => stage switch
|
|
{
|
|
StartupStage.Initializing => "initializing",
|
|
StartupStage.LoadingSettings => "settings",
|
|
StartupStage.LoadingPlugins => "plugins",
|
|
StartupStage.TrayReady => "shell",
|
|
StartupStage.InitializingUI => "ui",
|
|
StartupStage.ShellInitialized => "shell",
|
|
StartupStage.BackgroundReady => "ready",
|
|
StartupStage.DesktopVisible => "ready",
|
|
StartupStage.ActivationRedirected => "activation",
|
|
StartupStage.ActivationFailed => "error",
|
|
StartupStage.Ready => "ready",
|
|
_ => "launch"
|
|
};
|
|
|
|
private static LauncherResult BuildResult(
|
|
bool success,
|
|
string stage,
|
|
string code,
|
|
string message,
|
|
Dictionary<string, string>? details = null,
|
|
string? errorMessage = null)
|
|
{
|
|
Logger.Info($"Launcher result prepared. Success={success}; Stage='{stage}'; Code='{code}'.");
|
|
return new LauncherResult
|
|
{
|
|
Success = success,
|
|
Stage = stage,
|
|
Code = code,
|
|
Message = message,
|
|
ErrorMessage = errorMessage,
|
|
Details = details ?? []
|
|
};
|
|
}
|
|
|
|
private static LauncherResult WithAdditionalDetails(LauncherResult result, Dictionary<string, string> details)
|
|
{
|
|
return new LauncherResult
|
|
{
|
|
Success = result.Success,
|
|
Stage = result.Stage,
|
|
Code = result.Code,
|
|
Message = result.Message,
|
|
CurrentVersion = result.CurrentVersion,
|
|
TargetVersion = result.TargetVersion,
|
|
RolledBackTo = result.RolledBackTo,
|
|
Details = MergeDetails(details, result.Details),
|
|
InstalledPackagePath = result.InstalledPackagePath,
|
|
ManifestId = result.ManifestId,
|
|
ManifestName = result.ManifestName,
|
|
ErrorMessage = result.ErrorMessage
|
|
};
|
|
}
|
|
|
|
private static Dictionary<string, string> BuildLauncherContextDetails(
|
|
CommandContext context,
|
|
OobeLaunchDecision oobeDecision,
|
|
string appRoot)
|
|
{
|
|
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["command"] = context.Command,
|
|
["launchSource"] = context.LaunchSource,
|
|
["isGuiMode"] = context.IsGuiCommand.ToString(),
|
|
["isDebugMode"] = context.IsDebugMode.ToString(),
|
|
["isElevated"] = oobeDecision.IsElevated.ToString(),
|
|
["resolvedAppRoot"] = appRoot,
|
|
["oobeStatePath"] = oobeDecision.StatePath,
|
|
["oobeStateStatus"] = oobeDecision.Status.ToString(),
|
|
["oobeDecision"] = oobeDecision.ShouldShowOobe ? "show" : "skip",
|
|
["oobeSuppressionReason"] = oobeDecision.SuppressionReason,
|
|
["oobeResultCode"] = oobeDecision.ResultCode,
|
|
["userSid"] = oobeDecision.UserSid ?? string.Empty,
|
|
["usedLegacyOobeMarker"] = oobeDecision.UsedLegacyMarker.ToString(),
|
|
["migratedLegacyOobeMarker"] = oobeDecision.MigratedLegacyMarker.ToString(),
|
|
["oobeStateError"] = oobeDecision.ErrorMessage
|
|
};
|
|
}
|
|
|
|
private static Dictionary<string, string> BuildResolutionDetails(
|
|
HostResolutionResult resolution,
|
|
HostStartAttempt? firstAttempt,
|
|
HostStartAttempt? secondAttempt,
|
|
string? failureStage)
|
|
{
|
|
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["resolvedAppRoot"] = resolution.AppRoot,
|
|
["explicitAppRoot"] = resolution.ExplicitAppRoot ?? string.Empty,
|
|
["resolvedHostPath"] = resolution.ResolvedHostPath ?? string.Empty,
|
|
["resolutionSource"] = resolution.ResolutionSource ?? string.Empty,
|
|
["devModeConfigIgnored"] = resolution.DevModeConfigIgnored.ToString(),
|
|
["searchedPaths"] = string.Join(" | ", resolution.SearchedPaths),
|
|
["failureStage"] = failureStage ?? string.Empty
|
|
};
|
|
|
|
if (firstAttempt is not null)
|
|
{
|
|
details["startMode"] = firstAttempt.StartMode.ToString();
|
|
details["processCreated"] = firstAttempt.ProcessCreated.ToString();
|
|
details["hostPid"] = firstAttempt.ProcessId?.ToString() ?? string.Empty;
|
|
details["packageRoot"] = firstAttempt.PackageRoot ?? string.Empty;
|
|
details["workingDirectory"] = firstAttempt.WorkingDirectory ?? string.Empty;
|
|
details["arguments"] = firstAttempt.Arguments ?? string.Empty;
|
|
details["firstAttemptFailureReason"] = firstAttempt.FailureReason ?? string.Empty;
|
|
details["firstAttemptExitCode"] = firstAttempt.ExitCode?.ToString() ?? string.Empty;
|
|
}
|
|
|
|
if (secondAttempt is not null)
|
|
{
|
|
details["fallbackStartMode"] = secondAttempt.StartMode.ToString();
|
|
details["fallbackProcessCreated"] = secondAttempt.ProcessCreated.ToString();
|
|
details["fallbackHostPid"] = secondAttempt.ProcessId?.ToString() ?? string.Empty;
|
|
details["fallbackPackageRoot"] = secondAttempt.PackageRoot ?? string.Empty;
|
|
details["fallbackWorkingDirectory"] = secondAttempt.WorkingDirectory ?? string.Empty;
|
|
details["fallbackArguments"] = secondAttempt.Arguments ?? string.Empty;
|
|
details["fallbackFailureReason"] = secondAttempt.FailureReason ?? string.Empty;
|
|
details["fallbackExitCode"] = secondAttempt.ExitCode?.ToString() ?? string.Empty;
|
|
}
|
|
|
|
return details;
|
|
}
|
|
|
|
private static Dictionary<string, string> MergeDetails(
|
|
Dictionary<string, string> left,
|
|
Dictionary<string, string> right)
|
|
{
|
|
var merged = new Dictionary<string, string>(left, StringComparer.OrdinalIgnoreCase);
|
|
foreach (var pair in right)
|
|
{
|
|
merged[pair.Key] = pair.Value;
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
private static void EnsureExecutable(string path)
|
|
{
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var mode = File.GetUnixFileMode(path);
|
|
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
|
|
File.SetUnixFileMode(path, mode);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
private static async Task<bool> TryConnectToPublicIpcAsync(
|
|
LanMountainDesktopIpcClient ipcClient,
|
|
TimeSpan timeout)
|
|
{
|
|
if (ipcClient.IsConnected)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
try
|
|
{
|
|
var connectTask = ipcClient.ConnectAsync();
|
|
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
|
if (completedTask != connectTask)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
await connectTask.ConfigureAwait(false);
|
|
return ipcClient.IsConnected;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Info($"Public IPC is not ready yet: {ex.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
internal static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context)
|
|
{
|
|
if (!string.Equals(context.Command, "launch", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (context.IsPreviewCommand || context.IsMaintenanceCommand)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return !string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior()
|
|
{
|
|
try
|
|
{
|
|
var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(_dataLocationResolver.ResolveDataRoot());
|
|
return HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(settingsPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warn($"Failed to load multi-instance launch behavior. Falling back to default. {ex.Message}");
|
|
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
|
|
}
|
|
}
|
|
|
|
internal static bool IsExistingHostReadyForLauncherDecision(PublicShellStatus? status)
|
|
{
|
|
return status is { PublicIpcReady: true, ProcessId: > 0 };
|
|
}
|
|
|
|
private static async Task<PublicShellStatus?> TryGetExistingHostStatusAsync(
|
|
LanMountainDesktopIpcClient ipcClient,
|
|
TimeSpan timeout)
|
|
{
|
|
try
|
|
{
|
|
var connected = ipcClient.IsConnected ||
|
|
await TryConnectToPublicIpcAsync(ipcClient, timeout).ConfigureAwait(false);
|
|
if (!connected)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
|
return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Info($"Existing host status probe did not complete: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static async Task<ExistingHostBehaviorResult> ApplyExistingHostBehaviorAsync(
|
|
LanMountainDesktopIpcClient ipcClient,
|
|
MultiInstanceLaunchBehavior behavior,
|
|
PublicShellStatus status)
|
|
{
|
|
try
|
|
{
|
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
|
return behavior switch
|
|
{
|
|
MultiInstanceLaunchBehavior.OpenDesktopSilently => await ActivateExistingHostForBehaviorAsync(
|
|
shellProxy,
|
|
showLauncherNotice: false,
|
|
successCode: "existing_host_activated",
|
|
successMessage: "Launcher activated the existing desktop instance.",
|
|
failureCode: "existing_host_activation_failed").ConfigureAwait(false),
|
|
|
|
MultiInstanceLaunchBehavior.NotifyAndOpenDesktop => await ActivateExistingHostForBehaviorAsync(
|
|
shellProxy,
|
|
showLauncherNotice: true,
|
|
successCode: "existing_host_activated_with_notice",
|
|
successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
|
|
failureCode: "existing_host_activation_failed").ConfigureAwait(false),
|
|
|
|
MultiInstanceLaunchBehavior.PromptOnly => await ShowPromptOnlyExistingHostAsync(
|
|
shellProxy,
|
|
status).ConfigureAwait(false),
|
|
|
|
MultiInstanceLaunchBehavior.RestartApp => await RestartExistingHostAsync(shellProxy).ConfigureAwait(false),
|
|
|
|
_ => await ActivateExistingHostForBehaviorAsync(
|
|
shellProxy,
|
|
showLauncherNotice: true,
|
|
successCode: "existing_host_activated_with_notice",
|
|
successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
|
|
failureCode: "existing_host_activation_failed").ConfigureAwait(false)
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warn($"Failed to apply multi-instance behavior '{behavior}': {ex.Message}");
|
|
return new ExistingHostBehaviorResult(
|
|
false,
|
|
"multi_instance_behavior_failed",
|
|
$"Failed to apply multi-instance behavior '{behavior}': {ex.Message}",
|
|
null);
|
|
}
|
|
}
|
|
|
|
private static async Task<ExistingHostBehaviorResult> ActivateExistingHostForBehaviorAsync(
|
|
IPublicShellControlService shellProxy,
|
|
bool showLauncherNotice,
|
|
string successCode,
|
|
string successMessage,
|
|
string failureCode)
|
|
{
|
|
var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
|
var success = activation.Accepted || IsRecoverableActivationFailure(activation);
|
|
if (showLauncherNotice && success)
|
|
{
|
|
var promptResult = await ShowMultiInstancePromptAsync(activation.Status).ConfigureAwait(false);
|
|
if (promptResult == MultiInstancePromptResult.OpenDesktop)
|
|
{
|
|
activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
return new ExistingHostBehaviorResult(
|
|
success,
|
|
activation.Accepted ? successCode : success ? "existing_host_startup_pending" : failureCode,
|
|
activation.Accepted ? successMessage : activation.Message,
|
|
activation);
|
|
}
|
|
|
|
private static async Task<ExistingHostBehaviorResult> RestartExistingHostAsync(
|
|
IPublicShellControlService shellProxy)
|
|
{
|
|
var accepted = await shellProxy.RestartAsync().ConfigureAwait(false);
|
|
return new ExistingHostBehaviorResult(
|
|
accepted,
|
|
accepted ? "existing_host_restart_requested" : "existing_host_restart_failed",
|
|
accepted
|
|
? "Launcher requested the existing desktop instance to restart."
|
|
: "Launcher could not request restart from the existing desktop instance.",
|
|
null);
|
|
}
|
|
|
|
private static async Task<ExistingHostBehaviorResult> ShowPromptOnlyExistingHostAsync(
|
|
IPublicShellControlService shellProxy,
|
|
PublicShellStatus status)
|
|
{
|
|
var promptResult = await ShowMultiInstancePromptAsync(status).ConfigureAwait(false);
|
|
|
|
if (promptResult == MultiInstancePromptResult.OpenDesktop)
|
|
{
|
|
return await ActivateExistingHostForBehaviorAsync(
|
|
shellProxy,
|
|
showLauncherNotice: false,
|
|
successCode: "existing_host_activated_from_prompt",
|
|
successMessage: "Launcher activated the existing desktop instance from the prompt.",
|
|
failureCode: "existing_host_activation_failed").ConfigureAwait(false);
|
|
}
|
|
|
|
return new ExistingHostBehaviorResult(
|
|
true,
|
|
"existing_host_prompt_only",
|
|
"Launcher showed the repeated-launch prompt and did not open the desktop automatically.",
|
|
null);
|
|
}
|
|
|
|
private static async Task<MultiInstancePromptResult> ShowMultiInstancePromptAsync(PublicShellStatus status)
|
|
{
|
|
return await Dispatcher.UIThread.InvokeAsync(async () =>
|
|
{
|
|
var prompt = new MultiInstancePromptWindow();
|
|
prompt.SetDetails(status.ProcessId, status.ShellState);
|
|
prompt.Show();
|
|
return await prompt.WaitForChoiceAsync().ConfigureAwait(true);
|
|
});
|
|
}
|
|
|
|
private static async Task<PublicShellActivationResult?> TryActivateExistingHostWithStatusAsync(
|
|
LanMountainDesktopIpcClient ipcClient,
|
|
TimeSpan timeout)
|
|
{
|
|
try
|
|
{
|
|
var connected = ipcClient.IsConnected ||
|
|
await TryConnectToPublicIpcAsync(ipcClient, timeout).ConfigureAwait(false);
|
|
if (!connected)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
|
return await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Info($"Existing host activation probe did not complete: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static async Task<StartupSuccessState?> TryRecoverActivationThroughExistingHostAsync(
|
|
LanMountainDesktopIpcClient ipcClient,
|
|
StartupSuccessTracker startupSuccessTracker,
|
|
TimeSpan timeout)
|
|
{
|
|
var activation = await TryActivateExistingHostWithStatusAsync(ipcClient, timeout).ConfigureAwait(false);
|
|
if (activation is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess))
|
|
{
|
|
return shellSuccess;
|
|
}
|
|
|
|
if (activation.Accepted)
|
|
{
|
|
return startupSuccessTracker.BuildRecoverySuccessState();
|
|
}
|
|
|
|
return IsRecoverableActivationFailure(activation)
|
|
? new StartupSuccessState(
|
|
StartupStage.Ready,
|
|
"startup_pending",
|
|
activation.Message)
|
|
: null;
|
|
}
|
|
|
|
internal static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
|
|
{
|
|
if (activation.Accepted)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (string.Equals(activation.Code, "shutdown_in_progress", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return activation.Status.PublicIpcReady &&
|
|
(!activation.Status.MainWindowOpened ||
|
|
!activation.Status.DesktopVisible ||
|
|
string.Equals(activation.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(activation.Code, "startup_pending", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
internal static bool IsSuccessfulActivationExitCode(int exitCode) =>
|
|
exitCode == HostExitCodes.SecondaryActivationSucceeded;
|
|
|
|
internal static bool IsFailedActivationExitCode(int exitCode) =>
|
|
exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired;
|
|
|
|
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
|
|
LanMountainDesktopIpcClient ipcClient)
|
|
{
|
|
try
|
|
{
|
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
|
return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warn($"Failed to query public shell status: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static async Task<StartupSuccessState?> TryRecoverWithPublicActivationAsync(
|
|
LanMountainDesktopIpcClient ipcClient,
|
|
Process hostProcess,
|
|
Task<StartupSuccessState> successTask,
|
|
StartupSuccessTracker startupSuccessTracker)
|
|
{
|
|
try
|
|
{
|
|
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
|
var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
|
if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess))
|
|
{
|
|
return shellSuccess;
|
|
}
|
|
|
|
var completedTask = await Task.WhenAny(successTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false);
|
|
if (completedTask == successTask)
|
|
{
|
|
return await successTask.ConfigureAwait(false);
|
|
}
|
|
|
|
if (!hostProcess.HasExited && (activation.Accepted || IsRecoverableActivationFailure(activation)))
|
|
{
|
|
return startupSuccessTracker.BuildRecoverySuccessState();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warn($"Public activation recovery failed: {ex.Message}");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static LoadingStateMessage BuildDelayedLoadingState(
|
|
LoadingStateMessage loadingState,
|
|
string summaryMessage,
|
|
string detailMessage,
|
|
DateTimeOffset startedAtUtc)
|
|
{
|
|
var delayedItems = loadingState.ActiveItems
|
|
.Where(item => !string.Equals(item.Id, "launcher-soft-timeout", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
delayedItems.Insert(0, new LoadingItem
|
|
{
|
|
Id = "launcher-soft-timeout",
|
|
Type = LoadingItemType.System,
|
|
Name = "Startup still in progress",
|
|
Description = detailMessage,
|
|
State = LoadingState.Delayed,
|
|
ProgressPercent = Math.Max(loadingState.OverallProgressPercent, 1),
|
|
Message = detailMessage,
|
|
StartTime = startedAtUtc
|
|
});
|
|
|
|
return loadingState with
|
|
{
|
|
ActiveItems = delayedItems,
|
|
Message = summaryMessage,
|
|
Timestamp = DateTimeOffset.UtcNow,
|
|
TotalCount = Math.Max(loadingState.TotalCount, delayedItems.Count)
|
|
};
|
|
}
|
|
|
|
private static Dictionary<string, string> BuildAttemptDetails(
|
|
StartupAttemptRecord? trackedAttempt,
|
|
bool attachedToExistingAttempt,
|
|
bool ipcConnected,
|
|
bool hostProcessAlive,
|
|
StartupStage lastStage,
|
|
string lastStageMessage,
|
|
string? activationFailureReason,
|
|
bool softTimeoutShown,
|
|
bool recoveryActivationAttempted)
|
|
{
|
|
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["hostProcessAlive"] = hostProcessAlive.ToString(),
|
|
["attachedToExistingAttempt"] = attachedToExistingAttempt.ToString(),
|
|
["ipcConnected"] = ipcConnected.ToString(),
|
|
["ipcStage"] = lastStage.ToString(),
|
|
["ipcMessage"] = lastStageMessage,
|
|
["activationFailureReason"] = activationFailureReason ?? string.Empty,
|
|
["softTimeoutShown"] = softTimeoutShown.ToString(),
|
|
["recoveryActivationAttempted"] = recoveryActivationAttempted.ToString()
|
|
};
|
|
|
|
if (trackedAttempt is not null)
|
|
{
|
|
details["startupAttemptId"] = trackedAttempt.AttemptId;
|
|
details["startupAttemptState"] = trackedAttempt.State.ToString();
|
|
details["startupAttemptStartedAtUtc"] = trackedAttempt.StartedAtUtc.ToString("O");
|
|
details["startupAttemptUpdatedAtUtc"] = trackedAttempt.UpdatedAtUtc.ToString("O");
|
|
details["startupAttemptHeartbeatAtUtc"] = trackedAttempt.HeartbeatAtUtc.ToString("O");
|
|
details["successPolicy"] = trackedAttempt.SuccessPolicy;
|
|
details["hostPid"] = trackedAttempt.HostPid.ToString();
|
|
details["coordinatorPid"] = trackedAttempt.CoordinatorPid.ToString();
|
|
details["coordinatorPipeName"] = trackedAttempt.CoordinatorPipeName;
|
|
details["reservedBeforeHostStart"] = trackedAttempt.ReservedBeforeHostStart.ToString();
|
|
details["publicIpcConnected"] = trackedAttempt.PublicIpcConnected.ToString();
|
|
details["shellStatus"] = trackedAttempt.ShellStatus;
|
|
}
|
|
|
|
return details;
|
|
}
|
|
|
|
private static bool TryGetLiveProcess(int processId, out Process? process)
|
|
{
|
|
process = null;
|
|
if (processId <= 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
process = Process.GetProcessById(processId);
|
|
return !process.HasExited;
|
|
}
|
|
catch
|
|
{
|
|
process?.Dispose();
|
|
process = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private enum HostStartMode
|
|
{
|
|
ShellExecute,
|
|
Direct
|
|
}
|
|
|
|
private sealed record HostStartAttempt(
|
|
HostStartMode StartMode,
|
|
bool ProcessCreated,
|
|
Process? Process,
|
|
bool ExitedEarly,
|
|
int? ExitCode,
|
|
string? FailureReason,
|
|
string? PackageRoot,
|
|
string? WorkingDirectory,
|
|
string? Arguments)
|
|
{
|
|
public int? ProcessId => Process?.Id;
|
|
|
|
public static HostStartAttempt Started(HostStartMode startMode, Process process, HostLaunchPlan plan) =>
|
|
new(
|
|
startMode,
|
|
true,
|
|
process,
|
|
false,
|
|
null,
|
|
null,
|
|
plan.PackageRoot,
|
|
plan.WorkingDirectory,
|
|
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
|
|
|
|
public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode, HostLaunchPlan plan) =>
|
|
new(
|
|
startMode,
|
|
true,
|
|
process,
|
|
true,
|
|
exitCode,
|
|
null,
|
|
plan.PackageRoot,
|
|
plan.WorkingDirectory,
|
|
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
|
|
|
|
public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason, HostLaunchPlan? plan = null) =>
|
|
new(
|
|
startMode,
|
|
false,
|
|
null,
|
|
false,
|
|
null,
|
|
failureReason,
|
|
plan?.PackageRoot,
|
|
plan?.WorkingDirectory,
|
|
plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
|
|
}
|
|
|
|
private sealed record ExistingHostBehaviorResult(
|
|
bool Success,
|
|
string Code,
|
|
string Message,
|
|
PublicShellActivationResult? ActivationResult);
|
|
|
|
private sealed record HostLaunchOutcome(
|
|
LauncherResult Result,
|
|
Process? Process,
|
|
LauncherResult? ImmediateResult,
|
|
Dictionary<string, string> Details)
|
|
{
|
|
public static HostLaunchOutcome FromResult(LauncherResult result) =>
|
|
new(result, null, result.Success ? result : null, result.Details);
|
|
|
|
public static HostLaunchOutcome FromImmediateResult(LauncherResult result) =>
|
|
new(result, null, result, result.Details);
|
|
|
|
public static HostLaunchOutcome FromProcess(Process process, LauncherResult result, Dictionary<string, string> details) =>
|
|
new(result, process, null, details);
|
|
}
|
|
|
|
private sealed class StartupSuccessTracker
|
|
{
|
|
private readonly LaunchSuccessPolicy _policy;
|
|
private bool _trayReady;
|
|
private bool _backgroundReady;
|
|
|
|
public string PolicyKey => _policy.ToString();
|
|
|
|
public StartupSuccessTracker(CommandContext context)
|
|
{
|
|
var restartPresentation = LauncherRuntimeMetadata.GetRestartPresentationMode(context.RawArgs);
|
|
var isRestartLaunch = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
|
|
|
|
_policy = !isRestartLaunch
|
|
? LaunchSuccessPolicy.Foreground
|
|
: restartPresentation switch
|
|
{
|
|
RestartPresentationMode.Tray => LaunchSuccessPolicy.RestartTray,
|
|
RestartPresentationMode.Minimized => LaunchSuccessPolicy.RestartBackground,
|
|
_ => LaunchSuccessPolicy.Foreground
|
|
};
|
|
}
|
|
|
|
public bool TryResolve(StartupStage stage, out StartupSuccessState successState)
|
|
{
|
|
switch (stage)
|
|
{
|
|
case StartupStage.ActivationRedirected:
|
|
successState = new StartupSuccessState(
|
|
stage,
|
|
"activation_redirected",
|
|
"Launcher activation was redirected to the existing desktop instance.");
|
|
return true;
|
|
|
|
case StartupStage.DesktopVisible:
|
|
successState = new StartupSuccessState(
|
|
stage,
|
|
_policy == LaunchSuccessPolicy.Foreground ? "ok" : "desktop_visible_fallback",
|
|
_policy == LaunchSuccessPolicy.Foreground
|
|
? "Desktop is visible and ready."
|
|
: "Desktop recovered in a visible state.");
|
|
return true;
|
|
|
|
case StartupStage.Ready:
|
|
successState = new StartupSuccessState(
|
|
stage,
|
|
_policy == LaunchSuccessPolicy.Foreground ? "ready" : "background_ready",
|
|
"Desktop reported that startup is ready.");
|
|
return true;
|
|
|
|
case StartupStage.TrayReady:
|
|
_trayReady = true;
|
|
break;
|
|
|
|
case StartupStage.BackgroundReady:
|
|
_backgroundReady = true;
|
|
break;
|
|
}
|
|
|
|
if (_policy == LaunchSuccessPolicy.RestartBackground && _backgroundReady)
|
|
{
|
|
successState = new StartupSuccessState(
|
|
StartupStage.BackgroundReady,
|
|
"background_ready",
|
|
"Desktop restart completed in the background.");
|
|
return true;
|
|
}
|
|
|
|
if (_policy == LaunchSuccessPolicy.RestartTray && _trayReady && _backgroundReady)
|
|
{
|
|
successState = new StartupSuccessState(
|
|
StartupStage.BackgroundReady,
|
|
"background_ready",
|
|
"Desktop restart completed with tray recovery ready.");
|
|
return true;
|
|
}
|
|
|
|
successState = default!;
|
|
return false;
|
|
}
|
|
|
|
public bool TryResolve(PublicShellStatus? status, out StartupSuccessState successState)
|
|
{
|
|
if (status is not null &&
|
|
(status.DesktopVisible || status.MainWindowVisible || status.MainWindowOpened))
|
|
{
|
|
successState = new StartupSuccessState(
|
|
status.DesktopVisible || status.MainWindowVisible
|
|
? StartupStage.DesktopVisible
|
|
: StartupStage.Ready,
|
|
_policy == LaunchSuccessPolicy.Foreground ? "ok" : "background_ready",
|
|
status.DesktopVisible || status.MainWindowVisible
|
|
? "Desktop shell is visible and ready."
|
|
: "Desktop shell window has opened.");
|
|
return true;
|
|
}
|
|
|
|
successState = default!;
|
|
return false;
|
|
}
|
|
|
|
public StartupSuccessState BuildRecoverySuccessState()
|
|
{
|
|
return _policy switch
|
|
{
|
|
LaunchSuccessPolicy.RestartTray => new StartupSuccessState(
|
|
StartupStage.DesktopVisible,
|
|
"recovery_activation_requested",
|
|
"Launcher requested a visible recovery because the background restart never confirmed tray readiness."),
|
|
LaunchSuccessPolicy.RestartBackground => new StartupSuccessState(
|
|
StartupStage.DesktopVisible,
|
|
"recovery_activation_requested",
|
|
"Launcher requested a visible recovery because the background restart never confirmed readiness."),
|
|
_ => new StartupSuccessState(
|
|
StartupStage.DesktopVisible,
|
|
"recovery_activation_requested",
|
|
"Launcher requested a visible recovery from the running desktop instance.")
|
|
};
|
|
}
|
|
}
|
|
|
|
private sealed record StartupSuccessState(
|
|
StartupStage Stage,
|
|
string Code,
|
|
string Message);
|
|
|
|
private enum LaunchSuccessPolicy
|
|
{
|
|
Foreground,
|
|
RestartBackground,
|
|
RestartTray
|
|
}
|
|
}
|