mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-29 22:24:26 +08:00
Compare commits
3 Commits
17873f0f43
...
v0.8.7.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2ac302ee7 | ||
|
|
c351a8e7f3 | ||
|
|
21e970c5b6 |
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,54 +80,11 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
|
||||
}
|
||||
|
||||
|
||||
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
||||
{
|
||||
startInfo.ArgumentList.Add(name);
|
||||
@@ -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;
|
||||
|
||||
@@ -14,7 +14,7 @@ internal static class Commands
|
||||
{
|
||||
var source = context.GetOption("source") ?? string.Empty;
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
||||
result = installer.InstallPackage(source, pluginsDir);
|
||||
result = installer.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -91,12 +91,12 @@ internal static class Commands
|
||||
{
|
||||
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||
return pluginInstaller.InstallPackage(source, pluginsDir);
|
||||
return pluginInstaller.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
|
||||
}
|
||||
case "update":
|
||||
{
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
||||
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir, context.ExplicitAppRoot);
|
||||
}
|
||||
default:
|
||||
return new LauncherResult
|
||||
|
||||
@@ -193,8 +193,10 @@ internal sealed class DataLocationResolver
|
||||
|
||||
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
||||
{
|
||||
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath)
|
||||
? Path.GetFullPath(customPath)
|
||||
var targetDataRoot = mode == DataLocationMode.Portable
|
||||
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
|
||||
? customPath
|
||||
: DefaultPortableDataPath)
|
||||
: _defaultSystemDataPath;
|
||||
|
||||
var config = new DataLocationConfig
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ internal sealed class PluginInstallerService
|
||||
TimeSpan.FromMilliseconds(500)
|
||||
];
|
||||
|
||||
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
|
||||
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory, string? appRoot = null)
|
||||
{
|
||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||
@@ -32,7 +32,7 @@ internal sealed class PluginInstallerService
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
|
||||
if (TryBuildElevationRequiredResult(fullPluginsDirectory, appRoot) is { } elevationRequiredResult)
|
||||
{
|
||||
return elevationRequiredResult;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ internal sealed class PluginInstallerService
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
|
||||
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory, string? appRoot)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
@@ -68,8 +68,10 @@ internal sealed class PluginInstallerService
|
||||
string? allowedRoot = null;
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
var resolvedAppRoot = !string.IsNullOrWhiteSpace(appRoot)
|
||||
? Path.GetFullPath(appRoot)
|
||||
: Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(resolvedAppRoot);
|
||||
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -14,7 +14,7 @@ internal sealed class PluginUpgradeQueueService
|
||||
_installerService = installerService;
|
||||
}
|
||||
|
||||
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
|
||||
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory, string? appRoot = null)
|
||||
{
|
||||
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
||||
if (!File.Exists(pendingPath))
|
||||
@@ -43,7 +43,7 @@ internal sealed class PluginUpgradeQueueService
|
||||
|
||||
try
|
||||
{
|
||||
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
|
||||
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory, appRoot);
|
||||
succeeded.Add(item);
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
35
LanMountainDesktop.Tests/DataLocationResolverTests.cs
Normal file
35
LanMountainDesktop.Tests/DataLocationResolverTests.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class DataLocationResolverTests : IDisposable
|
||||
{
|
||||
private readonly string _appRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.Tests",
|
||||
nameof(DataLocationResolverTests),
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public void ApplyLocationChoice_PortableWithoutCustomPath_UsesAppRootDesktopDirectory()
|
||||
{
|
||||
Directory.CreateDirectory(_appRoot);
|
||||
var resolver = new DataLocationResolver(_appRoot);
|
||||
|
||||
var applied = resolver.ApplyLocationChoice(DataLocationMode.Portable);
|
||||
|
||||
Assert.True(applied);
|
||||
Assert.Equal(
|
||||
Path.Combine(Path.GetFullPath(_appRoot), "Desktop"),
|
||||
resolver.ResolveDataRoot());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_appRoot))
|
||||
{
|
||||
Directory.Delete(_appRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,22 @@ public sealed class MaterialColorSettingsPageViewModelTests
|
||||
Assert.Equal(1, facade.ThemeSaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserSelection_SystemMaterialModeRequestsRestart()
|
||||
{
|
||||
var facade = new FakeSettingsFacade(CreateThemeState(ThemeAppearanceValues.MaterialNone));
|
||||
var materialService = new FakeMaterialColorService(CreateSnapshot(ThemeAppearanceValues.MaterialNone));
|
||||
var viewModel = new MaterialColorSettingsPageViewModel(facade, materialService);
|
||||
string? restartReason = null;
|
||||
viewModel.RestartRequested += reason => restartReason = reason;
|
||||
|
||||
viewModel.SelectedSystemMaterialMode = viewModel.SystemMaterialModes.Single(option =>
|
||||
option.Value == ThemeAppearanceValues.MaterialMica);
|
||||
|
||||
Assert.Equal(viewModel.SystemMaterialRestartMessage, restartReason);
|
||||
Assert.False(string.IsNullOrWhiteSpace(restartReason));
|
||||
}
|
||||
|
||||
private static ThemeAppearanceSettingsState CreateThemeState(string materialMode)
|
||||
{
|
||||
return new ThemeAppearanceSettingsState(
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using LanMountainDesktop.Launcher.Plugins;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
@@ -34,10 +35,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("ok", result.Code);
|
||||
@@ -49,6 +50,42 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_AllowsConfiguredPortableDataRootOutsideUserScope()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
var appRoot = Path.Combine(_tempRoot, "PackageRoot");
|
||||
var portableDataRoot = Path.Combine(appRoot, "Desktop");
|
||||
var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
|
||||
Directory.CreateDirectory(launcherDataRoot);
|
||||
File.WriteAllText(
|
||||
Path.Combine(launcherDataRoot, "data-location.config.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
DataLocationMode = "Portable",
|
||||
SystemDataPath = Path.Combine(_tempRoot, "System"),
|
||||
PortableDataPath = portableDataRoot
|
||||
}));
|
||||
|
||||
var packagePath = Path.Combine(_tempRoot, "portable.laapp");
|
||||
CreatePluginPackage(packagePath, "plugin.json", "plugin.portable.sample", "Portable Plugin");
|
||||
|
||||
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("ok", result.Code);
|
||||
Assert.True(File.Exists(result.InstalledPackagePath));
|
||||
Assert.StartsWith(Path.GetFullPath(portableDataRoot), Path.GetFullPath(result.InstalledPackagePath!), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
|
||||
{
|
||||
@@ -58,11 +95,11 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
|
||||
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var first = service.InstallPackage(firstPackagePath, pluginsDirectory);
|
||||
var second = service.InstallPackage(secondPackagePath, pluginsDirectory);
|
||||
var first = service.InstallPackage(firstPackagePath, pluginsDirectory, appRoot);
|
||||
var second = service.InstallPackage(secondPackagePath, pluginsDirectory, appRoot);
|
||||
|
||||
Assert.True(first.Success);
|
||||
Assert.True(second.Success);
|
||||
@@ -77,10 +114,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("plugin.legacy.sample", result.ManifestId);
|
||||
@@ -103,18 +140,24 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
""");
|
||||
}
|
||||
|
||||
private static string CreateUserScopedPluginsDirectory()
|
||||
private string CreateConfiguredPortablePluginsDirectory(out string appRoot)
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Tests",
|
||||
nameof(PluginInstallerServiceTests),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
appRoot = Path.Combine(_tempRoot, "ConfiguredPackageRoot", Guid.NewGuid().ToString("N"));
|
||||
var portableDataRoot = Path.Combine(appRoot, "Desktop");
|
||||
var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
|
||||
Directory.CreateDirectory(launcherDataRoot);
|
||||
File.WriteAllText(
|
||||
Path.Combine(launcherDataRoot, "data-location.config.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
DataLocationMode = "Portable",
|
||||
SystemDataPath = Path.Combine(_tempRoot, "System"),
|
||||
PortableDataPath = portableDataRoot
|
||||
}));
|
||||
|
||||
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
|
||||
Directory.CreateDirectory(pluginsDirectory);
|
||||
return pluginsDirectory;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
40
LanMountainDesktop.Tests/PluginRuntimeDataPathTests.cs
Normal file
40
LanMountainDesktop.Tests/PluginRuntimeDataPathTests.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PluginRuntimeDataPathTests : IDisposable
|
||||
{
|
||||
private readonly string _dataRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.Tests",
|
||||
nameof(PluginRuntimeDataPathTests),
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public void PluginRuntime_UsesHostDataRootForPluginsAndMarketData()
|
||||
{
|
||||
AppDataPathProvider.Initialize(["--data-root", _dataRoot]);
|
||||
|
||||
using var runtime = new PluginRuntimeService();
|
||||
|
||||
Assert.Equal(
|
||||
Path.Combine(Path.GetFullPath(_dataRoot), "Extensions", "Plugins"),
|
||||
runtime.PluginsDirectory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AppDataPathProvider.ResetForTests();
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_dataRoot))
|
||||
{
|
||||
Directory.Delete(_dataRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
103
LanMountainDesktop.Tests/SystemChromeModeTests.cs
Normal file
103
LanMountainDesktop.Tests/SystemChromeModeTests.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class SystemChromeModeTests
|
||||
{
|
||||
[Fact]
|
||||
public void SettingsWindow_SystemChromeUsesNativeDecorations()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsWindow.axaml.cs");
|
||||
var applyChromeMode = ExtractMethodSource(source, "ApplyChromeMode");
|
||||
var onLoaded = ExtractMethodSource(source, "OnLoaded");
|
||||
|
||||
Assert.Contains("_useSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();", applyChromeMode);
|
||||
Assert.Contains("WindowDecorations = WindowDecorations.Full;", applyChromeMode);
|
||||
Assert.Contains("ExtendClientAreaToDecorationsHint = !_useSystemChrome;", applyChromeMode);
|
||||
Assert.Contains("ExtendClientAreaTitleBarHeightHint = _useSystemChrome ? 0d : CustomTitleBarHeight;", applyChromeMode);
|
||||
Assert.Contains("TitleBar.ExtendsContentIntoTitleBar = !_useSystemChrome;", applyChromeMode);
|
||||
Assert.Contains("WindowTitleBarHost.IsVisible = false;", applyChromeMode);
|
||||
Assert.Contains("WindowTitleBarHost.IsVisible = true;", applyChromeMode);
|
||||
Assert.DoesNotContain("TitleBar.ExtendsContentIntoTitleBar = true;", onLoaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComponentEditorWindow_SystemChromeUsesNativeDecorations()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "ComponentEditorWindow.axaml.cs");
|
||||
var applyChromeMode = ExtractMethodSource(source, "ApplyChromeMode");
|
||||
|
||||
Assert.Contains("var preferSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();", applyChromeMode);
|
||||
Assert.Contains("WindowDecorations = WindowDecorations.Full;", applyChromeMode);
|
||||
Assert.Contains("ExtendClientAreaToDecorationsHint = false;", applyChromeMode);
|
||||
Assert.Contains("ExtendClientAreaTitleBarHeightHint = 0d;", applyChromeMode);
|
||||
Assert.Contains("CustomTitleBarHost.IsVisible = false;", applyChromeMode);
|
||||
Assert.Contains("WindowDecorations = WindowDecorations.BorderOnly;", applyChromeMode);
|
||||
Assert.Contains("ExtendClientAreaToDecorationsHint = true;", applyChromeMode);
|
||||
Assert.Contains("CustomTitleBarHost.IsVisible = true;", applyChromeMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SavingSystemChromeSynchronizesWindowsPatcherState()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Services", "Settings", "SettingsDomainServices.cs");
|
||||
|
||||
Assert.Contains("if (OperatingSystem.IsWindows())", source);
|
||||
Assert.Contains("LanMountainDesktop.Platform.Windows.ChromePatchState.UseSystemChrome = state.UseSystemChrome;", source);
|
||||
}
|
||||
|
||||
private static string ReadRepositoryFile(params string[] segments)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return File.ReadAllText(candidate);
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'.");
|
||||
}
|
||||
|
||||
private static string ExtractMethodSource(string source, string methodName)
|
||||
{
|
||||
var methodIndex = source.IndexOf($"private void {methodName}(", StringComparison.Ordinal);
|
||||
if (methodIndex < 0)
|
||||
{
|
||||
methodIndex = source.IndexOf($"public void {methodName}(", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.True(methodIndex >= 0, $"Could not locate method '{methodName}'.");
|
||||
|
||||
var braceIndex = source.IndexOf('{', methodIndex);
|
||||
Assert.True(braceIndex >= 0, $"Could not locate method body for '{methodName}'.");
|
||||
|
||||
var depth = 0;
|
||||
for (var i = braceIndex; i < source.Length; i++)
|
||||
{
|
||||
if (source[i] == '{')
|
||||
{
|
||||
depth++;
|
||||
}
|
||||
else if (source[i] == '}')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0)
|
||||
{
|
||||
return source.Substring(methodIndex, i - methodIndex + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Could not extract method '{methodName}'.");
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -394,8 +394,6 @@
|
||||
"settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.",
|
||||
"settings.appearance.theme_color_preview.system": "Currently previewing colors extracted from the system wallpaper.",
|
||||
"settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.",
|
||||
"settings.appearance.corner_radius.label": "Global corner radius style",
|
||||
"settings.appearance.corner_radius.description": "Select a fixed corner radius style inspired by Xiaomi HyperOS.",
|
||||
"component.color_scheme.follow_system": "Follow system color scheme",
|
||||
"component.color_scheme.native": "Use component custom color scheme",
|
||||
"component.settings.color_scheme": "Color Scheme",
|
||||
@@ -406,7 +404,7 @@
|
||||
"settings.appearance.system_material_desc.switchable": "Apply the selected material to windows, Dock, status bar, and component hosts.",
|
||||
"settings.appearance.system_material_desc.fixed": "Your current system only exposes the material modes listed here.",
|
||||
"settings.appearance.system_material_desc.auto": "Auto prefers Mica on Windows 11, Acrylic on Windows 10, and falls back to no material when unavailable.",
|
||||
"settings.appearance.restart_message": "Theme source and system material changes require restarting the app.",
|
||||
"settings.appearance.restart_message": "Window chrome changes require restarting the app.",
|
||||
"settings.appearance.preview.primary": "Primary",
|
||||
"settings.appearance.preview.secondary": "Secondary",
|
||||
"settings.appearance.preview.tertiary": "Tertiary",
|
||||
@@ -442,6 +440,7 @@
|
||||
"settings.material_color.wallpaper_seed.label": "Seed",
|
||||
"settings.material_color.system_material.label": "System material",
|
||||
"settings.material_color.system_material.description": "Apply the selected material mode to windows and host surfaces.",
|
||||
"settings.material_color.system_material.restart_message": "System material changes require restarting the app.",
|
||||
"settings.material_color.native_events.label": "Native wallpaper change events",
|
||||
"settings.material_color.native_events.description": "Use OS wallpaper notifications first and keep polling as fallback.",
|
||||
"settings.material_color.native_events.active": "Native wallpaper events active",
|
||||
|
||||
@@ -394,8 +394,6 @@
|
||||
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
|
||||
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
|
||||
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
|
||||
"settings.appearance.corner_radius.label": "全局圆角样式",
|
||||
"settings.appearance.corner_radius.description": "选择固定的全局圆角样式,受 HyperOS 启发。",
|
||||
"component.color_scheme.follow_system": "跟随系统配色",
|
||||
"component.color_scheme.native": "使用组件自定义配色",
|
||||
"component.settings.color_scheme": "配色方案",
|
||||
@@ -406,7 +404,7 @@
|
||||
"settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。",
|
||||
"settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。",
|
||||
"settings.appearance.system_material_desc.auto": "自动模式会在 Windows 11 优先使用 Mica,在 Windows 10 优先使用 Acrylic,不可用时回退到无材质。",
|
||||
"settings.appearance.restart_message": "主题色来源和系统材质更改需要重启应用。",
|
||||
"settings.appearance.restart_message": "窗口边框模式更改需要重启应用。",
|
||||
"settings.appearance.preview.primary": "主色",
|
||||
"settings.appearance.preview.secondary": "次色",
|
||||
"settings.appearance.preview.tertiary": "三次色",
|
||||
@@ -442,6 +440,7 @@
|
||||
"settings.material_color.wallpaper_seed.label": "种子色",
|
||||
"settings.material_color.system_material.label": "系统材质",
|
||||
"settings.material_color.system_material.description": "将所选材质模式应用到窗口和宿主表面。",
|
||||
"settings.material_color.system_material.restart_message": "系统材质更改需要重启应用。",
|
||||
"settings.material_color.native_events.label": "原生壁纸变更事件",
|
||||
"settings.material_color.native_events.description": "优先使用操作系统壁纸通知,并保持轮询作为回退。",
|
||||
"settings.material_color.native_events.active": "原生壁纸事件已激活",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ public static class AppDataPathProvider
|
||||
return Path.Combine(GetDataRoot(), "Wallpapers");
|
||||
}
|
||||
|
||||
internal static void ResetForTests()
|
||||
{
|
||||
_overriddenDataRoot = null;
|
||||
}
|
||||
|
||||
private static string? ResolveDataRootFromArgs(string[] args)
|
||||
{
|
||||
const string prefix = "--data-root=";
|
||||
|
||||
237
LanMountainDesktop/Services/ElevatedPluginInstallService.cs
Normal file
237
LanMountainDesktop/Services/ElevatedPluginInstallService.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed record ElevatedPluginInstallResult(
|
||||
bool Success,
|
||||
string? Code,
|
||||
string? Message,
|
||||
string? ErrorMessage,
|
||||
string? InstalledPackagePath,
|
||||
string? ManifestId,
|
||||
string? ManifestName);
|
||||
|
||||
internal sealed class ElevatedPluginInstallService
|
||||
{
|
||||
public async Task<ElevatedPluginInstallResult> InstallAsync(
|
||||
string sourcePackagePath,
|
||||
string pluginsDirectory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourcePackagePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsDirectory);
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return new ElevatedPluginInstallResult(
|
||||
false,
|
||||
"elevation_unsupported",
|
||||
"Elevated plugin installation is only supported on Windows.",
|
||||
"Elevated plugin installation is only supported on Windows.",
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
var launcherPath = ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
{
|
||||
return new ElevatedPluginInstallResult(
|
||||
false,
|
||||
"launcher_not_found",
|
||||
"Launcher executable was not found for elevated plugin installation.",
|
||||
$"Launcher executable was not found. ResolvedPath='{launcherPath ?? string.Empty}'.",
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
var resultPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"LanMountainDesktop.PluginInstall.{Guid.NewGuid():N}.json");
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
UseShellExecute = true,
|
||||
Verb = "runas",
|
||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory
|
||||
};
|
||||
startInfo.ArgumentList.Add("plugin");
|
||||
startInfo.ArgumentList.Add("install");
|
||||
startInfo.ArgumentList.Add("--source");
|
||||
startInfo.ArgumentList.Add(Path.GetFullPath(sourcePackagePath));
|
||||
startInfo.ArgumentList.Add("--plugins-dir");
|
||||
startInfo.ArgumentList.Add(Path.GetFullPath(pluginsDirectory));
|
||||
startInfo.ArgumentList.Add("--result");
|
||||
startInfo.ArgumentList.Add(resultPath);
|
||||
|
||||
var packageRoot = LauncherRuntimeMetadata.GetPackageRoot();
|
||||
if (!string.IsNullOrWhiteSpace(packageRoot))
|
||||
{
|
||||
startInfo.ArgumentList.Add("--app-root");
|
||||
startInfo.ArgumentList.Add(Path.GetFullPath(packageRoot));
|
||||
}
|
||||
|
||||
var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return new ElevatedPluginInstallResult(
|
||||
false,
|
||||
"launch_failed",
|
||||
"Elevated plugin installer did not start.",
|
||||
"Elevated plugin installer did not start.",
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (File.Exists(resultPath))
|
||||
{
|
||||
return ReadResult(resultPath);
|
||||
}
|
||||
|
||||
return new ElevatedPluginInstallResult(
|
||||
process.ExitCode == 0,
|
||||
process.ExitCode == 0 ? "ok" : "installer_failed",
|
||||
process.ExitCode == 0 ? "Plugin installed." : $"Elevated installer exited with code {process.ExitCode}.",
|
||||
process.ExitCode == 0 ? null : $"Elevated installer exited with code {process.ExitCode}.",
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||
{
|
||||
return new ElevatedPluginInstallResult(
|
||||
false,
|
||||
"elevation_cancelled",
|
||||
"Plugin installation was cancelled before elevation was approved.",
|
||||
ex.Message,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ElevatedPluginInstallResult(
|
||||
false,
|
||||
"elevation_failed",
|
||||
"Elevated plugin installation failed.",
|
||||
ex.Message,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(resultPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static ElevatedPluginInstallResult ReadResult(string resultPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(resultPath));
|
||||
var root = document.RootElement;
|
||||
return new ElevatedPluginInstallResult(
|
||||
GetBoolean(root, "Success"),
|
||||
GetString(root, "Code"),
|
||||
GetString(root, "Message"),
|
||||
GetString(root, "ErrorMessage"),
|
||||
GetString(root, "InstalledPackagePath"),
|
||||
GetString(root, "ManifestId"),
|
||||
GetString(root, "ManifestName"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ElevatedPluginInstallResult(
|
||||
false,
|
||||
"invalid_result",
|
||||
"Elevated plugin installer returned an invalid result.",
|
||||
ex.Message,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveLauncherExecutablePath()
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
LauncherRuntimeMetadata.GetPackageRoot(),
|
||||
AppContext.BaseDirectory,
|
||||
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."))
|
||||
};
|
||||
|
||||
foreach (var root in candidates.Where(candidate => !string.IsNullOrWhiteSpace(candidate)))
|
||||
{
|
||||
var path = Path.Combine(root!, OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.Launcher.exe"
|
||||
: "LanMountainDesktop.Launcher");
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool GetBoolean(JsonElement element, string propertyName)
|
||||
{
|
||||
return TryGetProperty(element, propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.True;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
return TryGetProperty(element, propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement property)
|
||||
{
|
||||
foreach (var candidate in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
property = candidate.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
property = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
@@ -11,8 +9,6 @@ namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
{
|
||||
private const string UpgradeHelperExecutableName = "LanMountainDesktop.PluginUpgradeHelper.exe";
|
||||
|
||||
public bool TryExit(HostApplicationLifecycleRequest? request = null)
|
||||
{
|
||||
App? app = null;
|
||||
@@ -53,11 +49,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HasPendingPluginUpgrades())
|
||||
{
|
||||
return TryRestartWithUpgradeHelper(request);
|
||||
}
|
||||
|
||||
return TryRestartDirectly(request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -68,61 +59,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasPendingPluginUpgrades()
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginsDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
var pendingUpgradesPath = Path.Combine(pluginsDirectory, ".pending-plugin-upgrades.json");
|
||||
return File.Exists(pendingUpgradesPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryRestartWithUpgradeHelper(HostApplicationLifecycleRequest? request)
|
||||
{
|
||||
AppLogger.Info("HostLifecycle", "Detected pending plugin upgrades. Using upgrade helper for restart.");
|
||||
|
||||
var helperPath = ResolveUpgradeHelperPath();
|
||||
if (!File.Exists(helperPath))
|
||||
{
|
||||
AppLogger.Warn("HostLifecycle", $"Upgrade helper not found at '{helperPath}'. Falling back to direct restart.");
|
||||
return TryRestartDirectly(request);
|
||||
}
|
||||
|
||||
var pluginsDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
|
||||
var app = Application.Current as App;
|
||||
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
|
||||
var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory;
|
||||
var launchArgs = startInfo?.Arguments ?? "";
|
||||
|
||||
var helperStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = helperPath,
|
||||
Arguments = $"--plugins-dir \"{pluginsDirectory}\" --parent-pid {Environment.ProcessId} --launch \"{launchCommand}\" --launch-args \"{launchArgs}\" --working-dir \"{AppContext.BaseDirectory}\"",
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = AppContext.BaseDirectory
|
||||
};
|
||||
|
||||
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
|
||||
|
||||
Process.Start(helperStartInfo);
|
||||
return app?.TrySubmitShutdown(HostShutdownMode.Restart, request) == true;
|
||||
}
|
||||
|
||||
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
|
||||
{
|
||||
var app = Application.Current as App;
|
||||
@@ -149,8 +85,4 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true;
|
||||
}
|
||||
|
||||
private static string ResolveUpgradeHelperPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "PluginUpgradeHelper", UpgradeHelperExecutableName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ public static class PendingRestartStateService
|
||||
public const string RenderModeReason = "RenderMode";
|
||||
public const string PluginCatalogReason = "PluginCatalog";
|
||||
public const string SettingsWindowReason = "SettingsWindow";
|
||||
public const string SystemMaterialReason = "SystemMaterial";
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
28
LanMountainDesktop/Services/PluginInstallTargetAccess.cs
Normal file
28
LanMountainDesktop/Services/PluginInstallTargetAccess.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal static class PluginInstallTargetAccess
|
||||
{
|
||||
public static bool CanWriteDirectory(string directory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
var probePath = Path.Combine(directory, $".write-test-{Guid.NewGuid():N}.tmp");
|
||||
File.WriteAllText(probePath, string.Empty);
|
||||
File.Delete(probePath);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,6 +315,10 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
{
|
||||
snapshot.UseSystemChrome = state.UseSystemChrome;
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.UseSystemChrome));
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
LanMountainDesktop.Platform.Windows.ChromePatchState.UseSystemChrome = state.UseSystemChrome;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.Equals(GlobalAppearanceSettings.NormalizeCornerRadiusStyle(snapshot.CornerRadiusStyle), normalizedCornerRadiusStyle, StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -299,6 +299,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService
|
||||
? ThemeVariant.Dark
|
||||
: ThemeVariant.Light;
|
||||
appearanceThemeService.ApplyThemeResources(window.Resources);
|
||||
window.ApplyFluentCornerRadius();
|
||||
}
|
||||
|
||||
private void ApplyThemeVariantAndResources(SettingsWindow window)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
using var applyLock = TryAcquireApplyLock(launcherRoot);
|
||||
if (applyLock is null)
|
||||
{
|
||||
return ApplyUpdateResults.Failed(
|
||||
"update.apply",
|
||||
"apply_in_progress",
|
||||
"Another update apply operation is already running.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
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));
|
||||
|
||||
var result = await applier.ApplyAsync().WaitAsync(ct).ConfigureAwait(false);
|
||||
if (result.Success)
|
||||
{
|
||||
AppLogger.Warn("UpdateInstallGateway", "Launcher executable not found. Falling back to next-startup apply.");
|
||||
return false;
|
||||
DeploymentLockService.ClearLock(launcherRoot);
|
||||
}
|
||||
|
||||
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source apply-update",
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = resolvedLauncherRoot
|
||||
};
|
||||
|
||||
Process.Start(startInfo);
|
||||
AppLogger.Info("UpdateInstallGateway", $"Launched Launcher for apply-update: {launcherPath}");
|
||||
return true;
|
||||
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;
|
||||
}
|
||||
|
||||
_stateStore.TransitionTo(UpdatePhase.RolledBack);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ public sealed partial class MaterialColorSettingsPageViewModel : ViewModelBase
|
||||
_materialColorService.MaterialColorChanged += OnMaterialColorChanged;
|
||||
}
|
||||
|
||||
public event Action<string>? RestartRequested;
|
||||
|
||||
public IReadOnlyList<SelectionOption> ColorModes { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> WallpaperColorSources { get; }
|
||||
@@ -176,6 +178,9 @@ public sealed partial class MaterialColorSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _systemMaterialDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _systemMaterialRestartMessage = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _nativeWallpaperEventsLabel = string.Empty;
|
||||
|
||||
@@ -313,7 +318,14 @@ public sealed partial class MaterialColorSettingsPageViewModel : ViewModelBase
|
||||
return;
|
||||
}
|
||||
|
||||
var currentMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(_settingsFacade.Theme.Get().SystemMaterialMode);
|
||||
var requestedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(value.Value);
|
||||
SaveTheme();
|
||||
if (!string.Equals(currentMode, requestedMode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
PendingRestartStateService.SetPending(PendingRestartStateService.SystemMaterialReason, true);
|
||||
RestartRequested?.Invoke(SystemMaterialRestartMessage);
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedRefreshIntervalChanged(SelectionOption value)
|
||||
@@ -577,6 +589,9 @@ public sealed partial class MaterialColorSettingsPageViewModel : ViewModelBase
|
||||
WallpaperSeedLabel = L("settings.material_color.wallpaper_seed.label", "Seed");
|
||||
SystemMaterialLabel = L("settings.material_color.system_material.label", "System material");
|
||||
SystemMaterialDescription = L("settings.material_color.system_material.description", "Apply the selected material mode to windows and host surfaces.");
|
||||
SystemMaterialRestartMessage = L(
|
||||
"settings.material_color.system_material.restart_message",
|
||||
"System material changes require restarting the app.");
|
||||
NativeWallpaperEventsLabel = L("settings.material_color.native_events.label", "Native wallpaper change events");
|
||||
NativeWallpaperEventsDescription = L("settings.material_color.native_events.description", "Use OS wallpaper notifications first and keep polling as fallback.");
|
||||
RefreshIntervalLabel = L("settings.material_color.refresh_interval.label", "Polling interval");
|
||||
|
||||
@@ -1032,27 +1032,12 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _useSystemChromeLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _cornerRadiusStyleLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _cornerRadiusStyleDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _themeHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _appearanceRestartMessage = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _cornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle;
|
||||
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<SelectionOption> _cornerRadiusStyleOptions = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption? _selectedCornerRadiusStyle;
|
||||
|
||||
public void Load()
|
||||
{
|
||||
var theme = _settingsFacade.Theme.Get();
|
||||
@@ -1101,17 +1086,6 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
PersistCurrentState(restartRequired: true);
|
||||
}
|
||||
|
||||
partial void OnSelectedCornerRadiusStyleChanged(SelectionOption? value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CornerRadiusStyle = value.Value;
|
||||
PersistCurrentState(restartRequired: false);
|
||||
}
|
||||
|
||||
private void RefreshLocalizedText()
|
||||
{
|
||||
ThemeHeader = L("settings.appearance.theme_header", "Theme");
|
||||
@@ -1121,25 +1095,15 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
ThemeModeDarkText = L("settings.appearance.theme_mode.dark", "Dark");
|
||||
ThemeModeFollowSystemText = L("settings.appearance.theme_mode.follow_system", "Follow system");
|
||||
UseSystemChromeLabel = L("settings.color.use_system_chrome_toggle", "Use system window chrome");
|
||||
CornerRadiusStyleLabel = L("settings.appearance.corner_radius.label", "Global corner radius style");
|
||||
CornerRadiusStyleDescription = L("settings.appearance.corner_radius.description", "Select a fixed corner radius style inspired by Xiaomi HyperOS.");
|
||||
AppearanceRestartMessage = L(
|
||||
"settings.appearance.restart_message",
|
||||
"Window chrome changes require restarting the app.");
|
||||
|
||||
CornerRadiusStyleOptions = GlobalAppearanceSettings.AllCornerRadiusStyles
|
||||
.Select(style => new SelectionOption(style, L($"settings.appearance.corner_radius.style_{style.ToLower()}", style)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void ApplySavedState(ThemeAppearanceSettingsState theme)
|
||||
{
|
||||
IsNightMode = theme.IsNightMode;
|
||||
UseSystemChrome = theme.UseSystemChrome;
|
||||
CornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(theme.CornerRadiusStyle);
|
||||
SelectedCornerRadiusStyle = CornerRadiusStyleOptions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, CornerRadiusStyle, StringComparison.OrdinalIgnoreCase))
|
||||
?? CornerRadiusStyleOptions.FirstOrDefault(o => o.Value == GlobalAppearanceSettings.DefaultCornerRadiusStyle);
|
||||
|
||||
var savedThemeMode = NormalizeThemeMode(theme.ThemeMode);
|
||||
SelectedThemeMode = ThemeModeOptions.FirstOrDefault(option =>
|
||||
@@ -1201,7 +1165,6 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
IsNightMode = IsNightMode,
|
||||
UseSystemChrome = UseSystemChrome,
|
||||
CornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(CornerRadiusStyle),
|
||||
ThemeMode = SelectedThemeMode?.Value ?? ThemeAppearanceValues.ThemeModeLight
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,14 +59,16 @@ public partial class ComponentEditorWindow : Window
|
||||
var preferSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();
|
||||
if (preferSystemChrome)
|
||||
{
|
||||
ExtendClientAreaToDecorationsHint = true;
|
||||
WindowDecorations = WindowDecorations.Full;
|
||||
ExtendClientAreaToDecorationsHint = false;
|
||||
ExtendClientAreaTitleBarHeightHint = 0d;
|
||||
CustomTitleBarHost.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
WindowDecorations = WindowDecorations.BorderOnly;
|
||||
ExtendClientAreaToDecorationsHint = true;
|
||||
ExtendClientAreaTitleBarHeightHint = 0d;
|
||||
CustomTitleBarHost.IsVisible = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,23 +38,6 @@
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Header="{Binding CornerRadiusStyleLabel}"
|
||||
Description="{Binding CornerRadiusStyleDescription}">
|
||||
<ui:FASettingsExpander.IconSource>
|
||||
<ui:FAFontIconSource Glyph="󰄦" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||
</ui:FASettingsExpander.IconSource>
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<ComboBox Width="220"
|
||||
ItemsSource="{Binding CornerRadiusStyleOptions}"
|
||||
SelectedItem="{Binding SelectedCornerRadiusStyle}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</ui:FASettingsExpander.Footer>
|
||||
</ui:FASettingsExpander>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -27,12 +27,18 @@ public partial class MaterialColorSettingsPage : SettingsPageBase
|
||||
public MaterialColorSettingsPage(MaterialColorSettingsPageViewModel viewModel)
|
||||
{
|
||||
ViewModel = viewModel;
|
||||
ViewModel.RestartRequested += OnRestartRequested;
|
||||
DataContext = ViewModel;
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public MaterialColorSettingsPageViewModel ViewModel { get; }
|
||||
|
||||
private void OnRestartRequested(string reason)
|
||||
{
|
||||
RequestRestart(reason);
|
||||
}
|
||||
|
||||
private void OnWallpaperSeedCandidateClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = e;
|
||||
|
||||
@@ -15,6 +15,7 @@ using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using Symbol = FluentIcons.Common.Symbol;
|
||||
|
||||
@@ -33,6 +34,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
|
||||
private const double MinPaneOpenLength = 260d;
|
||||
private const double MaxPaneOpenLength = 288d;
|
||||
private const double BaseNarrowThreshold = 800d;
|
||||
private const double CustomTitleBarHeight = 48d;
|
||||
private const string PaneToggleItemTag = "__pane_toggle__";
|
||||
|
||||
private readonly ISettingsPageRegistry _pageRegistry;
|
||||
@@ -89,14 +91,14 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
|
||||
|
||||
private void OnLoaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
TitleBar.Height = 48;
|
||||
TitleBar.ExtendsContentIntoTitleBar = true;
|
||||
TitleBar.Height = CustomTitleBarHeight;
|
||||
|
||||
// SecRandom MainWindow:标题栏按钮悬停/按下/非活动色,与系统 caption 更一致
|
||||
// Match the native caption button feedback used by SecRandom MainWindow.
|
||||
TitleBar.ButtonHoverBackgroundColor = Color.FromArgb(23, 0, 0, 0);
|
||||
TitleBar.ButtonPressedBackgroundColor = Color.FromArgb(52, 0, 0, 0);
|
||||
TitleBar.ButtonInactiveForegroundColor = Colors.Gray;
|
||||
|
||||
ApplyChromeMode(_useSystemChrome);
|
||||
SyncPendingRestartState();
|
||||
SyncTitleText();
|
||||
UpdateChromeMetrics();
|
||||
@@ -186,8 +188,11 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
|
||||
{
|
||||
_useSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();
|
||||
|
||||
ExtendClientAreaToDecorationsHint = true;
|
||||
WindowDecorations = WindowDecorations.Full;
|
||||
ExtendClientAreaToDecorationsHint = !_useSystemChrome;
|
||||
ExtendClientAreaTitleBarHeightHint = _useSystemChrome ? 0d : CustomTitleBarHeight;
|
||||
TitleBar.ExtendsContentIntoTitleBar = !_useSystemChrome;
|
||||
TitleBar.Height = CustomTitleBarHeight;
|
||||
|
||||
if (_useSystemChrome)
|
||||
{
|
||||
@@ -564,6 +569,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
|
||||
CloseButtonText = ViewModel.RestartDialogCloseText,
|
||||
DefaultButton = FAContentDialogButton.Primary
|
||||
};
|
||||
ApplyFluentCornerRadius(dialog.Resources);
|
||||
|
||||
var result = await dialog.ShowAsync(this);
|
||||
if (result == FAContentDialogResult.Primary)
|
||||
@@ -802,27 +808,40 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override global corner radius tokens on the settings window root grid
|
||||
/// Override inherited corner radius tokens on the settings window root grid
|
||||
/// so all child controls use Microsoft Fluent Design System values,
|
||||
/// independent of the user's global corner radius preference.
|
||||
/// independent of the user's component corner radius preference.
|
||||
/// </summary>
|
||||
private void ApplyFluentCornerRadius()
|
||||
public void ApplyFluentCornerRadius()
|
||||
{
|
||||
if (RootGrid is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tokens = AppearanceCornerRadiusTokenFactory.Create(
|
||||
GlobalAppearanceSettings.CornerRadiusStyleFluent);
|
||||
RootGrid.Resources["DesignCornerRadiusMicro"] = tokens.Micro;
|
||||
RootGrid.Resources["DesignCornerRadiusXs"] = tokens.Xs;
|
||||
RootGrid.Resources["DesignCornerRadiusSm"] = tokens.Sm;
|
||||
RootGrid.Resources["DesignCornerRadiusMd"] = tokens.Md;
|
||||
RootGrid.Resources["DesignCornerRadiusLg"] = tokens.Lg;
|
||||
RootGrid.Resources["DesignCornerRadiusXl"] = tokens.Xl;
|
||||
RootGrid.Resources["DesignCornerRadiusIsland"] = tokens.Island;
|
||||
RootGrid.Resources["DesignCornerRadiusComponent"] = tokens.Component;
|
||||
ApplyFluentCornerRadius(Resources, tokens);
|
||||
if (RootGrid is not null)
|
||||
{
|
||||
ApplyFluentCornerRadius(RootGrid.Resources, tokens);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyFluentCornerRadius(IResourceDictionary resources)
|
||||
{
|
||||
var tokens = AppearanceCornerRadiusTokenFactory.Create(
|
||||
GlobalAppearanceSettings.CornerRadiusStyleFluent);
|
||||
ApplyFluentCornerRadius(resources, tokens);
|
||||
}
|
||||
|
||||
private static void ApplyFluentCornerRadius(
|
||||
IResourceDictionary resources,
|
||||
AppearanceCornerRadiusTokens tokens)
|
||||
{
|
||||
resources["DesignCornerRadiusMicro"] = tokens.Micro;
|
||||
resources["DesignCornerRadiusXs"] = tokens.Xs;
|
||||
resources["DesignCornerRadiusSm"] = tokens.Sm;
|
||||
resources["DesignCornerRadiusMd"] = tokens.Md;
|
||||
resources["DesignCornerRadiusLg"] = tokens.Lg;
|
||||
resources["DesignCornerRadiusXl"] = tokens.Xl;
|
||||
resources["DesignCornerRadiusIsland"] = tokens.Island;
|
||||
resources["DesignCornerRadiusComponent"] = tokens.Component;
|
||||
}
|
||||
|
||||
private void OnTitleBarDragZonePointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
@@ -973,7 +992,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
|
||||
var height = Bounds.Height > 1 ? Bounds.Height : Math.Max(Height, MinHeight);
|
||||
var layoutScale = Math.Clamp(Math.Min(width / 1120d, height / 760d), 0.90, 1.18);
|
||||
|
||||
const double titleBarHeight = 48d;
|
||||
const double titleBarHeight = CustomTitleBarHeight;
|
||||
var titleFontSize = Math.Clamp(12d * layoutScale, 11d, 14d);
|
||||
var titleBarIconSize = Math.Clamp(16d * layoutScale, 15d, 20d);
|
||||
var drawerTitleFontSize = Math.Clamp(16d * layoutScale, 14d, 20d);
|
||||
|
||||
@@ -67,10 +67,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
||||
public PluginMarketEmbeddedView(PluginRuntimeService runtime)
|
||||
{
|
||||
_runtime = runtime;
|
||||
var dataDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"AirAppMarket");
|
||||
var dataDirectory = AppDataPathProvider.GetPluginMarketDirectory();
|
||||
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory));
|
||||
_installService = new AirAppMarketInstallService(runtime, dataDirectory);
|
||||
_readmeService = new AirAppMarketReadmeService();
|
||||
|
||||
@@ -19,13 +19,14 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
private readonly ResumableDownloadService _downloadService;
|
||||
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
|
||||
private readonly PendingPluginUpgradeService _pendingUpgradeService;
|
||||
private readonly ElevatedPluginInstallService _elevatedInstallService = new();
|
||||
private readonly string _downloadsDirectory;
|
||||
private readonly Version? _hostVersion;
|
||||
|
||||
public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_downloadsDirectory = Path.Combine(dataDirectory, "downloads");
|
||||
_downloadsDirectory = ResolveDownloadsDirectory(dataDirectory);
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(2)
|
||||
@@ -77,9 +78,10 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
bool isUpgrade,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var canWritePluginsDirectory = PluginInstallTargetAccess.CanWriteDirectory(_runtime.PluginsDirectory);
|
||||
AppLogger.Info(
|
||||
"PluginMarket",
|
||||
$"Detected {(isUpgrade ? "upgrade" : "new install")} scenario. Downloading package for deferred install. PluginId='{plugin.Id}'.");
|
||||
$"Detected {(isUpgrade ? "upgrade" : "new install")} scenario. Downloading package for {(canWritePluginsDirectory ? "deferred" : "elevated")} install. PluginId='{plugin.Id}'; PluginsDirectory='{_runtime.PluginsDirectory}'; CanWritePluginsDirectory={canWritePluginsDirectory}.");
|
||||
|
||||
var sourceErrors = new List<string>();
|
||||
foreach (var source in sources)
|
||||
@@ -98,6 +100,25 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
try
|
||||
{
|
||||
var manifest = ReadManifestFromPackage(downloadResult.PackagePath);
|
||||
if (!canWritePluginsDirectory)
|
||||
{
|
||||
var elevatedResult = await _elevatedInstallService.InstallAsync(
|
||||
downloadResult.PackagePath,
|
||||
_runtime.PluginsDirectory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!elevatedResult.Success)
|
||||
{
|
||||
sourceErrors.Add($"{source.SourceKind}: {elevatedResult.ErrorMessage ?? elevatedResult.Message ?? elevatedResult.Code ?? "Elevated install failed."}");
|
||||
continue;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"PluginMarket",
|
||||
$"Plugin package installed through elevated installer. PluginId='{manifest.Id}'; Version='{manifest.Version ?? plugin.Version}'; PackagePath='{downloadResult.PackagePath}'; IsUpgrade={isUpgrade}.");
|
||||
|
||||
return new AirAppMarketInstallResult(true, manifest, null, RestartRequired: true);
|
||||
}
|
||||
|
||||
_pendingUpgradeService.AddPendingInstallOrUpgrade(
|
||||
manifest.Id,
|
||||
downloadResult.PackagePath,
|
||||
@@ -276,6 +297,21 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveDownloadsDirectory(string dataDirectory)
|
||||
{
|
||||
var preferred = Path.Combine(dataDirectory, "downloads");
|
||||
if (PluginInstallTargetAccess.CanWriteDirectory(preferred))
|
||||
{
|
||||
return preferred;
|
||||
}
|
||||
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var fallbackRoot = string.IsNullOrWhiteSpace(localAppData)
|
||||
? Path.GetTempPath()
|
||||
: Path.Combine(localAppData, "LanMountainDesktop");
|
||||
return Path.Combine(fallbackRoot, "PluginMarket", "downloads");
|
||||
}
|
||||
|
||||
private async Task<DownloadPackageResult> DownloadPackageAsync(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
AirAppMarketPluginPackageSourceEntry source,
|
||||
|
||||
@@ -46,9 +46,10 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
ISettingsFacadeService? settingsFacade = null,
|
||||
PublicIpcHostService? publicIpcHostService = null)
|
||||
{
|
||||
PluginsDirectory = Path.Combine(GetUserDataRootDirectory(), "Extensions", "Plugins");
|
||||
var dataRoot = AppDataPathProvider.GetDataRoot();
|
||||
PluginsDirectory = Path.Combine(dataRoot, "Extensions", "Plugins");
|
||||
_sharedContractManager = new PluginSharedContractManager(
|
||||
Path.Combine(GetUserDataRootDirectory(), "PluginMarket"));
|
||||
AppDataPathProvider.GetPluginMarketDirectory());
|
||||
_packageManager = new PluginRuntimePackageManager(this);
|
||||
_settingsFacade = settingsFacade ?? new SettingsFacadeService();
|
||||
_publicIpcHostService = publicIpcHostService;
|
||||
@@ -388,9 +389,15 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
AppLogger.Info(
|
||||
"PluginRuntime",
|
||||
$"Installing package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'; PluginsDirectory='{PluginsDirectory}'.");
|
||||
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
||||
|
||||
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
if (!PluginInstallTargetAccess.CanWriteDirectory(PluginsDirectory))
|
||||
{
|
||||
return InstallPluginPackageWithElevation(fullPackagePath, manifest, destinationPath);
|
||||
}
|
||||
|
||||
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
||||
|
||||
if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
FileOperationRetryHelper.CopyWithRetry(fullPackagePath, destinationPath, overwrite: true, "PluginRuntime");
|
||||
@@ -405,6 +412,41 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true);
|
||||
}
|
||||
|
||||
private PluginPackageInstallResult InstallPluginPackageWithElevation(
|
||||
string fullPackagePath,
|
||||
PluginManifest manifest,
|
||||
string destinationPath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
throw new UnauthorizedAccessException(
|
||||
$"Plugin directory '{PluginsDirectory}' is not writable by the current process.");
|
||||
}
|
||||
|
||||
var elevatedResult = new ElevatedPluginInstallService()
|
||||
.InstallAsync(fullPackagePath, PluginsDirectory, CancellationToken.None)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
if (!elevatedResult.Success)
|
||||
{
|
||||
throw new UnauthorizedAccessException(
|
||||
elevatedResult.ErrorMessage ??
|
||||
elevatedResult.Message ??
|
||||
$"Elevated plugin install failed with code '{elevatedResult.Code ?? "unknown"}'.");
|
||||
}
|
||||
|
||||
var installedPath = !string.IsNullOrWhiteSpace(elevatedResult.InstalledPackagePath)
|
||||
? elevatedResult.InstalledPackagePath
|
||||
: destinationPath;
|
||||
UpdateCatalogAfterPackageInstall(manifest, installedPath);
|
||||
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||
AppLogger.Info(
|
||||
"PluginRuntime",
|
||||
$"Package staged through elevated installer. PluginId='{manifest.Id}'; Destination='{installedPath}'.");
|
||||
|
||||
return new PluginPackageInstallResult(manifest, ReplacedExisting: false, RestartRequired: true);
|
||||
}
|
||||
|
||||
private PluginManifest RegisterInstalledPluginPackageCore(string packagePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
@@ -694,17 +736,6 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
private static string GetUserDataRootDirectory()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Data");
|
||||
}
|
||||
|
||||
return Path.Combine(localAppData, "LanMountainDesktop");
|
||||
}
|
||||
|
||||
private static PluginLoaderOptions CreateOptions()
|
||||
{
|
||||
var devOptions = DevPluginOptions.Current;
|
||||
|
||||
@@ -257,13 +257,7 @@ internal sealed class PluginSharedContractManager : IDisposable
|
||||
|
||||
private static string GetSharedContractRootDirectory()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Data");
|
||||
}
|
||||
|
||||
return Path.Combine(localAppData, "LanMountainDesktop");
|
||||
return AppDataPathProvider.GetDataRoot();
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
|
||||
@@ -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,327 @@
|
||||
|
||||
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
|
||||
|
||||
|
||||
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
|
||||
|
||||
Commands marked with * may be preceded by a number, _N.
|
||||
Notes in parentheses indicate the behavior if _N is given.
|
||||
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
|
||||
|
||||
h H Display this help.
|
||||
q :q Q :Q ZZ Exit.
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
MMOOVVIINNGG
|
||||
|
||||
e ^E j ^N CR * Forward one line (or _N lines).
|
||||
y ^Y k ^K ^P * Backward one line (or _N lines).
|
||||
ESC-j * Forward one file line (or _N file lines).
|
||||
ESC-k * Backward one file line (or _N file lines).
|
||||
f ^F ^V SPACE * Forward one window (or _N lines).
|
||||
b ^B ESC-v * Backward one window (or _N lines).
|
||||
z * Forward one window (and set window to _N).
|
||||
w * Backward one window (and set window to _N).
|
||||
ESC-SPACE * Forward one window, but don't stop at end-of-file.
|
||||
ESC-b * Backward one window, but don't stop at beginning-of-file.
|
||||
d ^D * Forward one half-window (and set half-window to _N).
|
||||
u ^U * Backward one half-window (and set half-window to _N).
|
||||
ESC-) RightArrow * Right one half screen width (or _N positions).
|
||||
ESC-( LeftArrow * Left one half screen width (or _N positions).
|
||||
ESC-} ^RightArrow Right to last column displayed.
|
||||
ESC-{ ^LeftArrow Left to first column.
|
||||
F Forward forever; like "tail -f".
|
||||
ESC-F Like F but stop when search pattern is found.
|
||||
r ^R ^L Repaint screen.
|
||||
R Repaint screen, discarding buffered input.
|
||||
---------------------------------------------------
|
||||
Default "window" is the screen height.
|
||||
Default "half-window" is half of the screen height.
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
SSEEAARRCCHHIINNGG
|
||||
|
||||
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
|
||||
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
|
||||
n * Repeat previous search (for _N-th occurrence).
|
||||
N * Repeat previous search in reverse direction.
|
||||
ESC-n * Repeat previous search, spanning files.
|
||||
ESC-N * Repeat previous search, reverse dir. & spanning files.
|
||||
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
|
||||
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
|
||||
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
|
||||
ESC-u Undo (toggle) search highlighting.
|
||||
ESC-U Clear search highlighting.
|
||||
&_p_a_t_t_e_r_n * Display only matching lines.
|
||||
---------------------------------------------------
|
||||
Search is case-sensitive unless changed with -i or -I.
|
||||
A search pattern may begin with one or more of:
|
||||
^N or ! Search for NON-matching lines.
|
||||
^E or * Search multiple files (pass thru END OF FILE).
|
||||
^F or @ Start search at FIRST file (for /) or last file (for ?).
|
||||
^K Highlight matches, but don't move (KEEP position).
|
||||
^R Don't use REGULAR EXPRESSIONS.
|
||||
^S _n Search for match in _n-th parenthesized subpattern.
|
||||
^W WRAP search if no match found.
|
||||
^L Enter next character literally into pattern.
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
JJUUMMPPIINNGG
|
||||
|
||||
g < ESC-< * Go to first line in file (or line _N).
|
||||
G > ESC-> * Go to last line in file (or line _N).
|
||||
p % * Go to beginning of file (or _N percent into file).
|
||||
t * Go to the (_N-th) next tag.
|
||||
T * Go to the (_N-th) previous tag.
|
||||
{ ( [ * Find close bracket } ) ].
|
||||
} ) ] * Find open bracket { ( [.
|
||||
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
|
||||
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
|
||||
---------------------------------------------------
|
||||
Each "find close bracket" command goes forward to the close bracket
|
||||
matching the (_N-th) open bracket in the top line.
|
||||
Each "find open bracket" command goes backward to the open bracket
|
||||
matching the (_N-th) close bracket in the bottom line.
|
||||
|
||||
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
|
||||
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
|
||||
'_<_l_e_t_t_e_r_> Go to a previously marked position.
|
||||
'' Go to the previous position.
|
||||
^X^X Same as '.
|
||||
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
|
||||
---------------------------------------------------
|
||||
A mark is any upper-case or lower-case letter.
|
||||
Certain marks are predefined:
|
||||
^ means beginning of the file
|
||||
$ means end of the file
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
CCHHAANNGGIINNGG FFIILLEESS
|
||||
|
||||
:e [_f_i_l_e] Examine a new file.
|
||||
^X^V Same as :e.
|
||||
:n * Examine the (_N-th) next file from the command line.
|
||||
:p * Examine the (_N-th) previous file from the command line.
|
||||
:x * Examine the first (or _N-th) file from the command line.
|
||||
^O^O Open the currently selected OSC8 hyperlink.
|
||||
:d Delete the current file from the command line list.
|
||||
= ^G :f Print current file name.
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
Set prompt style.
|
||||
-n ......... --line-numbers
|
||||
Suppress line numbers in prompts and messages.
|
||||
-N ......... --LINE-NUMBERS
|
||||
Display line number at start of each line.
|
||||
-o [_f_i_l_e] .. --log-file=[_f_i_l_e]
|
||||
Copy to log file (standard input only).
|
||||
-O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e]
|
||||
Copy to log file (unconditionally overwrite).
|
||||
-p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n]
|
||||
Start at pattern (from command line).
|
||||
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
|
||||
Define new prompt.
|
||||
-q -Q .... --quiet --QUIET --silent --SILENT
|
||||
Quiet the terminal bell.
|
||||
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
|
||||
Output "raw" control characters.
|
||||
-s ........ --squeeze-blank-lines
|
||||
Squeeze multiple blank lines.
|
||||
-S ........ --chop-long-lines
|
||||
Chop (truncate) long lines rather than wrapping.
|
||||
-t _t_a_g .... --tag=[_t_a_g]
|
||||
Find a tag.
|
||||
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
|
||||
Use an alternate tags file.
|
||||
-u -U .... --underline-special --UNDERLINE-SPECIAL
|
||||
Change handling of backspaces, tabs and carriage returns.
|
||||
-V ........ --version
|
||||
Display the version number of "less".
|
||||
-w ........ --hilite-unread
|
||||
Highlight first new line after forward-screen.
|
||||
-W ........ --HILITE-UNREAD
|
||||
Highlight first new line after any forward movement.
|
||||
-x [_N[,...]] --tabs=[_N[,...]]
|
||||
Set tab stops.
|
||||
-X ........ --no-init
|
||||
Don't use termcap init/deinit strings.
|
||||
-y [_N] .... --max-forw-scroll=[_N]
|
||||
Forward scroll limit.
|
||||
-z [_N] .... --window=[_N]
|
||||
Set size of window.
|
||||
-" [_c[_c]] . --quotes=[_c[_c]]
|
||||
Set shell quote characters.
|
||||
-~ ........ --tilde
|
||||
Don't display tildes after end of file.
|
||||
-# [_N] .... --shift=[_N]
|
||||
Set horizontal scroll amount (0 = one half screen width).
|
||||
|
||||
--exit-follow-on-close
|
||||
Exit F command on a pipe when writer closes pipe.
|
||||
--file-size
|
||||
Automatically determine the size of the input file.
|
||||
--follow-name
|
||||
The F command changes files if the input file is renamed.
|
||||
--form-feed
|
||||
Stop scrolling when a form feed character is reached.
|
||||
--header=[_L[,_C[,_N]]]
|
||||
Use _L lines (starting at line _N) and _C columns as headers.
|
||||
--incsearch
|
||||
Search file as each pattern character is typed in.
|
||||
--intr=[_C]
|
||||
Use _C instead of ^X to interrupt a read.
|
||||
--lesskey-context=_t_e_x_t
|
||||
Use lesskey source file contents.
|
||||
--lesskey-src=_f_i_l_e
|
||||
Use a lesskey source file.
|
||||
--line-num-width=[_N]
|
||||
Set the width of the -N line number field to _N characters.
|
||||
--match-shift=[_N]
|
||||
Show at least _N characters to the left of a search match.
|
||||
--modelines=[_N]
|
||||
Read _N lines from the input file and look for vim modelines.
|
||||
--mouse
|
||||
Enable mouse input.
|
||||
--no-edit-warn
|
||||
Don't warn when using v command on a file opened via LESSOPEN.
|
||||
--no-keypad
|
||||
Don't send termcap keypad init/deinit strings.
|
||||
--no-histdups
|
||||
Remove duplicates from command history.
|
||||
--no-number-headers
|
||||
Don't give line numbers to header lines.
|
||||
--no-paste
|
||||
Ignore pasted input.
|
||||
--no-search-header-lines
|
||||
Searches do not include header lines.
|
||||
--no-search-header-columns
|
||||
Searches do not include header columns.
|
||||
--no-search-headers
|
||||
Searches do not include header lines or columns.
|
||||
--no-vbell
|
||||
Disable the terminal's visual bell.
|
||||
--redraw-on-quit
|
||||
Redraw final screen when quitting.
|
||||
--rscroll=[_C]
|
||||
Set the character used to mark truncated lines.
|
||||
--save-marks
|
||||
Retain marks across invocations of less.
|
||||
--search-options=[EFKNRW-]
|
||||
Set default options for every search.
|
||||
--show-preproc-errors
|
||||
Display a message if preprocessor exits with an error status.
|
||||
--proc-backspace
|
||||
Process backspaces for bold/underline.
|
||||
--PROC-BACKSPACE
|
||||
Treat backspaces as control characters.
|
||||
--proc-return
|
||||
Delete carriage returns before newline.
|
||||
--PROC-RETURN
|
||||
Treat carriage returns as control characters.
|
||||
--proc-tab
|
||||
Expand tabs to spaces.
|
||||
--PROC-TAB
|
||||
Treat tabs as control characters.
|
||||
--status-col-width=[_N]
|
||||
Set the width of the -J status column to _N characters.
|
||||
--status-line
|
||||
Highlight or color the entire line containing a mark.
|
||||
--use-backslash
|
||||
Subsequent options use backslash as escape char.
|
||||
--use-color
|
||||
Enables colored text.
|
||||
--wheel-lines=[_N]
|
||||
Each click of the mouse wheel moves _N lines.
|
||||
--wordwrap
|
||||
Wrap lines at spaces.
|
||||
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
LLIINNEE EEDDIITTIINNGG
|
||||
|
||||
These keys can be used to edit text being entered
|
||||
on the "command line" at the bottom of the screen.
|
||||
|
||||
RightArrow ..................... ESC-l ... Move cursor right one character.
|
||||
LeftArrow ...................... ESC-h ... Move cursor left one character.
|
||||
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
|
||||
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
|
||||
HOME ........................... ESC-0 ... Move cursor to start of line.
|
||||
END ............................ ESC-$ ... Move cursor to end of line.
|
||||
BACKSPACE ................................ Delete char to left of cursor.
|
||||
DELETE ......................... ESC-x ... Delete char under cursor.
|
||||
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
|
||||
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
|
||||
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
|
||||
UpArrow ........................ ESC-k ... Retrieve previous command line.
|
||||
DownArrow ...................... ESC-j ... Retrieve next command line.
|
||||
TAB ...................................... Complete filename & cycle.
|
||||
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
|
||||
ctrl-L ................................... Complete filename, list all.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,3 +42,68 @@
|
||||
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
|
||||
n * Repeat previous search (for _N-th occurrence).
|
||||
N * Repeat previous search in reverse direction.
|
||||
ESC-n * Repeat previous search, spanning files.
|
||||
ESC-N * Repeat previous search, reverse dir. & spanning files.
|
||||
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
|
||||
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
|
||||
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
|
||||
ESC-u Undo (toggle) search highlighting.
|
||||
ESC-U Clear search highlighting.
|
||||
&_p_a_t_t_e_r_n * Display only matching lines.
|
||||
---------------------------------------------------
|
||||
Search is case-sensitive unless changed with -i or -I.
|
||||
A search pattern may begin with one or more of:
|
||||
^N or ! Search for NON-matching lines.
|
||||
^E or * Search multiple files (pass thru END OF FILE).
|
||||
^F or @ Start search at FIRST file (for /) or last file (for ?).
|
||||
^K Highlight matches, but don't move (KEEP position).
|
||||
^R Don't use REGULAR EXPRESSIONS.
|
||||
^S _n Search for match in _n-th parenthesized subpattern.
|
||||
^W WRAP search if no match found.
|
||||
^L Enter next character literally into pattern.
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
JJUUMMPPIINNGG
|
||||
|
||||
g < ESC-< * Go to first line in file (or line _N).
|
||||
G > ESC-> * Go to last line in file (or line _N).
|
||||
p % * Go to beginning of file (or _N percent into file).
|
||||
t * Go to the (_N-th) next tag.
|
||||
T * Go to the (_N-th) previous tag.
|
||||
{ ( [ * Find close bracket } ) ].
|
||||
} ) ] * Find open bracket { ( [.
|
||||
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
|
||||
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
|
||||
---------------------------------------------------
|
||||
Each "find close bracket" command goes forward to the close bracket
|
||||
matching the (_N-th) open bracket in the top line.
|
||||
Each "find open bracket" command goes backward to the open bracket
|
||||
matching the (_N-th) close bracket in the bottom line.
|
||||
|
||||
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
|
||||
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
|
||||
'_<_l_e_t_t_e_r_> Go to a previously marked position.
|
||||
'' Go to the previous position.
|
||||
^X^X Same as '.
|
||||
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
|
||||
---------------------------------------------------
|
||||
A mark is any upper-case or lower-case letter.
|
||||
Certain marks are predefined:
|
||||
^ means beginning of the file
|
||||
$ means end of the file
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
CCHHAANNGGIINNGG FFIILLEESS
|
||||
|
||||
:e [_f_i_l_e] Examine a new file.
|
||||
^X^V Same as :e.
|
||||
:n * Examine the (_N-th) next file from the command line.
|
||||
:p * Examine the (_N-th) previous file from the command line.
|
||||
:x * Examine the first (or _N-th) file from the command line.
|
||||
^O^O Open the currently selected OSC8 hyperlink.
|
||||
:d Delete the current file from the command line list.
|
||||
= ^G :f Print current file name.
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
|
||||
|
||||
|
||||
@@ -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,11 @@ 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.
|
||||
- Default plugin install targets the Host data root (`AppDataPathProvider.GetDataRoot()/Extensions/Plugins`) and should not ask for UAC when that directory is writable.
|
||||
- In portable data mode, plugin packages follow the configured application data root. If that root is under an administrator-protected install path, Host downloads/verifies the package from a user-writable staging directory and invokes the restricted Launcher `plugin install` command with UAC to copy only into the configured data root.
|
||||
- Marketplace plugin installs are queued under the Host data root when writable and take effect after restart; protected portable installs are applied immediately through the elevated maintenance command and still require restart before loading.
|
||||
|
||||
@@ -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
|
||||
运行主程序,打开设置页中的更新区域触发回滚。
|
||||
```
|
||||
|
||||
回退会切换到上一个有效版本,并保留快照记录。
|
||||
@@ -165,12 +161,12 @@ Use `LanMountainDesktop.slnx` as the workspace entry point. The standard loop is
|
||||
|
||||
For packaging, see `LanMountainDesktop/PACKAGING.md`. For plugin package generation or local feed workflows, use `scripts/Pack-PluginPackages.ps1`.
|
||||
|
||||
In-app marketplace plugin installs use a per-user pending plugin queue. The package is downloaded and verified immediately, then applied on the next Host startup before plugin discovery. `LanMountainDesktop.Launcher.exe plugin install` remains only as a maintenance compatibility command.
|
||||
In-app marketplace plugin installs use the Host data root. When `Extensions/Plugins` is writable, the package is downloaded and verified immediately, then queued and applied on the next Host startup before plugin discovery. When portable data lives under an administrator-protected install path, Host stages the download in a user-writable location and invokes the restricted `LanMountainDesktop.Launcher.exe plugin install --app-root <package-root>` maintenance command with UAC to copy into the configured data root.
|
||||
|
||||
**Launcher Architecture**: LanMountainDesktop uses a Launcher as the single entry point, responsible for version management, updates, and launching the main application. See the Chinese section above for detailed architecture documentation.
|
||||
|
||||
## 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.
|
||||
|
||||
131
docs/LAUNCHER.md
131
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,13 +439,15 @@ 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.
|
||||
- Default plugin installation targets the Host data root and must not request elevation when that directory is writable.
|
||||
- The Launcher `plugin install` maintenance command accepts `--app-root` so it can verify the configured data root before writing. It rejects targets outside that root.
|
||||
- In-app market installs are deferred Host-side operations when the data root is writable: download and verify now, apply from the pending queue on the next Host startup.
|
||||
- If portable data is configured under an administrator-protected install path, Host stages the package in a user-writable download directory and invokes the restricted Launcher maintenance command with UAC to copy the package into `Extensions/Plugins`.
|
||||
|
||||
## Public IPC Baseline
|
||||
|
||||
|
||||
@@ -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 # 当前版本标记文件
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user