mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Stamp release versions and harden launcher
Add automatic release version stamping and multiple launcher reliability improvements. The Release workflow now runs scripts/Set-ReleaseVersion.ps1 in build jobs to inject tag-derived Version/AssemblyVersion into project metadata; several .csproj/Directory.Build.props and app.manifest files were changed to use a dev placeholder. Introduced AppVersionProvider (and related runtime metadata) to centralize version resolution and updated DeploymentLocator to use it and to prefer package-root/version.json. Launcher startup flow was hardened: added startup success tracking, public-activation recovery path, improved success/fallback semantics, and related IPC handling. UI/UX fixes include OOBE entrance/exit animation improvements (scaling-aware, concurrent fade+translate) and minor window lifecycle reorder in DesktopShellHost. CommandContext now recognizes restart and key=value args. New DesktopTrayService and .trae spec files (spec, checklist, tasks) document shell/tray hardening work. Miscellaneous logging, comments and housekeeping edits across launcher and shared contracts to support the above.
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
public static class AppVersionProvider
|
||||
{
|
||||
private const string DefaultVersion = "0.0.0";
|
||||
private const string DefaultCodename = "Administrate";
|
||||
private const string VersionFileName = "version.json";
|
||||
|
||||
public static AppVersionInfo ResolveForCurrentProcess(
|
||||
IReadOnlyList<string>? commandLineArgs = null,
|
||||
string? executablePath = null,
|
||||
string? deploymentDirectory = null)
|
||||
{
|
||||
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
|
||||
return Resolve(
|
||||
packageRoot: LauncherRuntimeMetadata.GetPackageRoot(args),
|
||||
deploymentDirectory: deploymentDirectory ?? AppContext.BaseDirectory,
|
||||
executablePath: executablePath ?? Environment.ProcessPath,
|
||||
versionOverride: LauncherRuntimeMetadata.GetForwardedVersion(args),
|
||||
codenameOverride: LauncherRuntimeMetadata.GetForwardedCodename(args));
|
||||
}
|
||||
|
||||
public static AppVersionInfo ResolveFromDeploymentDirectory(
|
||||
string? deploymentDirectory,
|
||||
string? executablePath = null,
|
||||
string? versionOverride = null,
|
||||
string? codenameOverride = null)
|
||||
{
|
||||
return Resolve(
|
||||
packageRoot: null,
|
||||
deploymentDirectory: deploymentDirectory,
|
||||
executablePath: executablePath,
|
||||
versionOverride: versionOverride,
|
||||
codenameOverride: codenameOverride);
|
||||
}
|
||||
|
||||
public static AppVersionInfo ResolveFromPackageRoot(
|
||||
string? packageRoot,
|
||||
string executableName,
|
||||
string? versionOverride = null,
|
||||
string? codenameOverride = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packageRoot))
|
||||
{
|
||||
return CreateFallback(versionOverride, codenameOverride);
|
||||
}
|
||||
|
||||
var deploymentDirectory = FindCurrentDeploymentDirectory(packageRoot, executableName);
|
||||
var executablePath = !string.IsNullOrWhiteSpace(deploymentDirectory)
|
||||
? Path.Combine(deploymentDirectory, executableName)
|
||||
: null;
|
||||
|
||||
return Resolve(
|
||||
packageRoot: packageRoot,
|
||||
deploymentDirectory: deploymentDirectory,
|
||||
executablePath: executablePath,
|
||||
versionOverride: versionOverride,
|
||||
codenameOverride: codenameOverride);
|
||||
}
|
||||
|
||||
public static AppVersionInfo Resolve(
|
||||
string? packageRoot,
|
||||
string? deploymentDirectory,
|
||||
string? executablePath,
|
||||
string? versionOverride = null,
|
||||
string? codenameOverride = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(versionOverride))
|
||||
{
|
||||
return Create(versionOverride, codenameOverride);
|
||||
}
|
||||
|
||||
var normalizedDeploymentDirectory = NormalizeExistingDirectory(deploymentDirectory)
|
||||
?? ResolveDeploymentFromPackageRoot(packageRoot, executablePath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedDeploymentDirectory) &&
|
||||
TryReadVersionFile(normalizedDeploymentDirectory, out var fileInfo))
|
||||
{
|
||||
return OverrideMissingParts(fileInfo, versionOverride, codenameOverride);
|
||||
}
|
||||
|
||||
var normalizedExecutablePath = NormalizeExistingFile(executablePath)
|
||||
?? ResolveExecutableFromDeployment(normalizedDeploymentDirectory, executablePath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedExecutablePath) &&
|
||||
TryReadExecutableVersion(normalizedExecutablePath, out var executableInfo))
|
||||
{
|
||||
return OverrideMissingParts(executableInfo, versionOverride, codenameOverride);
|
||||
}
|
||||
|
||||
var versionFromDirectory = TryParseVersionFromDeploymentDirectory(normalizedDeploymentDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(versionFromDirectory))
|
||||
{
|
||||
return Create(versionFromDirectory, codenameOverride);
|
||||
}
|
||||
|
||||
return CreateFallback(versionOverride, codenameOverride);
|
||||
}
|
||||
|
||||
public static string NormalizeVersionText(string? rawValue, string fallback = DefaultVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var normalized = rawValue.Split('+', 2, StringSplitOptions.TrimEntries)[0].Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized)
|
||||
? fallback
|
||||
: normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeCodename(string? rawValue, string fallback = DefaultCodename)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(rawValue)
|
||||
? fallback
|
||||
: rawValue.Trim();
|
||||
}
|
||||
|
||||
private static AppVersionInfo OverrideMissingParts(
|
||||
AppVersionInfo source,
|
||||
string? versionOverride,
|
||||
string? codenameOverride)
|
||||
{
|
||||
return new AppVersionInfo
|
||||
{
|
||||
Version = NormalizeVersionText(versionOverride ?? source.Version),
|
||||
Codename = NormalizeCodename(codenameOverride ?? source.Codename)
|
||||
};
|
||||
}
|
||||
|
||||
private static AppVersionInfo CreateFallback(string? versionOverride, string? codenameOverride)
|
||||
{
|
||||
return Create(versionOverride ?? DefaultVersion, codenameOverride ?? DefaultCodename);
|
||||
}
|
||||
|
||||
private static AppVersionInfo Create(string version, string? codename)
|
||||
{
|
||||
return new AppVersionInfo
|
||||
{
|
||||
Version = NormalizeVersionText(version),
|
||||
Codename = NormalizeCodename(codename)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryReadVersionFile(string deploymentDirectory, out AppVersionInfo info)
|
||||
{
|
||||
info = default!;
|
||||
var versionFilePath = Path.Combine(deploymentDirectory, VersionFileName);
|
||||
if (!File.Exists(versionFilePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(versionFilePath);
|
||||
var parsedInfo = JsonSerializer.Deserialize<AppVersionInfo>(json);
|
||||
if (parsedInfo is null || string.IsNullOrWhiteSpace(parsedInfo.Version))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
info = new AppVersionInfo
|
||||
{
|
||||
Version = NormalizeVersionText(parsedInfo.Version),
|
||||
Codename = NormalizeCodename(parsedInfo.Codename)
|
||||
};
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadExecutableVersion(string executablePath, out AppVersionInfo info)
|
||||
{
|
||||
info = default!;
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = FileVersionInfo.GetVersionInfo(executablePath);
|
||||
var version = NormalizeVersionText(fileInfo.ProductVersion);
|
||||
if (string.Equals(version, DefaultVersion, StringComparison.Ordinal) &&
|
||||
!string.IsNullOrWhiteSpace(fileInfo.FileVersion))
|
||||
{
|
||||
version = NormalizeVersionText(fileInfo.FileVersion);
|
||||
}
|
||||
|
||||
if (string.Equals(version, DefaultVersion, StringComparison.Ordinal))
|
||||
{
|
||||
var assemblyNameVersion = AssemblyName.GetAssemblyName(executablePath).Version;
|
||||
if (assemblyNameVersion is not null)
|
||||
{
|
||||
version = NormalizeVersionText(assemblyNameVersion.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
info = new AppVersionInfo
|
||||
{
|
||||
Version = version,
|
||||
Codename = DefaultCodename
|
||||
};
|
||||
return !string.Equals(version, DefaultVersion, StringComparison.Ordinal);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveDeploymentFromPackageRoot(string? packageRoot, string? executablePath)
|
||||
{
|
||||
var normalizedPackageRoot = NormalizeExistingDirectory(packageRoot);
|
||||
if (string.IsNullOrWhiteSpace(normalizedPackageRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedExecutablePath = NormalizeExistingFile(executablePath);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedExecutablePath))
|
||||
{
|
||||
var executableDirectory = NormalizeExistingDirectory(Path.GetDirectoryName(normalizedExecutablePath));
|
||||
if (!string.IsNullOrWhiteSpace(executableDirectory) &&
|
||||
executableDirectory.StartsWith(normalizedPackageRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return executableDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
var executableName = Path.GetFileName(normalizedExecutablePath);
|
||||
return FindCurrentDeploymentDirectory(normalizedPackageRoot, executableName);
|
||||
}
|
||||
|
||||
private static string? ResolveExecutableFromDeployment(string? deploymentDirectory, string? executablePath)
|
||||
{
|
||||
var normalizedExecutablePath = NormalizeExistingFile(executablePath);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedExecutablePath))
|
||||
{
|
||||
return normalizedExecutablePath;
|
||||
}
|
||||
|
||||
var normalizedDeploymentDirectory = NormalizeExistingDirectory(deploymentDirectory);
|
||||
if (string.IsNullOrWhiteSpace(normalizedDeploymentDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var candidateName in GetExecutableCandidates(executablePath))
|
||||
{
|
||||
var candidatePath = Path.Combine(normalizedDeploymentDirectory, candidateName);
|
||||
if (File.Exists(candidatePath))
|
||||
{
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetExecutableCandidates(string? executablePath)
|
||||
{
|
||||
var fileName = Path.GetFileName(executablePath);
|
||||
if (!string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return [fileName];
|
||||
}
|
||||
|
||||
return OperatingSystem.IsWindows()
|
||||
? ["LanMountainDesktop.exe"]
|
||||
: ["LanMountainDesktop"];
|
||||
}
|
||||
|
||||
private static string? FindCurrentDeploymentDirectory(string packageRoot, string? executableName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidates = Directory.GetDirectories(packageRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
.Where(path => !File.Exists(Path.Combine(path, ".destroy")))
|
||||
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
IsCurrent = File.Exists(Path.Combine(path, ".current")),
|
||||
HasExecutable = string.IsNullOrWhiteSpace(executableName) || File.Exists(Path.Combine(path, executableName)),
|
||||
Version = TryParseVersionFromDeploymentDirectory(path)
|
||||
})
|
||||
.Where(item => item.HasExecutable)
|
||||
.OrderByDescending(item => item.IsCurrent)
|
||||
.ThenByDescending(item => item.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return candidates.FirstOrDefault()?.Path;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryParseVersionFromDeploymentDirectory(string? deploymentDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deploymentDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var directoryName = Path.GetFileName(deploymentDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (string.IsNullOrWhiteSpace(directoryName) ||
|
||||
!directoryName.StartsWith("app-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var remaining = directoryName["app-".Length..];
|
||||
var segments = remaining.Split('-', StringSplitOptions.RemoveEmptyEntries);
|
||||
return segments.Length > 0
|
||||
? NormalizeVersionText(segments[0])
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingDirectory(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
return Directory.Exists(fullPath) ? fullPath : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingFile(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
return File.Exists(fullPath) ? fullPath : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user