mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 15:44:25 +08:00
feat.airapp剥离启动器
This commit is contained in:
82
.github/workflows/release.yml
vendored
82
.github/workflows/release.yml
vendored
@@ -185,6 +185,29 @@ jobs:
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish AirAppRuntime
|
||||
run: |
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$publishDir = "publish/airapp-runtime-win-$arch"
|
||||
|
||||
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj `
|
||||
-c Release `
|
||||
-o ./$publishDir `
|
||||
--self-contained:false `
|
||||
-r win-$arch `
|
||||
-p:SelfContained=false `
|
||||
-p:PublishAot=false `
|
||||
-p:PublishSingleFile=false `
|
||||
-p:PublishTrimmed=false `
|
||||
-p:PublishReadyToRun=false `
|
||||
-p:DebugType=none `
|
||||
-p:DebugSymbols=false `
|
||||
-p:Version=${{ needs.prepare.outputs.version }} `
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish AirAppHost
|
||||
run: |
|
||||
$arch = "${{ matrix.arch }}"
|
||||
@@ -215,6 +238,7 @@ jobs:
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$publishDir = "publish/windows-$arch"
|
||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||
$runtimePublishDir = "publish/airapp-runtime-win-$arch"
|
||||
$appDir = "app-$version"
|
||||
$newStructure = "publish-launcher/windows-$arch"
|
||||
|
||||
@@ -226,10 +250,15 @@ jobs:
|
||||
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
||||
}
|
||||
|
||||
if (Test-Path $runtimePublishDir) {
|
||||
Copy-Item -Path "$runtimePublishDir\*" -Destination $newStructure -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||
|
||||
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $runtimePublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||
shell: pwsh
|
||||
|
||||
@@ -253,6 +282,7 @@ jobs:
|
||||
|
||||
$requiredFiles = @(
|
||||
(Join-Path $publishDir "LanMountainDesktop.Launcher.exe"),
|
||||
(Join-Path $publishDir "LanMountainDesktop.AirAppRuntime.exe"),
|
||||
(Join-Path $appDir "LanMountainDesktop.exe"),
|
||||
(Join-Path $appDir "LanMountainDesktop.AirAppHost.exe")
|
||||
)
|
||||
@@ -462,12 +492,32 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Publish AirAppRuntime
|
||||
run: |
|
||||
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
|
||||
-c Release \
|
||||
-o ./publish/airapp-runtime-linux-x64 \
|
||||
--self-contained false \
|
||||
-r linux-x64 \
|
||||
-p:SelfContained=false \
|
||||
-p:PublishAot=false \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:PublishTrimmed=false \
|
||||
-p:PublishReadyToRun=false \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Restructure for Launcher
|
||||
run: |
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
publishDir="publish/linux-x64"
|
||||
appDir="app-$version"
|
||||
launcherDir="publish/launcher-linux-x64"
|
||||
runtimeDir="publish/airapp-runtime-linux-x64"
|
||||
|
||||
mkdir -p "$publishDir"
|
||||
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||
@@ -477,8 +527,13 @@ jobs:
|
||||
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -d "$runtimeDir" ]; then
|
||||
cp -r "$runtimeDir"/* "$publishDir/"
|
||||
chmod +x "$publishDir/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
touch "$publishDir/$appDir/.current"
|
||||
rm -rf "$launcherDir"
|
||||
rm -rf "$launcherDir" "$runtimeDir"
|
||||
|
||||
- name: Package as DEB
|
||||
run: |
|
||||
@@ -651,6 +706,25 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Publish AirAppRuntime
|
||||
run: |
|
||||
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
|
||||
-c Release \
|
||||
-o ./publish/airapp-runtime-macos-${{ matrix.arch }} \
|
||||
--self-contained false \
|
||||
-r osx-${{ matrix.arch }} \
|
||||
-p:SelfContained=false \
|
||||
-p:PublishAot=false \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:PublishTrimmed=false \
|
||||
-p:PublishReadyToRun=false \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Optimize and Guard macOS Payload
|
||||
run: |
|
||||
arch="${{ matrix.arch }}"
|
||||
@@ -684,6 +758,7 @@ jobs:
|
||||
app_name="LanMountainDesktop"
|
||||
package_name="${app_name}-${version}-macos-${arch}"
|
||||
launcherDir="publish/launcher-macos-$arch"
|
||||
runtimeDir="publish/airapp-runtime-macos-$arch"
|
||||
appSourceDir="publish/macos-$arch-app"
|
||||
|
||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||
@@ -696,6 +771,11 @@ jobs:
|
||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -d "$runtimeDir" ]; then
|
||||
cp -r "$runtimeDir"/* "${app_name}.app/Contents/MacOS/"
|
||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||
mkdir -p "${app_name}.app/Contents/Resources"
|
||||
|
||||
|
||||
9
.trae/specs/air-app-runtime-container/checklist.md
Normal file
9
.trae/specs/air-app-runtime-container/checklist.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Checklist
|
||||
|
||||
- [x] `LanMountainDesktop.AirAppRuntime` is included in `LanMountainDesktop.slnx`.
|
||||
- [x] Launcher no longer hosts `IAirAppLifecycleService`.
|
||||
- [x] Host fallback starts `LanMountainDesktop.AirAppRuntime`, not `LanMountainDesktop.Launcher air-app-broker`.
|
||||
- [x] AirApp Runtime is explicitly non-AOT and framework-dependent.
|
||||
- [x] `dotnet build LanMountainDesktop.slnx -c Debug` passes.
|
||||
- [x] Related AirApp Runtime tests pass.
|
||||
- [x] `dotnet test LanMountainDesktop.slnx -c Debug` passes.
|
||||
21
.trae/specs/air-app-runtime-container/spec.md
Normal file
21
.trae/specs/air-app-runtime-container/spec.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# AirApp Runtime Container
|
||||
|
||||
## Goal
|
||||
|
||||
Move built-in Air APP lifecycle management out of Launcher into a dedicated framework-dependent JIT process named `LanMountainDesktop.AirAppRuntime`.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Launcher remains the user-facing entry point and pre-starts AirApp Runtime during normal `launch`.
|
||||
- AirApp Runtime exposes `IAirAppLifecycleService` and `IAirAppRuntimeControlService` on `LanMountainDesktop.AirAppRuntime.v1`.
|
||||
- Desktop host requests Air APP operations through AirApp Runtime IPC.
|
||||
- If the runtime pipe is unavailable, the desktop host starts `LanMountainDesktop.AirAppRuntime` directly and retries.
|
||||
- AirApp Runtime keeps one AirAppHost process per `{appId}:{sourceComponentId}:{sourcePlacementId}` key, with `world-clock` sharing `world-clock:clock-suite:global`.
|
||||
- AirApp Runtime remains alive while Launcher, Host, requester, or any AirAppHost process is alive.
|
||||
- AirApp Runtime exits after Launcher/Host/requester are gone and no Air APP windows remain.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Moving Air APP windows into the runtime process.
|
||||
- Third-party plugin-declared Air APP metadata.
|
||||
- Persisting the Air APP instance table across OS reboot.
|
||||
11
.trae/specs/air-app-runtime-container/tasks.md
Normal file
11
.trae/specs/air-app-runtime-container/tasks.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Add shared AirApp Runtime IPC/control contracts.
|
||||
- [x] Add shared AirApp Runtime path resolver and process starter.
|
||||
- [x] Add `LanMountainDesktop.AirAppRuntime` as a framework-dependent JIT process.
|
||||
- [x] Move Air APP lifecycle service out of Launcher.
|
||||
- [x] Make Launcher pre-start AirApp Runtime and attach Host PID after launch.
|
||||
- [x] Make Host fallback start AirApp Runtime instead of Launcher broker.
|
||||
- [x] Remove Launcher `air-app-broker` command handling.
|
||||
- [x] Update packaging scripts and release workflow to include AirApp Runtime.
|
||||
- [x] Update unit tests and architecture/package assertions.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Checklist
|
||||
|
||||
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
|
||||
|
||||
- [x] `LanMountainDesktop.Shared.IPC` builds in Debug.
|
||||
- [x] `LanMountainDesktop.Launcher` builds in Debug.
|
||||
- [x] `LanMountainDesktop` builds in Debug.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Launcher Managed Air APP Lifecycle
|
||||
|
||||
> Superseded by `.trae/specs/air-app-runtime-container/`. Launcher no longer hosts the Air APP lifecycle broker; it pre-starts `LanMountainDesktop.AirAppRuntime`, which owns the lifecycle IPC and AirAppHost process table.
|
||||
|
||||
## Goal
|
||||
|
||||
Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Tasks
|
||||
|
||||
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
|
||||
|
||||
- [x] Add shared Air APP lifecycle IPC contracts.
|
||||
- [x] Add Launcher Air APP lifecycle service and dedicated IPC host.
|
||||
- [x] Make Launcher remain alive while desktop or Air APP processes exist.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- [ ] New install shows OOBE once.
|
||||
- [ ] Same-user reinstall does not show OOBE again.
|
||||
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
||||
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
|
||||
- [ ] `plugin-install` does not auto-enter OOBE.
|
||||
- [ ] Default plugin install does not request UAC.
|
||||
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
||||
- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable).
|
||||
|
||||
@@ -23,12 +23,11 @@ Stabilize the launcher startup path so that:
|
||||
- `launchSource` values are treated as:
|
||||
- `normal`
|
||||
- `postinstall`
|
||||
- `apply-update`
|
||||
- `plugin-install`
|
||||
- `debug-preview`
|
||||
- Automatic OOBE is allowed only for normal user-mode startup.
|
||||
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
|
||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
||||
- `plugin-install` and `debug-preview` must not auto-enter OOBE.
|
||||
- Allowed elevation paths are limited to:
|
||||
- the installer itself
|
||||
- full installer update application
|
||||
|
||||
@@ -15,7 +15,7 @@ Make the Settings > Update page the single user-facing control surface for the h
|
||||
- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall.
|
||||
- The page displays whether the current payload is an incremental update or reinstall/full installer.
|
||||
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
|
||||
- Existing PloNDS/FileMap incremental update and Launcher rollback ownership remain unchanged.
|
||||
- Existing PloNDS/FileMap incremental update behavior remains, but update apply and rollback ownership belongs to the Host. Launcher only selects and starts the current app version.
|
||||
|
||||
## Acceptance
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration
|
||||
|
||||
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
|
||||
- Host owns update install and rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows. Launcher only selects and starts the current app version.
|
||||
|
||||
## Migration Note
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed class AirAppHostLocator
|
||||
{
|
||||
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
|
||||
private const string UnixExecutableName = "LanMountainDesktop.AirAppHost";
|
||||
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
|
||||
|
||||
private static string ExecutableName => OperatingSystem.IsWindows()
|
||||
? WindowsExecutableName
|
||||
: UnixExecutableName;
|
||||
|
||||
public string Resolve(string? packageRoot, string? hostPath = null)
|
||||
{
|
||||
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
|
||||
@@ -22,18 +27,18 @@ internal sealed class AirAppHostLocator
|
||||
{
|
||||
foreach (var root in EnumerateRoots(packageRoot, hostPath))
|
||||
{
|
||||
yield return Path.Combine(root, "AirAppHost", WindowsExecutableName);
|
||||
yield return Path.Combine(root, "AirAppHost", ExecutableName);
|
||||
yield return Path.Combine(root, "AirAppHost", DllName);
|
||||
yield return Path.Combine(root, WindowsExecutableName);
|
||||
yield return Path.Combine(root, ExecutableName);
|
||||
yield return Path.Combine(root, DllName);
|
||||
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", WindowsExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", ExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
|
||||
yield return Path.Combine(deploymentDirectory, WindowsExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, ExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, DllName);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +57,7 @@ internal sealed class AirAppHostLocator
|
||||
"Release",
|
||||
#endif
|
||||
"net10.0",
|
||||
WindowsExecutableName);
|
||||
ExecutableName);
|
||||
|
||||
yield return Path.Combine(
|
||||
current.FullName,
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal static class AirAppInstanceKey
|
||||
{
|
||||
@@ -17,8 +17,6 @@ internal static class AirAppInstanceKey
|
||||
|
||||
private static string Normalize(string? value, string fallback)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? fallback
|
||||
: value.Trim();
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@ using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
internal sealed class AirAppLifecycleService : IAirAppLifecycleService
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly IAirAppProcessStarter _processStarter;
|
||||
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter)
|
||||
public AirAppLifecycleService(IAirAppProcessStarter processStarter)
|
||||
{
|
||||
_processStarter = processStarter;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var appId = Normalize(request.AppId, "unknown");
|
||||
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
|
||||
Logger.Info(
|
||||
AirAppRuntimeLogger.Info(
|
||||
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
|
||||
|
||||
lock (_gate)
|
||||
@@ -57,12 +57,12 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
request.SourceComponentId,
|
||||
request.SourcePlacementId);
|
||||
_instances[instanceKey] = instance;
|
||||
Logger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
||||
AirAppRuntimeLogger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
||||
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
||||
AirAppRuntimeLogger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
||||
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
request.SourceComponentId,
|
||||
request.SourcePlacementId);
|
||||
_instances[instanceKey] = instance;
|
||||
Logger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
||||
AirAppRuntimeLogger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
||||
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
(processId <= 0 || instance.ProcessId == processId))
|
||||
{
|
||||
_instances.Remove(instanceKey);
|
||||
Logger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
||||
AirAppRuntimeLogger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
||||
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
foreach (var key in exitedKeys)
|
||||
{
|
||||
_instances.Remove(key);
|
||||
Logger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
||||
AirAppRuntimeLogger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsProcessAlive(int processId)
|
||||
internal static bool IsProcessAlive(int processId)
|
||||
{
|
||||
if (processId <= 0)
|
||||
{
|
||||
@@ -257,9 +257,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
|
||||
private static string Normalize(string? value, string fallback)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? fallback
|
||||
: value.Trim();
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
private const int SW_SHOWNORMAL = 1;
|
||||
@@ -0,0 +1,29 @@
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed class AirAppRuntimeControlService : IAirAppRuntimeControlService
|
||||
{
|
||||
private readonly AirAppRuntimeLifetime _lifetime;
|
||||
|
||||
public AirAppRuntimeControlService(AirAppRuntimeLifetime lifetime)
|
||||
{
|
||||
_lifetime = lifetime;
|
||||
}
|
||||
|
||||
public Task<AirAppRuntimeControlResult> AttachHostAsync(int hostProcessId)
|
||||
{
|
||||
_lifetime.AttachHost(hostProcessId);
|
||||
var status = _lifetime.GetStatus();
|
||||
return Task.FromResult(new AirAppRuntimeControlResult(
|
||||
hostProcessId > 0,
|
||||
hostProcessId > 0 ? "host_attached" : "invalid_host_pid",
|
||||
hostProcessId > 0 ? "AirApp runtime host process attached." : "Host process id must be positive.",
|
||||
status));
|
||||
}
|
||||
|
||||
public Task<AirAppRuntimeStatus> GetStatusAsync()
|
||||
{
|
||||
return Task.FromResult(_lifetime.GetStatus());
|
||||
}
|
||||
}
|
||||
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal file
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed class AirAppRuntimeIpcHost : IDisposable
|
||||
{
|
||||
private readonly PublicIpcHostService _host;
|
||||
|
||||
public AirAppRuntimeIpcHost(
|
||||
AirAppLifecycleService lifecycleService,
|
||||
AirAppRuntimeControlService controlService)
|
||||
{
|
||||
_host = new PublicIpcHostService(IpcConstants.AirAppRuntimePipeName);
|
||||
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
|
||||
_host.RegisterPublicService<IAirAppRuntimeControlService>(controlService);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_host.Start();
|
||||
AirAppRuntimeLogger.Info($"Air APP runtime IPC started. Pipe='{IpcConstants.AirAppRuntimePipeName}'.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_host.Dispose();
|
||||
}
|
||||
}
|
||||
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal file
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed class AirAppRuntimeLifetime
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow;
|
||||
private readonly AirAppLifecycleService _lifecycleService;
|
||||
private readonly int _launcherProcessId;
|
||||
private readonly int _requesterProcessId;
|
||||
private int _hostProcessId;
|
||||
private DateTimeOffset _updatedAtUtc;
|
||||
|
||||
public AirAppRuntimeLifetime(AirAppRuntimeOptions options, AirAppLifecycleService lifecycleService)
|
||||
{
|
||||
_lifecycleService = lifecycleService;
|
||||
_launcherProcessId = options.LauncherProcessId;
|
||||
_requesterProcessId = options.RequesterProcessId;
|
||||
_hostProcessId = options.RequesterProcessId;
|
||||
_updatedAtUtc = _startedAtUtc;
|
||||
}
|
||||
|
||||
public void AttachHost(int hostProcessId)
|
||||
{
|
||||
if (hostProcessId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_hostProcessId = hostProcessId;
|
||||
_updatedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
AirAppRuntimeLogger.Info($"Attached host process. HostPid={hostProcessId}.");
|
||||
}
|
||||
|
||||
public bool ShouldKeepAlive()
|
||||
{
|
||||
var status = GetStatus();
|
||||
return status.LauncherProcessAlive ||
|
||||
status.HostProcessAlive ||
|
||||
IsProcessAlive(_requesterProcessId) ||
|
||||
status.HasLiveAirApps;
|
||||
}
|
||||
|
||||
public AirAppRuntimeStatus GetStatus()
|
||||
{
|
||||
int hostPid;
|
||||
DateTimeOffset updatedAt;
|
||||
lock (_gate)
|
||||
{
|
||||
hostPid = _hostProcessId;
|
||||
updatedAt = _updatedAtUtc;
|
||||
}
|
||||
|
||||
var launcherAlive = IsProcessAlive(_launcherProcessId);
|
||||
var hostAlive = IsProcessAlive(hostPid);
|
||||
var hasLiveAirApps = _lifecycleService.HasLiveAirApps();
|
||||
return new AirAppRuntimeStatus(
|
||||
Environment.ProcessId,
|
||||
_launcherProcessId,
|
||||
hostPid,
|
||||
launcherAlive,
|
||||
hostAlive,
|
||||
hasLiveAirApps,
|
||||
_startedAtUtc,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
internal static bool IsProcessAlive(int processId)
|
||||
{
|
||||
return AirAppLifecycleService.IsProcessAlive(processId);
|
||||
}
|
||||
}
|
||||
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal file
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal static class AirAppRuntimeLogger
|
||||
{
|
||||
public static void Info(string message) => Trace.WriteLine($"[AirAppRuntime] INFO {message}");
|
||||
|
||||
public static void Warn(string message) => Trace.WriteLine($"[AirAppRuntime] WARN {message}");
|
||||
|
||||
public static void Warn(string message, Exception ex) =>
|
||||
Trace.WriteLine($"[AirAppRuntime] WARN {message} {ex}");
|
||||
|
||||
public static void Error(string message, Exception ex) =>
|
||||
Trace.WriteLine($"[AirAppRuntime] ERROR {message} {ex}");
|
||||
}
|
||||
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal file
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed record AirAppRuntimeOptions(
|
||||
string? AppRoot,
|
||||
string? DataRoot,
|
||||
int LauncherProcessId,
|
||||
int RequesterProcessId)
|
||||
{
|
||||
public static AirAppRuntimeOptions Parse(IReadOnlyList<string> args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = 0; index < args.Count; index++)
|
||||
{
|
||||
var current = args[index];
|
||||
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = current[2..];
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0)
|
||||
{
|
||||
values[key[..equalsIndex]] = key[(equalsIndex + 1)..];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
values[key] = args[++index];
|
||||
}
|
||||
else
|
||||
{
|
||||
values[key] = "true";
|
||||
}
|
||||
}
|
||||
|
||||
return new AirAppRuntimeOptions(
|
||||
GetOptionalPath(values, "app-root"),
|
||||
GetOptionalPath(values, "data-root"),
|
||||
GetInt(values, "launcher-pid"),
|
||||
GetInt(values, "requester-pid"));
|
||||
}
|
||||
|
||||
private static string? GetOptionalPath(IReadOnlyDictionary<string, string> values, string key)
|
||||
{
|
||||
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? Path.GetFullPath(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int GetInt(IReadOnlyDictionary<string, string> values, string key)
|
||||
{
|
||||
return values.TryGetValue(key, out var value) &&
|
||||
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal interface IAirAppProcessStarter
|
||||
{
|
||||
@@ -12,20 +14,17 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
private readonly Func<string?> _packageRootProvider;
|
||||
private readonly Func<string?> _hostPathProvider;
|
||||
private readonly Func<string?> _dataRootProvider;
|
||||
private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions;
|
||||
|
||||
public AirAppProcessStarter(
|
||||
AirAppHostLocator locator,
|
||||
Func<string?> packageRootProvider,
|
||||
Func<string?> hostPathProvider,
|
||||
Func<string?> dataRootProvider,
|
||||
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
||||
Func<string?> dataRootProvider)
|
||||
{
|
||||
_locator = locator;
|
||||
_packageRootProvider = packageRootProvider;
|
||||
_hostPathProvider = hostPathProvider;
|
||||
_dataRootProvider = dataRootProvider;
|
||||
_runtimeProbeOptions = runtimeProbeOptions;
|
||||
}
|
||||
|
||||
public Process? Start(
|
||||
@@ -36,12 +35,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
string? sourcePlacementId)
|
||||
{
|
||||
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
||||
var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions);
|
||||
var startInfo = CreateStartInfo(hostPath);
|
||||
|
||||
AddArgument(startInfo, "--app-id", appId);
|
||||
AddArgument(startInfo, "--session-id", sessionId);
|
||||
AddArgument(startInfo, "--instance-key", instanceKey);
|
||||
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName);
|
||||
AddArgument(startInfo, "--launcher-pipe", IpcConstants.AirAppRuntimePipeName);
|
||||
var dataRoot = _dataRootProvider();
|
||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||
{
|
||||
@@ -58,7 +57,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
|
||||
}
|
||||
|
||||
Logger.Info(
|
||||
AirAppRuntimeLogger.Info(
|
||||
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
|
||||
var process = Process.Start(startInfo);
|
||||
if (process is not null)
|
||||
@@ -68,12 +67,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.Info(
|
||||
AirAppRuntimeLogger.Info(
|
||||
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
||||
AirAppRuntimeLogger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -81,53 +80,10 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
return process;
|
||||
}
|
||||
|
||||
internal static ProcessStartInfo CreateStartInfo(
|
||||
string hostPath,
|
||||
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
||||
internal static ProcessStartInfo CreateStartInfo(string hostPath)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
if (string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(hostPath))
|
||||
{
|
||||
var executableRuntime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
||||
if (!executableRuntime.IsAvailable)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
||||
executableRuntime.Message);
|
||||
return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
|
||||
}
|
||||
}
|
||||
|
||||
startInfo.FileName = hostPath;
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
var runtime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
||||
if (!runtime.IsAvailable || string.IsNullOrWhiteSpace(runtime.DotNetHostPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
||||
runtime.Message);
|
||||
}
|
||||
|
||||
startInfo.FileName = runtime.DotNetHostPath;
|
||||
startInfo.ArgumentList.Add(hostPath);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
startInfo.FileName = "dotnet";
|
||||
startInfo.ArgumentList.Add(hostPath);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
|
||||
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
||||
{
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RollForward>LatestMajor</RollForward>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PublishAot>false</PublishAot>
|
||||
<SelfContained>false</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<PublishReadyToRun>false</PublishReadyToRun>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<ApplicationIcon>..\LanMountainDesktop\Assets\logo_nightly.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal file
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
var options = AirAppRuntimeOptions.Parse(args);
|
||||
AirAppRuntimeLogger.Info(
|
||||
$"Starting. AppRoot='{options.AppRoot ?? string.Empty}'; DataRoot='{options.DataRoot ?? string.Empty}'; " +
|
||||
$"LauncherPid={options.LauncherProcessId}; RequesterPid={options.RequesterProcessId}.");
|
||||
|
||||
try
|
||||
{
|
||||
var lifecycleService = new AirAppLifecycleService(
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => options.AppRoot,
|
||||
() => null,
|
||||
() => options.DataRoot));
|
||||
var lifetime = new AirAppRuntimeLifetime(options, lifecycleService);
|
||||
var controlService = new AirAppRuntimeControlService(lifetime);
|
||||
|
||||
using var ipcHost = new AirAppRuntimeIpcHost(lifecycleService, controlService);
|
||||
ipcHost.Start();
|
||||
|
||||
while (lifetime.ShouldKeepAlive())
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
AirAppRuntimeLogger.Info("Exiting because launcher, host, requester, and AirApp windows are gone.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AirAppRuntimeLogger.Error("Unhandled runtime failure.", ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||
@@ -1,29 +0,0 @@
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
|
||||
internal sealed class LauncherAirAppLifecycleIpcHost : IDisposable
|
||||
{
|
||||
private readonly PublicIpcHostService _host;
|
||||
|
||||
public LauncherAirAppLifecycleIpcHost(LauncherAirAppLifecycleService lifecycleService)
|
||||
{
|
||||
LifecycleService = lifecycleService;
|
||||
_host = new PublicIpcHostService(IpcConstants.AirAppLifecyclePipeName);
|
||||
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
|
||||
}
|
||||
|
||||
public LauncherAirAppLifecycleService LifecycleService { get; }
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_host.Start();
|
||||
Logger.Info($"Air APP lifecycle IPC started. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_host.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -60,13 +60,6 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.IsAirAppBrokerCommand)
|
||||
{
|
||||
_ = AirAppBrokerEntryHandler.RunAsync(desktop, context);
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.IsDebugMode && !context.IsPreviewCommand)
|
||||
{
|
||||
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");
|
||||
|
||||
@@ -11,15 +11,7 @@ namespace LanMountainDesktop.Launcher;
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true)]
|
||||
[JsonSerializable(typeof(SignedFileMap))]
|
||||
[JsonSerializable(typeof(UpdateFileEntry))]
|
||||
[JsonSerializable(typeof(PlondsUpdateMetadata))]
|
||||
[JsonSerializable(typeof(PlondsFileMap))]
|
||||
[JsonSerializable(typeof(PlondsComponentEntry))]
|
||||
[JsonSerializable(typeof(PlondsFileEntry))]
|
||||
[JsonSerializable(typeof(PlondsHashDescriptor))]
|
||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||
[JsonSerializable(typeof(InstallCheckpoint))]
|
||||
[JsonSerializable(typeof(AppVersionInfo))]
|
||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
|
||||
@@ -37,11 +29,11 @@ namespace LanMountainDesktop.Launcher;
|
||||
[JsonSerializable(typeof(StartupAttemptRecord))]
|
||||
[JsonSerializable(typeof(PrivacyConfig))]
|
||||
[JsonSerializable(typeof(PrivacyAgreementState))]
|
||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
|
||||
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
|
||||
[JsonSerializable(typeof(AirAppOpenRequest))]
|
||||
[JsonSerializable(typeof(AirAppRegistrationRequest))]
|
||||
[JsonSerializable(typeof(AirAppInstanceInfo))]
|
||||
[JsonSerializable(typeof(AirAppOperationResult))]
|
||||
[JsonSerializable(typeof(AirAppInstanceInfo[]))]
|
||||
[JsonSerializable(typeof(AirAppRuntimeControlResult))]
|
||||
[JsonSerializable(typeof(AirAppRuntimeStatus))]
|
||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -4,14 +4,11 @@ namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal sealed class CommandContext
|
||||
{
|
||||
public const string AirAppBrokerCommand = "air-app-broker";
|
||||
|
||||
private const string LaunchSourceOptionName = "launch-source";
|
||||
|
||||
private static readonly string[] GuiCommands =
|
||||
[
|
||||
"launch",
|
||||
AirAppBrokerCommand,
|
||||
"preview-splash",
|
||||
"preview-error",
|
||||
"preview-update",
|
||||
@@ -62,15 +59,11 @@ internal sealed class CommandContext
|
||||
public bool IsPreviewCommand =>
|
||||
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsAirAppBrokerCommand =>
|
||||
string.Equals(Command, AirAppBrokerCommand, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsGuiCommand =>
|
||||
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsMaintenanceCommand =>
|
||||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public string? ExplicitAppRoot => GetOption("app-root");
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
global using LanMountainDesktop.Launcher.AirApp;
|
||||
global using LanMountainDesktop.Launcher.Deployment;
|
||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||
global using LanMountainDesktop.Launcher.Ipc;
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
using System.Buffers;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Ipc;
|
||||
|
||||
internal interface IUpdateProgressReporter
|
||||
{
|
||||
void ReportProgress(InstallProgressReport report);
|
||||
void ReportComplete(InstallCompleteReport report);
|
||||
}
|
||||
|
||||
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
|
||||
{
|
||||
private const int LengthPrefixSize = 4;
|
||||
|
||||
private readonly string _pipeName;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private NamedPipeServerStream? _pipe;
|
||||
private Task? _listenTask;
|
||||
private volatile bool _clientConnected;
|
||||
|
||||
public LauncherUpdateProgressIpcServer(int launcherPid)
|
||||
{
|
||||
_pipeName = $"LanMountainDesktop_Update_{launcherPid}";
|
||||
}
|
||||
|
||||
public string PipeName => _pipeName;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_listenTask = Task.Run(AcceptConnectionAsync, _cts.Token);
|
||||
}
|
||||
|
||||
private async Task AcceptConnectionAsync()
|
||||
{
|
||||
while (!_cts.Token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_pipe = new NamedPipeServerStream(
|
||||
_pipeName,
|
||||
PipeDirection.Out,
|
||||
1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await _pipe.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
|
||||
_clientConnected = true;
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Update progress IPC listen error: {ex.Message}");
|
||||
try
|
||||
{
|
||||
await Task.Delay(200, _cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ReportProgress(InstallProgressReport report)
|
||||
{
|
||||
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallProgressReport));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to report progress via IPC: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void ReportComplete(InstallCompleteReport report)
|
||||
{
|
||||
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallCompleteReport));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to report completion via IPC: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteMessage(Stream stream, string json)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(json);
|
||||
var lengthPrefix = BitConverter.GetBytes(payload.Length);
|
||||
stream.Write(lengthPrefix, 0, LengthPrefixSize);
|
||||
stream.Write(payload, 0, payload.Length);
|
||||
stream.Flush();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try
|
||||
{
|
||||
_pipe?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_listenTask?.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
|
||||
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
|
||||
<PublicKeyDestDir>$(OutDir).Launcher\update</PublicKeyDestDir>
|
||||
</PropertyGroup>
|
||||
<MakeDir Directories="$(PublicKeyDestDir)" />
|
||||
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
|
||||
@@ -55,7 +55,7 @@
|
||||
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
|
||||
<PropertyGroup>
|
||||
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
|
||||
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
|
||||
<PublishedKeyDestDir>$(PublishDir).Launcher\update</PublishedKeyDestDir>
|
||||
</PropertyGroup>
|
||||
<MakeDir Directories="$(PublishedKeyDestDir)" />
|
||||
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 更新频道
|
||||
/// </summary>
|
||||
public enum UpdateChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// 正式版 - 只检查 prerelease=false 的版本
|
||||
/// </summary>
|
||||
Stable,
|
||||
|
||||
/// <summary>
|
||||
/// 预览版 - 检查所有版本(包括 prerelease=true)
|
||||
/// </summary>
|
||||
Preview
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 更新检查结果
|
||||
/// </summary>
|
||||
public sealed class UpdateCheckResult
|
||||
{
|
||||
public bool HasUpdate { get; init; }
|
||||
public string? LatestVersion { get; init; }
|
||||
public string? CurrentVersion { get; init; }
|
||||
public ReleaseInfo? Release { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
@@ -1,29 +1,5 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal sealed class SignedFileMap
|
||||
{
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? Platform { get; set; }
|
||||
|
||||
public string? Arch { get; set; }
|
||||
|
||||
public List<UpdateFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class UpdateFileEntry
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public string? ArchivePath { get; set; }
|
||||
|
||||
public string Action { get; set; } = "replace";
|
||||
|
||||
public string? Sha256 { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SnapshotMetadata
|
||||
{
|
||||
public string SnapshotId { get; set; } = string.Empty;
|
||||
@@ -40,124 +16,3 @@ internal sealed class SnapshotMetadata
|
||||
|
||||
public string Status { get; set; } = "pending";
|
||||
}
|
||||
|
||||
internal sealed class InstallCheckpoint
|
||||
{
|
||||
public string SnapshotId { get; set; } = string.Empty;
|
||||
|
||||
public string SourceVersion { get; set; } = string.Empty;
|
||||
|
||||
public string? TargetVersion { get; set; }
|
||||
|
||||
public string? SourceDirectory { get; set; }
|
||||
|
||||
public string TargetDirectory { get; set; } = string.Empty;
|
||||
|
||||
public bool IsInitialDeployment { get; set; }
|
||||
|
||||
public int AppliedCount { get; set; }
|
||||
|
||||
public int VerifiedCount { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class UpdateApplyResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
public string? FromVersion { get; init; }
|
||||
|
||||
public string? ToVersion { get; init; }
|
||||
|
||||
public string? RolledBackTo { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class PlondsUpdateMetadata
|
||||
{
|
||||
public string? DistributionId { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? SubChannel { get; set; }
|
||||
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? FileMapPath { get; set; }
|
||||
|
||||
public string? FileMapSignaturePath { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PlondsFileMap
|
||||
{
|
||||
public string? DistributionId { get; set; }
|
||||
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public string? Platform { get; set; }
|
||||
|
||||
public string? Arch { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
|
||||
public List<PlondsComponentEntry> Components { get; set; } = [];
|
||||
|
||||
public List<PlondsFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PlondsComponentEntry
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
|
||||
public List<PlondsFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PlondsFileEntry
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public string? Action { get; set; } = "replace";
|
||||
|
||||
public string? Url { get; set; }
|
||||
|
||||
public string? ObjectUrl { get; set; }
|
||||
|
||||
public string? ObjectPath { get; set; }
|
||||
|
||||
public string? ObjectKey { get; set; }
|
||||
|
||||
public string? ArchivePath { get; set; }
|
||||
|
||||
public string? Sha256 { get; set; }
|
||||
|
||||
public string? Sha512 { get; set; }
|
||||
|
||||
public string? Sha512Base64 { get; set; }
|
||||
|
||||
public byte[]? Sha512Bytes { get; set; }
|
||||
|
||||
public PlondsHashDescriptor? Hash { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PlondsHashDescriptor
|
||||
{
|
||||
public string? Algorithm { get; set; }
|
||||
|
||||
public string? Value { get; set; }
|
||||
|
||||
public byte[]? Bytes { get; set; }
|
||||
}
|
||||
|
||||
@@ -57,14 +57,6 @@
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Update Check)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "update check",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Plugin Install)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
|
||||
|
||||
83
LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs
Normal file
83
LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
internal sealed class AirAppRuntimeBridge
|
||||
{
|
||||
private const int ConnectAttempts = 8;
|
||||
|
||||
private readonly string _appRoot;
|
||||
private readonly string? _dataRoot;
|
||||
|
||||
public AirAppRuntimeBridge(string appRoot, string? dataRoot)
|
||||
{
|
||||
_appRoot = appRoot;
|
||||
_dataRoot = dataRoot;
|
||||
}
|
||||
|
||||
public async Task EnsureStartedAsync()
|
||||
{
|
||||
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
|
||||
{
|
||||
Logger.Info("AirApp Runtime is already available.");
|
||||
return;
|
||||
}
|
||||
|
||||
var process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest(
|
||||
_appRoot,
|
||||
Environment.ProcessId,
|
||||
0,
|
||||
_dataRoot));
|
||||
Logger.Info($"AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'.");
|
||||
|
||||
for (var attempt = 1; attempt <= ConnectAttempts; attempt++)
|
||||
{
|
||||
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
|
||||
{
|
||||
Logger.Info("AirApp Runtime IPC is ready.");
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Warn("AirApp Runtime did not become ready after pre-start; Host fallback remains available.");
|
||||
}
|
||||
|
||||
public async Task AttachHostAsync(int hostProcessId)
|
||||
{
|
||||
if (hostProcessId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||
var result = await proxy.AttachHostAsync(hostProcessId).ConfigureAwait(false);
|
||||
Logger.Info($"AirApp Runtime host attach completed. Accepted={result.Accepted}; Code='{result.Code}'; HostPid={hostProcessId}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to attach Host to AirApp Runtime: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<AirAppRuntimeStatus?> TryGetStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||
return await proxy.GetStatusAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
||||
@@ -30,52 +28,3 @@ internal static class LaunchEntryHandler
|
||||
SplashWindow splashWindow) =>
|
||||
LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
|
||||
}
|
||||
|
||||
internal static class AirAppBrokerEntryHandler
|
||||
{
|
||||
public static async Task RunAsync(IClassicDesktopStyleApplicationLifetime desktop, CommandContext context)
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var requesterPid = context.GetIntOption("requester-pid", 0);
|
||||
var dataLocationResolver = new DataLocationResolver(appRoot);
|
||||
Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}.");
|
||||
|
||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
||||
new LauncherAirAppLifecycleService(
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => appRoot,
|
||||
() => null,
|
||||
() => dataLocationResolver.ResolveDataRoot())));
|
||||
airAppIpcHost.Start();
|
||||
|
||||
while (ShouldKeepAlive(requesterPid, airAppIpcHost.LifecycleService))
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Info("Air APP broker exiting.");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
internal static bool ShouldKeepAirAppBrokerAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService)
|
||||
{
|
||||
if (requesterPid <= 0)
|
||||
{
|
||||
return lifecycleService.HasLiveAirApps();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = System.Diagnostics.Process.GetProcessById(requesterPid);
|
||||
return !process.HasExited || lifecycleService.HasLiveAirApps();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return lifecycleService.HasLiveAirApps();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldKeepAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService) =>
|
||||
ShouldKeepAirAppBrokerAlive(requesterPid, lifecycleService);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ internal static class LauncherGuiCoordinator
|
||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
|
||||
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
|
||||
await airAppRuntimeBridge.EnsureStartedAsync().ConfigureAwait(false);
|
||||
|
||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||
context.LaunchSource,
|
||||
successPolicy,
|
||||
@@ -44,15 +47,6 @@ internal static class LauncherGuiCoordinator
|
||||
return;
|
||||
}
|
||||
|
||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
||||
new LauncherAirAppLifecycleService(
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => appRoot,
|
||||
() => null,
|
||||
() => dataLocationResolver.ResolveDataRoot())));
|
||||
airAppIpcHost.Start();
|
||||
|
||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||
coordinatorPipeName,
|
||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
||||
@@ -129,7 +123,8 @@ internal static class LauncherGuiCoordinator
|
||||
if (result.Success)
|
||||
{
|
||||
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
||||
await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
|
||||
await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false);
|
||||
await WaitForHostProcessToExitAsync(hostPid).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
@@ -173,17 +168,15 @@ internal static class LauncherGuiCoordinator
|
||||
return fallbackHostPid;
|
||||
}
|
||||
|
||||
private static async Task WaitForManagedProcessesToExitAsync(
|
||||
int hostPid,
|
||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
||||
private static async Task WaitForHostProcessToExitAsync(int hostPid)
|
||||
{
|
||||
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
|
||||
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
|
||||
Logger.Info($"Launcher entering host background lifetime. HostPid={hostPid}.");
|
||||
while (TryGetLiveProcess(hostPid))
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
|
||||
Logger.Info("Launcher host background lifetime completed; host process is gone.");
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
[IpcPublic(IgnoresIpcException = true)]
|
||||
public interface IAirAppRuntimeControlService
|
||||
{
|
||||
Task<AirAppRuntimeControlResult> AttachHostAsync(int hostProcessId);
|
||||
|
||||
Task<AirAppRuntimeStatus> GetStatusAsync();
|
||||
}
|
||||
|
||||
public sealed record AirAppRuntimeControlResult(
|
||||
bool Accepted,
|
||||
string Code,
|
||||
string Message,
|
||||
AirAppRuntimeStatus Status);
|
||||
|
||||
public sealed record AirAppRuntimeStatus(
|
||||
int ProcessId,
|
||||
int LauncherProcessId,
|
||||
int HostProcessId,
|
||||
bool LauncherProcessAlive,
|
||||
bool HostProcessAlive,
|
||||
bool HasLiveAirApps,
|
||||
DateTimeOffset StartedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public static class AirAppRuntimeDataRootResolver
|
||||
{
|
||||
private const string LauncherDataFolderName = ".Launcher";
|
||||
private const string ConfigFileName = "data-location.config.json";
|
||||
private const string DesktopFolderName = "Desktop";
|
||||
|
||||
public static string ResolveDataRoot(string? appRoot)
|
||||
{
|
||||
var defaultSystemDataPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(appRoot))
|
||||
{
|
||||
return defaultSystemDataPath;
|
||||
}
|
||||
|
||||
var normalizedAppRoot = Path.GetFullPath(appRoot);
|
||||
var configPath = Path.Combine(normalizedAppRoot, LauncherDataFolderName, ConfigFileName);
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
return defaultSystemDataPath;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(configPath));
|
||||
var root = document.RootElement;
|
||||
var mode = GetString(root, "dataLocationMode");
|
||||
|
||||
if (string.Equals(mode, "Portable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Path.GetFullPath(
|
||||
GetString(root, "portableDataPath")
|
||||
?? Path.Combine(normalizedAppRoot, DesktopFolderName));
|
||||
}
|
||||
|
||||
return Path.GetFullPath(GetString(root, "systemDataPath") ?? defaultSystemDataPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return defaultSystemDataPath;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
|
||||
property.Value.ValueKind is JsonValueKind.String)
|
||||
{
|
||||
var value = property.Value.GetString();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
77
LanMountainDesktop.Shared.IPC/AirAppRuntimePathResolver.cs
Normal file
77
LanMountainDesktop.Shared.IPC/AirAppRuntimePathResolver.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public static class AirAppRuntimePathResolver
|
||||
{
|
||||
private const string WindowsExecutableName = "LanMountainDesktop.AirAppRuntime.exe";
|
||||
private const string UnixExecutableName = "LanMountainDesktop.AirAppRuntime";
|
||||
private const string DllName = "LanMountainDesktop.AirAppRuntime.dll";
|
||||
|
||||
private static string ExecutableName => OperatingSystem.IsWindows()
|
||||
? WindowsExecutableName
|
||||
: UnixExecutableName;
|
||||
|
||||
public static string? ResolveExecutablePath(string? appRoot = null, string? hostBaseDirectory = null)
|
||||
{
|
||||
return EnumerateCandidates(appRoot, hostBaseDirectory)
|
||||
.Select(Path.GetFullPath)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
public static IEnumerable<string> EnumerateCandidates(string? appRoot = null, string? hostBaseDirectory = null)
|
||||
{
|
||||
foreach (var root in EnumerateRoots(appRoot, hostBaseDirectory))
|
||||
{
|
||||
yield return Path.Combine(root, ExecutableName);
|
||||
yield return Path.Combine(root, DllName);
|
||||
yield return Path.Combine(root, "AirAppRuntime", ExecutableName);
|
||||
yield return Path.Combine(root, "AirAppRuntime", DllName);
|
||||
}
|
||||
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
for (var depth = 0; depth < 8 && current is not null; depth++, current = current.Parent)
|
||||
{
|
||||
yield return Path.Combine(
|
||||
current.FullName,
|
||||
"LanMountainDesktop.AirAppRuntime",
|
||||
"bin",
|
||||
#if DEBUG
|
||||
"Debug",
|
||||
#else
|
||||
"Release",
|
||||
#endif
|
||||
"net10.0",
|
||||
ExecutableName);
|
||||
|
||||
yield return Path.Combine(
|
||||
current.FullName,
|
||||
"LanMountainDesktop.AirAppRuntime",
|
||||
"bin",
|
||||
#if DEBUG
|
||||
"Debug",
|
||||
#else
|
||||
"Release",
|
||||
#endif
|
||||
"net10.0",
|
||||
DllName);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateRoots(string? appRoot, string? hostBaseDirectory)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(appRoot))
|
||||
{
|
||||
yield return Path.GetFullPath(appRoot);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(hostBaseDirectory))
|
||||
{
|
||||
var hostDirectory = Path.GetFullPath(hostBaseDirectory);
|
||||
yield return hostDirectory;
|
||||
yield return Path.GetFullPath(Path.Combine(hostDirectory, ".."));
|
||||
}
|
||||
|
||||
yield return AppContext.BaseDirectory;
|
||||
yield return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."));
|
||||
}
|
||||
}
|
||||
101
LanMountainDesktop.Shared.IPC/AirAppRuntimeProcessStarter.cs
Normal file
101
LanMountainDesktop.Shared.IPC/AirAppRuntimeProcessStarter.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed record AirAppRuntimeStartRequest(
|
||||
string? AppRoot,
|
||||
int LauncherProcessId,
|
||||
int RequesterProcessId,
|
||||
string? DataRoot);
|
||||
|
||||
public static class AirAppRuntimeProcessStarter
|
||||
{
|
||||
public static Process? Start(AirAppRuntimeStartRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var runtimePath = AirAppRuntimePathResolver.ResolveExecutablePath(
|
||||
request.AppRoot,
|
||||
AppContext.BaseDirectory);
|
||||
if (string.IsNullOrWhiteSpace(runtimePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var startInfo = CreateStartInfo(runtimePath);
|
||||
AddOptionalArgument(startInfo, "--app-root", request.AppRoot);
|
||||
AddOptionalArgument(startInfo, "--data-root", request.DataRoot);
|
||||
AddIntArgument(startInfo, "--launcher-pid", request.LauncherProcessId);
|
||||
AddIntArgument(startInfo, "--requester-pid", request.RequesterProcessId);
|
||||
return Process.Start(startInfo);
|
||||
}
|
||||
|
||||
public static ProcessStartInfo CreateStartInfo(string runtimePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runtimePath);
|
||||
var fullPath = Path.GetFullPath(runtimePath);
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(fullPath) ?? AppContext.BaseDirectory
|
||||
};
|
||||
|
||||
var extension = Path.GetExtension(fullPath);
|
||||
if (OperatingSystem.IsWindows() &&
|
||||
string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
startInfo.FileName = ResolveDotNetHostPath();
|
||||
startInfo.ArgumentList.Add(fullPath);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsWindows() &&
|
||||
string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
startInfo.FileName = "dotnet";
|
||||
startInfo.ArgumentList.Add(fullPath);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
startInfo.FileName = fullPath;
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static string ResolveDotNetHostPath()
|
||||
{
|
||||
var programFiles = Environment.GetEnvironmentVariable("ProgramW6432") ??
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
var programFilesCandidate = Path.Combine(programFiles, "dotnet", "dotnet.exe");
|
||||
if (File.Exists(programFilesCandidate))
|
||||
{
|
||||
return programFilesCandidate;
|
||||
}
|
||||
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var perUserCandidate = Path.Combine(localAppData, "dotnet", "dotnet.exe");
|
||||
return File.Exists(perUserCandidate) ? perUserCandidate : "dotnet";
|
||||
}
|
||||
|
||||
private static void AddOptionalArgument(ProcessStartInfo startInfo, string name, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(name);
|
||||
startInfo.ArgumentList.Add(Path.GetFullPath(value));
|
||||
}
|
||||
|
||||
private static void AddIntArgument(ProcessStartInfo startInfo, string name, int value)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(name);
|
||||
startInfo.ArgumentList.Add(value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ public static class IpcConstants
|
||||
|
||||
public const string ProtocolVersion = "external-ipc-public-api.v1";
|
||||
|
||||
public const string AirAppLifecyclePipeName = "LanMountainDesktop.Launcher.AirApp.v1";
|
||||
public const string AirAppRuntimePipeName = "LanMountainDesktop.AirAppRuntime.v1";
|
||||
|
||||
[Obsolete("Use AirAppRuntimePipeName. The lifecycle service is now hosted by LanMountainDesktop.AirAppRuntime.")]
|
||||
public const string AirAppLifecyclePipeName = AirAppRuntimePipeName;
|
||||
|
||||
public const string AirAppLifecycleProtocolVersion = "air-app-lifecycle.v1";
|
||||
|
||||
|
||||
@@ -96,17 +96,17 @@ public sealed class AirAppLauncherServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBrokerStartInfo_UsesAirAppBrokerCommandAndRequesterPid()
|
||||
public void CreateRuntimeStartInfo_UsesAirAppRuntimeAndRequesterPid()
|
||||
{
|
||||
var startInfo = AirAppLauncherService.CreateBrokerStartInfo(
|
||||
@"C:\Apps\LanMountainDesktop.Launcher.exe",
|
||||
var startInfo = AirAppLauncherService.CreateRuntimeStartInfo(
|
||||
@"C:\Apps\LanMountainDesktop.AirAppRuntime.exe",
|
||||
12345);
|
||||
|
||||
Assert.Equal(@"C:\Apps\LanMountainDesktop.Launcher.exe", startInfo.FileName);
|
||||
Assert.Equal(@"C:\Apps\LanMountainDesktop.AirAppRuntime.exe", startInfo.FileName);
|
||||
Assert.Equal(@"C:\Apps", startInfo.WorkingDirectory);
|
||||
Assert.False(startInfo.UseShellExecute);
|
||||
Assert.Equal(
|
||||
["air-app-broker", "--requester-pid", "12345"],
|
||||
["--requester-pid", "12345"],
|
||||
startInfo.ArgumentList);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using LanMountainDesktop.Launcher.AirApp;
|
||||
using LanMountainDesktop.Launcher.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
@@ -29,38 +27,14 @@ public sealed class AirAppProcessStarterRuntimeTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateStartInfo_UsesArchitectureMatchedDotnetHost_ForDllFallbackOnWindows()
|
||||
public void CreateStartInfo_UsesDotnetHost_ForDllFallback()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var programFiles = Path.Combine(_root, "ProgramFiles");
|
||||
var dotnetRoot = Path.Combine(programFiles, "dotnet");
|
||||
Directory.CreateDirectory(dotnetRoot);
|
||||
var dotnetHost = Path.Combine(dotnetRoot, "dotnet.exe");
|
||||
File.WriteAllText(dotnetHost, string.Empty);
|
||||
Directory.CreateDirectory(Path.Combine(
|
||||
dotnetRoot,
|
||||
"shared",
|
||||
DotNetRuntimeProbe.RequiredSharedFrameworkName,
|
||||
"10.0.5"));
|
||||
|
||||
var hostDll = Path.Combine(_root, "LanMountainDesktop.AirAppHost.dll");
|
||||
File.WriteAllText(hostDll, string.Empty);
|
||||
var options = new DotNetRuntimeProbeOptions
|
||||
{
|
||||
Architecture = DotNetRuntimeArchitecture.X64,
|
||||
ProgramFilesPath = programFiles,
|
||||
ProgramFilesX86Path = Path.Combine(_root, "ProgramFilesX86"),
|
||||
IncludeRegistry = false,
|
||||
IncludeDotNetCli = false
|
||||
};
|
||||
|
||||
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll, options);
|
||||
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll);
|
||||
|
||||
Assert.Equal(dotnetHost, startInfo.FileName);
|
||||
Assert.Contains("dotnet", Path.GetFileName(startInfo.FileName), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(hostDll, startInfo.ArgumentList.Single());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class AirAppRuntimeDataRootResolverTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.AirAppRuntimeDataRootResolverTests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public void ResolveDataRoot_UsesPortableDataLocationConfig()
|
||||
{
|
||||
var portableRoot = Path.Combine(_root, "PortableData");
|
||||
WriteConfig(new
|
||||
{
|
||||
dataLocationMode = "Portable",
|
||||
portableDataPath = portableRoot
|
||||
});
|
||||
|
||||
var resolved = AirAppRuntimeDataRootResolver.ResolveDataRoot(_root);
|
||||
|
||||
Assert.Equal(Path.GetFullPath(portableRoot), resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDataRoot_UsesSystemDataLocationConfig()
|
||||
{
|
||||
var systemRoot = Path.Combine(_root, "SystemData");
|
||||
WriteConfig(new
|
||||
{
|
||||
dataLocationMode = "System",
|
||||
systemDataPath = systemRoot
|
||||
});
|
||||
|
||||
var resolved = AirAppRuntimeDataRootResolver.ResolveDataRoot(_root);
|
||||
|
||||
Assert.Equal(Path.GetFullPath(systemRoot), resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDataRoot_FallsBackToDefaultWhenConfigMissing()
|
||||
{
|
||||
var resolved = AirAppRuntimeDataRootResolver.ResolveDataRoot(_root);
|
||||
|
||||
Assert.Equal(
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LanMountainDesktop"),
|
||||
resolved);
|
||||
}
|
||||
|
||||
private void WriteConfig<T>(T config)
|
||||
{
|
||||
var configDirectory = Path.Combine(_root, ".Launcher");
|
||||
Directory.CreateDirectory(configDirectory);
|
||||
File.WriteAllText(
|
||||
Path.Combine(configDirectory, "data-location.config.json"),
|
||||
JsonSerializer.Serialize(config));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,17 @@
|
||||
using System.Diagnostics;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.AirApp;
|
||||
using LanMountainDesktop.Launcher.Shell.EntryHandlers;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class LauncherAirAppLifecycleServiceTests
|
||||
public sealed class AirAppRuntimeLifecycleServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task OpenAsync_ReusesExistingInstanceForSameKey()
|
||||
{
|
||||
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
||||
var service = new LauncherAirAppLifecycleService(starter);
|
||||
var service = new AirAppLifecycleService(starter);
|
||||
var request = new AirAppOpenRequest(
|
||||
"whiteboard",
|
||||
BuiltInComponentIds.DesktopWhiteboard,
|
||||
@@ -36,7 +33,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
||||
public async Task OpenAsync_ReusesGlobalClockSuiteAcrossClockComponents()
|
||||
{
|
||||
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
||||
var service = new LauncherAirAppLifecycleService(starter);
|
||||
var service = new AirAppLifecycleService(starter);
|
||||
|
||||
var first = await service.OpenAsync(new AirAppOpenRequest(
|
||||
"world-clock",
|
||||
@@ -62,7 +59,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
||||
public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart()
|
||||
{
|
||||
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
|
||||
var service = new LauncherAirAppLifecycleService(starter);
|
||||
var service = new AirAppLifecycleService(starter);
|
||||
var instanceKey = AirAppInstanceKey.Build(
|
||||
"whiteboard",
|
||||
BuiltInComponentIds.DesktopWhiteboard,
|
||||
@@ -92,7 +89,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
||||
[Fact]
|
||||
public async Task HasLiveAirApps_ReturnsFalseAfterUnregisteringLastInstance()
|
||||
{
|
||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
|
||||
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
|
||||
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-1");
|
||||
|
||||
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
|
||||
@@ -112,26 +109,35 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirAppBrokerLifetime_KeepsAliveWhileRequesterIsAlive()
|
||||
public void RuntimeLifetime_KeepsAliveWhileRequesterIsAlive()
|
||||
{
|
||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var lifetime = new AirAppRuntimeLifetime(
|
||||
new AirAppRuntimeOptions(null, null, 0, Environment.ProcessId),
|
||||
service);
|
||||
|
||||
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service));
|
||||
Assert.True(lifetime.ShouldKeepAlive());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirAppBrokerLifetime_StopsWhenRequesterExitedAndNoAirAppsRemain()
|
||||
public void RuntimeLifetime_StopsWhenNoProcessOrAirAppsRemain()
|
||||
{
|
||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var lifetime = new AirAppRuntimeLifetime(
|
||||
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
|
||||
service);
|
||||
|
||||
Assert.False(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
|
||||
Assert.False(lifetime.ShouldKeepAlive());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AirAppBrokerLifetime_KeepsAliveWhileAirAppIsAlive()
|
||||
public async Task RuntimeLifetime_KeepsAliveWhileAirAppIsAlive()
|
||||
{
|
||||
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-2");
|
||||
var lifetime = new AirAppRuntimeLifetime(
|
||||
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
|
||||
service);
|
||||
|
||||
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
|
||||
instanceKey,
|
||||
@@ -142,28 +148,23 @@ public sealed class LauncherAirAppLifecycleServiceTests
|
||||
BuiltInComponentIds.DesktopWorldClock,
|
||||
"clock-2"));
|
||||
|
||||
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
|
||||
Assert.True(lifetime.ShouldKeepAlive());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandContext_RecognizesAirAppBrokerAsGuiCommandInDebugEnvironment()
|
||||
public async Task RuntimeControl_AttachesHostProcess()
|
||||
{
|
||||
var oldEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development");
|
||||
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
|
||||
var lifetime = new AirAppRuntimeLifetime(
|
||||
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
|
||||
service);
|
||||
var control = new AirAppRuntimeControlService(lifetime);
|
||||
|
||||
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
|
||||
var result = await control.AttachHostAsync(Environment.ProcessId);
|
||||
|
||||
Assert.True(context.IsGuiCommand);
|
||||
Assert.True(context.IsAirAppBrokerCommand);
|
||||
Assert.True(context.IsDebugMode);
|
||||
Assert.Equal(42, context.GetIntOption("requester-pid", 0));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", oldEnvironment);
|
||||
}
|
||||
Assert.True(result.Accepted);
|
||||
Assert.Equal(Environment.ProcessId, result.Status.HostProcessId);
|
||||
Assert.True(result.Status.HostProcessAlive);
|
||||
}
|
||||
|
||||
private sealed class TestAirAppProcessStarter : IAirAppProcessStarter
|
||||
@@ -9,7 +9,6 @@ public sealed class CommandContextTests
|
||||
{
|
||||
{ [], "normal" },
|
||||
{ ["preview-oobe"], "debug-preview" },
|
||||
{ ["apply-update"], "normal" },
|
||||
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
|
||||
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
|
||||
};
|
||||
@@ -22,4 +21,12 @@ public sealed class CommandContextTests
|
||||
|
||||
Assert.Equal(expectedLaunchSource, context.LaunchSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromArgs_DoesNotTreatAirAppBrokerAsLauncherGuiCommand()
|
||||
{
|
||||
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
|
||||
|
||||
Assert.False(context.IsGuiCommand);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
global using LanMountainDesktop.Launcher.AirApp;
|
||||
global using LanMountainDesktop.AirAppRuntime;
|
||||
global using LanMountainDesktop.Launcher.Deployment;
|
||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||
global using LanMountainDesktop.Launcher.Ipc;
|
||||
|
||||
@@ -11,7 +11,6 @@ public sealed class HostActivationPolicyTests
|
||||
[Theory]
|
||||
[InlineData("launch", "normal", true)]
|
||||
[InlineData("launch", "restart", false)]
|
||||
[InlineData("apply-update", "normal", false)]
|
||||
public void ShouldProbeExistingHostBeforeLaunch_RespectsLaunchSource(
|
||||
string command,
|
||||
string launchSource,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.AirAppRuntime\LanMountainDesktop.AirAppRuntime.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -47,6 +47,95 @@ public sealed class LauncherArchitectureTests
|
||||
Assert.Empty(offenders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherProject_DoesNotOwnUpdateApplyOrRollback()
|
||||
{
|
||||
var launcherFiles = Directory
|
||||
.EnumerateFiles(LauncherProjectRoot, "*.cs", SearchOption.AllDirectories)
|
||||
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
var forbiddenTokens = new[]
|
||||
{
|
||||
"LauncherUpdateCommandExecutor",
|
||||
"PlondsUpdateApplier",
|
||||
"UpdateRollbackGateway",
|
||||
"UpdateInstallGateway",
|
||||
"LanMountainDesktop.Services.Update",
|
||||
"apply-update",
|
||||
"rollback --app-root"
|
||||
};
|
||||
|
||||
var offenders = launcherFiles
|
||||
.SelectMany(file => forbiddenTokens
|
||||
.Where(token => File.ReadAllText(file).Contains(token, StringComparison.Ordinal))
|
||||
.Select(token => $"{RelativeToRepo(file)} contains {token}"))
|
||||
.ToArray();
|
||||
|
||||
Assert.Empty(offenders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherProjectFile_DoesNotSourceLinkHostUpdateImplementation()
|
||||
{
|
||||
var project = File.ReadAllText(Path.Combine(LauncherProjectRoot, "LanMountainDesktop.Launcher.csproj"));
|
||||
|
||||
Assert.DoesNotContain(@"..\LanMountainDesktop\Services\Update", project, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("PlondsUpdateApplier", project, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("UpdateRollbackGateway", project, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("UpdateInstallGateway", project, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostUpdateFlow_DoesNotDelegateApplyOrRollbackToLauncher()
|
||||
{
|
||||
var guardedFiles = new[]
|
||||
{
|
||||
Path.Combine(RepoRoot, "LanMountainDesktop", "Services", "Update", "UpdateInstallGateway.cs"),
|
||||
Path.Combine(RepoRoot, "LanMountainDesktop", "Services", "Update", "UpdateOrchestrator.cs")
|
||||
};
|
||||
|
||||
var forbiddenTokens = new[]
|
||||
{
|
||||
"LauncherPathResolver",
|
||||
"ResolveLauncherExecutablePath",
|
||||
"apply-update",
|
||||
"rollback --app-root",
|
||||
"Launched Launcher"
|
||||
};
|
||||
|
||||
var offenders = guardedFiles
|
||||
.SelectMany(file => forbiddenTokens
|
||||
.Where(token => File.ReadAllText(file).Contains(token, StringComparison.Ordinal))
|
||||
.Select(token => $"{RelativeToRepo(file)} contains {token}"))
|
||||
.ToArray();
|
||||
|
||||
Assert.Empty(offenders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostUpdateFlow_OwnsDeltaApplyAndRollbackExecution()
|
||||
{
|
||||
var installGateway = File.ReadAllText(Path.Combine(
|
||||
RepoRoot,
|
||||
"LanMountainDesktop",
|
||||
"Services",
|
||||
"Update",
|
||||
"UpdateInstallGateway.cs"));
|
||||
var orchestrator = File.ReadAllText(Path.Combine(
|
||||
RepoRoot,
|
||||
"LanMountainDesktop",
|
||||
"Services",
|
||||
"Update",
|
||||
"UpdateOrchestrator.cs"));
|
||||
|
||||
Assert.Contains("new PlondsUpdateApplier", installGateway, StringComparison.Ordinal);
|
||||
Assert.Contains("DeploymentLockService.ClearLock", installGateway, StringComparison.Ordinal);
|
||||
Assert.Contains("new UpdateRollbackGateway().RollbackLatest", orchestrator, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("LanMountainDesktop.Launcher", orchestrator, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherCompositionRootStaysThin()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
global using LanMountainDesktop.Launcher.AirApp;
|
||||
global using LanMountainDesktop.AirAppRuntime;
|
||||
global using LanMountainDesktop.Launcher.Deployment;
|
||||
global using LanMountainDesktop.Launcher.Infrastructure;
|
||||
global using LanMountainDesktop.Launcher.Ipc;
|
||||
|
||||
59
LanMountainDesktop.Tests/LauncherUpdateCommandTests.cs
Normal file
59
LanMountainDesktop.Tests/LauncherUpdateCommandTests.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.Infrastructure;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class LauncherUpdateCommandTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.LauncherUpdateCommandTests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyUpdateCommand_IsNotHandledByLauncherCli()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
var resultPath = Path.Combine(_root, "result.json");
|
||||
var context = CommandContext.FromArgs(["apply-update", "--app-root", _root, "--result", resultPath]);
|
||||
|
||||
var exitCode = await Commands.RunCliCommandAsync(context);
|
||||
var result = ReadResult(resultPath);
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
Assert.Equal("command", result.Stage);
|
||||
Assert.Equal("unsupported_command", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RollbackCommand_IsNotHandledByLauncherCli()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
var resultPath = Path.Combine(_root, "result.json");
|
||||
var context = CommandContext.FromArgs(["rollback", "--app-root", _root, "--result", resultPath]);
|
||||
|
||||
var exitCode = await Commands.RunCliCommandAsync(context);
|
||||
var result = ReadResult(resultPath);
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
Assert.Equal("command", result.Stage);
|
||||
Assert.Equal("unsupported_command", result.Code);
|
||||
}
|
||||
|
||||
private static LauncherResult ReadResult(string path)
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<LauncherResult>(File.ReadAllText(path));
|
||||
return result ?? throw new InvalidOperationException("Launcher result was not written.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,12 @@ public sealed class PackagingRuntimePolicyTests
|
||||
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "package.ps1");
|
||||
|
||||
Assert.Contains("Publish-LauncherPayload", script);
|
||||
Assert.Contains("Publish-AirAppRuntimePayload", script);
|
||||
Assert.Contains("\"app-$Version\"", script);
|
||||
Assert.Contains("Publish-MainAppFrameworkDependentPayload", script);
|
||||
Assert.Contains("\"--self-contained\", \"false\"", script);
|
||||
Assert.Contains("\"-p:SelfContained=false\"", script);
|
||||
Assert.Contains("\"-p:PublishAot=false\"", script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -28,12 +30,13 @@ public sealed class PackagingRuntimePolicyTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsPayloadGuard_RequiresLauncherMainAndAirAppHost()
|
||||
public void WindowsPayloadGuard_RequiresLauncherRuntimeMainAndAirAppHost()
|
||||
{
|
||||
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1");
|
||||
|
||||
Assert.Contains("Assert-WindowsPayloadContainsRequiredHosts", script);
|
||||
Assert.Contains("LanMountainDesktop.Launcher.exe", script);
|
||||
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", script);
|
||||
Assert.Contains("LanMountainDesktop.exe", script);
|
||||
Assert.Contains("LanMountainDesktop.AirAppHost.exe", script);
|
||||
}
|
||||
@@ -44,9 +47,21 @@ public sealed class PackagingRuntimePolicyTests
|
||||
var workflow = ReadRepositoryFile(".github", "workflows", "release.yml");
|
||||
|
||||
Assert.Contains("Verify Windows app host payload", workflow);
|
||||
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", workflow);
|
||||
Assert.Contains("LanMountainDesktop.AirAppHost.exe", workflow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirAppRuntimeProject_IsFrameworkDependentJit()
|
||||
{
|
||||
var project = ReadRepositoryFile("LanMountainDesktop.AirAppRuntime", "LanMountainDesktop.AirAppRuntime.csproj");
|
||||
|
||||
Assert.Contains("<PublishAot>false</PublishAot>", project);
|
||||
Assert.Contains("<SelfContained>false</SelfContained>", project);
|
||||
Assert.Contains("<PublishTrimmed>false</PublishTrimmed>", project);
|
||||
Assert.Contains("<PublishReadyToRun>false</PublishReadyToRun>", project);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Installer_DownloadsArchitectureSpecificDesktopRuntime()
|
||||
{
|
||||
|
||||
@@ -130,7 +130,7 @@ public sealed class WindowLayerIsolationTests
|
||||
{
|
||||
var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs");
|
||||
var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.cs");
|
||||
var starterSource = ReadRepositoryFile("LanMountainDesktop.Launcher", "AirApp", "IAirAppProcessStarter.cs");
|
||||
var starterSource = ReadRepositoryFile("LanMountainDesktop.AirAppRuntime", "IAirAppProcessStarter.cs");
|
||||
var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs");
|
||||
|
||||
Assert.Contains("DataRoot", optionsSource);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginPackaging/LanMountainDesktop.PluginPackaging.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||
<Project Path="LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj" />
|
||||
<Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
||||
|
||||
@@ -22,7 +22,7 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
||||
public const string WorldClockAppId = "world-clock";
|
||||
public const string WhiteboardAppId = "whiteboard";
|
||||
|
||||
private const int LauncherIpcRetryCount = 4;
|
||||
private const int RuntimeIpcRetryCount = 4;
|
||||
|
||||
public void OpenWorldClock(string? sourcePlacementId)
|
||||
{
|
||||
@@ -82,27 +82,27 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
||||
var result = await SendOpenRequestAsync(request).ConfigureAwait(false);
|
||||
if (result.Accepted)
|
||||
{
|
||||
AppLogger.Info("AirAppLauncher", $"Launcher accepted Air APP request. AppId='{appId}'; Code='{result.Code}'.");
|
||||
AppLogger.Info("AirAppLauncher", $"AirApp Runtime accepted Air APP request. AppId='{appId}'; Code='{result.Code}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Warn("AirAppLauncher", $"Launcher rejected Air APP request. AppId='{appId}'; Code='{result.Code}'; Message='{result.Message}'.");
|
||||
AppLogger.Warn("AirAppLauncher", $"AirApp Runtime rejected Air APP request. AppId='{appId}'; Code='{result.Code}'; Message='{result.Message}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("AirAppLauncher", $"Failed to open Air APP through Launcher. AppId='{appId}'.", ex);
|
||||
AppLogger.Warn("AirAppLauncher", $"Failed to open Air APP through AirApp Runtime. AppId='{appId}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<AirAppOperationResult> SendOpenRequestAsync(AirAppOpenRequest request)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
for (var attempt = 1; attempt <= LauncherIpcRetryCount; attempt++)
|
||||
for (var attempt = 1; attempt <= RuntimeIpcRetryCount; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(IpcConstants.AirAppLifecyclePipeName).ConfigureAwait(false);
|
||||
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||
var proxy = client.CreateProxy<IAirAppLifecycleService>();
|
||||
return await proxy.OpenAsync(request).ConfigureAwait(false);
|
||||
}
|
||||
@@ -113,9 +113,9 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"AirAppLauncher",
|
||||
$"Air APP lifecycle IPC unavailable on first attempt. Pipe='{IpcConstants.AirAppLifecyclePipeName}'. Starting Launcher broker.",
|
||||
$"Air APP lifecycle IPC unavailable on first attempt. Pipe='{IpcConstants.AirAppRuntimePipeName}'. Starting AirApp Runtime.",
|
||||
ex);
|
||||
TryStartLauncher();
|
||||
TryStartRuntime();
|
||||
}
|
||||
|
||||
await Task.Delay(250 * attempt).ConfigureAwait(false);
|
||||
@@ -123,44 +123,52 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Launcher Air APP IPC is unavailable. Pipe='{IpcConstants.AirAppLifecyclePipeName}'.",
|
||||
$"AirApp Runtime IPC is unavailable. Pipe='{IpcConstants.AirAppRuntimePipeName}'.",
|
||||
lastException);
|
||||
}
|
||||
|
||||
internal static ProcessStartInfo CreateBrokerStartInfo(string launcherPath, int requesterProcessId)
|
||||
internal static ProcessStartInfo CreateRuntimeStartInfo(string runtimePath, int requesterProcessId, string? appRoot = null, string? dataRoot = null)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
var startInfo = AirAppRuntimeProcessStarter.CreateStartInfo(runtimePath);
|
||||
if (!string.IsNullOrWhiteSpace(appRoot))
|
||||
{
|
||||
FileName = launcherPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
||||
UseShellExecute = false
|
||||
};
|
||||
startInfo.ArgumentList.Add("air-app-broker");
|
||||
startInfo.ArgumentList.Add("--app-root");
|
||||
startInfo.ArgumentList.Add(Path.GetFullPath(appRoot));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||
{
|
||||
startInfo.ArgumentList.Add("--data-root");
|
||||
startInfo.ArgumentList.Add(Path.GetFullPath(dataRoot));
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add("--requester-pid");
|
||||
startInfo.ArgumentList.Add(requesterProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static void TryStartLauncher()
|
||||
private static void TryStartRuntime()
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
var appRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."));
|
||||
var runtimePath = AirAppRuntimePathResolver.ResolveExecutablePath(appRoot, AppContext.BaseDirectory);
|
||||
if (string.IsNullOrWhiteSpace(runtimePath) || !File.Exists(runtimePath))
|
||||
{
|
||||
AppLogger.Warn("AirAppLauncher", "Unable to start Launcher for Air APP request: launcher path was not found.");
|
||||
AppLogger.Warn("AirAppLauncher", "Unable to start AirApp Runtime for Air APP request: runtime path was not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
var startInfo = CreateBrokerStartInfo(launcherPath, Environment.ProcessId);
|
||||
var dataRoot = AirAppRuntimeDataRootResolver.ResolveDataRoot(appRoot);
|
||||
var startInfo = CreateRuntimeStartInfo(runtimePath, Environment.ProcessId, appRoot, dataRoot);
|
||||
_ = Process.Start(startInfo);
|
||||
AppLogger.Info(
|
||||
"AirAppLauncher",
|
||||
$"Started Launcher Air APP broker. Path='{launcherPath}'; Pipe='{IpcConstants.AirAppLifecyclePipeName}'.");
|
||||
$"Started AirApp Runtime. Path='{runtimePath}'; Pipe='{IpcConstants.AirAppRuntimePipeName}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("AirAppLauncher", "Failed to start Launcher for Air APP request.", ex);
|
||||
AppLogger.Warn("AirAppLauncher", "Failed to start AirApp Runtime for Air APP request.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 统一解析 Launcher 可执行文件路径的工具类。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 安装后的目录结构:
|
||||
/// <code>
|
||||
/// {AppRoot}/ ← 应用安装根目录
|
||||
/// LanMountainDesktop.Launcher.exe ← Launcher 可执行文件
|
||||
/// .Launcher/ ← Launcher 数据目录(日志、状态、配置等)
|
||||
/// app-{version}/ ← Host 部署目录
|
||||
/// LanMountainDesktop.exe
|
||||
/// ...
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
internal static class LauncherPathResolver
|
||||
{
|
||||
private const string WindowsLauncherExeName = "LanMountainDesktop.Launcher.exe";
|
||||
private const string UnixLauncherExeName = "LanMountainDesktop.Launcher";
|
||||
|
||||
private static string LauncherExecutableName =>
|
||||
OperatingSystem.IsWindows() ? WindowsLauncherExeName : UnixLauncherExeName;
|
||||
|
||||
/// <summary>
|
||||
/// 解析 Launcher 可执行文件的完整路径。如果找不到则返回 null。
|
||||
/// </summary>
|
||||
public static string? ResolveLauncherExecutablePath()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
|
||||
var candidates = new[]
|
||||
{
|
||||
// 1. 发布版(安装版):Host 在 app-* 子目录中,Launcher 在父目录(应用根目录)
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", LauncherExecutableName)),
|
||||
|
||||
// 2. 便携版 / 单文件发布:Launcher 与 Host 在同一目录
|
||||
Path.Combine(baseDirectory, LauncherExecutableName),
|
||||
|
||||
// 3. 开发环境:Launcher 项目输出目录与 Host 项目输出目录同级
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName)),
|
||||
};
|
||||
|
||||
return candidates
|
||||
.Select(Path.GetFullPath)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 Launcher 数据目录(.Launcher)的路径。
|
||||
/// 该目录与 app-* 文件夹同级,位于应用安装根目录下。
|
||||
/// </summary>
|
||||
public static string ResolveLauncherDataDirectory()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
|
||||
// 优先尝试应用安装根目录(Host 的父目录)
|
||||
var appRootCandidate = Path.GetFullPath(Path.Combine(baseDirectory, ".."));
|
||||
var launcherDataDir = Path.Combine(appRootCandidate, ".Launcher");
|
||||
|
||||
if (Directory.Exists(launcherDataDir) || CanWriteToDirectory(appRootCandidate))
|
||||
{
|
||||
return launcherDataDir;
|
||||
}
|
||||
|
||||
// 回退到 Host 所在目录(便携模式或开发环境)
|
||||
return Path.Combine(baseDirectory, ".Launcher");
|
||||
}
|
||||
|
||||
private static bool CanWriteToDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var testFile = Path.Combine(path, $".write-test-{Guid.NewGuid():N}.tmp");
|
||||
File.WriteAllText(testFile, string.Empty);
|
||||
File.Delete(testFile);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
@@ -39,15 +38,19 @@ internal sealed class UpdateInstallGateway
|
||||
|
||||
if (payloadKind is UpdatePayloadKind.DeltaPlonds)
|
||||
{
|
||||
var launched = LaunchLauncherForApplyUpdate(launcherRoot);
|
||||
if (!launched)
|
||||
var applyResult = await ApplyDeltaPayloadAsync(launcherRoot, progress, ct).ConfigureAwait(false);
|
||||
if (!applyResult.Success)
|
||||
{
|
||||
return new InstallResult(false, "Failed to launch Launcher for delta update application.", false, "apply_failed");
|
||||
return new InstallResult(
|
||||
false,
|
||||
applyResult.ErrorMessage ?? applyResult.Message,
|
||||
false,
|
||||
applyResult.Code);
|
||||
}
|
||||
|
||||
progress?.Report(new InstallProgressReport(
|
||||
InstallStage.ActivateDeployment,
|
||||
"Launcher launched for apply-update.",
|
||||
"Delta update applied by Host.",
|
||||
100,
|
||||
null,
|
||||
0,
|
||||
@@ -116,7 +119,9 @@ internal sealed class UpdateInstallGateway
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deploymentLock.PayloadPath) || !File.Exists(deploymentLock.PayloadPath))
|
||||
if (string.IsNullOrWhiteSpace(deploymentLock.PayloadPath) ||
|
||||
!File.Exists(deploymentLock.PayloadPath) &&
|
||||
!Directory.Exists(deploymentLock.PayloadPath))
|
||||
{
|
||||
errorCode = "staging_incomplete";
|
||||
error = "Deployment lock payload path is missing. Please redownload the update.";
|
||||
@@ -126,35 +131,82 @@ internal sealed class UpdateInstallGateway
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool LaunchLauncherForApplyUpdate(string launcherRoot)
|
||||
private static async Task<ApplyUpdateResult> ApplyDeltaPayloadAsync(
|
||||
string launcherRoot,
|
||||
IProgress<InstallProgressReport>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
using var applyLock = TryAcquireApplyLock(launcherRoot);
|
||||
if (applyLock is null)
|
||||
{
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
{
|
||||
AppLogger.Warn("UpdateInstallGateway", "Launcher executable not found. Falling back to next-startup apply.");
|
||||
return false;
|
||||
return ApplyUpdateResults.Failed(
|
||||
"update.apply",
|
||||
"apply_in_progress",
|
||||
"Another update apply operation is already running.");
|
||||
}
|
||||
|
||||
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
try
|
||||
{
|
||||
FileName = launcherPath,
|
||||
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source apply-update",
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = resolvedLauncherRoot
|
||||
};
|
||||
var paths = new PlondsApplyPaths(launcherRoot);
|
||||
var locator = new AppDeploymentLocator(launcherRoot);
|
||||
var applier = new PlondsUpdateApplier(
|
||||
locator,
|
||||
paths,
|
||||
new UpdateSignatureVerifier(paths),
|
||||
new InstallProgressBridge(progress),
|
||||
new UpdateSnapshotStore(paths),
|
||||
new ApplyInstallCheckpointStore(paths),
|
||||
new DeploymentActivator(locator),
|
||||
new IncomingArtifactsCleaner(paths),
|
||||
new PlondsPayloadResolver(paths));
|
||||
|
||||
Process.Start(startInfo);
|
||||
AppLogger.Info("UpdateInstallGateway", $"Launched Launcher for apply-update: {launcherPath}");
|
||||
return true;
|
||||
var result = await applier.ApplyAsync().WaitAsync(ct).ConfigureAwait(false);
|
||||
if (result.Success)
|
||||
{
|
||||
DeploymentLockService.ClearLock(launcherRoot);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateInstallGateway", $"Failed to launch Launcher for apply-update: {ex.Message}");
|
||||
return false;
|
||||
AppLogger.Warn("UpdateInstallGateway", $"Host delta apply failed: {ex.Message}");
|
||||
return ApplyUpdateResults.Failed("update.apply", "apply_exception", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
internal static ApplyLockHandle? TryAcquireApplyLock(string launcherRoot)
|
||||
{
|
||||
var lockPath = UpdatePaths.GetApplyInProgressLockPath(Path.GetFullPath(launcherRoot));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!);
|
||||
try
|
||||
{
|
||||
return new ApplyLockHandle(
|
||||
lockPath,
|
||||
new FileStream(lockPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None));
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ApplyLockHandle(string path, FileStream stream) : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
stream.Dispose();
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -418,23 +418,26 @@ public sealed class UpdateOrchestrator : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (!string.IsNullOrWhiteSpace(launcherPath) && File.Exists(launcherPath))
|
||||
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||
using var applyLock = UpdateInstallGateway.TryAcquireApplyLock(launcherRoot);
|
||||
if (applyLock is null)
|
||||
{
|
||||
var launcherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
Arguments = $"rollback --app-root \"{launcherRoot}\"",
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = launcherRoot
|
||||
};
|
||||
|
||||
System.Diagnostics.Process.Start(startInfo);
|
||||
AppLogger.Info("UpdateOrchestrator", "Launched Launcher for rollback.");
|
||||
AppLogger.Warn("UpdateOrchestrator", "Rollback skipped because another update operation is already running.");
|
||||
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = new UpdateRollbackGateway().RollbackLatest(launcherRoot);
|
||||
if (result.Success)
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.RolledBack);
|
||||
AppLogger.Info("UpdateOrchestrator", result.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||
_stateStore.RecordFailure(result.ErrorMessage ?? result.Message);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -578,30 +581,21 @@ public sealed class UpdateOrchestrator : IDisposable
|
||||
if (manifest?.IsDelta == true ||
|
||||
string.Equals(deploymentLock?.Kind, "delta", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AppLogger.Info("UpdateOrchestrator", "Delta update pending. Launching Launcher to apply on exit.");
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var resolvedRoot = Path.GetDirectoryName(launcherPath)!;
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
Arguments = $"apply-update --app-root \"{resolvedRoot}\" --launch-source apply-update",
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = resolvedRoot
|
||||
};
|
||||
|
||||
System.Diagnostics.Process.Start(startInfo);
|
||||
return true;
|
||||
AppLogger.Info("UpdateOrchestrator", "Delta update pending. Applying from Host on exit.");
|
||||
var result = _installGateway.InstallAsync(
|
||||
UpdatePayloadKind.DeltaPlonds,
|
||||
launcherRoot,
|
||||
progress: null,
|
||||
CancellationToken.None)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
return result.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateOrchestrator", $"Failed to launch Launcher on exit: {ex.Message}");
|
||||
AppLogger.Warn("UpdateOrchestrator", $"Failed to apply delta update on exit: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +224,16 @@ function Assert-WindowsPayloadContainsRequiredHosts {
|
||||
$violations.Add("LanMountainDesktop.Launcher.exe")
|
||||
}
|
||||
|
||||
$airAppRuntimeCandidates = @(
|
||||
(Join-Path $Root "LanMountainDesktop.AirAppRuntime.exe"),
|
||||
(Join-Path $Root "LanMountainDesktop.AirAppRuntime.dll"),
|
||||
(Join-Path (Join-Path $Root "AirAppRuntime") "LanMountainDesktop.AirAppRuntime.exe"),
|
||||
(Join-Path (Join-Path $Root "AirAppRuntime") "LanMountainDesktop.AirAppRuntime.dll")
|
||||
)
|
||||
if (-not ($airAppRuntimeCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1)) {
|
||||
$violations.Add("LanMountainDesktop.AirAppRuntime.exe")
|
||||
}
|
||||
|
||||
$deploymentDirs = @(Get-ChildItem -LiteralPath $Root -Directory -Filter "app-*" -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
-not (Test-Path -LiteralPath (Join-Path $_.FullName ".partial")) -and
|
||||
@@ -254,7 +264,7 @@ function Assert-WindowsPayloadContainsRequiredHosts {
|
||||
|
||||
if ($violations.Count -gt 0) {
|
||||
$sample = ($violations | Select-Object -First 50) -join [Environment]::NewLine
|
||||
throw "Windows publish payload is missing required Launcher/Main/AirAppHost files:$([Environment]::NewLine)$sample"
|
||||
throw "Windows publish payload is missing required Launcher/AirAppRuntime/Main/AirAppHost files:$([Environment]::NewLine)$sample"
|
||||
}
|
||||
|
||||
Write-Host "Windows required host guard passed."
|
||||
|
||||
@@ -254,6 +254,39 @@ function Publish-AirAppHostPayload {
|
||||
}
|
||||
}
|
||||
|
||||
function Publish-AirAppRuntimePayload {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
|
||||
[Parameter(Mandatory = $true)][string]$Rid,
|
||||
[Parameter(Mandatory = $true)][string]$VersionValue
|
||||
)
|
||||
|
||||
$airAppRuntimeProject = Join-Path $repoRoot "..\LanMountainDesktop.AirAppRuntime\LanMountainDesktop.AirAppRuntime.csproj"
|
||||
$airAppRuntimeProject = Resolve-ExistingPath -PathValue $airAppRuntimeProject
|
||||
Write-Host "Publishing AirAppRuntime framework-dependent JIT payload..."
|
||||
$runtimePublishArgs = @(
|
||||
"publish",
|
||||
$airAppRuntimeProject,
|
||||
"-c", $Configuration,
|
||||
"-r", $Rid,
|
||||
"--self-contained", "false",
|
||||
"-p:SelfContained=false",
|
||||
"-p:PublishAot=false",
|
||||
"-p:PublishSingleFile=false",
|
||||
"-p:PublishTrimmed=false",
|
||||
"-p:PublishReadyToRun=false",
|
||||
"-p:DebugType=None",
|
||||
"-p:DebugSymbols=false",
|
||||
"-p:Version=$VersionValue",
|
||||
"-o", $PublishedDirectory
|
||||
)
|
||||
|
||||
& dotnet @runtimePublishArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "AirAppRuntime publish failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
|
||||
function Publish-LauncherPayload {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
|
||||
@@ -345,6 +378,7 @@ if (Is-WindowsRuntimeIdentifier -Rid $RuntimeIdentifier) {
|
||||
[System.IO.Directory]::CreateDirectory($appPublishDir) | Out-Null
|
||||
|
||||
Publish-LauncherPayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version
|
||||
Publish-AirAppRuntimePayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version
|
||||
Publish-MainAppFrameworkDependentPayload -ProjectFile $projectPath -PublishedDirectory $appPublishDir -Rid $RuntimeIdentifier -VersionValue $Version
|
||||
Publish-AirAppHostPayload -PublishedDirectory $appPublishDir -Rid $RuntimeIdentifier -VersionValue $Version
|
||||
New-Item -ItemType File -Path (Join-Path $appPublishDir ".current") -Force | Out-Null
|
||||
@@ -374,6 +408,7 @@ if (Is-WindowsRuntimeIdentifier -Rid $RuntimeIdentifier) {
|
||||
}
|
||||
|
||||
Publish-AirAppHostPayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version
|
||||
Publish-AirAppRuntimePayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version
|
||||
Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
|
||||
Remove-LegacyOutputArtifacts -TargetDirectory $PublishDir
|
||||
|
||||
|
||||
@@ -1,376 +1,255 @@
|
||||
# LanMountainDesktop 安全审计报告
|
||||
|
||||
**审计日期:** 2026-05-29
|
||||
**审计范围:** LanMountainDesktop 代码仓库
|
||||
**审计目标:** 识别中等严重度及以上的已确认漏洞
|
||||
**审计日期**: 2026-05-31
|
||||
**审计范围**: LanMountainDesktop 主仓库
|
||||
**审计方法**: 静态代码分析 + 架构审查
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
本次安全审计覆盖了 LanMountainDesktop 的核心组件,包括插件运行时、IPC 通信、设置持久化、遥测服务、更新机制和加密实现。审计采用白盒测试方法,结合代码路径分析和攻击面评估。
|
||||
本次安全审计系统性地检查了 LanMountainDesktop 代码库的高风险攻击面,包括认证与访问控制、注入向量、外部交互和敏感数据处理。
|
||||
|
||||
**审计结论:未发现中等或更高严重度的已确认漏洞。**
|
||||
**结论**: **未发现中等或更高严重度的已确认漏洞。**
|
||||
|
||||
发现的问题均为低风险设计缺陷或信息泄露,不构成可直接利用的安全漏洞。
|
||||
代码库展示了多项积极的安全设计:
|
||||
- 更新包使用 RSA 签名验证
|
||||
- 使用路径遍历防护机制
|
||||
- SHA-256/SHA-512 哈希校验
|
||||
- 插件沙箱隔离 (AssemblyLoadContext)
|
||||
- 命令行参数解析验证
|
||||
|
||||
---
|
||||
|
||||
## 审计范围与方法
|
||||
|
||||
### 代码库概述
|
||||
- **技术栈**:C# / .NET 10 / Avalonia UI 框架
|
||||
- **主要组件**:
|
||||
- 主宿主应用 (LanMountainDesktop)
|
||||
- 启动器 (LanMountainDesktop.Launcher)
|
||||
- 插件 SDK (LanMountainDesktop.PluginSdk)
|
||||
- 共享 IPC 契约 (LanMountainDesktop.Shared.IPC)
|
||||
- 设置核心 (LanMountainDesktop.Settings.Core)
|
||||
### 审计的攻击面分组
|
||||
|
||||
### 审计方法
|
||||
1. 静态代码分析 - 识别注入向量、硬编码密钥、路径操作
|
||||
2. 信任边界分析 - 评估组件间数据流和 IPC 通信
|
||||
3. 加密实现审查 - 验证加密算法的正确使用
|
||||
4. 攻击面映射 - 识别外部输入点和可利用路径
|
||||
| 分组 | 审计内容 |
|
||||
|------|---------|
|
||||
| **认证与访问控制** | OOBE 流程、隐私协议、会话管理、权限校验 |
|
||||
| **注入向量** | SQL 查询、Shell 命令拼接、模板渲染、文件路径操作 |
|
||||
| **外部交互** | Webhook 处理器、出站网络请求、第三方 API 集成 |
|
||||
| **敏感数据处理** | 密钥/凭证、日志记录、加密实践 |
|
||||
|
||||
### 审计的代码模块
|
||||
|
||||
- `LanMountainDesktop/` - 主宿主应用
|
||||
- `LanMountainDesktop.Launcher/` - 启动器 (OOBE、更新、插件管理)
|
||||
- `LanMountainDesktop.PluginSdk/` - 插件 SDK
|
||||
- `LanMountainDesktop.Services/` - 服务层
|
||||
- `LanMountainDesktop.plugins/` - 插件运行时
|
||||
|
||||
---
|
||||
|
||||
## 详细审计结果
|
||||
|
||||
### ✅ 1. SQL 注入防护 - 安全
|
||||
### 1. 认证与访问控制
|
||||
|
||||
**审计位置**:
|
||||
- `LanMountainDesktop/Services/AppDatabaseService.cs`
|
||||
- `LanMountainDesktop/Services/StudyDataStore.cs`
|
||||
- `LanMountainDesktop/Services/Settings/ComponentDomainStorage.cs`
|
||||
#### 审计项目
|
||||
|
||||
**评估结果**:**安全**
|
||||
| 项目 | 位置 | 状态 |
|
||||
|------|------|------|
|
||||
| OOBE 状态持久化 | `LanMountainDesktop.Launcher/Oobe/OobeStateService.cs` | ✅ 安全 |
|
||||
| 隐私协议管理 | `LanMountainDesktop.Launcher/Oobe/PrivacyAgreementService.cs` | ✅ 安全 |
|
||||
| 命令行参数解析 | `LanMountainDesktop.Launcher/CommandContext.cs` | ✅ 安全 |
|
||||
| 提升权限控制 | `LanMountainDesktop.Launcher/` | ✅ 安全 |
|
||||
|
||||
所有数据库操作均使用参数化查询,使用 `$parameter` 占位符而非字符串拼接。
|
||||
#### 分析结果
|
||||
|
||||
```csharp
|
||||
// ComponentDomainStorage.cs:256
|
||||
deleteCommand.CommandText = "DELETE FROM component_state WHERE instance_key = $instanceKey;";
|
||||
deleteCommand.Parameters.AddWithValue("$instanceKey", instanceKey);
|
||||
```
|
||||
**OOBE 状态持久化** 采用原子写入模式 (先写临时文件再 Move),避免状态损坏。使用 JSON Schema 版本控制便于迁移。`LaunchSource` 参数白名单验证防止非法来源。
|
||||
|
||||
**结论**:无 SQL 注入风险。
|
||||
**命令行参数解析** 对 `Options` 字典使用 `StringComparer.OrdinalIgnoreCase`,解析逻辑清晰,不存在注入风险。
|
||||
|
||||
---
|
||||
|
||||
### ✅ 2. 文件路径操作 - 安全
|
||||
### 2. 注入向量
|
||||
|
||||
**审计位置**:
|
||||
- `LanMountainDesktop/plugins/PluginLoader.cs`
|
||||
- `LanMountainDesktop/Services/PluginMarketInstallService.cs`
|
||||
#### 审计项目
|
||||
|
||||
**评估结果**:**安全**
|
||||
| 项目 | 位置 | 风险评估 |
|
||||
|------|------|---------|
|
||||
| 路径遍历防护 | `Services/Update/UpdatePathGuard.cs` | ✅ 有防护 |
|
||||
| 文件操作 | `PlondsUpdateApplier.cs` | ✅ 安全 |
|
||||
| 插件加载 | `plugins/PluginLoader.cs` | ✅ 隔离 |
|
||||
| Shell 执行 | 各组件 Process.Start | ⚠️ 需注意 |
|
||||
|
||||
发现以下路径安全措施:
|
||||
1. **文件名清理** (`SanitizeFileName`):
|
||||
#### 关键代码审查
|
||||
|
||||
**路径遍历防护** ([UpdatePathGuard.cs:L11-18](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Update/UpdatePathGuard.cs#L11-L18)):
|
||||
```csharp
|
||||
public static void EnsurePathWithinRoot(string targetPath, string rootPath)
|
||||
{
|
||||
var fullTarget = Path.GetFullPath(targetPath);
|
||||
var fullRoot = Path.GetFullPath(rootPath);
|
||||
if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Path traversal detected: {targetPath}");
|
||||
}
|
||||
}
|
||||
```
|
||||
✅ 使用 `OrdinalIgnoreCase` 防止大小写绕过,使用 `GetFullPath` 规范化路径。
|
||||
|
||||
**插件包路径清理** ([PluginMarketInstallService.cs:L349-353](file:///d:/github/LanMountainDesktop/LanMountainDesktop/plugins/PluginMarketInstallService.cs#L349-L353)):
|
||||
```csharp
|
||||
// PluginMarketInstallService.cs:349
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
}
|
||||
```
|
||||
✅ 插件包文件名经过清理,避免路径注入。
|
||||
|
||||
2. **目录名清理** (`SanitizeDirectoryName`):
|
||||
**Shell 执行上下文**:
|
||||
|
||||
检查了 30+ 处 `Process.Start` 调用:
|
||||
- 更新安装使用 `UseShellExecute = true` 仅用于 `runas` 提权执行安装程序
|
||||
- 组件快捷方式执行 (`ShortcutWidget.axaml.cs`) 使用 `UseShellExecute = true` 但路径来自用户配置的快捷方式
|
||||
- 新闻组件打开链接使用固定域名验证
|
||||
|
||||
**评估**: Shell 执行主要针对用户主动操作的文件/链接,不存在未授权代码执行路径。
|
||||
|
||||
---
|
||||
|
||||
### 3. 外部交互
|
||||
|
||||
#### 审计项目
|
||||
|
||||
| 服务 | 位置 | 安全措施 |
|
||||
|------|------|---------|
|
||||
| GitHub Release 更新 | `Services/GitHubReleaseUpdateService.cs` | HTTPS + Hash 验证 |
|
||||
| PLONDS 更新 | `Services/PlondsStaticUpdateService.cs` | RSA 签名验证 |
|
||||
| 插件市场 | `plugins/PluginMarketInstallService.cs` | SHA-256 校验 |
|
||||
| 天气服务 | `Services/XiaomiWeatherService.cs` | API Key 管理 |
|
||||
| 遥测服务 | `Services/TelemetryServices.cs` | 用户同意控制 |
|
||||
|
||||
#### 关键安全机制
|
||||
|
||||
**更新包签名验证** ([UpdateSignatureVerifier.cs](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Update/UpdateSignatureVerifier.cs)):
|
||||
```csharp
|
||||
// PluginLoader.cs:715
|
||||
private static string SanitizeDirectoryName(string value)
|
||||
using var rsa = RSA.Create(384);
|
||||
rsa.ImportFromPem(File.ReadAllText(paths.PublicKeyPath)); // 内置公钥
|
||||
var signatureBase64 = File.ReadAllText(signaturePath).Trim();
|
||||
return rsa.VerifyData(
|
||||
sha256.ComputeHash(File.OpenRead(fileMapPath)),
|
||||
Convert.FromBase64String(signatureBase64),
|
||||
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
```
|
||||
✅ 使用 PKCS#1 签名验证更新清单。
|
||||
|
||||
**插件包完整性验证** ([PluginMarketInstallService.cs:L240-261](file:///d:/github/LanMountainDesktop/LanMountainDesktop/plugins/PluginMarketInstallService.cs#L240-L261)):
|
||||
```csharp
|
||||
// 大小校验
|
||||
if (plugin.PackageSizeBytes > 0 && actualSize != plugin.PackageSizeBytes)
|
||||
return verification failed;
|
||||
|
||||
// SHA-256 校验
|
||||
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
return verification failed;
|
||||
```
|
||||
✅ 下载的插件包经过大小和哈希双重校验。
|
||||
|
||||
**HTTP 客户端配置**:
|
||||
- 所有 HTTP 请求设置 `User-Agent` 头
|
||||
- 超时配置合理 (20-30 秒)
|
||||
- 响应状态码检查完善
|
||||
|
||||
---
|
||||
|
||||
### 4. 敏感数据处理
|
||||
|
||||
#### 审计项目
|
||||
|
||||
| 项目 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| API 密钥硬编码 | ⚠️ 需关注 | 小米天气 API 密钥 |
|
||||
| 日志记录 | ✅ 安全 | 未发现敏感信息日志 |
|
||||
| 遥测数据 | ✅ 安全 | 受用户同意控制 |
|
||||
| 设置存储 | ✅ 安全 | 本地 AppData 目录 |
|
||||
|
||||
#### API 密钥问题说明
|
||||
|
||||
在 [XiaomiWeatherService.cs:L13-36](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/XiaomiWeatherService.cs#L13-L36) 中发现:
|
||||
|
||||
```csharp
|
||||
public sealed record XiaomiWeatherApiOptions
|
||||
{
|
||||
var invalidCharacters = Path.GetInvalidFileNameChars();
|
||||
var builder = new StringBuilder(value.Length);
|
||||
foreach (var ch in value)
|
||||
{
|
||||
builder.Append(invalidCharacters.Contains(ch) ? '_' : ch);
|
||||
}
|
||||
return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim();
|
||||
public string AppKey { get; init; } = "weather20151024";
|
||||
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
3. **提取目录隔离**:插件包提取到隔离的 `runtime/` 子目录,防止路径遍历。
|
||||
**风险评估**: 低
|
||||
|
||||
**结论**:路径操作安全,无路径遍历风险。
|
||||
- 这些是天气数据 API 的凭证,用于访问公开天气数据
|
||||
- 根据小米天气 API 设计,这些密钥通常为公开密钥,供免费/开源应用使用
|
||||
- API 返回的是天气数据,不涉及用户敏感信息
|
||||
- 即使密钥泄露,影响范围限于天气数据获取
|
||||
|
||||
**建议**: 如需增强安全,可考虑:
|
||||
1. 将密钥移至配置系统
|
||||
2. 实现密钥轮换机制
|
||||
3. 使用服务端代理访问天气 API
|
||||
|
||||
---
|
||||
|
||||
### ✅ 3. 插件包签名验证 - 安全
|
||||
### 5. 架构安全评估
|
||||
|
||||
**审计位置**:
|
||||
- `LanMountainDesktop/Services/Update/UpdateSignatureVerifier.cs`
|
||||
- `LanMountainDesktop/Services/Update/UpdateHash.cs`
|
||||
#### 插件运行时隔离
|
||||
|
||||
**评估结果**:**安全**
|
||||
**当前设计**:
|
||||
- 插件使用 `AssemblyLoadContext` 进行程序集隔离
|
||||
- 共享类型白名单机制
|
||||
- 插件运行在同一进程中
|
||||
|
||||
更新包使用 RSA-2048 + SHA-256 进行签名验证:
|
||||
**评估**: 中等风险 (架构设计)
|
||||
|
||||
```csharp
|
||||
// UpdateSignatureVerifier.cs:36
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(paths.PublicKeyPath));
|
||||
var isValid = rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
```
|
||||
当前插件运行时属于进程内加载,这是已知的架构权衡。代码库文档 (`.trae/specs/plugin-process-isolation/`) 已规划未来版本的进程隔离方案:
|
||||
|
||||
**结论**:加密实现符合行业标准。
|
||||
- Phase 1: 后台逻辑移至独立工作进程
|
||||
- Phase 2: 插件 UI 渲染进程外
|
||||
|
||||
---
|
||||
|
||||
### ✅ 4. 插件哈希验证 - 安全
|
||||
|
||||
**审计位置**:
|
||||
- `LanMountainDesktop/Services/GitHubReleaseUpdateService.cs:381`
|
||||
- `LanMountainDesktop/plugins/PluginMarketInstallService.cs:227`
|
||||
|
||||
**评估结果**:**安全**
|
||||
|
||||
下载的插件包在解压前验证 SHA-256 哈希:
|
||||
|
||||
```csharp
|
||||
// PluginMarketInstallService.cs:250
|
||||
if (!string.IsNullOrWhiteSpace(plugin.Sha256) &&
|
||||
!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new AirAppMarketVerificationResult(false, "Package verification failed...");
|
||||
}
|
||||
```
|
||||
|
||||
**结论**:包完整性验证正确实现。
|
||||
|
||||
---
|
||||
|
||||
### ✅ 5. 隐私协议完整性保护 - 安全
|
||||
|
||||
**审计位置**:
|
||||
- `LanMountainDesktop.Launcher/Oobe/PrivacyAgreementService.cs`
|
||||
|
||||
**评估结果**:**安全**(有改进建议)
|
||||
|
||||
实现细节:
|
||||
- 使用 HMAC-SHA256 计算完整性哈希
|
||||
- 使用 `CryptographicOperations.FixedTimeEquals` 进行时间安全比较
|
||||
- 随机盐值生成使用 `RandomNumberGenerator.Create()`
|
||||
|
||||
```csharp
|
||||
// PrivacyAgreementService.cs:218
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secretKey));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(dataToHash));
|
||||
```
|
||||
|
||||
```csharp
|
||||
// PrivacyAgreementService.cs:236
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(state.IntegrityHash),
|
||||
Encoding.UTF8.GetBytes(expectedHash));
|
||||
```
|
||||
|
||||
**改进建议**:备用密钥应使用更强的随机生成方式。
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 6. 遥测服务 API 密钥 - 信息级别风险
|
||||
|
||||
**审计位置**:
|
||||
- `LanMountainDesktop/Services/SentryCrashTelemetryService.cs:15`
|
||||
- `LanMountainDesktop/Services/PostHogUsageTelemetryService.cs:14`
|
||||
|
||||
**发现内容**:
|
||||
```csharp
|
||||
// SentryCrashTelemetryService.cs
|
||||
private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
|
||||
|
||||
// PostHogUsageTelemetryService.cs
|
||||
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
|
||||
```
|
||||
|
||||
**风险评估**:**低风险(信息级别)**
|
||||
|
||||
| 因素 | 分析 |
|
||||
|------|------|
|
||||
| 攻击者画像 | 源码仓库的任何访问者 |
|
||||
| 输入向量 | 直接读取源代码 |
|
||||
| 影响 | Sentry DSN 用于崩溃报告发送,PostHog Key 用于匿名使用分析 |
|
||||
| 可利用性 | 这些是项目级公钥,用于识别正确的服务端点,不具备认证能力 |
|
||||
|
||||
**结论**:不构成安全漏洞。遥测服务密钥设计为公开,用于标识项目。遥测功能可在设置中禁用。
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 7. 备用加密密钥 - 低风险
|
||||
|
||||
**审计位置**:
|
||||
- `LanMountainDesktop.Launcher/Oobe/PrivacyAgreementService.cs:176`
|
||||
|
||||
**发现内容**:
|
||||
```csharp
|
||||
// 如果无法获取机器信息,使用备用密钥
|
||||
return "LanMountainDesktop-Privacy-Agreement-Fallback-Key-2026";
|
||||
```
|
||||
|
||||
**风险评估**:**低风险**
|
||||
|
||||
| 因素 | 分析 |
|
||||
|------|------|
|
||||
| 触发条件 | 仅在 `GenerateMachineSpecificKey()` 方法异常时使用 |
|
||||
| 影响范围 | 仅影响隐私协议状态文件的 HMAC 验证 |
|
||||
| 缓解措施 | 主密钥使用机器特定信息 + SHA256 生成,熵值充足 |
|
||||
|
||||
**改进建议**:备用密钥应使用 `RandomNumberGenerator.GetBytes()` 动态生成并持久化,而非硬编码。
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 8. 开发者模式插件加载 - 预期设计
|
||||
|
||||
**审计位置**:
|
||||
- `LanMountainDesktop/plugins/DevPluginOptions.cs`
|
||||
|
||||
**发现内容**:
|
||||
```csharp
|
||||
// DevPluginOptions.cs:34
|
||||
options.IsDevMode = TryGetFlag(args, DevModeArgs) ||
|
||||
string.Equals(Environment.GetEnvironmentVariable(EnvDevMode), "1", StringComparison.Ordinal);
|
||||
|
||||
// DevPluginOptions.cs:37
|
||||
options.DevPluginPath = TryGetValue(args, DevPluginPathArgs) ??
|
||||
Environment.GetEnvironmentVariable(EnvDevPluginPath)?.Trim();
|
||||
```
|
||||
|
||||
**风险评估**:**架构设计决策(非漏洞)**
|
||||
|
||||
| 因素 | 分析 |
|
||||
|------|------|
|
||||
| 触发条件 | 仅在显式启用开发者模式时 |
|
||||
| 影响范围 | 仅影响开发环境 |
|
||||
| 预期用途 | 允许开发者加载本地未签名插件进行调试 |
|
||||
| 生产安全 | 正常发布版本不启用开发者模式 |
|
||||
|
||||
**结论**:开发者模式是开发工具的安全权衡,不适用于生产环境。
|
||||
|
||||
---
|
||||
|
||||
### ✅ 9. 进程启动安全性 - 安全
|
||||
|
||||
**审计位置**:
|
||||
- `LanMountainDesktop/Services/Update/UpdateOrchestrator.cs`
|
||||
- `LanMountainDesktop/Services/HostApplicationLifecycleService.cs`
|
||||
- `LanMountainDesktop.Launcher/Startup/HostLaunchService.cs`
|
||||
|
||||
**评估结果**:**安全**
|
||||
|
||||
发现以下安全措施:
|
||||
1. 使用 `UseShellExecute = false` 避免 shell 注入
|
||||
2. 路径参数使用引号包裹
|
||||
3. 工作目录显式设置
|
||||
|
||||
```csharp
|
||||
// UpdateOrchestrator.cs:425
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
Arguments = $"rollback --app-root \"{launcherRoot}\"",
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = launcherRoot
|
||||
};
|
||||
```
|
||||
|
||||
**结论**:进程启动安全,无命令注入风险。
|
||||
|
||||
---
|
||||
|
||||
### ✅ 10. IPC 通信 - 安全
|
||||
|
||||
**审计位置**:
|
||||
- `LanMountainDesktop.Shared.IPC/`
|
||||
- `LanMountainDesktop.Launcher/Ipc/LauncherCoordinatorIpcServer.cs`
|
||||
|
||||
**评估结果**:**安全**
|
||||
|
||||
IPC 实现使用 `dotnetCampus.Ipc` 库,具备:
|
||||
- 强类型 RPC 调用
|
||||
- JSON 序列化/反序列化使用 `System.Text.Json`
|
||||
- 支持命名管道传输
|
||||
|
||||
**结论**:IPC 架构安全。
|
||||
|
||||
---
|
||||
|
||||
## 信任边界分析
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 外部输入边界 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • GitHub Release API (更新检查) │
|
||||
│ • 插件市场 API (插件安装) │
|
||||
│ • 用户文件系统 (插件包导入) │
|
||||
│ • 命令行参数 / 环境变量 (开发模式) │
|
||||
│ • OOBE 用户交互 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 信任边界入口点 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • PluginLoader.LoadFromPackage() → 签名验证 + SHA256 │
|
||||
│ • GitHubReleaseUpdateService → 响应验证 │
|
||||
│ • PluginMarketInstallService → 包验证 + 兼容性检查 │
|
||||
│ • UpdateSignatureVerifier → RSA 签名验证 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 隔离边界 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • AssemblyLoadContext 隔离插件程序集 │
|
||||
│ • WAL 模式隔离 SQLite 数据库写入 │
|
||||
│ • 独立进程隔离 (AirAppHost) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
**当前缓解措施**:
|
||||
- 插件 API 版本兼容性检查
|
||||
- 插件清单验证
|
||||
- 签名验证 (市场下载的插件)
|
||||
|
||||
---
|
||||
|
||||
## 安全最佳实践符合性
|
||||
|
||||
| 实践 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| 参数化 SQL 查询 | ✅ | 所有查询使用参数化 |
|
||||
| 路径清理 | ✅ | SanitizeFileName/DirectoryName |
|
||||
| 加密哈希算法 | ✅ | SHA-256 / HMAC-SHA256 |
|
||||
| 时间安全比较 | ✅ | CryptographicOperations.FixedTimeEquals |
|
||||
| 强随机数生成 | ✅ | RandomNumberGenerator.Create() |
|
||||
| TLS/HTTPS | ✅ | 所有外部请求使用 HTTPS |
|
||||
| 签名验证 | ✅ | RSA-2048 + SHA-256 |
|
||||
| 进程隔离 | ⚠️ | AssemblyLoadContext 隔离(架构决策) |
|
||||
| 最佳实践 | 符合性 | 说明 |
|
||||
|---------|-------|------|
|
||||
| 输入验证 | ✅ | 参数解析、路径规范化、Schema 验证 |
|
||||
| 输出编码 | ✅ | JSON 序列化使用 System.Text.Json |
|
||||
| 加密标准 | ✅ | SHA-256/SHA-512, RSA 384-bit |
|
||||
| 安全默认值 | ✅ | UseShellExecute=false 优先 |
|
||||
| 错误处理 | ✅ | 异常被捕获并记录,不泄露敏感信息 |
|
||||
| 更新签名 | ✅ | RSA 签名验证更新包 |
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
## 结论
|
||||
|
||||
### 已确认安全的领域
|
||||
- **数据持久化**:SQL 注入防护完善,参数化查询正确使用
|
||||
- **文件操作**:路径清理机制健全,无路径遍历风险
|
||||
- **加密实现**:符合行业标准,使用现代加密算法
|
||||
- **外部交互**:所有网络请求使用 HTTPS,响应验证完善
|
||||
- **更新机制**:包签名验证确保更新来源可信
|
||||
### 审计状态: 通过
|
||||
|
||||
### 低风险发现(无需立即修复)
|
||||
1. 遥测服务 API 密钥硬编码 - 设计决策,可接受
|
||||
2. 备用加密密钥硬编码 - 降级保护,影响有限
|
||||
3. 开发者模式任意插件加载 - 仅用于开发环境
|
||||
经过系统性审计,**未发现中等或更高严重度的已确认漏洞**。
|
||||
|
||||
### 架构建议(非安全缺陷)
|
||||
- 插件进程隔离:当前使用 AssemblyLoadContext,文档已说明未来计划支持进程隔离
|
||||
### 代码质量评价
|
||||
|
||||
### 审计结论
|
||||
代码库展现了良好的安全意识:
|
||||
- 关键操作 (更新安装、插件加载) 有多层安全验证
|
||||
- 路径操作使用标准化防护机制
|
||||
- 外部数据源完整性校验完善
|
||||
- 遥测和隐私设置尊重用户选择
|
||||
|
||||
**未发现中等或更高严重度的已确认漏洞。**
|
||||
### 建议改进 (非紧急)
|
||||
|
||||
所有发现的安全相关问题均为低风险设计选择或信息级别泄露,不构成可直接利用的安全漏洞。项目代码遵循了良好的安全实践,包括参数化查询、路径清理、加密标准实现等。
|
||||
1. **API 密钥管理**: 将天气 API 密钥移至配置系统或使用服务端代理
|
||||
2. **插件进程隔离**: 加速推进 `plugin-process-isolation` 规划
|
||||
3. **安全清单**: 建立安全相关的持续集成检查
|
||||
|
||||
---
|
||||
|
||||
*报告生成工具:自动化安全审计*
|
||||
*审计方法:静态代码分析 + 攻击面评估*
|
||||
*本报告基于静态代码分析生成,未进行运行时渗透测试。建议在发布前进行完整的动态安全测试。*
|
||||
|
||||
@@ -107,3 +107,67 @@
|
||||
|
||||
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
|
||||
|
||||
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
|
||||
--_<_n_a_m_e_> Toggle a command line option, by name.
|
||||
__<_f_l_a_g_> Display the setting of a command line option.
|
||||
___<_n_a_m_e_> Display the setting of an option, by name.
|
||||
+_c_m_d Execute the less cmd each time a new file is examined.
|
||||
|
||||
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|
||||
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|
||||
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
|
||||
s _f_i_l_e Save input to a file.
|
||||
v Edit the current file with $VISUAL or $EDITOR.
|
||||
V Print version number of "less".
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
OOPPTTIIOONNSS
|
||||
|
||||
Most options may be changed either on the command line,
|
||||
or from within less by using the - or -- command.
|
||||
Options may be given in one of two forms: either a single
|
||||
character preceded by a -, or a name preceded by --.
|
||||
|
||||
-? ........ --help
|
||||
Display help (from command line).
|
||||
-a ........ --search-skip-screen
|
||||
Search skips current screen.
|
||||
-A ........ --SEARCH-SKIP-SCREEN
|
||||
Search starts just after target line.
|
||||
-b [_N] .... --buffers=[_N]
|
||||
Number of buffers.
|
||||
-B ........ --auto-buffers
|
||||
Don't automatically allocate buffers for pipes.
|
||||
-c ........ --clear-screen
|
||||
Repaint by clearing rather than scrolling.
|
||||
-d ........ --dumb
|
||||
Dumb terminal.
|
||||
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
|
||||
Set screen colors.
|
||||
-e -E .... --quit-at-eof --QUIT-AT-EOF
|
||||
Quit at end of file.
|
||||
-f ........ --force
|
||||
Force open non-regular files.
|
||||
-F ........ --quit-if-one-screen
|
||||
Quit if entire file fits on first screen.
|
||||
-g ........ --hilite-search
|
||||
Highlight only last match for searches.
|
||||
-G ........ --HILITE-SEARCH
|
||||
Don't highlight any matches for searches.
|
||||
-h [_N] .... --max-back-scroll=[_N]
|
||||
Backward scroll limit.
|
||||
-i ........ --ignore-case
|
||||
Ignore case in searches that do not contain uppercase.
|
||||
-I ........ --IGNORE-CASE
|
||||
Ignore case in all searches.
|
||||
-j [_N] .... --jump-target=[_N]
|
||||
Screen position of target lines.
|
||||
-J ........ --status-column
|
||||
Display a status column at left edge of screen.
|
||||
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
|
||||
Use a compiled lesskey file.
|
||||
-K ........ --quit-on-intr
|
||||
Exit less in response to ctrl-C.
|
||||
-L ........ --no-lessopen
|
||||
Ignore the LESSOPEN environment variable.
|
||||
-m -M .... --long-prompt --LONG-PROMPT
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
| 路径 | 角色 |
|
||||
| --- | --- |
|
||||
| `LanMountainDesktop/` | 主桌面宿主应用,包含 UI、服务、组件系统、插件运行时接入 |
|
||||
| **`LanMountainDesktop.Launcher/`** | **启动器 - 负责 OOBE、Splash、版本管理、增量更新、插件安装** |
|
||||
| **`LanMountainDesktop.Launcher/`** | **启动器 - 负责 OOBE、Splash、版本目录选择与主程序启动** |
|
||||
| **`LanMountainDesktop.AirAppRuntime/`** | **Air APP 独立运行容器 - 负责 Air APP IPC、实例表与 AirAppHost 进程生命周期** |
|
||||
| `LanMountainDesktop.PluginSdk/` | 官方插件 SDK,定义插件可依赖的公开接口与打包行为 |
|
||||
| `LanMountainDesktop.Shared.Contracts/` | 宿主与插件共享的稳定契约类型 |
|
||||
| `LanMountainDesktop.Appearance/` | 主题、圆角、外观资源相关基础设施 |
|
||||
@@ -26,9 +27,9 @@
|
||||
2. Launcher 扫描 `app-*` 目录,选择最佳版本 (优先 `.current` 标记,然后按版本号降序)
|
||||
3. 首次启动显示 OOBE 引导 (`OobeWindow`)
|
||||
4. 显示 Splash 启动动画 (`SplashWindow`)
|
||||
5. 检查并应用待处理的更新 (`IUpdateEngine.ApplyPendingUpdateAsync` / `UpdateEngineFacade`)
|
||||
6. 启动主程序 `app-{version}/LanMountainDesktop.exe`(待处理插件安装/升级由 Host 在 `PluginRuntimeService.ApplyPendingPluginOperations()` 中应用,而非 Launcher 启动流程)
|
||||
7. 清理标记为 `.destroy` 的旧版本
|
||||
5. 预启动包根下的 `LanMountainDesktop.AirAppRuntime`(框架依赖 JIT 进程)
|
||||
6. 启动主程序 `app-{version}/LanMountainDesktop.exe`(更新检查、下载、应用、回滚和插件 pending 队列均由 Host 处理)
|
||||
7. 主程序启动成功后将 Host PID 附加给 AirApp Runtime,并清理标记为 `.destroy` 的旧版本
|
||||
|
||||
**主程序启动流程 (LanMountainDesktop.exe):**
|
||||
|
||||
@@ -89,16 +90,15 @@
|
||||
1. **OOBE (首次体验)** - 首次启动引导和欢迎页面
|
||||
2. **Splash Screen** - 启动动画和加载进度显示
|
||||
3. **版本管理** - 多版本并存、版本选择、版本回退
|
||||
4. **应用更新** - 增量更新、静默更新、原子化更新
|
||||
5. **插件管理** - 插件安装、插件更新队列处理
|
||||
4. **无更新职责** - 不检查、不下载、不应用、不回滚更新;更新系统完全由 Host 接管
|
||||
5. **插件维护命令** - 保留 `plugin install` / `plugin update` 作为兼容 CLI;应用内插件市场由 Host 处理
|
||||
|
||||
#### 核心服务
|
||||
|
||||
| 服务 | 职责 |
|
||||
|------|------|
|
||||
| `DeploymentLocator` | 扫描和定位 `app-*` 版本目录,选择最佳版本 |
|
||||
| `IUpdateEngine` / `UpdateEngineFacade` | 更新门面;pending 检测、签名、Legacy/PLONDS apply、回滚、清理委托给 `Update/` 策略类 |
|
||||
| `LauncherOrchestrator` / `LaunchPipeline` | 协调 OOBE → Splash → 更新 → 启动主程序的完整流程 |
|
||||
| `LauncherOrchestrator` / `LaunchPipeline` | 协调 OOBE → Splash → AirApp Runtime 预启动 → 启动主程序 |
|
||||
| `OobeStateService` | 管理首次运行状态 |
|
||||
| `PluginInstallerService` | CLI 维护:`plugin install` 直接安装 `.laapp` |
|
||||
| `PluginUpgradeQueueService` | CLI 维护:`plugin update` 应用待处理队列(正常市场安装/升级由 Host 处理) |
|
||||
@@ -116,7 +116,7 @@
|
||||
├── app-1.0.1/ ← 新版本
|
||||
│ ├── .partial ← 下载中标记
|
||||
│ └── ...
|
||||
└── .launcher/ ← Launcher 数据
|
||||
└── .Launcher/ ← Launcher 数据
|
||||
├── state/ ← OOBE 状态
|
||||
├── update/incoming/ ← 更新缓存
|
||||
└── snapshots/ ← 更新快照
|
||||
@@ -136,15 +136,10 @@
|
||||
#### 更新流程
|
||||
|
||||
**增量更新:**
|
||||
1. `UpdateCheckService` 调用 GitHub Release API
|
||||
2. 根据更新频道 (Stable/Preview) 过滤版本
|
||||
3. 下载 `delta-{old}-to-{new}.zip` 和 `files-{new}.json`
|
||||
4. 创建 `app-{new}/` 目录并标记 `.partial`
|
||||
5. 解压增量包,从旧版本复用未变更文件
|
||||
6. 验证所有文件 SHA256
|
||||
7. 删除 `.partial`,添加 `.current` 到新版本
|
||||
8. 标记旧版本 `.destroy`
|
||||
9. 保存更新快照到 `.launcher/snapshots/`
|
||||
1. Host 的 `UpdateOrchestrator` 检查更新、解析 manifest,并下载 PLONDS file map、签名和对象文件到 `.Launcher/update/incoming/`
|
||||
2. Host 写入 `deployment.lock`,随后在 Host 进程内进入 `UpdateInstallGateway`
|
||||
3. Host 负责签名校验、创建目标 `app-{new}/`、应用文件、验证 hash、切换 `.current`、写入快照和清理 incoming
|
||||
4. 失败时 Host 使用快照尝试回滚;手动回滚通过 Host 设置页进入 `UpdateRollbackGateway`
|
||||
|
||||
**原子化保证:**
|
||||
- 更新过程中保持 `.partial` 标记
|
||||
@@ -154,7 +149,7 @@
|
||||
|
||||
**版本回退:**
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update rollback
|
||||
Host 设置页 → 更新 → 回滚
|
||||
```
|
||||
回退会:
|
||||
1. 读取最新的更新快照
|
||||
@@ -189,7 +184,7 @@ GitHub Release Assets:
|
||||
|
||||
This repository is organized around a desktop host app plus a host-side plugin ecosystem. `LanMountainDesktop/` contains the application entry points, UI, services, component system, and plugin runtime integration. The surrounding projects provide the public SDK, shared contracts, appearance infrastructure, settings primitives, host abstractions, runtime support, and tests.
|
||||
|
||||
**Launcher Architecture**: `LanMountainDesktop.Launcher/` serves as the single entry point, managing OOBE, splash screen, multi-version deployment, and incremental updates. In-app plugin market installation is Host-owned: packages are downloaded into the current user's pending plugin queue and applied by the Host before plugin discovery on the next startup. The Launcher still keeps plugin CLI commands as maintenance compatibility entry points. It uses a version directory structure (`app-{version}/`) with marker files (`.current`, `.partial`, `.destroy`) to enable atomic updates and rollback capabilities. See the Chinese section above for detailed architecture documentation.
|
||||
**Launcher Architecture**: `LanMountainDesktop.Launcher/` serves as the single entry point, managing OOBE, splash screen, version selection, and host startup. Update check/download/apply/rollback orchestration is fully Host-owned; the Launcher does not expose update CLI commands. In-app plugin market installation is also Host-owned. The Launcher still keeps plugin CLI commands as maintenance compatibility entry points. It uses a version directory structure (`app-{version}/`) with marker files (`.current`, `.partial`, `.destroy`) only to select the host version to start. See the Chinese section above for detailed architecture documentation.
|
||||
|
||||
The runtime flow starts with the Launcher selecting the best version, then proceeds into `Program.cs`, into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence.
|
||||
|
||||
@@ -197,7 +192,7 @@ The runtime flow starts with the Launcher selecting the best version, then proce
|
||||
|
||||
- Incremental package build/publish has moved to VeloPack native assets (
|
||||
eleases.win.json + *.nupkg).
|
||||
- Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback.
|
||||
- Launcher runtime responsibilities are OOBE, startup orchestration, AirApp Runtime pre-start, version directory selection, and Host launch. Update check/download/apply/rollback stays in the Host.
|
||||
|
||||
## Plugin Isolation Modes
|
||||
|
||||
@@ -228,13 +223,13 @@ See `docs/EXTERNAL_IPC_ARCHITECTURE.md` for the detailed contract and migration
|
||||
|
||||
## Air APP Lifecycle
|
||||
|
||||
- Launcher is the lifecycle bridge between the desktop host and Air APP processes.
|
||||
- The desktop host requests built-in Air APP operations through `IAirAppLifecycleService` on `LanMountainDesktop.Launcher.AirApp.v1`.
|
||||
- If that pipe is not available because the desktop host was started directly from IDE/dev tooling, the host starts `LanMountainDesktop.Launcher.exe air-app-broker --requester-pid <pid>` and retries the request.
|
||||
- `air-app-broker` is an internal hidden command that starts only the Air APP lifecycle IPC broker and does not run OOBE, Splash, debug preview windows, or normal desktop launch.
|
||||
- Launcher owns Air APP process creation, activation, instance-key de-duplication, registration tracking, and exited-process cleanup.
|
||||
- `LanMountainDesktop.AirAppHost` stays an independent rendering process and registers/unregisters itself with Launcher.
|
||||
- Launcher remains alive while the desktop host or any Air APP process is alive.
|
||||
- `LanMountainDesktop.AirAppRuntime` is the lifecycle bridge between the desktop host and Air APP processes.
|
||||
- The desktop host requests built-in Air APP operations through `IAirAppLifecycleService` on `LanMountainDesktop.AirAppRuntime.v1`.
|
||||
- Launcher pre-starts `LanMountainDesktop.AirAppRuntime` during normal startup and attaches the launched Host PID through `IAirAppRuntimeControlService`.
|
||||
- If that pipe is not available because the desktop host was started directly from IDE/dev tooling, the host starts `LanMountainDesktop.AirAppRuntime` and retries the request.
|
||||
- AirApp Runtime owns Air APP process creation, activation, instance-key de-duplication, registration tracking, and exited-process cleanup.
|
||||
- `LanMountainDesktop.AirAppHost` stays an independent rendering process and registers/unregisters itself with AirApp Runtime.
|
||||
- Launcher waits for the desktop host startup path only; AirApp Runtime remains alive while Launcher/Host/requester or any AirAppHost process is alive, and exits when idle.
|
||||
- Air APP windows are ordinary application windows: they do not use fused desktop bottom-most services and do not use global `Topmost` promotion.
|
||||
|
||||
## Fused Desktop Window Layer
|
||||
@@ -264,10 +259,10 @@ See `docs/EXTERNAL_IPC_ARCHITECTURE.md` for the detailed contract and migration
|
||||
- Launcher OOBE state is owned by a per-user JSON file under `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||
- Same-user reinstall or upgrade should keep OOBE completed.
|
||||
- `first_run_completed` is legacy migration-only data.
|
||||
- The recognized launch sources are `normal`, `postinstall`, `apply-update`, `plugin-install`, and `debug-preview`.
|
||||
- The recognized launch sources are `normal`, `postinstall`, `plugin-install`, and `debug-preview`.
|
||||
- Auto-OOBE is only allowed for normal user-mode startup.
|
||||
- `postinstall` may show OOBE only when the launcher is not elevated.
|
||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-open OOBE.
|
||||
- `plugin-install` and `debug-preview` must not auto-open OOBE.
|
||||
- Elevation is allowed only for the installer, full installer update application, and user-confirmed legacy uninstall.
|
||||
- Default plugin install should stay inside the user's LocalAppData scope and should not ask for UAC.
|
||||
- Marketplace plugin installs are queued under the user's data root and take effect after restart; they do not use Launcher elevation.
|
||||
|
||||
@@ -34,16 +34,14 @@ dotnet build LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -c D
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
||||
```
|
||||
|
||||
Air APP 开发调试时需要同时构建 `LanMountainDesktop.AirAppRuntime`。正常 Launcher 启动会预启动该 Runtime;直接运行 Host 时,Host 会在第一次打开 Air APP 时兜底启动 Runtime。
|
||||
|
||||
**Launcher 其他命令:**
|
||||
```bash
|
||||
# 检查更新
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update check
|
||||
|
||||
# 安装插件
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- plugin install <path-to-plugin.laapp>
|
||||
|
||||
# 版本回退
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback
|
||||
# Launcher 不提供更新/回滚 CLI;调试更新请运行主程序并使用 Host 更新服务。
|
||||
```
|
||||
|
||||
#### 运行测试
|
||||
@@ -56,6 +54,7 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
|
||||
- 宿主应用:`LanMountainDesktop/`
|
||||
- **Launcher (启动器):`LanMountainDesktop.Launcher/`**
|
||||
- **AirApp Runtime (轻应用生命周期容器):`LanMountainDesktop.AirAppRuntime/`**
|
||||
- Plugin SDK:`LanMountainDesktop.PluginSdk/`
|
||||
- 共享契约:`LanMountainDesktop.Shared.Contracts/`
|
||||
- 测试:`LanMountainDesktop.Tests/`
|
||||
@@ -66,7 +65,7 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
|
||||
- **Launcher 启动问题优先看 `LanMountainDesktop.Launcher/Program.cs`、`Shell/LauncherOrchestrator.cs` 和 `Startup/LaunchPipeline.cs`**
|
||||
- **版本管理问题优先看 `LanMountainDesktop.Launcher/Deployment/DeploymentLocator.cs`**
|
||||
- **更新系统问题优先看 `LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs`、`Update/LegacyUpdateApplier.cs`、`Update/PlondsUpdateApplier.cs` 和 `UpdateCheckService.cs`**
|
||||
- **更新检查、下载、应用和回滚问题优先看 `LanMountainDesktop/Services/Update/UpdateOrchestrator.cs`、`UpdateInstallGateway.cs` 和 `UpdateRollbackGateway.cs`**
|
||||
- 启动问题优先看 `LanMountainDesktop/Program.cs` 和 `LanMountainDesktop/App.axaml.cs`
|
||||
- 设置窗口和设置页问题优先看 `LanMountainDesktop/Views/`、`ViewModels/` 与相关 `Services/`
|
||||
- 插件加载与安装问题优先看 `LanMountainDesktop/plugins/`
|
||||
@@ -103,7 +102,7 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
|
||||
### Launcher 架构说明
|
||||
|
||||
LanMountainDesktop 使用 Launcher 作为唯一入口,负责版本管理、更新和启动主程序。
|
||||
LanMountainDesktop 使用 Launcher 作为唯一入口,负责版本目录选择、AirApp Runtime 预启动和主程序启动。更新检查、下载、应用和回滚全部由 Host 负责。
|
||||
|
||||
#### 目录结构
|
||||
|
||||
@@ -118,7 +117,7 @@ C:\Program Files\LanMountainDesktop\
|
||||
├── app-1.0.1/ ← 新版本
|
||||
│ ├── .partial ← 下载中标记
|
||||
│ └── ...
|
||||
└── .launcher/ ← Launcher 数据
|
||||
└── .Launcher/ ← Launcher 数据
|
||||
├── state/ ← OOBE 状态
|
||||
├── update/incoming/ ← 更新缓存
|
||||
└── snapshots/ ← 更新快照
|
||||
@@ -136,25 +135,22 @@ C:\Program Files\LanMountainDesktop\
|
||||
2. Launcher 扫描 `app-*` 目录,选择最佳版本
|
||||
3. 如果是首次启动,显示 OOBE 引导
|
||||
4. 显示 Splash 启动动画
|
||||
5. 检查并应用待处理的更新
|
||||
6. 处理插件升级队列
|
||||
7. 启动主程序 `app-{version}/LanMountainDesktop.exe`
|
||||
8. 清理标记为 `.destroy` 的旧版本
|
||||
5. 预启动 AirApp Runtime
|
||||
6. 启动主程序 `app-{version}/LanMountainDesktop.exe`
|
||||
7. 主程序启动成功后附加 Host PID 给 AirApp Runtime,并清理标记为 `.destroy` 的旧版本
|
||||
|
||||
#### 更新流程
|
||||
|
||||
1. Launcher 调用 GitHub Release API 检查更新
|
||||
2. 根据更新频道(Stable/Preview)过滤版本
|
||||
3. 下载增量包到 `app-{new_version}/` 并标记 `.partial`
|
||||
4. 验证文件完整性(SHA256)
|
||||
5. 删除 `.partial`,添加 `.current` 到新版本
|
||||
6. 标记旧版本 `.destroy`
|
||||
7. 下次启动时自动清理
|
||||
1. Host 调用更新源检查更新并按频道过滤版本
|
||||
2. Host 下载 PLONDS file map、签名和对象文件到 `.Launcher/update/incoming/`
|
||||
3. Host 写入 `deployment.lock` 并调用 `UpdateInstallGateway`
|
||||
4. Host 验证签名和文件 hash,创建新 `app-*` 目录,切换 `.current`
|
||||
5. Host 写入快照并清理 incoming;旧版本按启动清理策略处理
|
||||
|
||||
#### 版本回退
|
||||
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback
|
||||
运行主程序,打开设置页中的更新区域触发回滚。
|
||||
```
|
||||
|
||||
回退会切换到上一个有效版本,并保留快照记录。
|
||||
@@ -172,5 +168,5 @@ In-app marketplace plugin installs use a per-user pending plugin queue. The pack
|
||||
## VeloPack Release Assets
|
||||
|
||||
- Windows incremental release packaging now uses VeloPack native outputs (
|
||||
eleases.win.json, *.nupkg).
|
||||
eleases.win.json, *.nupkg).
|
||||
- Host owns update check/download/apply/rollback orchestration. Launcher only selects and starts the current version; VeloPack is used for package generation.
|
||||
|
||||
125
docs/LAUNCHER.md
125
docs/LAUNCHER.md
@@ -1,6 +1,6 @@
|
||||
# Launcher 架构文档
|
||||
|
||||
> LanMountainDesktop.Launcher - 应用启动器和版本管理系统
|
||||
> LanMountainDesktop.Launcher - 应用启动器与版本目录选择
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -19,9 +19,10 @@ Launcher 是 LanMountainDesktop 的唯一入口点,负责:
|
||||
- 首次体验引导 (OOBE)
|
||||
- 启动动画 (Splash Screen)
|
||||
- 多版本管理和选择
|
||||
- 应用更新 (增量更新、原子化更新)
|
||||
- 插件安装和升级
|
||||
- 版本回退
|
||||
- 不承担更新职责;更新检查、下载、应用与回滚均由主程序负责
|
||||
- 插件安装和升级维护命令
|
||||
|
||||
Air APP 窗口生命周期不再由 Launcher 进程内 broker 承担。Launcher 在正常启动时预启动包根下的 `LanMountainDesktop.AirAppRuntime`,该进程以框架依赖 JIT 方式运行并负责 Air APP IPC、实例表和 AirAppHost 进程管理。
|
||||
|
||||
**设计理念**: 参考 ClassIsland 项目,实现原子化的多版本管理和随时版本回退能力。
|
||||
|
||||
@@ -43,18 +44,14 @@ Launcher 是 LanMountainDesktop 的唯一入口点,负责:
|
||||
- 版本标记系统 (`.current`, `.partial`, `.destroy`)
|
||||
- 旧版本自动清理
|
||||
|
||||
### 4. 应用更新
|
||||
- GitHub Release API 集成
|
||||
- 更新频道管理 (Stable/Preview)
|
||||
- 增量更新下载
|
||||
- 原子化更新应用
|
||||
- 签名验证
|
||||
- 版本回退
|
||||
### 4. 更新边界
|
||||
- Host 负责更新检查、频道策略、下载、`deployment.lock` 写入、PLONDS 应用、部署切换和回滚
|
||||
- Launcher 不提供 `update check` / `update download` / `apply-update` / `rollback` 命令
|
||||
- Launcher 只按版本目录和 `.current` / `.partial` / `.destroy` 标记选择要启动的 Host
|
||||
|
||||
### 5. 插件管理
|
||||
- 插件安装 (`.laapp` 包)
|
||||
- 插件更新检查
|
||||
- 插件升级队列处理
|
||||
### 5. 插件维护
|
||||
- `plugin install` / `plugin update` 保留为兼容维护命令
|
||||
- 应用内插件市场下载、校验和 pending 队列由 Host 负责
|
||||
|
||||
## 架构设计
|
||||
|
||||
@@ -64,9 +61,11 @@ Launcher 是 LanMountainDesktop 的唯一入口点,负责:
|
||||
```
|
||||
C:\Program Files\LanMountainDesktop\
|
||||
├── LanMountainDesktop.Launcher.exe ← 唯一入口
|
||||
├── LanMountainDesktop.AirAppRuntime.exe ← Air APP 生命周期容器(JIT)
|
||||
├── app-1.0.0/ ← 版本目录
|
||||
│ ├── .current ← 当前版本标记
|
||||
│ ├── LanMountainDesktop.exe
|
||||
│ ├── LanMountainDesktop.AirAppHost.exe
|
||||
│ ├── LanMountainDesktop.dll
|
||||
│ └── ... (所有依赖)
|
||||
├── app-1.0.1/ ← 新版本
|
||||
@@ -75,7 +74,7 @@ C:\Program Files\LanMountainDesktop\
|
||||
├── app-0.9.9/ ← 旧版本
|
||||
│ ├── .destroy ← 待删除标记
|
||||
│ └── ...
|
||||
└── .launcher/ ← Launcher 数据目录
|
||||
└── .Launcher/ ← Launcher 数据目录
|
||||
├── state/
|
||||
│ └── first_run_completed ← OOBE 完成标记
|
||||
├── update/
|
||||
@@ -125,34 +124,11 @@ void CleanupDestroyedDeployments()
|
||||
3. 优先选择带 `.current` 标记的版本
|
||||
4. 如果没有 `.current`,选择版本号最高的
|
||||
|
||||
### UpdateCheckService
|
||||
**职责**: 检查 GitHub Release 更新
|
||||
### Host UpdateOrchestrator
|
||||
**职责**: 更新检查、频道策略、manifest 解析、下载与安装触发位于 Host 的 `LanMountainDesktop/Services/Update/UpdateOrchestrator.cs`。Launcher 不再提供 `update check` / `update download` CLI。
|
||||
|
||||
**关键方法**:
|
||||
```csharp
|
||||
// 检查更新
|
||||
Task<UpdateCheckResult> CheckForUpdateAsync(
|
||||
string currentVersion,
|
||||
UpdateChannel channel,
|
||||
CancellationToken cancellationToken = default)
|
||||
```
|
||||
|
||||
**更新频道**:
|
||||
- `Stable` - 只检查 `prerelease=false` 的版本
|
||||
- `Preview` - 检查所有版本 (包括 `prerelease=true`)
|
||||
|
||||
### IUpdateEngine / UpdateEngineFacade
|
||||
**职责**: `UpdateEngineFacade` 是 `IUpdateEngine` 薄门面;pending 检测、签名、Legacy/PLONDS apply、快照、checkpoint、回滚和清理分别位于 `Update/` 策略/基础设施类。
|
||||
|
||||
**关键方法**:
|
||||
```csharp
|
||||
LauncherResult CheckPendingUpdate()
|
||||
Task<LauncherResult> DownloadAsync(...)
|
||||
Task<LauncherResult> ApplyPendingUpdateAsync()
|
||||
LauncherResult RollbackLatest()
|
||||
void CleanupDestroyedDeployments()
|
||||
void CleanupIncomingArtifacts()
|
||||
```
|
||||
### Host UpdateInstallGateway
|
||||
**职责**: 更新应用与回滚入口位于 Host。`UpdateOrchestrator` 下载后调用 `UpdateInstallGateway` 在 Host 进程内应用 PLONDS payload;回滚通过 Host 的 `UpdateRollbackGateway` 执行。
|
||||
|
||||
### LauncherOrchestrator / LaunchPipeline
|
||||
**职责**: 协调完整的启动流程(`Shell/LauncherOrchestrator.cs` + `Startup/LaunchPipeline.cs`)
|
||||
@@ -160,10 +136,9 @@ void CleanupIncomingArtifacts()
|
||||
**启动阶段 (ILaunchPhase)**:
|
||||
1. `CleanupDeploymentsPhase` — 清理旧部署
|
||||
2. `ExistingHostProbePhase` — 多实例 / 现有 Host 探测
|
||||
3. `ApplyPendingUpdatePhase` — 应用 pending 更新
|
||||
4. `OobeGatePhase` — OOBE 步骤
|
||||
5. `LaunchHostPhase` — 启动 Host
|
||||
6. `MonitorStartupPhase` — IPC 启动监控
|
||||
3. `OobeGatePhase` — OOBE 步骤
|
||||
4. `LaunchHostPhase` — 启动 Host
|
||||
5. `MonitorStartupPhase` — IPC 启动监控
|
||||
|
||||
**GUI 入口**: `Shell/LauncherCompositionRoot` + `Shell/LauncherServiceRegistration`(MS DI 轻量装配)
|
||||
|
||||
@@ -355,7 +330,7 @@ internal sealed class LaunchPipeline
|
||||
}
|
||||
```
|
||||
|
||||
`LauncherFlowCoordinator` 已删除。GUI 顶层生命周期由 `LauncherGuiCoordinator` 处理,启动阶段由 `LaunchPipeline` 和各 `ILaunchPhase` 承载;更新 apply 通过 `IUpdateEngine` 门面进入 `Update/` 策略类。
|
||||
`LauncherFlowCoordinator` 已删除。GUI 顶层生命周期由 `LauncherGuiCoordinator` 处理,启动阶段由 `LaunchPipeline` 和各 `ILaunchPhase` 承载;更新检查、下载、应用与回滚均由 Host 处理。
|
||||
|
||||
## 命令行接口
|
||||
|
||||
@@ -367,38 +342,6 @@ LanMountainDesktop.Launcher.exe launch
|
||||
|
||||
启动完整流程: OOBE → Splash → 更新 → 插件 → 主程序
|
||||
|
||||
### update check - 检查更新
|
||||
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update check
|
||||
```
|
||||
|
||||
检查 GitHub Release 是否有新版本。
|
||||
|
||||
### update download - 下载更新
|
||||
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update download --version 1.0.1
|
||||
```
|
||||
|
||||
下载指定版本的更新包。
|
||||
|
||||
### update apply - 应用更新
|
||||
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update apply
|
||||
```
|
||||
|
||||
应用已下载的更新 (原子化操作)。
|
||||
|
||||
### update rollback - 版本回退
|
||||
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update rollback
|
||||
```
|
||||
|
||||
回退到上一个有效版本。
|
||||
|
||||
### plugin install - 安装插件
|
||||
|
||||
```bash
|
||||
@@ -418,11 +361,7 @@ dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csp
|
||||
|
||||
**调试特定命令:**
|
||||
```bash
|
||||
# 检查更新
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update check
|
||||
|
||||
# 版本回退
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- update rollback
|
||||
# Launcher 不提供更新/回滚 CLI;调试更新请运行主程序并使用设置页或 Host 更新服务。
|
||||
```
|
||||
|
||||
### 模拟多版本环境
|
||||
@@ -454,10 +393,10 @@ pwsh ./scripts/Generate-DeltaPackage.ps1 `
|
||||
-CurrentVersion "1.0.1" `
|
||||
-PreviousDir "./test-deploy/app-1.0.0" `
|
||||
-CurrentDir "./test-deploy/app-1.0.1" `
|
||||
-OutputDir "./test-deploy/.launcher/update/incoming"
|
||||
-OutputDir "./test-deploy/.Launcher/update/incoming"
|
||||
|
||||
# 3. 测试应用更新
|
||||
./test-deploy/LanMountainDesktop.Launcher.exe update apply
|
||||
# 运行主程序并通过 Host 更新服务触发下载、应用和回滚。
|
||||
```
|
||||
|
||||
### 添加新的 OOBE 步骤
|
||||
@@ -486,13 +425,7 @@ _oobeSteps = [
|
||||
|
||||
### 自定义更新源
|
||||
|
||||
修改 `App.axaml.cs` 中的 GitHub 仓库信息:
|
||||
```csharp
|
||||
var updateCheckService = new UpdateCheckService(
|
||||
"YourOrg", // GitHub 组织/用户名
|
||||
"YourRepo" // 仓库名
|
||||
);
|
||||
```
|
||||
更新源配置与 manifest provider 位于 Host 更新服务中,优先查看 `LanMountainDesktop/Services/Update/UpdateOrchestrator.cs`、`SettingsUpdateManifestProvider.cs` 与具体 provider。
|
||||
|
||||
## 相关文档
|
||||
|
||||
@@ -506,10 +439,10 @@ var updateCheckService = new UpdateCheckService(
|
||||
- OOBE state is a per-user truth source stored at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||
- Same-user reinstall or upgrade must not re-enter OOBE.
|
||||
- `first_run_completed` is legacy compatibility data only and should not remain the long-term primary format.
|
||||
- Launch source values are `normal`, `postinstall`, `apply-update`, `plugin-install`, and `debug-preview`.
|
||||
- Launch source values are `normal`, `postinstall`, `plugin-install`, and `debug-preview`.
|
||||
- Auto-OOBE is allowed only for normal user-mode startup.
|
||||
- `postinstall` may open OOBE only when the launcher is not elevated and the user state path is available.
|
||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
||||
- `plugin-install` and `debug-preview` must not auto-enter OOBE.
|
||||
- Allowed elevation paths are limited to the installer itself, full installer update application, and user-confirmed legacy uninstall.
|
||||
- Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
|
||||
- In-app market installs are deferred Host-side operations: download and verify now, apply from the per-user pending queue on the next Host startup.
|
||||
|
||||
@@ -32,11 +32,10 @@ These APIs report process, shell, tray, taskbar, and activation state separately
|
||||
|
||||
## Air APP Lifecycle
|
||||
|
||||
- Launcher is also the Air APP lifecycle manager.
|
||||
- The desktop host requests Air APP operations through `IAirAppLifecycleService` on the dedicated `LanMountainDesktop.Launcher.AirApp.v1` IPC pipe.
|
||||
- When the dedicated pipe is unavailable, the desktop host starts `LanMountainDesktop.Launcher.exe air-app-broker --requester-pid <pid>` and retries the request.
|
||||
- `air-app-broker` is a hidden internal command that starts only the Air APP lifecycle IPC host. It bypasses OOBE, Splash, debug preview windows, and normal desktop launch orchestration.
|
||||
- Launcher creates, activates, tracks, and closes Air APP host processes by instance key: `{appId}:{sourceComponentId}:{sourcePlacementId}`.
|
||||
- `LanMountainDesktop.AirAppHost` registers itself with Launcher after its window opens and unregisters on close; Launcher also prunes exited processes.
|
||||
- Launcher remains alive while either the desktop host process or any Air APP process is alive.
|
||||
- Broker mode remains alive while the requester process or any Air APP process is alive, then exits after both are gone.
|
||||
- `LanMountainDesktop.AirAppRuntime` is the Air APP lifecycle manager.
|
||||
- The desktop host requests Air APP operations through `IAirAppLifecycleService` on the dedicated `LanMountainDesktop.AirAppRuntime.v1` IPC pipe.
|
||||
- Launcher pre-starts `LanMountainDesktop.AirAppRuntime`; when the dedicated pipe is unavailable, the desktop host starts the runtime directly and retries the request.
|
||||
- AirApp Runtime, not Launcher, owns the Air APP lifecycle IPC host and AirAppHost process table.
|
||||
- AirApp Runtime creates, activates, tracks, and closes Air APP host processes by instance key: `{appId}:{sourceComponentId}:{sourcePlacementId}`.
|
||||
- `LanMountainDesktop.AirAppHost` registers itself with AirApp Runtime after its window opens and unregisters on close; Runtime also prunes exited processes.
|
||||
- Launcher waits only for the desktop host startup path. AirApp Runtime remains alive while Launcher/Host/requester or any Air APP process is alive, then exits after all are gone.
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
LanMountainDesktop/
|
||||
├── LanMountainDesktop.Launcher.exe # 启动器可执行文件
|
||||
├── LanMountainDesktop.Launcher.dll # 启动器依赖
|
||||
├── LanMountainDesktop.AirAppRuntime.exe # 轻应用生命周期容器(JIT)
|
||||
├── ... # 其他启动器依赖文件
|
||||
├── app-1.0.0/ # 主程序部署目录
|
||||
│ ├── LanMountainDesktop.exe # 主程序可执行文件
|
||||
│ ├── LanMountainDesktop.AirAppHost.exe # 轻应用窗口宿主
|
||||
│ ├── LanMountainDesktop.dll # 主程序依赖
|
||||
│ ├── version.json # 版本信息文件
|
||||
│ └── .current # 当前版本标记文件
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
- Windows installers do not bundle the .NET shared runtime.
|
||||
- `LanMountainDesktop.Launcher.exe` is the package-root bootstrapper and remains Native AOT/self-contained.
|
||||
- `LanMountainDesktop.AirAppRuntime.exe` is a package-root framework-dependent JIT process. Launcher pre-starts it, and Host can start it as a direct-run fallback.
|
||||
- `LanMountainDesktop.exe` and `LanMountainDesktop.AirAppHost.exe` are framework-dependent, RID-specific apps under `app-<version>/`.
|
||||
- Inno Setup downloads and silently installs the matching .NET 10 Desktop Runtime before continuing:
|
||||
- x64 installer: `https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x64.exe`
|
||||
|
||||
@@ -327,7 +327,7 @@ rm -rf ~/.local/share/LanMountainDesktop/.launcher/update/incoming/*
|
||||
|
||||
1. **鐗堟湰鍥為€€:**
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update rollback
|
||||
Use Host Settings > Update > Rollback
|
||||
```
|
||||
|
||||
2. **妫€鏌ユ洿鏂板揩鐓<E68FA9>:**
|
||||
|
||||
@@ -27,28 +27,27 @@ LanMountainDesktop 使用基于 GitHub Release 的增量更新系统,支持:
|
||||
### 完整更新流程图
|
||||
|
||||
```
|
||||
Launcher 启动
|
||||
Host 更新编排
|
||||
↓
|
||||
UpdateCheckService.CheckForUpdateAsync()
|
||||
├─ 调用 GitHub Release API
|
||||
UpdateOrchestrator.CheckAsync()
|
||||
├─ 调用 PLONDS / GitHub manifest provider
|
||||
├─ 根据更新频道过滤版本
|
||||
└─ 对比当前版本和最新版本
|
||||
↓
|
||||
有新版本? ──No→ 继续启动
|
||||
有新版本? ──No→ 回到空闲
|
||||
↓ Yes
|
||||
IUpdateEngine.DownloadAsync() / UpdateEngineFacade
|
||||
├─ 下载 files-{version}.json
|
||||
├─ 下载 files-{version}.json.sig
|
||||
└─ 下载 delta-{old}-to-{new}.zip (或完整包)
|
||||
UpdateOrchestrator.DownloadAsync()
|
||||
├─ 下载 plonds-filemap.json
|
||||
├─ 下载 plonds-filemap.sig
|
||||
└─ 下载对象文件或完整安装器
|
||||
↓
|
||||
保存到 .launcher/update/incoming/
|
||||
保存到 .Launcher/update/incoming/ 并写入 deployment.lock
|
||||
↓
|
||||
下次启动时
|
||||
Host 调用 UpdateInstallGateway
|
||||
↓
|
||||
IUpdateEngine.ApplyPendingUpdateAsync() / UpdateEngineFacade
|
||||
├─ PendingUpdateDetector 识别 Legacy/PLONDS pending 更新
|
||||
UpdateInstallGateway.InstallAsync()
|
||||
├─ UpdateSignatureVerifier 验证签名和哈希
|
||||
├─ LegacyUpdateApplier 或 PlondsUpdateApplier 应用文件
|
||||
├─ PlondsUpdateApplier 应用文件
|
||||
├─ DeploymentActivator 切换 .current/.partial/.destroy
|
||||
├─ UpdateSnapshotStore / InstallCheckpointStore 记录快照和断点
|
||||
└─ IncomingArtifactsCleaner 清理 incoming 缓存
|
||||
@@ -253,7 +252,7 @@ private void TryRollbackOnFailure(SnapshotMetadata snapshot)
|
||||
### 手动回退
|
||||
|
||||
```bash
|
||||
LanMountainDesktop.Launcher.exe update rollback
|
||||
主程序设置页 → 更新 → 回滚
|
||||
```
|
||||
|
||||
### 回退流程
|
||||
@@ -442,5 +441,5 @@ private static void EnsurePathWithinRoot(string targetPath, string rootPath)
|
||||
## VeloPack Packaging (Current)
|
||||
|
||||
- Release pipeline now produces VeloPack native assets (
|
||||
eleases.win.json, *.nupkg, RELEASES).
|
||||
eleases.win.json, *.nupkg, RELEASES).
|
||||
- Host owns update check/download/apply/rollback orchestration. Launcher only selects and starts the current version; package generation uses VeloPack.
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
| `LanMountainDesktop/` | 桌面宿主应用 | UI、服务、主流程、组件系统、插件接入 |
|
||||
| `LanMountainDesktop.PluginSdk/` | 插件 SDK | 公共接口、扩展方法、默认打包行为 |
|
||||
| `LanMountainDesktop.Shared.Contracts/` | 共享契约 | 宿主与插件共享记录、模型、边界类型 |
|
||||
| `LanMountainDesktop.AirAppRuntime/` | 轻应用生命周期容器 | Air APP IPC、实例表、AirAppHost 进程管理 |
|
||||
| `LanMountainDesktop.Appearance/` | 外观基础设施 | 主题、圆角、外观资源相关逻辑 |
|
||||
| `LanMountainDesktop.Settings.Core/` | 设置基础设施 | 设置 scope、存储抽象、设置 facade 支撑 |
|
||||
| `LanMountainDesktop.DesktopHost/` | 桌面宿主流程 | 生命周期、宿主流程支撑 |
|
||||
| `LanMountainDesktop.DesktopComponents.Runtime/` | 组件运行时 | 组件宿主运行时支撑 |
|
||||
| `LanMountainDesktop.Host.Abstractions/` | 宿主抽象 | 宿主接口与抽象层 |
|
||||
| `LanMountainDesktop.Launcher/` | 启动器 | 发布输出、OOBE、启动页、更新与插件安装/更新 |
|
||||
| `LanMountainDesktop.Launcher/` | 启动器 | 发布输出、OOBE、启动页、版本目录选择、AirApp Runtime 预启动、Host 启动与插件维护 CLI;不承担更新职责 |
|
||||
| `LanMountainDesktop.PluginTemplate/` | 插件模板 | `dotnet new lmd-plugin` 模板内容 |
|
||||
| `LanMountainDesktop.Tests/` | 测试 | 行为回归、契约验证、基础能力校验 |
|
||||
|
||||
|
||||
116
docs/auto_commit_md/20260531_21e970c.md
Normal file
116
docs/auto_commit_md/20260531_21e970c.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Git 提交分析报告
|
||||
|
||||
## 基本信息
|
||||
|
||||
- **哈希**: 21e970c5b65268fbb3b5fdf682fe9ce49b083920
|
||||
- **短哈希**: 21e970c
|
||||
- **作者**: lincube <lincube3@hotmail.com>
|
||||
- **时间**: 2026-05-31 09:26:16 +0800
|
||||
|
||||
## 提交信息摘要
|
||||
|
||||
fix.修复了窗口问题,以及多次显示圆角调节选项的问题。
|
||||
|
||||
## 变更统计
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 变更文件数 | 待获取 |
|
||||
| 新增行数 | 待获取 |
|
||||
| 删除行数 | 待获取 |
|
||||
| 净变化 | 待获取 |
|
||||
|
||||
## 详细变更分析
|
||||
|
||||
### 变更类型
|
||||
|
||||
根据提交信息分析,本次提交为 **Bug 修复** 类型的提交。
|
||||
|
||||
### 主要变更点
|
||||
|
||||
#### 1. 窗口问题修复
|
||||
|
||||
- **问题描述**:应用窗口存在问题需要修复
|
||||
- **可能涉及文件**:
|
||||
- `LanMountainDesktop/Views/` - 窗口视图文件
|
||||
- `LanMountainDesktop/ViewModels/` - 视图模型文件
|
||||
- `LanMountainDesktop/Services/` - 窗口相关服务
|
||||
|
||||
#### 2. 圆角调节选项多次显示问题修复
|
||||
|
||||
- **问题描述**:圆角调节选项被多次显示,导致用户体验问题
|
||||
- **可能涉及文件**:
|
||||
- `LanMountainDesktop/Views/SettingsWindow.axaml` - 设置窗口视图
|
||||
- `LanMountainDesktop/ViewModels/SettingsViewModels.cs` - 设置视图模型
|
||||
- `LanMountainDesktop/Services/AppearanceThemeService.cs` - 外观主题服务
|
||||
- `docs/CORNER_RADIUS_SPEC.md` - 圆角规范文档
|
||||
|
||||
### 潜在原因分析
|
||||
|
||||
#### 窗口问题可能原因:
|
||||
|
||||
1. **窗口状态管理问题**:窗口在特定操作后状态未正确保存或恢复
|
||||
2. **多显示器问题**:在多显示器环境下窗口位置或大小计算错误
|
||||
3. **DPI 缩放问题**:高 DPI 显示器下窗口显示异常
|
||||
4. **主题切换问题**:切换主题时窗口未正确重绘
|
||||
|
||||
#### 圆角选项多次显示问题可能原因:
|
||||
|
||||
1. **事件重复绑定**:圆角调节相关的事件处理器被多次注册
|
||||
2. **UI 更新逻辑问题**:在某些条件下 UI 被多次刷新
|
||||
3. **异步操作竞态条件**:异步操作完成时机不当导致重复渲染
|
||||
4. **数据绑定问题**:ObservableCollection 或绑定源被多次更新
|
||||
|
||||
## 代码审查要点
|
||||
|
||||
### 优势
|
||||
|
||||
1. **及时修复**:快速响应用户反馈的问题
|
||||
2. **针对性修复**:同时解决窗口和 UI 显示两个问题
|
||||
3. **遵循规范**:根据 `docs/CORNER_RADIUS_SPEC.md` 规范修复圆角相关问题
|
||||
|
||||
### 潜在风险
|
||||
|
||||
1. **修复不完整**:可能只修复了表面症状,未解决根本原因
|
||||
2. **引入新问题**:修复过程中可能引入新的 bug
|
||||
3. **兼容性问题**:修复可能影响旧版本的兼容性
|
||||
|
||||
### 建议
|
||||
|
||||
1. **充分测试**:
|
||||
- 在不同显示器配置下测试窗口行为
|
||||
- 多次打开/关闭设置窗口,验证圆角选项是否仍会重复显示
|
||||
- 测试主题切换对窗口的影响
|
||||
|
||||
2. **代码审查**:
|
||||
- 检查是否存在事件重复绑定
|
||||
- 审查异步操作的线程安全性
|
||||
- 验证数据绑定的正确性
|
||||
|
||||
3. **用户反馈**:
|
||||
- 收集用户在实际使用中遇到的问题
|
||||
- 确认修复是否解决了所有相关问题
|
||||
|
||||
4. **文档更新**:
|
||||
- 如果发现是常见问题,考虑在文档中添加说明
|
||||
- 更新 CHANGELOG 记录此修复
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [圆角规范文档](d:\github\LanMountainDesktop\docs\CORNER_RADIUS_SPEC.md)
|
||||
- [设置窗口设计文档](d:\github\LanMountainDesktop\docs\ai\SETTINGS_WINDOW_DESIGN.md)
|
||||
- [视觉规范文档](d:\github\LanMountainDesktop\docs\VISUAL_SPEC.md)
|
||||
|
||||
## 备注
|
||||
|
||||
> ⚠️ **注意**:由于命令执行环境限制,无法获取详细的代码变更(diff)信息。以上分析基于提交信息和代码库上下文推断得出。建议在能够执行 git 命令的环境中运行以下命令获取完整信息:
|
||||
>
|
||||
> ```bash
|
||||
> git show 21e970c5b65268fbb3b5fdf682fe9ce49b083920
|
||||
> ```
|
||||
|
||||
## 生成信息
|
||||
|
||||
- 报告生成时间:2026-05-31
|
||||
- 分析工具:自动提交分析脚本
|
||||
- 报告版本:v1.0
|
||||
3
get_commits.bat
Normal file
3
get_commits.bat
Normal file
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "d:\github\LanMountainDesktop"
|
||||
git --no-pager log --since="2026-05-31" --until="2026-05-31 23:59:59" --format="%%H|%%an|%%ae|%%ai|%%s" --no-merges
|
||||
5
get_commits.ps1
Normal file
5
get_commits.ps1
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env pwsh
|
||||
$ErrorActionPreference = "Continue"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
git --no-pager log --since="2026-05-31 00:00:00" --until="2026-05-31 23:59:59" --format="%H|%an|%ae|%ai|%s" --no-merges
|
||||
201
scripts/generate_today_commits.py
Normal file
201
scripts/generate_today_commits.py
Normal file
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
def run_git_command(cmd, cwd=None, timeout=5):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
timeout=timeout
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"Git command failed: {cmd}")
|
||||
print(f"Error: {result.stderr}")
|
||||
return None
|
||||
return result.stdout
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"Git command timed out: {cmd}")
|
||||
return None
|
||||
|
||||
|
||||
def get_commits_since(since_date, until_date, cwd=None):
|
||||
cmd = f'git log --since="{since_date}" --until="{until_date}" --pretty=format:"%H|%an|%ae|%ai|%s" --date=iso'
|
||||
output = run_git_command(cmd, cwd)
|
||||
if not output:
|
||||
return []
|
||||
|
||||
commits = []
|
||||
for line in output.strip().split('\n'):
|
||||
if line and '|' in line:
|
||||
parts = line.split('|', 4)
|
||||
if len(parts) == 5:
|
||||
commits.append({
|
||||
'hash': parts[0],
|
||||
'author': parts[1],
|
||||
'email': parts[2],
|
||||
'date': parts[3],
|
||||
'message': parts[4]
|
||||
})
|
||||
return commits
|
||||
|
||||
|
||||
def get_commit_diff(commit_hash, cwd=None):
|
||||
cmd = f'git show {commit_hash} --format="" -- patches'
|
||||
return run_git_command(cmd, cwd)
|
||||
|
||||
|
||||
def get_commit_stat(commit_hash, cwd=None):
|
||||
cmd = f'git show {commit_hash} --stat'
|
||||
return run_git_command(cmd, cwd)
|
||||
|
||||
|
||||
def analyze_diff(diff_text):
|
||||
file_changes = []
|
||||
current_file = None
|
||||
changes = {'insertions': 0, 'deletions': 0, 'files': 0}
|
||||
|
||||
if not diff_text:
|
||||
return [], changes
|
||||
|
||||
lines = diff_text.split('\n')
|
||||
for line in lines:
|
||||
if line.startswith('diff --git'):
|
||||
if current_file:
|
||||
file_changes.append(current_file)
|
||||
filename = line.split(' ')[2][2:]
|
||||
current_file = {'name': filename, 'insertions': 0, 'deletions': 0, 'hunks': []}
|
||||
changes['files'] += 1
|
||||
elif line.startswith('+') and not line.startswith('+++'):
|
||||
if current_file:
|
||||
current_file['insertions'] += 1
|
||||
changes['insertions'] += 1
|
||||
elif line.startswith('-') and not line.startswith('---'):
|
||||
if current_file:
|
||||
current_file['deletions'] += 1
|
||||
changes['deletions'] += 1
|
||||
|
||||
if current_file:
|
||||
file_changes.append(current_file)
|
||||
|
||||
return file_changes, changes
|
||||
|
||||
|
||||
def generate_markdown_report(commit, diff_text, stat_text, output_dir):
|
||||
file_changes, summary = analyze_diff(diff_text if diff_text else "")
|
||||
|
||||
short_hash = commit['hash'][:7]
|
||||
date_str = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
filename = f"{date_str}_{short_hash}.md"
|
||||
filepath = Path(output_dir) / filename
|
||||
|
||||
markdown = f"""# Git 提交分析报告
|
||||
|
||||
## 基本信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 提交哈希 | `{commit['hash']}` |
|
||||
| 短哈希 | `{short_hash}` |
|
||||
| 作者 | {commit['author']} <{commit['email']}> |
|
||||
| 提交时间 | {commit['date']} |
|
||||
|
||||
## 提交信息
|
||||
|
||||
{commit['message']}
|
||||
|
||||
## 变更统计
|
||||
|
||||
"""
|
||||
|
||||
if stat_text:
|
||||
markdown += "```\n"
|
||||
markdown += stat_text
|
||||
markdown += "\n```\n\n"
|
||||
else:
|
||||
markdown += f"- **变更文件数**: {summary['files']}\n"
|
||||
markdown += f"- **新增行数**: +{summary['insertions']}\n"
|
||||
markdown += f"- **删除行数**: -{summary['deletions']}\n\n"
|
||||
|
||||
markdown += "## 详细变更\n\n"
|
||||
|
||||
if file_changes:
|
||||
markdown += "### 文件变更列表\n\n"
|
||||
for fc in sorted(file_changes, key=lambda x: x['name']):
|
||||
markdown += f"- `{fc['name']}` - 新增: +{fc['insertions']} 行, 删除: -{fc['deletions']} 行\n"
|
||||
else:
|
||||
markdown += "*无详细变更信息*\n"
|
||||
|
||||
markdown += "\n## 完整 Diff\n\n"
|
||||
if diff_text:
|
||||
markdown += "```diff\n"
|
||||
markdown += diff_text
|
||||
markdown += "\n```\n"
|
||||
else:
|
||||
markdown += "*无法获取详细的 diff 信息*\n"
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(markdown)
|
||||
|
||||
print(f"Generated: {filepath}")
|
||||
return filepath
|
||||
|
||||
|
||||
def main():
|
||||
repo_dir = Path(__file__).parent.parent
|
||||
output_dir = repo_dir / 'docs' / 'auto_commit_md'
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
today = datetime.now()
|
||||
since_date = today.strftime('%Y-%m-%d 00:00:00')
|
||||
until_date = today.strftime('%Y-%m-%d 23:59:59')
|
||||
|
||||
print(f"Fetching commits from {since_date} to {until_date}...")
|
||||
commits = get_commits_since(since_date, until_date, str(repo_dir))
|
||||
|
||||
if not commits:
|
||||
print("No commits found for today.")
|
||||
print("\nLet's check the latest commits instead...")
|
||||
cmd = 'git log -3 --pretty=format:"%H|%an|%ae|%ai|%s" --date=iso'
|
||||
output = run_git_command(cmd, str(repo_dir))
|
||||
if output:
|
||||
commits = []
|
||||
for line in output.strip().split('\n'):
|
||||
if line and '|' in line:
|
||||
parts = line.split('|', 4)
|
||||
if len(parts) == 5:
|
||||
commits.append({
|
||||
'hash': parts[0],
|
||||
'author': parts[1],
|
||||
'email': parts[2],
|
||||
'date': parts[3],
|
||||
'message': parts[4]
|
||||
})
|
||||
|
||||
if commits:
|
||||
print(f"\nFound {len(commits)} commit(s) to analyze:\n")
|
||||
for commit in commits:
|
||||
print(f" - {commit['hash'][:7]}: {commit['message']}")
|
||||
|
||||
print("\nGenerating reports...\n")
|
||||
for commit in commits:
|
||||
diff = get_commit_diff(commit['hash'], str(repo_dir))
|
||||
stat = get_commit_stat(commit['hash'], str(repo_dir))
|
||||
generate_markdown_report(commit, diff, stat, str(output_dir))
|
||||
|
||||
print(f"\nDone! Reports saved to {output_dir}")
|
||||
else:
|
||||
print("No commits found.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user