mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb066b53f1 | ||
|
|
5ea242af9a | ||
|
|
abfa64b3d7 | ||
|
|
cbaf2a0c38 | ||
|
|
0e45c836c9 | ||
|
|
0b603384b4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -514,3 +514,4 @@ nul
|
||||
/*.AppImage
|
||||
/velopack-output-local-verify
|
||||
/velopack-output-local
|
||||
/test-aot-publish
|
||||
|
||||
376
.kilo/package-lock.json
generated
Normal file
376
.kilo/package-lock.json
generated
Normal file
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"name": ".kilo",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "7.2.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/plugin": {
|
||||
"version": "7.2.20",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.20.tgz",
|
||||
"integrity": "sha512-M5lMc58Mu9j1zveH+E3ZUKRHefzh+acNAqHGSG3TuF6K2l16KrZlCl38CZlgj2R5Qgaig6Jec/F2p9Rbn3BhCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "7.2.20",
|
||||
"effect": "4.0.0-beta.48",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.99",
|
||||
"@opentui/solid": ">=0.1.99"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/sdk": {
|
||||
"version": "7.2.20",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.20.tgz",
|
||||
"integrity": "sha512-KUpu1fyzcAyZWpiv//834zGLN+PYzIH65crs15VTtUJ9CDvGqcj08EM0XlkF9jMuGQAjHjfRbvCfml3+YO31+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.48",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
|
||||
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"find-my-way-ts": "^0.1.6",
|
||||
"ini": "^6.0.0",
|
||||
"kubernetes-types": "^1.30.0",
|
||||
"msgpackr": "^1.11.9",
|
||||
"multipasta": "^0.2.7",
|
||||
"toml": "^4.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
|
||||
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way-ts": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
|
||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kubernetes-types": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.10",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
|
||||
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
171
.kilo/plans/1776989126427-witty-island.md
Normal file
171
.kilo/plans/1776989126427-witty-island.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# LanMountainDesktop 启动器无法启动应用 - 问题分析与修复计划
|
||||
|
||||
## 1. 项目架构概述
|
||||
|
||||
LanMountainDesktop 采用**双进程架构**:
|
||||
- **Launcher** (`LanMountainDesktop.Launcher`) - 启动器,负责版本管理、更新、启动主程序
|
||||
- **Host** (`LanMountainDesktop`) - 主应用宿主
|
||||
|
||||
### 启动流程
|
||||
1. 用户启动 `LanMountainDesktop.Launcher.exe`
|
||||
2. Launcher 扫描 `app-*` 目录,选择最佳版本
|
||||
3. 检查并应用待处理的更新
|
||||
4. 处理插件升级队列
|
||||
5. 启动主程序 `app-{version}/LanMountainDesktop.exe`
|
||||
6. 通过 IPC 监控主程序启动进度
|
||||
|
||||
## 2. 问题分析
|
||||
|
||||
### 2.1 核心问题:主机可执行文件找不到
|
||||
|
||||
根据代码分析(`DeploymentLocator.cs`),启动器通过以下顺序查找主机可执行文件:
|
||||
|
||||
1. **显式 app-root**(如果通过命令行指定)
|
||||
2. **已发布部署**(查找 `app-*` 目录)
|
||||
3. **可移植主机**(直接在应用根目录)
|
||||
4. **调试主机**(开发模式,查找构建输出路径)
|
||||
5. **旧版回退路径**
|
||||
|
||||
**当前状态检查**:
|
||||
- ❌ 未找到 `app-*` 目录(生产部署结构不存在)
|
||||
- ❌ 未找到 `bin/Debug/**/*.exe`(项目未构建或构建输出不存在)
|
||||
|
||||
### 2.2 可能的启动失败原因
|
||||
|
||||
| 问题 | 描述 | 优先级 |
|
||||
|------|------|--------|
|
||||
| **项目未构建** | LanMountainDesktop 主程序未编译,没有可执行文件 | P0 |
|
||||
| **部署结构缺失** | 生产模式下缺少 `app-*` 目录结构 | P0 |
|
||||
| **开发模式路径问题** | 调试模式下路径计算错误或构建输出不在预期位置 | P1 |
|
||||
| **.NET 版本问题** | 项目使用 .NET 10.0,运行环境可能缺少对应运行时 | P1 |
|
||||
| **更新应用失败** | `ApplyPendingUpdateAsync` 失败导致无法完成部署 | P2 |
|
||||
| **IPC 连接超时** | 主程序启动后未及时建立 IPC 连接,导致启动器超时 | P2 |
|
||||
|
||||
### 2.3 关键代码位置
|
||||
|
||||
- **主机查找逻辑**: `LanMountainDesktop.Launcher/Services/DeploymentLocator.cs`
|
||||
- `FindCurrentDeploymentDirectory()` - 查找 app-* 目录
|
||||
- `ResolveHostExecutable()` - 解析主机路径
|
||||
|
||||
- **启动协调逻辑**: `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
|
||||
- `RunAsync()` - 主启动流程
|
||||
- `LaunchHostWithIpcAsync()` - 启动主机进程
|
||||
|
||||
- **更新引擎**: `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs`
|
||||
- `ApplyPendingUpdateAsync()` - 应用待处理的更新
|
||||
|
||||
## 3. 诊断步骤
|
||||
|
||||
### 步骤 1:检查构建状态
|
||||
```bash
|
||||
dotnet --info
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
### 步骤 2:验证主机可执行文件是否存在
|
||||
检查以下路径是否存在 `LanMountainDesktop.exe`:
|
||||
- `LanMountainDesktop/bin/Debug/net10.0/`
|
||||
- `LanMountainDesktop/bin/Release/net10.0/`
|
||||
|
||||
### 步骤 3:测试直接运行主程序(跳过 Launcher)
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
|
||||
### 步骤 4:检查 Launcher 启动日志
|
||||
在开发模式下运行 Launcher 并查看控制台输出:
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
||||
```
|
||||
|
||||
## 4. 修复计划
|
||||
|
||||
### 方案 A:构建并配置开发环境(推荐)
|
||||
|
||||
**适用场景**:开发或调试环境
|
||||
|
||||
1. **构建整个解决方案**
|
||||
```bash
|
||||
dotnet restore
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
2. **验证构建输出**
|
||||
- 确认 `LanMountainDesktop/bin/Debug/net10.0/LanMountainDesktop.exe` 存在
|
||||
- 确认 `LanMountainDesktop.Launcher/bin/Debug/net10.0/LanMountainDesktop.Launcher.exe` 存在
|
||||
|
||||
3. **测试 Launcher 启动**
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
||||
```
|
||||
|
||||
4. **如果路径查找失败,检查 `DeploymentLocator.cs` 中的开发路径**
|
||||
- 当前逻辑(第 366-375 行)查找:
|
||||
- `../LanMountainDesktop/bin/Debug/net10.0/LanMountainDesktop.exe`
|
||||
- `../LanMountainDesktop/bin/Release/net10.0/LanMountainDesktop.exe`
|
||||
- 确认这些路径与实际的构建输出路径匹配
|
||||
|
||||
### 方案 B:创建生产部署结构
|
||||
|
||||
**适用场景**:生产环境或模拟生产环境
|
||||
|
||||
1. **发布主程序**
|
||||
```bash
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -c Release -o app-1.0.0
|
||||
```
|
||||
|
||||
2. **创建 .current 标记文件**
|
||||
```bash
|
||||
echo. > app-1.0.0/.current
|
||||
```
|
||||
|
||||
3. **从 Launcher 启动**
|
||||
- Launcher 应该能找到 `app-1.0.0/LanMountainDesktop.exe`
|
||||
|
||||
### 方案 C:修复潜在的代码问题
|
||||
|
||||
如果上述方案无法解决问题,可能需要修复代码:
|
||||
|
||||
#### C1. 增强错误处理和日志
|
||||
在 `DeploymentLocator.cs` 中添加更详细的日志输出,帮助诊断路径查找失败的原因。
|
||||
|
||||
#### C2. 检查更新逻辑
|
||||
如果 `ApplyPendingUpdateAsync` 失败,可能导致启动中止。检查 `.launcher/update/incoming/` 目录是否有残留的更新文件。
|
||||
|
||||
#### C3. 调整超时设置
|
||||
如果主程序启动较慢,可以适当增加 `LauncherFlowCoordinator.cs` 中的超时时间:
|
||||
- `StartupSoftTimeout` (当前 10 秒)
|
||||
- `StartupHardTimeout` (当前 30 秒)
|
||||
|
||||
## 5. 建议执行顺序
|
||||
|
||||
1. ✅ **首先执行方案 A 的步骤 1-2**(构建项目)
|
||||
2. ✅ **执行诊断步骤 3**(测试直接运行主程序)
|
||||
3. ✅ **执行诊断步骤 4**(查看 Launcher 启动日志)
|
||||
4. 根据日志输出决定后续操作:
|
||||
- 如果显示 "host executable was not found" → 检查路径配置
|
||||
- 如果显示 "update apply failed" → 清理更新缓存
|
||||
- 如果主程序启动后超时 → 检查 IPC 连接或增加超时
|
||||
|
||||
## 6. 验证方法
|
||||
|
||||
修复后,通过以下方式验证:
|
||||
|
||||
```bash
|
||||
# 开发模式启动
|
||||
dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch
|
||||
|
||||
# 或直接运行 Launcher 可执行文件
|
||||
# (需要先构建 Launcher)
|
||||
```
|
||||
|
||||
启动后应该看到:
|
||||
1. Splash 窗口显示
|
||||
2. 主程序桌面窗口出现
|
||||
3. Launcher 自动退出(或最小化到托盘)
|
||||
|
||||
## 7. 注意事项
|
||||
|
||||
- 项目使用 .NET 10.0(`global.json` 指定版本 10.0.103)
|
||||
- 确保开发环境已安装对应的 .NET SDK
|
||||
- 如果修改了 `DeploymentLocator.cs` 的路径查找逻辑,需要同步更新文档 `docs/DEVELOPMENT.md`
|
||||
106
.trae/documents/avalonia12-migration-plan.md
Normal file
106
.trae/documents/avalonia12-migration-plan.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Avalonia 12 迁移计划
|
||||
|
||||
## 当前状态
|
||||
|
||||
项目已完成以下迁移准备:
|
||||
|
||||
* `Directory.Packages.props` 中 Avalonia 包已升级到 `12.0.1`
|
||||
|
||||
* `FluentAvaloniaUI` 已升级到 `3.0.0-preview1`
|
||||
|
||||
* `Avalonia.Diagnostics` 已替换为 `AvaloniaUI.DiagnosticsSupport`
|
||||
|
||||
* `Avalonia.Controls.WebView` 已升级到 `12.0.0`
|
||||
|
||||
* `ClassIsland.Markdown.Avalonia` 已升级到 `12.0.0`
|
||||
|
||||
## 构建错误清单(26 errors)
|
||||
|
||||
### 1. 窗口装饰 API 移除(8 errors)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/SettingsWindow.axaml.cs`(4 errors)
|
||||
|
||||
* `ExtendClientAreaChromeHints` 不存在(line 166, 179)
|
||||
|
||||
* `SystemDecorations` 已过时,需改用 `WindowDecorations`(line 168, 177)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/ComponentEditorWindow.axaml.cs`(4 errors)
|
||||
|
||||
* `ExtendClientAreaChromeHints` 不存在(line 63, 72)
|
||||
|
||||
* `SystemDecorations` 已过时,需改用 `WindowDecorations`(line 65, 70)
|
||||
|
||||
**AXAML 文件**:13 个文件使用 `SystemDecorations` 属性(编译警告)
|
||||
|
||||
### 2. 变量/字段未找到(8 errors)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`
|
||||
|
||||
* `centerLeft` 不存在(line 759, 766, 778)
|
||||
|
||||
* `positions` 不存在(line 1266)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/MainWindow.DesktopPaging.cs`
|
||||
|
||||
* `child` 不存在(line 312)
|
||||
|
||||
* `_isThreeFingerOrRightDragSwipeActive` 不存在(line 517, 828, 847, 850)
|
||||
|
||||
### 3. API 变更(3 errors)
|
||||
|
||||
**文件**:`LanMountainDesktop/App.axaml.cs`
|
||||
|
||||
* `BindingPlugins` 不可访问(line 532, 537)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/Components/DesktopComponentFailureView.cs`
|
||||
|
||||
* `IClipboard.SetTextAsync` 不存在(line 187)
|
||||
|
||||
**文件**:`LanMountainDesktop/Services/MonetColorService.cs`
|
||||
|
||||
* `Bitmap.CopyPixels` 参数不匹配(line 91)
|
||||
|
||||
### 4. 第三方库变更(1 error)
|
||||
|
||||
**文件**:`LanMountainDesktop/Views/SettingsWindow.axaml.cs`
|
||||
|
||||
* `FluentIcons.Avalonia.SymbolIconSource` 不存在(line 215)
|
||||
|
||||
### 5. 过时属性警告(需同步修复)
|
||||
|
||||
* `TextBox.Watermark` → `PlaceholderText`(7 处 .cs + 7 处 .axaml)
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
### Phase 1: 修复窗口装饰 API(高优先级)
|
||||
|
||||
1. 重写 `SettingsWindow.ApplyChromeMode()` 使用 Avalonia 12 新 API
|
||||
2. 重写 `ComponentEditorWindow.ApplyChromeMode()` 使用 Avalonia 12 新 API
|
||||
3. 批量替换所有 `.axaml` 中的 `SystemDecorations` → `WindowDecorations`
|
||||
|
||||
### Phase 2: 修复 MainWindow 编译错误(高优先级)
|
||||
|
||||
1. 检查 `MainWindow.ComponentSystem.cs` 中 `centerLeft` 和 `positions` 的作用域问题
|
||||
2. 检查 `MainWindow.DesktopPaging.cs` 中 `child` 和 `_isThreeFingerOrRightDragSwipeActive` 的作用域问题
|
||||
3. 确认这些变量是否被意外删除或重命名
|
||||
|
||||
### Phase 3: 修复 Avalonia 12 API 变更(中优先级)
|
||||
|
||||
1. `App.axaml.cs`: 替换 `BindingPlugins.DataValidators` 的访问方式
|
||||
2. `DesktopComponentFailureView.cs`: 使用新的剪贴板 API
|
||||
3. `MonetColorService.cs`: 更新 `Bitmap.CopyPixels` 调用签名
|
||||
|
||||
### Phase 4: 修复第三方库变更(中优先级)
|
||||
|
||||
1. `SettingsWindow.axaml.cs`: 替换 `FluentIcons.Avalonia.SymbolIconSource` 为 v3 等效 API
|
||||
|
||||
### Phase 5: 清理过时属性(低优先级)
|
||||
|
||||
1. 批量替换 `Watermark` → `PlaceholderText`(所有 .cs 和 .axaml)
|
||||
|
||||
## 验证步骤
|
||||
|
||||
* 每阶段修复后运行 `dotnet build LanMountainDesktop.slnx -c Debug`
|
||||
|
||||
* 最终运行 `dotnet test LanMountainDesktop.slnx -c Debug`
|
||||
|
||||
21
.trae/specs/avalonia-12-migration/checklist.md
Normal file
21
.trae/specs/avalonia-12-migration/checklist.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Checklist
|
||||
|
||||
- [x] `Directory.Packages.props` contains the Avalonia 12 dependency baseline.
|
||||
- [x] Main host references `Avalonia.Controls.WebView`.
|
||||
- [x] Source no longer references `WebView.Avalonia`, `AvaloniaWebView`, or `.UseDesktopWebView()`.
|
||||
- [x] `BrowserWidget` uses `NativeWebView.Source`, `Navigate`, `Refresh()`, `NavigationStarted`, and `EnvironmentRequested`.
|
||||
- [x] WebView blanking navigates to `about:blank`.
|
||||
- [x] Plugin SDK package version is `5.0.0`.
|
||||
- [x] `PluginSdkInfo.ApiVersion` is `5.0.0`.
|
||||
- [x] Plugin template package version default is `5.0.0`.
|
||||
- [x] Plugin template manifest `apiVersion` is `5.0.0`.
|
||||
- [x] Launcher data location config resolution cannot recurse through `ResolveDataRoot()`.
|
||||
- [x] `OobeStateServiceTests` pass.
|
||||
- [x] `dotnet build LanMountainDesktop.slnx -c Debug` has 0 errors.
|
||||
- [x] `dotnet test LanMountainDesktop.slnx -c Debug` completes without a test host stack overflow.
|
||||
- [ ] Windows host smoke test completed.
|
||||
- [ ] Windows Launcher smoke test completed.
|
||||
- [ ] Settings window FluentAvalonia 3 smoke test completed.
|
||||
- [ ] Component editor Material smoke test completed.
|
||||
- [ ] BrowserWidget navigation/refresh/page activation smoke test completed.
|
||||
- [ ] WebView2 missing-runtime diagnostic smoke test completed.
|
||||
49
.trae/specs/avalonia-12-migration/spec.md
Normal file
49
.trae/specs/avalonia-12-migration/spec.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Avalonia 12 Full Stack Migration
|
||||
|
||||
## Summary
|
||||
|
||||
LanMountainDesktop has moved its desktop stack to the Avalonia 12 baseline. The migration covers the main host, Launcher, Plugin SDK, plugin runtime loading policy, official WebView usage, ClassIsland Markdown, FluentAvalonia, FluentIcons, and Material-related dependencies.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Centralized Avalonia 12 dependency baseline
|
||||
|
||||
The solution SHALL use central package management for direct Avalonia-facing projects and keep the core UI dependency baseline on Avalonia `12.0.1`.
|
||||
|
||||
Required package baseline:
|
||||
|
||||
- `Avalonia*` `12.0.1`
|
||||
- `Avalonia.Controls.WebView` `12.0.0`
|
||||
- `ClassIsland.Markdown.Avalonia` `12.0.0`
|
||||
- `FluentAvaloniaUI` `3.0.0-preview1`
|
||||
- `FluentIcons.Avalonia` `2.1.325`
|
||||
- `Material.Avalonia` `3.16.0`
|
||||
- `Material.Icons.Avalonia` `3.0.2`
|
||||
|
||||
### Requirement: Official WebView
|
||||
|
||||
The host SHALL use `Avalonia.Controls.NativeWebView` for the browser widget and SHALL NOT reference `WebView.Avalonia`, `AvaloniaWebView`, or `.UseDesktopWebView()`.
|
||||
|
||||
Windows WebView2 user data configuration SHALL be supplied through `EnvironmentRequested` using `WindowsWebView2EnvironmentRequestedEventArgs.UserDataFolder`.
|
||||
|
||||
### Requirement: Plugin SDK v5
|
||||
|
||||
The Plugin SDK API baseline SHALL be `5.0.0`. SDK v4 plugins are treated as incompatible until rebuilt.
|
||||
|
||||
The SDK SHALL keep the existing public UI extension shape, including `SettingsPageBase` and Avalonia `Control` based desktop components.
|
||||
|
||||
### Requirement: Launcher data location stability
|
||||
|
||||
Launcher data location configuration SHALL be read from a fixed bootstrap Launcher data directory so resolving the selected data root cannot recursively require resolving itself.
|
||||
|
||||
### Requirement: OOBE state compatibility
|
||||
|
||||
The Launcher SHALL read current OOBE state from the resolved `Launcher/state` directory and SHALL continue to migrate the legacy `.launcher/state/first_run_completed` marker.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `dotnet build LanMountainDesktop.slnx -c Debug` completes with 0 errors.
|
||||
- `OobeStateServiceTests` pass.
|
||||
- Full `dotnet test LanMountainDesktop.slnx -c Debug` no longer aborts from `DataLocationResolver` recursion.
|
||||
- Plugin template defaults to SDK package version `5.0.0` and manifest `apiVersion` `5.0.0`.
|
||||
- Current developer documentation points to SDK v5 and Avalonia 12.
|
||||
18
.trae/specs/avalonia-12-migration/tasks.md
Normal file
18
.trae/specs/avalonia-12-migration/tasks.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Centralize Avalonia 12 package versions in `Directory.Packages.props`.
|
||||
- [x] Move the host, Launcher, Plugin SDK, DesktopHost, Shared.Contracts, and Avalonia-facing projects onto central package versions.
|
||||
- [x] Replace third-party `WebView.Avalonia` usage with official `NativeWebView`.
|
||||
- [x] Configure WebView2 user data through `EnvironmentRequested`.
|
||||
- [x] Move FluentAvalonia usages to the FA3 control names and package baseline.
|
||||
- [x] Move FluentIcons usage to `FluentIcons.Avalonia` and remove the old `.Fluent` package.
|
||||
- [x] Update Plugin SDK package version and API baseline to `5.0.0`.
|
||||
- [x] Update plugin runtime shared assembly policy for Avalonia 12 / FluentAvalonia / FluentIcons / Material.
|
||||
- [x] Fix Avalonia 12 compile breaks in window chrome, binding plugin access, clipboard, bitmap copy, and icon source usage.
|
||||
- [x] Fix Launcher data location recursion by using a fixed bootstrap config path.
|
||||
- [x] Fix OOBE state tests and legacy marker compatibility.
|
||||
- [x] Update PluginTemplate defaults to SDK v5.
|
||||
- [x] Add SDK v5 migration documentation.
|
||||
- [x] Update current docs from SDK v4 / Avalonia 11 examples to SDK v5 / Avalonia 12.
|
||||
- [x] Run full solution tests and record any remaining non-upgrade failures.
|
||||
- [ ] Perform Windows manual smoke test for host, Launcher, settings, component editor, BrowserWidget, and WebView2 missing-runtime handling.
|
||||
@@ -74,7 +74,7 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
- SDK 公共 API 以 `LanMountainDesktop.PluginSdk/` 为准
|
||||
- 共享契约以 `LanMountainDesktop.Shared.Contracts/` 为准
|
||||
- market 数据来源默认是兄弟仓库 `..\\LanAirApp`
|
||||
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V5_MIGRATION.md`
|
||||
|
||||
### 设置与主题
|
||||
|
||||
@@ -91,6 +91,6 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
- 视觉规范:`docs/VISUAL_SPEC.md`
|
||||
- 圆角规范:`docs/CORNER_RADIUS_SPEC.md`
|
||||
- 生态边界:`docs/ECOSYSTEM_BOUNDARIES.md`
|
||||
- SDK v4 迁移:`docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||
- SDK v5 迁移:`docs/PLUGIN_SDK_V5_MIGRATION.md`
|
||||
|
||||
如果多个文档都提到同一件事,以 `docs/ai/DOC_SOURCES.md` 列出的权威来源为准。
|
||||
|
||||
42
Directory.Packages.props
Normal file
42
Directory.Packages.props
Normal file
@@ -0,0 +1,42 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Avalonia" Version="12.0.1" />
|
||||
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.0" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="12.0.1" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.1" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.1" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" />
|
||||
<PackageVersion Include="ClassIsland.Markdown.Avalonia" Version="12.0.0" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
||||
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||
<PackageVersion Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
||||
<PackageVersion Include="Downloader" Version="4.1.1" />
|
||||
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview1" />
|
||||
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.16.0" />
|
||||
<PackageVersion Include="MaterialColorUtilities" Version="0.3.0" />
|
||||
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="MudTools.OfficeInterop" Version="2.0.8" />
|
||||
<PackageVersion Include="MudTools.OfficeInterop.Excel" Version="2.0.8" />
|
||||
<PackageVersion Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.8" />
|
||||
<PackageVersion Include="MudTools.OfficeInterop.Word" Version="2.0.8" />
|
||||
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
|
||||
<PackageVersion Include="PostHog" Version="2.4.0" />
|
||||
<PackageVersion Include="Sentry" Version="4.0.0" />
|
||||
<PackageVersion Include="System.Drawing.Common" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
|
||||
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
|
||||
<PackageVersion Include="log4net" Version="3.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -5,7 +5,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
|
||||
@@ -18,6 +18,12 @@ public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.Initialize();
|
||||
var context = LauncherRuntimeContext.Current;
|
||||
var execution = LauncherExecutionContext.Capture();
|
||||
@@ -32,6 +38,12 @@ public partial class App : Application
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
@@ -49,6 +61,18 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
// 调试模式:只显示 DevDebugWindow,不走正常启动流程
|
||||
// 避免启动主程序后 Launcher 自动退出,导致开发者无法预览 UI
|
||||
if (context.IsDebugMode && !context.IsPreviewCommand &&
|
||||
!string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Logger.Info("Debug mode active — showing DevDebugWindow instead of normal launch flow.");
|
||||
var devDebugWindow = new DevDebugWindow();
|
||||
devDebugWindow.Show();
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var updateWindow = new UpdateWindow();
|
||||
@@ -119,8 +143,7 @@ public partial class App : Application
|
||||
|
||||
private static SplashWindow CreateSplashWindow()
|
||||
{
|
||||
var preferences = StartupVisualPreferencesResolver.Resolve();
|
||||
var window = new SplashWindow(preferences.Mode);
|
||||
var window = new SplashWindow();
|
||||
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
|
||||
return window;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ namespace LanMountainDesktop.Launcher;
|
||||
[JsonSerializable(typeof(PendingUpgrade))]
|
||||
[JsonSerializable(typeof(List<PendingUpgrade>))]
|
||||
[JsonSerializable(typeof(OobeStateFile))]
|
||||
[JsonSerializable(typeof(DataLocationConfig))]
|
||||
[JsonSerializable(typeof(GitHubRelease))]
|
||||
[JsonSerializable(typeof(GitHubAsset))]
|
||||
[JsonSerializable(typeof(List<GitHubRelease>))]
|
||||
[JsonSerializable(typeof(StartupAttemptRecord))]
|
||||
[JsonSerializable(typeof(PrivacyConfig))]
|
||||
[JsonSerializable(typeof(PrivacyAgreementState))]
|
||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -37,11 +37,25 @@ internal sealed class CommandContext
|
||||
|
||||
/// <summary>
|
||||
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
|
||||
/// 仅当明确指定 --debug 参数或调试器附加时才启用
|
||||
/// 当满足以下任一条件时启用:
|
||||
/// 1. 明确指定 --debug 参数
|
||||
/// 2. 调试器附加(Debugger.IsAttached)
|
||||
/// 3. DOTNET_ENVIRONMENT 环境变量为 Development(IDE 调试启动时自动设置)
|
||||
/// </summary>
|
||||
public bool IsDebugMode =>
|
||||
Options.ContainsKey("debug") ||
|
||||
System.Diagnostics.Debugger.IsAttached;
|
||||
System.Diagnostics.Debugger.IsAttached ||
|
||||
IsDevelopmentEnvironment;
|
||||
|
||||
/// <summary>
|
||||
/// 是否为 Development 环境(DOTNET_ENVIRONMENT=Development)
|
||||
/// Rider/VS 调试启动时会自动设置此环境变量
|
||||
/// </summary>
|
||||
public bool IsDevelopmentEnvironment =>
|
||||
string.Equals(
|
||||
System.Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"),
|
||||
"Development",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsPreviewCommand =>
|
||||
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -22,11 +22,14 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="FluentAvaloniaUI" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="ClassIsland.Markdown.Avalonia" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 资源文件 -->
|
||||
|
||||
23
LanMountainDesktop.Launcher/Models/DataLocationModels.cs
Normal file
23
LanMountainDesktop.Launcher/Models/DataLocationModels.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal enum DataLocationMode
|
||||
{
|
||||
System,
|
||||
Portable
|
||||
}
|
||||
|
||||
internal sealed class DataLocationConfig
|
||||
{
|
||||
public string DataLocationMode { get; set; } = "System";
|
||||
|
||||
public string? SystemDataPath { get; set; }
|
||||
|
||||
public string? PortableDataPath { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class DataLocationPromptResult
|
||||
{
|
||||
public DataLocationMode SelectedMode { get; init; }
|
||||
|
||||
public bool MigrateExistingData { get; init; }
|
||||
}
|
||||
42
LanMountainDesktop.Launcher/Models/PrivacyAgreementState.cs
Normal file
42
LanMountainDesktop.Launcher/Models/PrivacyAgreementState.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 隐私协议同意状态模型(带防篡改保护)
|
||||
/// </summary>
|
||||
public class PrivacyAgreementState
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户是否同意隐私协议
|
||||
/// </summary>
|
||||
public bool IsAgreed { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 同意时间(UTC)
|
||||
/// </summary>
|
||||
public DateTime AgreedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 同意的协议版本
|
||||
/// </summary>
|
||||
public string AgreementVersion { get; set; } = "1.0";
|
||||
|
||||
/// <summary>
|
||||
/// 用户标识(匿名)
|
||||
/// </summary>
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 设备标识
|
||||
/// </summary>
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数据完整性校验哈希(HMAC-SHA256)
|
||||
/// </summary>
|
||||
public string IntegrityHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 用于生成哈希的随机盐值
|
||||
/// </summary>
|
||||
public string Salt { get; set; } = string.Empty;
|
||||
}
|
||||
22
LanMountainDesktop.Launcher/Models/PrivacyConfig.cs
Normal file
22
LanMountainDesktop.Launcher/Models/PrivacyConfig.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 隐私配置模型
|
||||
/// </summary>
|
||||
public class PrivacyConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用崩溃报告遥测
|
||||
/// </summary>
|
||||
public bool CrashTelemetryEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用使用统计遥测
|
||||
/// </summary>
|
||||
public bool UsageTelemetryEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 隐私追踪 ID
|
||||
/// </summary>
|
||||
public string TelemetryId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -4,10 +4,10 @@ using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal static class Program
|
||||
public static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static async Task<int> Main(string[] args)
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
var commandContext = CommandContext.FromArgs(args);
|
||||
var execution = LauncherExecutionContext.Capture();
|
||||
@@ -66,7 +66,7 @@ internal static class Program
|
||||
}
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp()
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
{
|
||||
return AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"Launcher (Debug Mode)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "launch --debug",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Launch Mode)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "launch",
|
||||
@@ -9,6 +17,46 @@
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Preview Debug Window)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "preview-debug",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Preview Splash)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "preview-splash",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Preview Error)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "preview-error",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Preview Update)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "preview-update",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Preview OOBE)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "preview-oobe",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Update Check)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "update check",
|
||||
|
||||
67
LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs
Normal file
67
LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class DataLocationOobeStep : IOobeStep
|
||||
{
|
||||
private readonly DataLocationResolver _resolver;
|
||||
|
||||
public DataLocationOobeStep(DataLocationResolver resolver)
|
||||
{
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var existingConfig = _resolver.LoadConfig();
|
||||
if (existingConfig is not null)
|
||||
{
|
||||
Logger.Info("DataLocation OOBE step skipped: config already exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
DataLocationPromptWindow? window = null;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
window = new DataLocationPromptWindow(_resolver);
|
||||
window.Show();
|
||||
});
|
||||
|
||||
if (window is null)
|
||||
{
|
||||
Logger.Warn("DataLocation OOBE step failed: window could not be created.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await window.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
Logger.Info("DataLocation OOBE step: user cancelled or closed window. Using default system location.");
|
||||
_resolver.ApplyLocationChoice(DataLocationMode.System, null, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var success = _resolver.ApplyLocationChoice(result.SelectedMode, null, result.MigrateExistingData);
|
||||
Logger.Info(
|
||||
$"DataLocation OOBE step: user selected '{result.SelectedMode}'. " +
|
||||
$"Migrate={result.MigrateExistingData}; Success={success}.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (window.IsVisible)
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
283
LanMountainDesktop.Launcher/Services/DataLocationResolver.cs
Normal file
283
LanMountainDesktop.Launcher/Services/DataLocationResolver.cs
Normal file
@@ -0,0 +1,283 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class DataLocationResolver
|
||||
{
|
||||
private const string ConfigFileName = "data-location.config.json";
|
||||
private const string LauncherFolderName = "Launcher";
|
||||
private const string DesktopFolderName = "Desktop";
|
||||
|
||||
private readonly string _appRoot;
|
||||
private readonly string _defaultSystemDataPath;
|
||||
|
||||
public DataLocationResolver(string appRoot)
|
||||
{
|
||||
_appRoot = Path.GetFullPath(appRoot);
|
||||
_defaultSystemDataPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
}
|
||||
|
||||
public string AppRoot => _appRoot;
|
||||
|
||||
/// <summary>
|
||||
/// 默认系统数据路径(用户目录)
|
||||
/// </summary>
|
||||
public string DefaultSystemDataPath => _defaultSystemDataPath;
|
||||
|
||||
/// <summary>
|
||||
/// 默认便携模式数据路径(应用目录下的 AppData)
|
||||
/// </summary>
|
||||
public string DefaultPortableDataPath => Path.Combine(_appRoot, "AppData");
|
||||
|
||||
private string ResolveBootstrapLauncherDataPath()
|
||||
{
|
||||
return Path.Combine(_defaultSystemDataPath, LauncherFolderName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否允许便携模式(应用目录是否可写)
|
||||
/// </summary>
|
||||
public bool IsPortableModeAllowed()
|
||||
{
|
||||
try
|
||||
{
|
||||
var testFile = Path.Combine(_appRoot, $".write-test-{Guid.NewGuid():N}.tmp");
|
||||
File.WriteAllText(testFile, string.Empty);
|
||||
File.Delete(testFile);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析数据根目录(用户选择的位置)
|
||||
/// </summary>
|
||||
public string ResolveDataRoot()
|
||||
{
|
||||
var config = LoadConfig();
|
||||
return ResolveDataRoot(config);
|
||||
}
|
||||
|
||||
private string ResolveDataRoot(DataLocationConfig? config)
|
||||
{
|
||||
if (config is null)
|
||||
{
|
||||
return _defaultSystemDataPath;
|
||||
}
|
||||
|
||||
if (string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var portablePath = !string.IsNullOrWhiteSpace(config.PortableDataPath)
|
||||
? config.PortableDataPath
|
||||
: DefaultPortableDataPath;
|
||||
return Path.GetFullPath(portablePath);
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(config.SystemDataPath)
|
||||
? Path.GetFullPath(config.SystemDataPath)
|
||||
: _defaultSystemDataPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动器数据目录(日志、配置、状态等)
|
||||
/// </summary>
|
||||
public string ResolveLauncherDataPath()
|
||||
{
|
||||
return Path.Combine(ResolveDataRoot(), LauncherFolderName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 桌面应用数据目录(组件、设置、插件等)
|
||||
/// </summary>
|
||||
public string ResolveDesktopDataPath()
|
||||
{
|
||||
return Path.Combine(ResolveDataRoot(), DesktopFolderName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 数据位置配置文件路径(保存在 Launcher 目录下)
|
||||
/// </summary>
|
||||
public string ResolveConfigPath()
|
||||
{
|
||||
return Path.Combine(ResolveBootstrapLauncherDataPath(), ConfigFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动器日志目录
|
||||
/// </summary>
|
||||
public string ResolveLauncherLogsPath()
|
||||
{
|
||||
return Path.Combine(ResolveLauncherDataPath(), "logs");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动器状态目录
|
||||
/// </summary>
|
||||
public string ResolveLauncherStatePath()
|
||||
{
|
||||
return Path.Combine(ResolveLauncherDataPath(), "state");
|
||||
}
|
||||
|
||||
public DataLocationMode ResolveMode()
|
||||
{
|
||||
var config = LoadConfig();
|
||||
if (config is null)
|
||||
{
|
||||
return DataLocationMode.System;
|
||||
}
|
||||
|
||||
return string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase)
|
||||
? DataLocationMode.Portable
|
||||
: DataLocationMode.System;
|
||||
}
|
||||
|
||||
public DataLocationConfig? LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 配置文件必须位于默认系统数据路径下的 Launcher 目录中
|
||||
// 避免循环依赖:不能调用 ResolveConfigPath() -> ResolveLauncherDataPath() -> ResolveDataRoot() -> LoadConfig()
|
||||
var configPath = Path.Combine(_defaultSystemDataPath, LauncherFolderName, ConfigFileName);
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(configPath);
|
||||
return JsonSerializer.Deserialize(json, AppJsonContext.Default.DataLocationConfig);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to load data location config. Error='{ex.Message}'.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool SaveConfig(DataLocationConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherPath = ResolveBootstrapLauncherDataPath();
|
||||
Directory.CreateDirectory(launcherPath);
|
||||
|
||||
var configPath = ResolveConfigPath();
|
||||
var json = JsonSerializer.Serialize(config, AppJsonContext.Default.DataLocationConfig);
|
||||
File.WriteAllText(configPath, json);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to save data location config. Error='{ex.Message}'.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
||||
{
|
||||
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath)
|
||||
? Path.GetFullPath(customPath)
|
||||
: _defaultSystemDataPath;
|
||||
|
||||
var config = new DataLocationConfig
|
||||
{
|
||||
DataLocationMode = mode.ToString(),
|
||||
SystemDataPath = _defaultSystemDataPath,
|
||||
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
|
||||
};
|
||||
|
||||
// 先创建目录结构
|
||||
try
|
||||
{
|
||||
var resolvedDataRoot = ResolveDataRoot(config);
|
||||
Directory.CreateDirectory(Path.Combine(resolvedDataRoot, LauncherFolderName));
|
||||
Directory.CreateDirectory(Path.Combine(resolvedDataRoot, DesktopFolderName));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to create data directories. Error='{ex.Message}'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
if (!SaveConfig(config))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (migrateExistingData && mode == DataLocationMode.Portable)
|
||||
{
|
||||
MigrateSystemDataToPortable(targetDataRoot);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool HasExistingSystemData()
|
||||
{
|
||||
var desktopPath = Path.Combine(_defaultSystemDataPath, DesktopFolderName);
|
||||
if (!Directory.Exists(desktopPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var markerFiles = new[]
|
||||
{
|
||||
Path.Combine(desktopPath, "settings.json"),
|
||||
Path.Combine(desktopPath, "component-state.db"),
|
||||
Path.Combine(desktopPath, "app.db")
|
||||
};
|
||||
|
||||
return markerFiles.Any(File.Exists);
|
||||
}
|
||||
|
||||
private void MigrateSystemDataToPortable(string targetDataRoot)
|
||||
{
|
||||
if (!HasExistingSystemData())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceDesktopPath = Path.Combine(_defaultSystemDataPath, DesktopFolderName);
|
||||
var targetDesktopPath = Path.Combine(targetDataRoot, DesktopFolderName);
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(targetDesktopPath);
|
||||
|
||||
// 迁移桌面数据
|
||||
if (Directory.Exists(sourceDesktopPath))
|
||||
{
|
||||
CopyDirectory(sourceDesktopPath, targetDesktopPath);
|
||||
}
|
||||
|
||||
Logger.Info($"Data migration completed. Target='{targetDataRoot}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Data migration failed. Target='{targetDataRoot}'. Error='{ex.Message}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string sourceDir, string destDir)
|
||||
{
|
||||
Directory.CreateDirectory(destDir);
|
||||
|
||||
foreach (var file in Directory.GetFiles(sourceDir))
|
||||
{
|
||||
var destFile = Path.Combine(destDir, Path.GetFileName(file));
|
||||
File.Copy(file, destFile, overwrite: true);
|
||||
}
|
||||
|
||||
foreach (var subDir in Directory.GetDirectories(sourceDir))
|
||||
{
|
||||
var destSubDir = Path.Combine(destDir, Path.GetFileName(subDir));
|
||||
CopyDirectory(subDir, destSubDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
@@ -360,51 +360,59 @@ internal sealed class DeploymentLocator
|
||||
/// </summary>
|
||||
private static string? ScanDevelopmentPaths(string executable)
|
||||
{
|
||||
var solutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
|
||||
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// 浠?Launcher 椤圭洰杩愯
|
||||
// 标准开发路径:解决方案根目录下的 LanMountainDesktop 项目
|
||||
Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 向后兼容
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 浠庤В鍐虫柟妗堟牴鐩綍杩愯
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// dev-test 鐩綍
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
// dev-test 目录
|
||||
Path.Combine(solutionRoot, "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
Logger.Info($"Scanning development path: {path}");
|
||||
if (File.Exists(path))
|
||||
{
|
||||
Logger.Info($"Found host at: {path}");
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 鑾峰彇寮€鍙戠幆澧冨彲鑳界殑涓荤▼搴忚矾寰? /// </summary>
|
||||
/// 鑾峰彇寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忚経
|
||||
/// </summary>
|
||||
private static IEnumerable<string> GetDevelopmentPaths(string executable)
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
|
||||
// 计算解决方案根目录:从 LanMountainDesktop.Launcher\bin\Debug\net10.0\ 向上4级
|
||||
var solutionRoot = Path.GetFullPath(Path.Combine(launcherDir, "..", "..", "..", ".."));
|
||||
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
// 浠?Launcher 椤圭洰杩愯锛?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
// 标准开发路径:解决方案根目录下的 LanMountainDesktop 项目
|
||||
Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 向后兼容:如果 Launcher 在特殊目录结构中
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 浠庤В鍐虫柟妗堟牴鐩綍杩愯锛歀anMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
|
||||
// 浠?dev-test 鐩綍杩愯
|
||||
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
// dev-test 目录
|
||||
Path.Combine(solutionRoot, "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
|
||||
return possiblePaths.Select(Path.GetFullPath).Distinct();
|
||||
}
|
||||
|
||||
@@ -489,7 +497,8 @@ internal sealed class DeploymentLocator
|
||||
}
|
||||
|
||||
// 3. 淇濈暀鏈夊揩鐓х殑鐗堟湰锛堢敤浜庡洖婊氾級
|
||||
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
|
||||
var resolver = new DataLocationResolver(_appRoot);
|
||||
var snapshotDir = Path.Combine(resolver.ResolveLauncherDataPath(), "snapshots");
|
||||
if (Directory.Exists(snapshotDir))
|
||||
{
|
||||
try
|
||||
|
||||
@@ -12,10 +12,12 @@ internal sealed record HostLaunchPlan(
|
||||
|
||||
internal static class HostLaunchPlanBuilder
|
||||
{
|
||||
public const string DataRootOptionName = "data-root";
|
||||
|
||||
private static readonly string[] LauncherOnlyOptions =
|
||||
[
|
||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||
"app-root",
|
||||
"app-root", DataRootOptionName,
|
||||
LauncherIpcConstants.LauncherPidEnvVar,
|
||||
LauncherIpcConstants.PackageRootEnvVar,
|
||||
LauncherIpcConstants.VersionEnvVar,
|
||||
@@ -25,7 +27,8 @@ internal static class HostLaunchPlanBuilder
|
||||
public static HostLaunchPlan Build(
|
||||
CommandContext context,
|
||||
DeploymentLocator deploymentLocator,
|
||||
HostResolutionResult resolution)
|
||||
HostResolutionResult resolution,
|
||||
string? dataRoot = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(deploymentLocator);
|
||||
@@ -39,7 +42,7 @@ internal static class HostLaunchPlanBuilder
|
||||
var hostPath = Path.GetFullPath(resolution.ResolvedHostPath);
|
||||
var packageRoot = ResolvePackageRoot(hostPath, resolution.AppRoot, resolution.ResolutionSource);
|
||||
var versionInfo = deploymentLocator.GetVersionInfo();
|
||||
var arguments = BuildForwardedArguments(context, packageRoot, versionInfo);
|
||||
var arguments = BuildForwardedArguments(context, packageRoot, versionInfo, dataRoot);
|
||||
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(),
|
||||
@@ -48,6 +51,11 @@ internal static class HostLaunchPlanBuilder
|
||||
[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||
{
|
||||
environment["LMD_DATA_ROOT"] = dataRoot;
|
||||
}
|
||||
|
||||
return new HostLaunchPlan(
|
||||
hostPath,
|
||||
packageRoot,
|
||||
@@ -92,7 +100,8 @@ internal static class HostLaunchPlanBuilder
|
||||
private static IReadOnlyList<string> BuildForwardedArguments(
|
||||
CommandContext context,
|
||||
string packageRoot,
|
||||
AppVersionInfo versionInfo)
|
||||
AppVersionInfo versionInfo,
|
||||
string? dataRoot = null)
|
||||
{
|
||||
var arguments = new List<string>();
|
||||
|
||||
@@ -144,6 +153,11 @@ internal static class HostLaunchPlanBuilder
|
||||
arguments.Add($"--{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
|
||||
arguments.Add($"--{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||
{
|
||||
arguments.Add($"--{DataRootOptionName}={dataRoot}");
|
||||
}
|
||||
|
||||
return arguments;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 启动器背景图片服务
|
||||
/// </summary>
|
||||
internal static class LauncherBackgroundService
|
||||
{
|
||||
private const string PictureFileName = "Launcher Picture";
|
||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
private const double WindowAspectRatio = 7.0 / 5.0; // 700:500
|
||||
private const double AspectRatioTolerance = 0.15; // 15% 误差
|
||||
|
||||
private static Bitmap? _cachedBitmap;
|
||||
private static string? _cachedPath;
|
||||
|
||||
/// <summary>
|
||||
/// 背景图片信息
|
||||
/// </summary>
|
||||
public record BackgroundImageInfo
|
||||
{
|
||||
public required bool Exists { get; init; }
|
||||
public required bool IsValid { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public Bitmap? Bitmap { get; init; }
|
||||
public int Width { get; init; }
|
||||
public int Height { get; init; }
|
||||
public double AspectRatio { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载背景图片
|
||||
/// </summary>
|
||||
public static BackgroundImageInfo LoadBackgroundImage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolver = new DataLocationResolver(AppContext.BaseDirectory);
|
||||
var launcherPath = resolver.ResolveLauncherDataPath();
|
||||
|
||||
// 查找图片文件
|
||||
var imagePath = FindImageFile(launcherPath);
|
||||
if (imagePath == null)
|
||||
{
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = false,
|
||||
IsValid = false,
|
||||
ErrorMessage = "未找到背景图片文件"
|
||||
};
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
var fileInfo = new FileInfo(imagePath);
|
||||
if (fileInfo.Length > MaxFileSize)
|
||||
{
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = false,
|
||||
FilePath = imagePath,
|
||||
ErrorMessage = $"图片文件过大 ({fileInfo.Length / 1024 / 1024}MB > 10MB)"
|
||||
};
|
||||
}
|
||||
|
||||
// 使用缓存
|
||||
if (_cachedBitmap != null && _cachedPath == imagePath)
|
||||
{
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = true,
|
||||
FilePath = imagePath,
|
||||
Bitmap = _cachedBitmap,
|
||||
Width = _cachedBitmap.PixelSize.Width,
|
||||
Height = _cachedBitmap.PixelSize.Height,
|
||||
AspectRatio = (double)_cachedBitmap.PixelSize.Width / _cachedBitmap.PixelSize.Height
|
||||
};
|
||||
}
|
||||
|
||||
// 加载图片
|
||||
var bitmap = new Bitmap(imagePath);
|
||||
var width = bitmap.PixelSize.Width;
|
||||
var height = bitmap.PixelSize.Height;
|
||||
var aspectRatio = (double)width / height;
|
||||
|
||||
// 校验比例
|
||||
var ratioDiff = Math.Abs(aspectRatio - WindowAspectRatio) / WindowAspectRatio;
|
||||
if (ratioDiff > AspectRatioTolerance)
|
||||
{
|
||||
bitmap.Dispose();
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = false,
|
||||
FilePath = imagePath,
|
||||
Width = width,
|
||||
Height = height,
|
||||
AspectRatio = aspectRatio,
|
||||
ErrorMessage = $"图片比例不符合要求 ({aspectRatio:F2},需要接近 {WindowAspectRatio:F2})"
|
||||
};
|
||||
}
|
||||
|
||||
// 缓存图片
|
||||
_cachedBitmap = bitmap;
|
||||
_cachedPath = imagePath;
|
||||
|
||||
Logger.Info($"[LauncherBackground] 背景图片加载成功: {imagePath} ({width}x{height}, 比例: {aspectRatio:F2})");
|
||||
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = true,
|
||||
FilePath = imagePath,
|
||||
Bitmap = bitmap,
|
||||
Width = width,
|
||||
Height = height,
|
||||
AspectRatio = aspectRatio
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[LauncherBackground] 加载背景图片失败: {ex.Message}");
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = false,
|
||||
IsValid = false,
|
||||
ErrorMessage = $"加载失败: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找图片文件
|
||||
/// </summary>
|
||||
private static string? FindImageFile(string directory)
|
||||
{
|
||||
var extensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
|
||||
|
||||
foreach (var ext in extensions)
|
||||
{
|
||||
var path = Path.Combine(directory, PictureFileName + ext);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// 也尝试不带扩展名的匹配(如果文件本身就有扩展名)
|
||||
var files = Directory.GetFiles(directory, PictureFileName + ".*");
|
||||
foreach (var file in files)
|
||||
{
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (extensions.Contains(ext))
|
||||
{
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除缓存
|
||||
/// </summary>
|
||||
public static void ClearCache()
|
||||
{
|
||||
_cachedBitmap?.Dispose();
|
||||
_cachedBitmap = null;
|
||||
_cachedPath = null;
|
||||
}
|
||||
}
|
||||
@@ -100,12 +100,22 @@ internal static class LauncherDebugSettingsStore
|
||||
|
||||
private static string ResolveConfigBaseDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
return resolver.ResolveLauncherDataPath();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrWhiteSpace(appData))
|
||||
{
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
|
||||
return Path.Combine(appData, "LanMountainDesktop", "Launcher");
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -114,11 +124,11 @@ internal static class LauncherDebugSettingsStore
|
||||
|
||||
try
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, ".launcher");
|
||||
return Path.Combine(AppContext.BaseDirectory, "Launcher");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), "Launcher");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
private readonly PluginInstallerService _pluginInstallerService;
|
||||
private readonly StartupAttemptRegistry _startupAttemptRegistry;
|
||||
private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer;
|
||||
private readonly DataLocationResolver _dataLocationResolver;
|
||||
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
||||
|
||||
public LauncherFlowCoordinator(
|
||||
@@ -41,7 +42,12 @@ internal sealed class LauncherFlowCoordinator
|
||||
_pluginInstallerService = pluginInstallerService;
|
||||
_startupAttemptRegistry = startupAttemptRegistry ?? new StartupAttemptRegistry();
|
||||
_coordinatorIpcServer = coordinatorIpcServer;
|
||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)];
|
||||
_dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot());
|
||||
_oobeSteps =
|
||||
[
|
||||
new WelcomeOobeStep(_oobeStateService, _context),
|
||||
new DataLocationOobeStep(_dataLocationResolver)
|
||||
];
|
||||
}
|
||||
|
||||
public static string ResolveSuccessPolicyKey(CommandContext context)
|
||||
@@ -270,7 +276,18 @@ internal sealed class LauncherFlowCoordinator
|
||||
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
return WithAdditionalDetails(updateResult, launcherContextDetails);
|
||||
Logger.Warn($"Update apply failed, will try to launch existing version. Error='{updateResult.Message}'.");
|
||||
reporter.Report("update", "Update failed, launching existing version...");
|
||||
// Clean up corrupted update files to prevent repeated failures
|
||||
try
|
||||
{
|
||||
_updateEngine.CleanupIncomingArtifacts();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to cleanup update artifacts after failed update: {ex.Message}");
|
||||
}
|
||||
// Continue to launch existing version instead of aborting
|
||||
}
|
||||
|
||||
reporter.Report("plugins", "Applying plugin upgrades...");
|
||||
@@ -278,7 +295,8 @@ internal sealed class LauncherFlowCoordinator
|
||||
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
||||
if (!queueResult.Success)
|
||||
{
|
||||
return WithAdditionalDetails(queueResult, launcherContextDetails);
|
||||
Logger.Warn($"Plugin upgrade failed, continuing startup. Error='{queueResult.Message}'.");
|
||||
reporter.Report("plugins", "Plugin upgrade failed, continuing...");
|
||||
}
|
||||
|
||||
if (oobeDecision.ShouldShowOobe)
|
||||
@@ -943,7 +961,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
try
|
||||
{
|
||||
await splashWindow.DismissAsync().ConfigureAwait(false);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.DismissAsync());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1013,7 +1031,8 @@ internal sealed class LauncherFlowCoordinator
|
||||
bool forceDirectMode,
|
||||
string? retryTag)
|
||||
{
|
||||
var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution);
|
||||
var dataRoot = _dataLocationResolver.ResolveDataRoot();
|
||||
var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot);
|
||||
var hostPath = plan.HostPath;
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
|
||||
@@ -53,12 +53,22 @@ internal static class Logger
|
||||
/// </summary>
|
||||
private static string? GetLogDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
return resolver.ResolveLauncherLogsPath();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrEmpty(appData))
|
||||
{
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "logs");
|
||||
return Path.Combine(appData, "LanMountainDesktop", "Launcher", "logs");
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -68,7 +78,7 @@ internal static class Logger
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
return Path.Combine(launcherDir, ".launcher", "logs");
|
||||
return Path.Combine(launcherDir, "Launcher", "logs");
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ internal sealed class OobeStateService
|
||||
|
||||
private readonly string _stateDirectory;
|
||||
private readonly string _statePath;
|
||||
private readonly string _legacyStatePath;
|
||||
private readonly string _legacyMarkerPath;
|
||||
private readonly LauncherExecutionSnapshot _executionSnapshot;
|
||||
|
||||
@@ -21,11 +22,17 @@ internal sealed class OobeStateService
|
||||
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
|
||||
|
||||
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? GetDefaultStateRoot()
|
||||
? ResolveStateRoot(appRoot)
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
_stateDirectory = Path.Combine(stateRoot, ".launcher", "state");
|
||||
_stateDirectory = Path.Combine(stateRoot, "Launcher", "state");
|
||||
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
|
||||
_legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed");
|
||||
|
||||
var legacyRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? Path.GetFullPath(appRoot)
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
var legacyStateDirectory = Path.Combine(legacyRoot, ".launcher", "state");
|
||||
_legacyStatePath = Path.Combine(legacyStateDirectory, "oobe-state.json");
|
||||
_legacyMarkerPath = Path.Combine(legacyStateDirectory, "first_run_completed");
|
||||
}
|
||||
|
||||
public OobeLaunchDecision Evaluate(CommandContext context)
|
||||
@@ -100,14 +107,12 @@ internal sealed class OobeStateService
|
||||
var migratedLegacyMarker = false;
|
||||
if (File.Exists(_statePath))
|
||||
{
|
||||
using var stream = File.OpenRead(_statePath);
|
||||
var state = JsonSerializer.Deserialize(stream, AppJsonContext.Default.OobeStateFile);
|
||||
if (state is null || state.SchemaVersion <= 0 || string.IsNullOrWhiteSpace(state.CompletedAtUtc))
|
||||
{
|
||||
return BuildUnavailableDecision(context, "OOBE state file is invalid.");
|
||||
}
|
||||
return EvaluateStateFile(context, _statePath, migratedLegacyState: false);
|
||||
}
|
||||
|
||||
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, migratedLegacyMarker: false);
|
||||
if (File.Exists(_legacyStatePath))
|
||||
{
|
||||
return EvaluateStateFile(context, _legacyStatePath, migratedLegacyState: false);
|
||||
}
|
||||
|
||||
if (File.Exists(_legacyMarkerPath))
|
||||
@@ -140,6 +145,18 @@ internal sealed class OobeStateService
|
||||
return result.Success;
|
||||
}
|
||||
|
||||
private OobeLaunchDecision EvaluateStateFile(CommandContext context, string statePath, bool migratedLegacyState)
|
||||
{
|
||||
using var stream = File.OpenRead(statePath);
|
||||
var state = JsonSerializer.Deserialize(stream, AppJsonContext.Default.OobeStateFile);
|
||||
if (state is null || state.SchemaVersion <= 0 || string.IsNullOrWhiteSpace(state.CompletedAtUtc))
|
||||
{
|
||||
return BuildUnavailableDecision(context, "OOBE state file is invalid.");
|
||||
}
|
||||
|
||||
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, migratedLegacyMarker: migratedLegacyState);
|
||||
}
|
||||
|
||||
private void TryDeleteLegacyMarker()
|
||||
{
|
||||
try
|
||||
@@ -208,14 +225,22 @@ internal sealed class OobeStateService
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDefaultStateRoot()
|
||||
private static string ResolveStateRoot(string appRoot)
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(appData))
|
||||
try
|
||||
{
|
||||
throw new InvalidOperationException("LocalApplicationData is unavailable.");
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
return resolver.ResolveDataRoot();
|
||||
}
|
||||
catch
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(appData))
|
||||
{
|
||||
throw new InvalidOperationException("LocalApplicationData is unavailable.");
|
||||
}
|
||||
|
||||
return Path.Combine(appData, "LanMountainDesktop");
|
||||
return Path.Combine(appData, "LanMountainDesktop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +63,28 @@ internal sealed class PluginInstallerService
|
||||
return null;
|
||||
}
|
||||
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
string? allowedRoot = null;
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop"));
|
||||
if (string.IsNullOrWhiteSpace(allowedRoot))
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop"));
|
||||
}
|
||||
|
||||
var normalizedPluginsDirectory = EnsureTrailingSeparator(Path.GetFullPath(pluginsDirectory));
|
||||
if (normalizedPluginsDirectory.StartsWith(allowedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
||||
245
LanMountainDesktop.Launcher/Services/PrivacyAgreementService.cs
Normal file
245
LanMountainDesktop.Launcher/Services/PrivacyAgreementService.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 隐私协议同意状态管理服务(带防篡改保护)
|
||||
/// </summary>
|
||||
internal sealed class PrivacyAgreementService
|
||||
{
|
||||
private readonly string _storagePath;
|
||||
private readonly string _secretKey;
|
||||
private const string ConfigFileName = "privacy-agreement.state.json";
|
||||
private const string CurrentAgreementVersion = "1.0";
|
||||
|
||||
public PrivacyAgreementService(string launcherDataPath)
|
||||
{
|
||||
_storagePath = Path.Combine(launcherDataPath, ConfigFileName);
|
||||
// 使用机器特定信息生成密钥,增加篡改难度
|
||||
_secretKey = GenerateMachineSpecificKey();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查用户是否已同意隐私协议
|
||||
/// </summary>
|
||||
public bool HasUserAgreed()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_storagePath))
|
||||
{
|
||||
Logger.Info("[PrivacyAgreementService] 未找到隐私协议同意状态文件");
|
||||
return false;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(_storagePath);
|
||||
var state = JsonSerializer.Deserialize(json, AppJsonContext.Default.PrivacyAgreementState);
|
||||
|
||||
if (state == null)
|
||||
{
|
||||
Logger.Warn("[PrivacyAgreementService] 无法解析隐私协议状态文件");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证数据完整性
|
||||
if (!VerifyIntegrity(state))
|
||||
{
|
||||
Logger.Warn("[PrivacyAgreementService] 隐私协议状态文件已被篡改!");
|
||||
// 删除被篡改的文件
|
||||
try
|
||||
{
|
||||
File.Delete(_storagePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[PrivacyAgreementService] 删除被篡改文件失败: {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查协议版本是否匹配
|
||||
if (state.AgreementVersion != CurrentAgreementVersion)
|
||||
{
|
||||
Logger.Info($"[PrivacyAgreementService] 隐私协议版本已更新: {state.AgreementVersion} -> {CurrentAgreementVersion}");
|
||||
return false;
|
||||
}
|
||||
|
||||
Logger.Info($"[PrivacyAgreementService] 用户已于 {state.AgreedAtUtc:yyyy-MM-dd HH:mm:ss} UTC 同意隐私协议");
|
||||
return state.IsAgreed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[PrivacyAgreementService] 检查同意状态时出错: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存用户同意隐私协议的状态
|
||||
/// </summary>
|
||||
public bool SaveAgreement(bool isAgreed, string userId, string deviceId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 确保目录存在
|
||||
var directory = Path.GetDirectoryName(_storagePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
// 生成随机盐值
|
||||
var salt = GenerateRandomSalt();
|
||||
|
||||
var state = new PrivacyAgreementState
|
||||
{
|
||||
IsAgreed = isAgreed,
|
||||
AgreedAtUtc = DateTime.UtcNow,
|
||||
AgreementVersion = CurrentAgreementVersion,
|
||||
UserId = userId,
|
||||
DeviceId = deviceId,
|
||||
Salt = salt
|
||||
};
|
||||
|
||||
// 计算完整性哈希
|
||||
state.IntegrityHash = CalculateIntegrityHash(state);
|
||||
|
||||
// 保存到文件
|
||||
var json = JsonSerializer.Serialize(state, AppJsonContext.Default.PrivacyAgreementState);
|
||||
File.WriteAllText(_storagePath, json);
|
||||
|
||||
Logger.Info($"[PrivacyAgreementService] 隐私协议同意状态已保存: IsAgreed={isAgreed}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[PrivacyAgreementService] 保存同意状态失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前的协议版本
|
||||
/// </summary>
|
||||
public string GetCurrentAgreementVersion() => CurrentAgreementVersion;
|
||||
|
||||
/// <summary>
|
||||
/// 清除同意状态(用于测试或重置)
|
||||
/// </summary>
|
||||
public bool ClearAgreement()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_storagePath))
|
||||
{
|
||||
File.Delete(_storagePath);
|
||||
Logger.Info("[PrivacyAgreementService] 隐私协议同意状态已清除");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[PrivacyAgreementService] 清除同意状态失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成机器特定的密钥
|
||||
/// </summary>
|
||||
private string GenerateMachineSpecificKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 组合多个机器特定信息生成密钥
|
||||
var machineName = Environment.MachineName;
|
||||
var userName = Environment.UserName;
|
||||
var osVersion = Environment.OSVersion.Version.ToString();
|
||||
var processorCount = Environment.ProcessorCount.ToString();
|
||||
|
||||
// 使用硬件信息(如果可用)
|
||||
var hardwareId = GetHardwareIdentifier();
|
||||
|
||||
var keyData = $"{machineName}:{userName}:{osVersion}:{processorCount}:{hardwareId}:LanMountainDesktop";
|
||||
|
||||
// 使用 SHA-256 生成固定长度的密钥
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(keyData));
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果无法获取机器信息,使用备用密钥
|
||||
return "LanMountainDesktop-Privacy-Agreement-Fallback-Key-2026";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取硬件标识符
|
||||
/// </summary>
|
||||
private string GetHardwareIdentifier()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 尝试使用系统目录创建时间作为硬件标识的一部分
|
||||
var systemDir = Environment.SystemDirectory;
|
||||
var dirInfo = new DirectoryInfo(systemDir);
|
||||
return dirInfo.CreationTimeUtc.ToString("yyyyMMddHHmmss");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成随机盐值
|
||||
/// </summary>
|
||||
private string GenerateRandomSalt()
|
||||
{
|
||||
var saltBytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(saltBytes);
|
||||
return Convert.ToHexString(saltBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算完整性哈希(HMAC-SHA256)
|
||||
/// </summary>
|
||||
private string CalculateIntegrityHash(PrivacyAgreementState state)
|
||||
{
|
||||
// 构建需要哈希的数据字符串
|
||||
var dataToHash = $"{state.IsAgreed}:{state.AgreedAtUtc:o}:{state.AgreementVersion}:{state.UserId}:{state.DeviceId}:{state.Salt}";
|
||||
|
||||
// 使用 HMAC-SHA256 计算哈希
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secretKey));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(dataToHash));
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证数据完整性
|
||||
/// </summary>
|
||||
private bool VerifyIntegrity(PrivacyAgreementState state)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(state.IntegrityHash) || string.IsNullOrEmpty(state.Salt))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedHash = CalculateIntegrityHash(state);
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(state.IntegrityHash),
|
||||
Encoding.UTF8.GetBytes(expectedHash));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,25 +10,35 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
internal sealed class StartupAttemptRegistry
|
||||
{
|
||||
private static readonly TimeSpan CoordinatorHeartbeatTimeout = TimeSpan.FromSeconds(10);
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly string _statePath;
|
||||
private readonly string _mutexName;
|
||||
private string? _ownedAttemptId;
|
||||
|
||||
public StartupAttemptRegistry()
|
||||
: this(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
".launcher",
|
||||
"state",
|
||||
"startup-attempt.json"))
|
||||
: this(ResolveDefaultStatePath())
|
||||
{
|
||||
}
|
||||
|
||||
private static string ResolveDefaultStatePath()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
return Path.Combine(resolver.ResolveLauncherStatePath(), "startup-attempt.json");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Launcher",
|
||||
"state",
|
||||
"startup-attempt.json");
|
||||
}
|
||||
}
|
||||
|
||||
internal StartupAttemptRegistry(string statePath)
|
||||
{
|
||||
_statePath = statePath;
|
||||
@@ -415,7 +425,7 @@ internal sealed class StartupAttemptRegistry
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_statePath);
|
||||
return JsonSerializer.Deserialize<StartupAttemptRecord>(json, SerializerOptions);
|
||||
return JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupAttemptRecord);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -431,7 +441,7 @@ internal sealed class StartupAttemptRegistry
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(_statePath, JsonSerializer.Serialize(record, SerializerOptions));
|
||||
File.WriteAllText(_statePath, JsonSerializer.Serialize(record, AppJsonContext.Default.StartupAttemptRecord));
|
||||
}
|
||||
|
||||
private static bool IsAttachable(StartupAttemptRecord record)
|
||||
|
||||
68
LanMountainDesktop.Launcher/Services/ThemeService.cs
Normal file
68
LanMountainDesktop.Launcher/Services/ThemeService.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Styling;
|
||||
using FluentAvalonia.Styling;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 主题服务,管理启动器的主题设置
|
||||
/// </summary>
|
||||
public static class ThemeService
|
||||
{
|
||||
private static ThemeVariant _currentTheme = ThemeVariant.Light;
|
||||
private static string _accentColor = "#0078D4";
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前主题
|
||||
/// </summary>
|
||||
public static ThemeVariant CurrentTheme => _currentTheme;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前主题色
|
||||
/// </summary>
|
||||
public static string AccentColor => _accentColor;
|
||||
|
||||
/// <summary>
|
||||
/// 应用主题设置
|
||||
/// </summary>
|
||||
public static void ApplyTheme(ThemeMode mode, string accentColor)
|
||||
{
|
||||
_currentTheme = mode switch
|
||||
{
|
||||
ThemeMode.Dark => ThemeVariant.Dark,
|
||||
_ => ThemeVariant.Light
|
||||
};
|
||||
_accentColor = accentColor;
|
||||
|
||||
// 应用到当前应用程序
|
||||
if (Application.Current is { } app)
|
||||
{
|
||||
app.RequestedThemeVariant = _currentTheme;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用浅色主题
|
||||
/// </summary>
|
||||
public static void ApplyLightTheme(string accentColor)
|
||||
{
|
||||
ApplyTheme(ThemeMode.Light, accentColor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用深色主题
|
||||
/// </summary>
|
||||
public static void ApplyDarkTheme(string accentColor)
|
||||
{
|
||||
ApplyTheme(ThemeMode.Dark, accentColor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 主题模式
|
||||
/// </summary>
|
||||
public enum ThemeMode
|
||||
{
|
||||
Light,
|
||||
Dark
|
||||
}
|
||||
@@ -7,7 +7,6 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class UpdateEngineService
|
||||
{
|
||||
private const string LauncherDirectoryName = ".launcher";
|
||||
private const string UpdateDirectoryName = "update";
|
||||
private const string IncomingDirectoryName = "incoming";
|
||||
private const string SnapshotsDirectoryName = "snapshots";
|
||||
@@ -30,7 +29,8 @@ internal sealed class UpdateEngineService
|
||||
{
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_appRoot = deploymentLocator.GetAppRoot();
|
||||
_launcherRoot = Path.Combine(_appRoot, LauncherDirectoryName);
|
||||
var resolver = new DataLocationResolver(_appRoot);
|
||||
_launcherRoot = resolver.ResolveLauncherDataPath();
|
||||
_incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName);
|
||||
_snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName);
|
||||
}
|
||||
@@ -1458,7 +1458,7 @@ internal sealed class UpdateEngineService
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupIncomingArtifacts()
|
||||
internal void CleanupIncomingArtifacts()
|
||||
{
|
||||
foreach (var path in new[]
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
private bool _isErrorEnabled = true;
|
||||
private bool _isUpdateEnabled = true;
|
||||
private bool _isOobeEnabled = true;
|
||||
private bool _isDataLocationEnabled = true;
|
||||
private string _statusMessage = "就绪";
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
@@ -87,6 +88,23 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 数据位置选择页面是否启用实际功能
|
||||
/// </summary>
|
||||
public bool IsDataLocationEnabled
|
||||
{
|
||||
get => _isDataLocationEnabled;
|
||||
set
|
||||
{
|
||||
if (_isDataLocationEnabled != value)
|
||||
{
|
||||
_isDataLocationEnabled = value;
|
||||
OnPropertyChanged();
|
||||
UpdateStatus($"数据位置选择: {(value ? "功能模式" : "仅查看")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 状态信息
|
||||
@@ -131,6 +149,11 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
/// </summary>
|
||||
public ICommand OpenOobeCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开数据位置选择页面命令
|
||||
/// </summary>
|
||||
public ICommand OpenDataLocationCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 全部切换到查看模式命令
|
||||
/// </summary>
|
||||
@@ -170,6 +193,11 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
/// </summary>
|
||||
public event EventHandler<OobeOpenEventArgs>? OpenOobeRequested;
|
||||
|
||||
/// <summary>
|
||||
/// 请求打开数据位置选择页面
|
||||
/// </summary>
|
||||
public event EventHandler<DataLocationOpenEventArgs>? OpenDataLocationRequested;
|
||||
|
||||
/// <summary>
|
||||
/// 请求关闭窗口
|
||||
/// </summary>
|
||||
@@ -199,12 +227,18 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
OpenOobeRequested?.Invoke(this, new OobeOpenEventArgs(IsOobeEnabled));
|
||||
});
|
||||
|
||||
OpenDataLocationCommand = new RelayCommand(() =>
|
||||
{
|
||||
OpenDataLocationRequested?.Invoke(this, new DataLocationOpenEventArgs(IsDataLocationEnabled));
|
||||
});
|
||||
|
||||
SetAllViewOnlyCommand = new RelayCommand(() =>
|
||||
{
|
||||
IsSplashEnabled = false;
|
||||
IsErrorEnabled = false;
|
||||
IsUpdateEnabled = false;
|
||||
IsOobeEnabled = false;
|
||||
IsDataLocationEnabled = false;
|
||||
UpdateStatus("全部页面已切换到查看模式");
|
||||
});
|
||||
|
||||
@@ -214,6 +248,7 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
|
||||
IsErrorEnabled = true;
|
||||
IsUpdateEnabled = true;
|
||||
IsOobeEnabled = true;
|
||||
IsDataLocationEnabled = true;
|
||||
UpdateStatus("全部页面已切换到功能模式");
|
||||
});
|
||||
|
||||
@@ -260,4 +295,10 @@ public class OobeOpenEventArgs : EventArgs
|
||||
public OobeOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
|
||||
}
|
||||
|
||||
public class DataLocationOpenEventArgs : EventArgs
|
||||
{
|
||||
public bool IsFunctional { get; }
|
||||
public DataLocationOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
154
LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml
Normal file
154
LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml
Normal file
@@ -0,0 +1,154 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="520"
|
||||
d:DesignHeight="480"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.DataLocationPromptWindow"
|
||||
x:DataType="views:DataLocationPromptWindow"
|
||||
Title="Choose Data Location"
|
||||
Width="520"
|
||||
Height="480"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Grid x:Name="ContentGrid"
|
||||
Opacity="0">
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform Y="24" />
|
||||
</Grid.RenderTransform>
|
||||
<Grid Margin="36" RowDefinitions="Auto,*,Auto">
|
||||
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,20">
|
||||
<TextBlock Text="Choose Data Location"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="Choose where launcher and desktop data should be stored. You can change this later in settings."
|
||||
FontSize="13"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Spacing="12">
|
||||
<Border x:Name="AdminWarningBanner"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="12,10"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<fi:SymbolIcon Symbol="Important"
|
||||
FontSize="16"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
<TextBlock Text="App folder is not writable"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="The current install directory requires elevated permissions. Data will be stored in the system user profile instead."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="SystemOptionBorder"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="2"
|
||||
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Padding="16,14">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="SystemRadio"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,2,12,0"
|
||||
GroupName="DataLocation"
|
||||
IsChecked="True" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="Store in the system user profile (Recommended)"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="Data stays tied to the current Windows user and remains intact across app reinstalls and updates."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<TextBlock x:Name="SystemPathText"
|
||||
FontSize="11"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="PortableOptionBorder"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
|
||||
Padding="16,14">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="PortableRadio"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,2,12,0"
|
||||
GroupName="DataLocation"
|
||||
IsEnabled="False" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="Store next to the app"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="Useful for portable installs. The whole app folder can be moved to another machine together with its data."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<TextBlock x:Name="PortablePathText"
|
||||
FontSize="11"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="MigrationInfoBorder"
|
||||
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="12,10"
|
||||
IsVisible="False">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<fi:SymbolIcon Symbol="Info"
|
||||
FontSize="16"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
|
||||
<TextBlock x:Name="MigrationInfoText"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="10"
|
||||
Margin="0,20,0,0">
|
||||
<Button x:Name="CancelButton"
|
||||
Content="Cancel"
|
||||
Theme="{DynamicResource ButtonTheme}"
|
||||
IsVisible="False" />
|
||||
<Button x:Name="ConfirmButton"
|
||||
Content="Confirm"
|
||||
Theme="{DynamicResource AccentButtonTheme}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,308 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
internal partial class DataLocationPromptWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<DataLocationPromptResult?> _completionSource = new();
|
||||
private readonly DataLocationResolver _resolver;
|
||||
private bool _isTransitioning;
|
||||
|
||||
public DataLocationPromptWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
_resolver = new DataLocationResolver(AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
internal DataLocationPromptWindow(DataLocationResolver resolver)
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
BindControls();
|
||||
UpdateUiState();
|
||||
}
|
||||
|
||||
private void BindControls()
|
||||
{
|
||||
var systemRadio = this.FindControl<RadioButton>("SystemRadio");
|
||||
var portableRadio = this.FindControl<RadioButton>("PortableRadio");
|
||||
var confirmButton = this.FindControl<Button>("ConfirmButton");
|
||||
var cancelButton = this.FindControl<Button>("CancelButton");
|
||||
|
||||
if (systemRadio is not null)
|
||||
{
|
||||
systemRadio.IsCheckedChanged += OnSelectionChanged;
|
||||
}
|
||||
|
||||
if (portableRadio is not null)
|
||||
{
|
||||
portableRadio.IsCheckedChanged += OnSelectionChanged;
|
||||
}
|
||||
|
||||
if (confirmButton is not null)
|
||||
{
|
||||
confirmButton.Click += OnConfirmClick;
|
||||
}
|
||||
|
||||
if (cancelButton is not null)
|
||||
{
|
||||
cancelButton.Click += OnCancelClick;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateUiState()
|
||||
{
|
||||
var systemPathText = this.FindControl<TextBlock>("SystemPathText");
|
||||
var portablePathText = this.FindControl<TextBlock>("PortablePathText");
|
||||
var adminWarningBanner = this.FindControl<Border>("AdminWarningBanner");
|
||||
var portableRadio = this.FindControl<RadioButton>("PortableRadio");
|
||||
var migrationInfoBorder = this.FindControl<Border>("MigrationInfoBorder");
|
||||
var migrationInfoText = this.FindControl<TextBlock>("MigrationInfoText");
|
||||
|
||||
if (systemPathText is not null)
|
||||
{
|
||||
systemPathText.Text = _resolver.DefaultSystemDataPath;
|
||||
}
|
||||
|
||||
if (portablePathText is not null)
|
||||
{
|
||||
portablePathText.Text = _resolver.DefaultPortableDataPath;
|
||||
}
|
||||
|
||||
var portableAllowed = _resolver.IsPortableModeAllowed();
|
||||
|
||||
if (adminWarningBanner is not null)
|
||||
{
|
||||
adminWarningBanner.IsVisible = !portableAllowed;
|
||||
}
|
||||
|
||||
if (portableRadio is not null)
|
||||
{
|
||||
portableRadio.IsEnabled = portableAllowed;
|
||||
}
|
||||
|
||||
var hasExistingData = _resolver.HasExistingSystemData();
|
||||
if (migrationInfoBorder is not null)
|
||||
{
|
||||
migrationInfoBorder.IsVisible = hasExistingData;
|
||||
}
|
||||
|
||||
if (migrationInfoText is not null && hasExistingData)
|
||||
{
|
||||
migrationInfoText.Text = "Existing system data was detected. Choosing portable mode will migrate the current data automatically.";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var systemRadio = this.FindControl<RadioButton>("SystemRadio");
|
||||
var portableRadio = this.FindControl<RadioButton>("PortableRadio");
|
||||
var systemBorder = this.FindControl<Border>("SystemOptionBorder");
|
||||
var portableBorder = this.FindControl<Border>("PortableOptionBorder");
|
||||
|
||||
var isSystem = systemRadio?.IsChecked == true;
|
||||
var isPortable = portableRadio?.IsChecked == true;
|
||||
|
||||
if (systemBorder is not null)
|
||||
{
|
||||
systemBorder.BorderBrush = isSystem
|
||||
? Application.Current?.FindResource("AccentFillColorDefaultBrush") as IBrush
|
||||
: Application.Current?.FindResource("CardStrokeColorDefaultBrush") as IBrush;
|
||||
systemBorder.BorderThickness = isSystem ? new Thickness(2) : new Thickness(1);
|
||||
}
|
||||
|
||||
if (portableBorder is not null)
|
||||
{
|
||||
portableBorder.BorderBrush = isPortable
|
||||
? Application.Current?.FindResource("AccentFillColorDefaultBrush") as IBrush
|
||||
: Application.Current?.FindResource("CardStrokeColorDefaultBrush") as IBrush;
|
||||
portableBorder.BorderThickness = isPortable ? new Thickness(2) : new Thickness(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnConfirmClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isTransitioning = true;
|
||||
|
||||
var portableRadio = this.FindControl<RadioButton>("PortableRadio");
|
||||
var selectedMode = portableRadio?.IsChecked == true
|
||||
? DataLocationMode.Portable
|
||||
: DataLocationMode.System;
|
||||
|
||||
var migrateExistingData = selectedMode == DataLocationMode.Portable && _resolver.HasExistingSystemData();
|
||||
|
||||
try
|
||||
{
|
||||
await PlayExitAnimationAsync();
|
||||
_completionSource.TrySetResult(new DataLocationPromptResult
|
||||
{
|
||||
SelectedMode = selectedMode,
|
||||
MigrateExistingData = migrateExistingData
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Error during data location prompt exit animation: {ex.Message}");
|
||||
_completionSource.TrySetResult(new DataLocationPromptResult
|
||||
{
|
||||
SelectedMode = selectedMode,
|
||||
MigrateExistingData = migrateExistingData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnCancelClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isTransitioning = true;
|
||||
|
||||
try
|
||||
{
|
||||
await PlayExitAnimationAsync();
|
||||
_completionSource.TrySetResult(null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Error during data location prompt cancel: {ex.Message}");
|
||||
_completionSource.TrySetResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
await PlayEntranceAnimationAsync();
|
||||
}
|
||||
|
||||
private async Task PlayEntranceAnimationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var translateTransform = contentGrid.RenderTransform as TranslateTransform ?? new TranslateTransform();
|
||||
contentGrid.RenderTransform = translateTransform;
|
||||
|
||||
contentGrid.Opacity = 0;
|
||||
translateTransform.Y = 24;
|
||||
|
||||
var fadeInAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(500),
|
||||
Easing = new CubicEaseOut(),
|
||||
FillMode = FillMode.Forward,
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 1.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(500)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var slideUpAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(500),
|
||||
Easing = new CubicEaseOut(),
|
||||
FillMode = FillMode.Forward,
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, 24.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(500)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Task.WhenAll(
|
||||
fadeInAnimation.RunAsync(contentGrid),
|
||||
slideUpAnimation.RunAsync(translateTransform));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Error playing data location prompt entrance animation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PlayExitAnimationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
await Task.Delay(150);
|
||||
return;
|
||||
}
|
||||
|
||||
var fadeOutAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(200),
|
||||
Easing = new CubicEaseIn(),
|
||||
FillMode = FillMode.Forward,
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 1.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(200)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fadeOutAnimation.RunAsync(contentGrid);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Error playing data location prompt exit animation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal Task<DataLocationPromptResult?> WaitForChoiceAsync() => _completionSource.Task;
|
||||
}
|
||||
@@ -141,6 +141,32 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 数据位置选择页面 -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="15">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="📁 数据位置选择 (DataLocationPromptWindow)"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="14" />
|
||||
<TextBlock Text="选择数据保存位置"
|
||||
FontSize="11"
|
||||
Opacity="0.6"
|
||||
Margin="0,3,0,0" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="8">
|
||||
<ToggleSwitch Content="启用功能"
|
||||
IsChecked="{Binding IsDataLocationEnabled}"
|
||||
OnContent="功能"
|
||||
OffContent="查看" />
|
||||
<Button Content="打开"
|
||||
Command="{Binding OpenDataLocationCommand}"
|
||||
HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ public partial class DevDebugWindow : Window
|
||||
_viewModel.OpenErrorRequested += OnOpenErrorRequested;
|
||||
_viewModel.OpenUpdateRequested += OnOpenUpdateRequested;
|
||||
_viewModel.OpenOobeRequested += OnOpenOobeRequested;
|
||||
_viewModel.OpenDataLocationRequested += OnOpenDataLocationRequested;
|
||||
_viewModel.CloseRequested += OnCloseRequested;
|
||||
}
|
||||
|
||||
@@ -135,6 +136,17 @@ public partial class DevDebugWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开数据位置选择页面
|
||||
/// </summary>
|
||||
private void OnOpenDataLocationRequested(object? sender, DataLocationOpenEventArgs e)
|
||||
{
|
||||
var appRoot = AppDomain.CurrentDomain.BaseDirectory;
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
var window = new DataLocationPromptWindow(resolver);
|
||||
window.Show();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关闭窗口
|
||||
/// </summary>
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="600"
|
||||
d:DesignHeight="500"
|
||||
d:DesignWidth="850"
|
||||
d:DesignHeight="650"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
|
||||
x:DataType="views:OobeWindow"
|
||||
Title="欢迎使用阑山桌面"
|
||||
Width="600"
|
||||
Height="500"
|
||||
Width="850"
|
||||
Height="650"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
@@ -21,59 +22,759 @@
|
||||
<views:OobeWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid x:Name="ContentGrid"
|
||||
Opacity="0">
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform Y="24" />
|
||||
</Grid.RenderTransform>
|
||||
<!-- 主内容区域 -->
|
||||
<Grid Margin="48" RowDefinitions="*,Auto">
|
||||
<!-- 中央内容区域 -->
|
||||
<StackPanel Grid.Row="0"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Spacing="24">
|
||||
|
||||
<!-- 顶部:完成状态勾号图标 -->
|
||||
<Border Width="80"
|
||||
Height="80"
|
||||
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
|
||||
CornerRadius="40"
|
||||
HorizontalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Accept"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<!-- 中央:欢迎文字 -->
|
||||
<StackPanel Spacing="8" HorizontalAlignment="Center">
|
||||
<TextBlock Text="欢迎使用阑山桌面"
|
||||
<Grid x:Name="ContentGrid">
|
||||
<!-- 步骤 1: 打字机动画开场 -->
|
||||
<Grid x:Name="TypingStep" Margin="60,80,60,60">
|
||||
<!-- 主标题区域(左上角) -->
|
||||
<StackPanel HorizontalAlignment="Left" VerticalAlignment="Top" Spacing="16">
|
||||
<!-- 打字机文本区域 -->
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock x:Name="TypingTextBlock"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
FontFamily="Consolas, Monaco, 'Courier New', monospace" />
|
||||
<Border x:Name="CursorBorder"
|
||||
Width="3"
|
||||
Height="28"
|
||||
Background="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="4,0,0,4">
|
||||
<Border.Styles>
|
||||
<Style Selector="Border">
|
||||
<Style.Animations>
|
||||
<Animation Duration="0:0:0.8" IterationCount="INFINITE">
|
||||
<KeyFrame Cue="0%">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="50%">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="51%">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="100%">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</KeyFrame>
|
||||
</Animation>
|
||||
</Style.Animations>
|
||||
</Style>
|
||||
</Border.Styles>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 副标题区域(流光渐变动画 + 打字机效果) -->
|
||||
<StackPanel x:Name="SubtitlePanel" Opacity="0" IsVisible="False" Spacing="4">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock x:Name="NextGenTextBlock"
|
||||
FontSize="48"
|
||||
FontWeight="Bold"
|
||||
FontFamily="Consolas, Monaco, 'Courier New', monospace">
|
||||
<TextBlock.Foreground>
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0.0" Color="#0078D4" />
|
||||
<GradientStop Offset="0.33" Color="#7B68EE" />
|
||||
<GradientStop Offset="0.66" Color="#FF8C00" />
|
||||
<GradientStop Offset="1.0" Color="#107C10" />
|
||||
</LinearGradientBrush>
|
||||
</TextBlock.Foreground>
|
||||
</TextBlock> <Border x:Name="SubtitleCursorBorder"
|
||||
Width="4"
|
||||
Height="48"
|
||||
Background="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="4,0,0,4"
|
||||
IsVisible="False">
|
||||
<Border.Styles>
|
||||
<Style Selector="Border">
|
||||
<Style.Animations>
|
||||
<Animation Duration="0:0:0.8" IterationCount="INFINITE">
|
||||
<KeyFrame Cue="0%">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="50%">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="51%">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="100%">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</KeyFrame>
|
||||
</Animation>
|
||||
</Style.Animations>
|
||||
</Style>
|
||||
</Border.Styles>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="DashboardTextBlock"
|
||||
FontSize="48"
|
||||
FontWeight="Bold"
|
||||
FontFamily="Consolas, Monaco, 'Courier New', monospace"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 按钮动画区域(左下角) -->
|
||||
<Grid x:Name="ButtonAnimationArea"
|
||||
Width="280"
|
||||
Height="80"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,40"
|
||||
IsVisible="False">
|
||||
|
||||
<!-- 方框边框(由鼠标画出) -->
|
||||
<Border x:Name="DrawnBorder"
|
||||
Width="160"
|
||||
Height="56"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Background="Transparent"
|
||||
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
BorderThickness="0"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}">
|
||||
</Border>
|
||||
|
||||
<!-- 开始按钮(从方框中弹出) -->
|
||||
<Button x:Name="StartButton"
|
||||
Width="160"
|
||||
Height="56"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Theme="{DynamicResource AccentButtonTheme}"
|
||||
Opacity="0"
|
||||
IsVisible="False"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Button.RenderTransform>
|
||||
<ScaleTransform ScaleX="0.1" ScaleY="0.1" />
|
||||
</Button.RenderTransform>
|
||||
<TextBlock Text="开始使用"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold" />
|
||||
</Button>
|
||||
|
||||
<!-- 鼠标光标 -->
|
||||
<Canvas x:Name="MouseCursor"
|
||||
Width="24"
|
||||
Height="24"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Margin="-50,-50,0,0"
|
||||
IsVisible="False">
|
||||
<Path Data="M0,0 L0,18 L4,14 L7,20 L10,19 L7,13 L12,13 Z"
|
||||
Fill="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
Stroke="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
StrokeThickness="1" />
|
||||
</Canvas>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- 步骤 2: 主题选择页面 -->
|
||||
<Grid x:Name="ThemeStep" Margin="48" RowDefinitions="Auto,*,Auto" IsVisible="False">
|
||||
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,24">
|
||||
<TextBlock Text="个性化你的桌面"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="选择你喜欢的主题样式,可随时在设置中更改"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="20">
|
||||
<!-- 浅色/深色模式选择 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="外观模式"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
|
||||
<Border x:Name="LightModeOption"
|
||||
Grid.Column="0"
|
||||
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="2"
|
||||
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Padding="16"
|
||||
Cursor="Hand">
|
||||
<StackPanel Spacing="8" HorizontalAlignment="Center">
|
||||
<Border Width="48"
|
||||
Height="48"
|
||||
Background="#F3F3F3"
|
||||
CornerRadius="8"
|
||||
BorderBrush="#E0E0E0"
|
||||
BorderThickness="1">
|
||||
<fi:SymbolIcon Symbol="WeatherSunny"
|
||||
FontSize="24"
|
||||
Foreground="#5F5F5F"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="浅色模式"
|
||||
FontSize="13"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<RadioButton x:Name="LightModeRadio"
|
||||
GroupName="ThemeMode"
|
||||
IsChecked="True"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="DarkModeOption"
|
||||
Grid.Column="1"
|
||||
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
|
||||
Padding="16"
|
||||
Cursor="Hand">
|
||||
<StackPanel Spacing="8" HorizontalAlignment="Center">
|
||||
<Border Width="48"
|
||||
Height="48"
|
||||
Background="#1E1E1E"
|
||||
CornerRadius="8"
|
||||
BorderBrush="#333333"
|
||||
BorderThickness="1">
|
||||
<fi:SymbolIcon Symbol="WeatherMoon"
|
||||
FontSize="24"
|
||||
Foreground="#E0E0E0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="深色模式"
|
||||
FontSize="13"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<RadioButton x:Name="DarkModeRadio"
|
||||
GroupName="ThemeMode"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 主题色选择 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="主题色"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<WrapPanel x:Name="AccentColorPanel" HorizontalAlignment="Left">
|
||||
<!-- 预设颜色 -->
|
||||
<Border x:Name="BlueColor"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Background="#0078D4"
|
||||
CornerRadius="20"
|
||||
BorderThickness="3"
|
||||
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Margin="0,0,12,12"
|
||||
Cursor="Hand">
|
||||
<Border.Styles>
|
||||
<Style Selector="Border:pointerover">
|
||||
<Setter Property="RenderTransform" Value="scale(1.1)" />
|
||||
</Style>
|
||||
</Border.Styles>
|
||||
</Border>
|
||||
<Border x:Name="PurpleColor"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Background="#7B68EE"
|
||||
CornerRadius="20"
|
||||
BorderThickness="0"
|
||||
Margin="0,0,12,12"
|
||||
Cursor="Hand" />
|
||||
<Border x:Name="GreenColor"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Background="#107C10"
|
||||
CornerRadius="20"
|
||||
BorderThickness="0"
|
||||
Margin="0,0,12,12"
|
||||
Cursor="Hand" />
|
||||
<Border x:Name="OrangeColor"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Background="#D83B01"
|
||||
CornerRadius="20"
|
||||
BorderThickness="0"
|
||||
Margin="0,0,12,12"
|
||||
Cursor="Hand" />
|
||||
<Border x:Name="PinkColor"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Background="#E3008C"
|
||||
CornerRadius="20"
|
||||
BorderThickness="0"
|
||||
Margin="0,0,12,12"
|
||||
Cursor="Hand" />
|
||||
<Border x:Name="TealColor"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Background="#008080"
|
||||
CornerRadius="20"
|
||||
BorderThickness="0"
|
||||
Margin="0,0,12,12"
|
||||
Cursor="Hand" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 莫奈取色来源 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="莫奈取色来源"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="从壁纸自动提取主题色,让界面与桌面完美融合"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<Border x:Name="MonetFromWallpaperOption"
|
||||
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="2"
|
||||
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Padding="12"
|
||||
Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="MonetFromWallpaperRadio"
|
||||
Grid.Column="0"
|
||||
GroupName="MonetSource"
|
||||
IsChecked="True"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="从桌面壁纸取色"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="自动分析当前壁纸颜色生成主题"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="MonetFromCustomOption"
|
||||
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
|
||||
Padding="12"
|
||||
Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="MonetFromCustomRadio"
|
||||
Grid.Column="0"
|
||||
GroupName="MonetSource"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="自定义图片取色"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="选择一张图片作为取色来源"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="MonetDisabledOption"
|
||||
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
|
||||
Padding="12"
|
||||
Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="MonetDisabledRadio"
|
||||
Grid.Column="0"
|
||||
GroupName="MonetSource"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="不使用莫奈取色"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="使用固定的预设主题色"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="12"
|
||||
Margin="0,24,0,0">
|
||||
<Button x:Name="ThemeBackButton"
|
||||
Content="返回"
|
||||
Theme="{DynamicResource ButtonTheme}" />
|
||||
<Button x:Name="ThemeNextButton"
|
||||
Content="下一步"
|
||||
Theme="{DynamicResource AccentButtonTheme}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 步骤 3: 数据位置选择页面 -->
|
||||
<Grid x:Name="DataLocationStep" Margin="48" RowDefinitions="Auto,*,Auto" IsVisible="False">
|
||||
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,32">
|
||||
<TextBlock Text="选择数据保存位置"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="决定将应用数据保存在哪里,可随时在设置中更改"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Spacing="20">
|
||||
<Border x:Name="AdminWarningBanner"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16,12"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:SymbolIcon Symbol="ShieldError"
|
||||
FontSize="20"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
<TextBlock Text="无法保存到应用目录"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="当前安装目录需要管理员权限才能写入,数据将自动保存到系统用户目录。"
|
||||
FontSize="13"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 系统用户目录选项 -->
|
||||
<Border x:Name="SystemOptionBorder"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="2"
|
||||
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Padding="20,18"
|
||||
Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="SystemRadio"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,16,0"
|
||||
GroupName="DataLocation"
|
||||
IsChecked="True" />
|
||||
<StackPanel Grid.Column="1" Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:SymbolIcon Symbol="Folder"
|
||||
FontSize="24"
|
||||
Foreground="{DynamicResource AccentFillColorDefaultBrush}" />
|
||||
<TextBlock Text="保存在系统用户目录(推荐)"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="数据与当前 Windows 用户绑定,重装应用或更新后数据不会丢失。适合大多数用户。"
|
||||
FontSize="13"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<Border Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Padding="12,8"
|
||||
Margin="0,4,0,0">
|
||||
<TextBlock x:Name="SystemPathText"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
FontFamily="Consolas, Monaco, monospace" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 便携模式选项 -->
|
||||
<Border x:Name="PortableOptionBorder"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
|
||||
Padding="20,18"
|
||||
Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<RadioButton x:Name="PortableRadio"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,16,0"
|
||||
GroupName="DataLocation"
|
||||
IsEnabled="False" />
|
||||
<StackPanel Grid.Column="1" Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:SymbolIcon Symbol="Save"
|
||||
FontSize="24"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<TextBlock Text="保存在应用安装目录(便携模式)"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="便于携带,可随应用文件夹整体移动到其他电脑。适合在多台电脑间使用或需要便携运行的场景。"
|
||||
FontSize="13"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<Border Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Padding="12,8"
|
||||
Margin="0,4,0,0">
|
||||
<TextBlock x:Name="PortablePathText"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
FontFamily="Consolas, Monaco, monospace" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 数据迁移提示 -->
|
||||
<Border x:Name="MigrationInfoBorder"
|
||||
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16,12"
|
||||
IsVisible="False">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:SymbolIcon Symbol="Checkmark"
|
||||
FontSize="20"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
|
||||
<TextBlock x:Name="MigrationInfoText"
|
||||
FontSize="13"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="12"
|
||||
Margin="0,32,0,0">
|
||||
<Button x:Name="DataLocationBackButton"
|
||||
Content="返回"
|
||||
Theme="{DynamicResource ButtonTheme}"
|
||||
Width="100"
|
||||
Height="36" />
|
||||
<Button x:Name="DataLocationNextButton"
|
||||
Content="下一步"
|
||||
Theme="{DynamicResource AccentButtonTheme}"
|
||||
Width="100"
|
||||
Height="36" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 步骤 4: 信息与隐私页面 -->
|
||||
<Grid x:Name="PrivacyStep" Margin="48" RowDefinitions="Auto,*,Auto" IsVisible="False">
|
||||
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,24">
|
||||
<TextBlock Text="信息与隐私"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="选择是否参与遥测计划,查看隐私政策"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Spacing="16">
|
||||
<!-- 崩溃报告 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="发送匿名崩溃报告"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="帮助改进应用稳定性,不包含个人身份信息"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch x:Name="CrashTelemetryToggle"
|
||||
Grid.Column="1"
|
||||
IsChecked="False"
|
||||
IsEnabled="False"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 使用统计 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="发送匿名使用统计"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="帮助了解功能使用情况,优化产品体验"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch x:Name="UsageTelemetryToggle"
|
||||
Grid.Column="1"
|
||||
IsChecked="False"
|
||||
IsEnabled="False"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 隐私追踪 ID -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="隐私追踪 ID"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="此 ID 用于匿名标识您的设备,不包含任何个人信息"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<TextBox x:Name="TelemetryIdTextBox"
|
||||
Text=""
|
||||
IsReadOnly="True"
|
||||
FontFamily="Consolas, Monaco, monospace"
|
||||
FontSize="12"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 隐私协议同意区域 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16"
|
||||
Margin="0,8,0,0">
|
||||
<StackPanel Spacing="12">
|
||||
<!-- 复选框和协议文本 -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<CheckBox x:Name="PrivacyAgreementCheckBox"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||
<TextBlock Text="同意"
|
||||
FontSize="13"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<Button x:Name="ViewPrivacyPolicyButton"
|
||||
Content="《阑山桌面遥测隐私数据收集协议》"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemAccentColor}">
|
||||
<Button.Styles>
|
||||
<Style Selector="Button:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="TextBlock.Foreground" Value="{DynamicResource SystemAccentColorDark1}" />
|
||||
</Style>
|
||||
</Button.Styles>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 提示文本 -->
|
||||
<TextBlock Text="您必须阅读并同意隐私协议后,才能开启遥测功能。遥测数据仅用于改进应用稳定性和优化产品体验,不包含任何个人身份信息。"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="12"
|
||||
Margin="0,24,0,0">
|
||||
<Button x:Name="PrivacyBackButton"
|
||||
Content="返回"
|
||||
Theme="{DynamicResource ButtonTheme}" />
|
||||
<Button x:Name="PrivacyNextButton"
|
||||
Content="下一步"
|
||||
Theme="{DynamicResource AccentButtonTheme}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 步骤 5: 欢迎完成页面 -->
|
||||
<Grid x:Name="WelcomeStep" Margin="48" RowDefinitions="*,Auto" IsVisible="False">
|
||||
<StackPanel Grid.Row="0"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Spacing="32">
|
||||
|
||||
<Border Width="96"
|
||||
Height="96"
|
||||
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
|
||||
CornerRadius="48"
|
||||
HorizontalAlignment="Center">
|
||||
<PathIcon Data="M9,16.17 L4.83,12 L3.41,13.41 L9,19 L21,7 L19.59,5.59 Z"
|
||||
Width="48"
|
||||
Height="48"
|
||||
Foreground="{DynamicResource SystemFillColorSuccessBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<StackPanel Spacing="12" HorizontalAlignment="Center">
|
||||
<TextBlock Text="欢迎使用阑山桌面"
|
||||
FontSize="32"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="你的桌面,不止一面"
|
||||
FontSize="14"
|
||||
FontSize="16"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- 底部:圆形开始按钮 -->
|
||||
<Button Grid.Row="1"
|
||||
x:Name="EnterButton"
|
||||
HorizontalAlignment="Center"
|
||||
Width="56"
|
||||
Height="56"
|
||||
Margin="0,0,0,16"
|
||||
Margin="0,0,0,24"
|
||||
Theme="{DynamicResource AccentButtonTheme}"
|
||||
CornerRadius="28">
|
||||
<ui:SymbolIcon Symbol="Forward"
|
||||
<fi:SymbolIcon Symbol="ArrowRight"
|
||||
FontSize="24"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"/>
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -1,182 +1,858 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
public partial class OobeWindow : Window
|
||||
{
|
||||
private const int AnimationDurationMs = 300;
|
||||
private const int TypingDelayMs = 100;
|
||||
|
||||
private readonly TaskCompletionSource<bool> _completionSource = new();
|
||||
private readonly DataLocationResolver _resolver;
|
||||
private bool _isTransitioning;
|
||||
private bool _isDebugMode;
|
||||
private int _currentStep = 1;
|
||||
|
||||
// 数据位置选择
|
||||
private DataLocationMode _selectedDataLocationMode = DataLocationMode.System;
|
||||
private bool _migrateExistingData;
|
||||
|
||||
// 主题选择
|
||||
private Services.ThemeMode _selectedThemeMode = Services.ThemeMode.Light;
|
||||
private string _selectedAccentColor = "#0078D4";
|
||||
private MonetSource _selectedMonetSource = MonetSource.Wallpaper;
|
||||
|
||||
public OobeWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
|
||||
var appRoot = AppDomain.CurrentDomain.BaseDirectory;
|
||||
_resolver = new DataLocationResolver(appRoot);
|
||||
}
|
||||
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
_isDebugMode = isDebugMode;
|
||||
}
|
||||
|
||||
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
|
||||
InitializeDataLocationStep();
|
||||
InitializePrivacySettings();
|
||||
SetupEventHandlers();
|
||||
}
|
||||
|
||||
var enterButton = this.FindControl<Button>("EnterButton");
|
||||
if (enterButton is not null)
|
||||
private void SetupEventHandlers()
|
||||
{
|
||||
// 步骤 1: 开始按钮
|
||||
if (this.FindControl<Button>("StartButton") is { } startButton)
|
||||
{
|
||||
startButton.Click += OnStartButtonClick;
|
||||
}
|
||||
|
||||
// 步骤 2: 主题选择页面
|
||||
if (this.FindControl<Button>("ThemeBackButton") is { } themeBackButton)
|
||||
{
|
||||
themeBackButton.Click += OnThemeBackClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("ThemeNextButton") is { } themeNextButton)
|
||||
{
|
||||
themeNextButton.Click += OnThemeNextClick;
|
||||
}
|
||||
|
||||
// 浅色/深色模式选择
|
||||
if (this.FindControl<Border>("LightModeOption") is { } lightModeOption)
|
||||
{
|
||||
lightModeOption.PointerPressed += (s, e) => SelectThemeMode(Services.ThemeMode.Light);
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("DarkModeOption") is { } darkModeOption)
|
||||
{
|
||||
darkModeOption.PointerPressed += (s, e) => SelectThemeMode(Services.ThemeMode.Dark);
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("LightModeRadio") is { } lightModeRadio)
|
||||
{
|
||||
lightModeRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (lightModeRadio.IsChecked == true) SelectThemeMode(Services.ThemeMode.Light);
|
||||
};
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("DarkModeRadio") is { } darkModeRadio)
|
||||
{
|
||||
darkModeRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (darkModeRadio.IsChecked == true) SelectThemeMode(Services.ThemeMode.Dark);
|
||||
};
|
||||
}
|
||||
|
||||
// 主题色选择
|
||||
SetupAccentColorHandlers();
|
||||
|
||||
// 莫奈取色来源选择
|
||||
if (this.FindControl<Border>("MonetFromWallpaperOption") is { } monetWallpaperOption)
|
||||
{
|
||||
monetWallpaperOption.PointerPressed += (s, e) => SelectMonetSource(MonetSource.Wallpaper);
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("MonetFromCustomOption") is { } monetCustomOption)
|
||||
{
|
||||
monetCustomOption.PointerPressed += (s, e) => SelectMonetSource(MonetSource.Custom);
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("MonetDisabledOption") is { } monetDisabledOption)
|
||||
{
|
||||
monetDisabledOption.PointerPressed += (s, e) => SelectMonetSource(MonetSource.Disabled);
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("MonetFromWallpaperRadio") is { } monetWallpaperRadio)
|
||||
{
|
||||
monetWallpaperRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (monetWallpaperRadio.IsChecked == true) SelectMonetSource(MonetSource.Wallpaper);
|
||||
};
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("MonetFromCustomRadio") is { } monetCustomRadio)
|
||||
{
|
||||
monetCustomRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (monetCustomRadio.IsChecked == true) SelectMonetSource(MonetSource.Custom);
|
||||
};
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("MonetDisabledRadio") is { } monetDisabledRadio)
|
||||
{
|
||||
monetDisabledRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (monetDisabledRadio.IsChecked == true) SelectMonetSource(MonetSource.Disabled);
|
||||
};
|
||||
}
|
||||
|
||||
// 步骤 3: 数据位置选择页面
|
||||
if (this.FindControl<Button>("DataLocationBackButton") is { } dataLocationBackButton)
|
||||
{
|
||||
dataLocationBackButton.Click += OnDataLocationBackClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("DataLocationNextButton") is { } dataLocationNextButton)
|
||||
{
|
||||
dataLocationNextButton.Click += OnDataLocationNextClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("SystemOptionBorder") is { } systemOption)
|
||||
{
|
||||
systemOption.PointerPressed += (s, e) => SelectDataLocationMode(DataLocationMode.System);
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("PortableOptionBorder") is { } portableOption)
|
||||
{
|
||||
portableOption.PointerPressed += (s, e) => SelectDataLocationMode(DataLocationMode.Portable);
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("SystemRadio") is { } systemRadio)
|
||||
{
|
||||
systemRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (systemRadio.IsChecked == true) SelectDataLocationMode(DataLocationMode.System);
|
||||
};
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("PortableRadio") is { } portableRadio)
|
||||
{
|
||||
portableRadio.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
if (portableRadio.IsChecked == true) SelectDataLocationMode(DataLocationMode.Portable);
|
||||
};
|
||||
}
|
||||
|
||||
// 步骤 4: 隐私设置页面
|
||||
if (this.FindControl<Button>("PrivacyBackButton") is { } privacyBackButton)
|
||||
{
|
||||
privacyBackButton.Click += OnPrivacyBackClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("PrivacyNextButton") is { } privacyNextButton)
|
||||
{
|
||||
privacyNextButton.Click += OnPrivacyNextClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("ViewPrivacyPolicyButton") is { } viewPrivacyPolicyButton)
|
||||
{
|
||||
viewPrivacyPolicyButton.Click += OnViewPrivacyPolicyClick;
|
||||
}
|
||||
|
||||
// 隐私协议复选框 - 控制遥测开关
|
||||
if (this.FindControl<CheckBox>("PrivacyAgreementCheckBox") is { } privacyCheckBox)
|
||||
{
|
||||
privacyCheckBox.IsCheckedChanged += OnPrivacyAgreementChanged;
|
||||
}
|
||||
|
||||
// 步骤 5: 欢迎完成页面
|
||||
if (this.FindControl<Button>("EnterButton") is { } enterButton)
|
||||
{
|
||||
enterButton.Click += OnEnterClick;
|
||||
Console.WriteLine("[OobeWindow] EnterButton event bound successfully");
|
||||
}
|
||||
else
|
||||
}
|
||||
|
||||
private void SetupAccentColorHandlers()
|
||||
{
|
||||
var colorMap = new Dictionary<string, string>
|
||||
{
|
||||
Console.Error.WriteLine("[OobeWindow] Failed to find EnterButton!");
|
||||
{ "BlueColor", "#0078D4" },
|
||||
{ "PurpleColor", "#7B68EE" },
|
||||
{ "GreenColor", "#107C10" },
|
||||
{ "OrangeColor", "#D83B01" },
|
||||
{ "PinkColor", "#E3008C" },
|
||||
{ "TealColor", "#008080" }
|
||||
};
|
||||
|
||||
foreach (var (name, color) in colorMap)
|
||||
{
|
||||
if (this.FindControl<Border>(name) is { } colorBorder)
|
||||
{
|
||||
colorBorder.PointerPressed += (s, e) => SelectAccentColor(name, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
|
||||
await PlayEntranceAnimationAsync();
|
||||
await PlayTypingAnimationAsync();
|
||||
}
|
||||
|
||||
private async Task PlayEntranceAnimationAsync()
|
||||
private async Task PlayTypingAnimationAsync()
|
||||
{
|
||||
try
|
||||
var typingTextBlock = this.FindControl<TextBlock>("TypingTextBlock");
|
||||
var cursorBorder = this.FindControl<Border>("CursorBorder");
|
||||
var subtitlePanel = this.FindControl<StackPanel>("SubtitlePanel");
|
||||
var buttonAnimationArea = this.FindControl<Grid>("ButtonAnimationArea");
|
||||
var startButton = this.FindControl<Button>("StartButton");
|
||||
var mouseCursor = this.FindControl<Canvas>("MouseCursor");
|
||||
|
||||
if (typingTextBlock == null || cursorBorder == null) return;
|
||||
|
||||
// 打字机效果:阑山桌面 LanMountain Desktop(在同一行)
|
||||
var fullText = "阑山桌面 LanMountain Desktop";
|
||||
for (int i = 0; i <= fullText.Length; i++)
|
||||
{
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var translateTransform = contentGrid.RenderTransform as TranslateTransform ?? new TranslateTransform();
|
||||
contentGrid.RenderTransform = translateTransform;
|
||||
|
||||
var offset = ResolveEntranceOffset();
|
||||
contentGrid.Opacity = 0;
|
||||
translateTransform.Y = offset;
|
||||
|
||||
var fadeInAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(600),
|
||||
Easing = new CubicEaseOut(),
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 1.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(600)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var slideUpAnimation = new Animation
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(600),
|
||||
Easing = new CubicEaseOut(),
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, offset) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(TranslateTransform.YProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(600)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Task.WhenAll(
|
||||
fadeInAnimation.RunAsync(contentGrid),
|
||||
slideUpAnimation.RunAsync(translateTransform));
|
||||
|
||||
Console.WriteLine("[OobeWindow] Entrance animation completed");
|
||||
typingTextBlock.Text = fullText.Substring(0, i);
|
||||
await Task.Delay(TypingDelayMs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
// 停顿一下
|
||||
await Task.Delay(500);
|
||||
|
||||
// 隐藏光标
|
||||
cursorBorder.IsVisible = false;
|
||||
|
||||
// 显示副标题(打字机效果:下一代 互动信息看板)
|
||||
if (subtitlePanel != null)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeWindow] Error playing entrance animation: {ex.Message}");
|
||||
subtitlePanel.IsVisible = true;
|
||||
subtitlePanel.Opacity = 1;
|
||||
await PlaySubtitleTypingAnimationAsync();
|
||||
}
|
||||
|
||||
// 停顿一下再显示按钮
|
||||
await Task.Delay(400);
|
||||
|
||||
// 显示按钮动画区域
|
||||
if (buttonAnimationArea != null)
|
||||
{
|
||||
buttonAnimationArea.IsVisible = true;
|
||||
}
|
||||
|
||||
// 鼠标拖拽按钮入场
|
||||
if (mouseCursor != null && startButton != null)
|
||||
{
|
||||
await AnimateMouseDragButtonAsync(mouseCursor, startButton);
|
||||
}
|
||||
}
|
||||
|
||||
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||
private async Task AnimateMouseDragButtonAsync(Canvas mouseCursor, Button button)
|
||||
{
|
||||
// 初始处于画面外部的 X 坐标
|
||||
var startX = -400.0;
|
||||
var endX = 0.0;
|
||||
|
||||
button.IsVisible = true;
|
||||
button.Opacity = 1;
|
||||
button.RenderTransform = new TranslateTransform(startX, 0);
|
||||
|
||||
// 鼠标位于按钮上,比如偏移 (100, 30) 的位置
|
||||
var mouseOffsetX = 100.0;
|
||||
var mouseOffsetY = 30.0;
|
||||
mouseCursor.Margin = new Thickness(startX + mouseOffsetX, mouseOffsetY, 0, 0);
|
||||
mouseCursor.IsVisible = true;
|
||||
|
||||
await Task.Delay(300);
|
||||
|
||||
var duration = 800;
|
||||
var steps = 40;
|
||||
var delay = duration / steps;
|
||||
|
||||
for (int i = 0; i <= steps; i++)
|
||||
{
|
||||
var progress = (double)i / steps;
|
||||
var eased = EaseOutBack(progress); // 使用 EaseOutBack 营造“拖拽到位”的清脆回弹感
|
||||
|
||||
var currentX = startX + (endX - startX) * eased;
|
||||
|
||||
button.RenderTransform = new TranslateTransform(currentX, 0);
|
||||
mouseCursor.Margin = new Thickness(currentX + mouseOffsetX, mouseOffsetY, 0, 0);
|
||||
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
|
||||
await Task.Delay(200);
|
||||
|
||||
// 隐藏鼠标光标
|
||||
await AnimateOpacityAsync(mouseCursor, 1, 0, 200);
|
||||
mouseCursor.IsVisible = false;
|
||||
}
|
||||
|
||||
private async Task PlaySubtitleTypingAnimationAsync()
|
||||
{
|
||||
var nextGenTextBlock = this.FindControl<TextBlock>("NextGenTextBlock");
|
||||
var dashboardTextBlock = this.FindControl<TextBlock>("DashboardTextBlock");
|
||||
var subtitleCursorBorder = this.FindControl<Border>("SubtitleCursorBorder");
|
||||
|
||||
if (nextGenTextBlock == null || dashboardTextBlock == null) return;
|
||||
|
||||
// 获取渐变画刷
|
||||
var gradientBrush = nextGenTextBlock.Foreground as LinearGradientBrush;
|
||||
|
||||
// 启动渐变色流动动画
|
||||
if (gradientBrush != null)
|
||||
{
|
||||
_ = AnimateGradientFlowAsync(gradientBrush);
|
||||
}
|
||||
|
||||
// 显示光标
|
||||
if (subtitleCursorBorder != null)
|
||||
{
|
||||
subtitleCursorBorder.IsVisible = true;
|
||||
}
|
||||
|
||||
// 打字机效果:下一代
|
||||
var nextGenText = "下一代";
|
||||
for (int i = 0; i <= nextGenText.Length; i++)
|
||||
{
|
||||
nextGenTextBlock.Text = nextGenText.Substring(0, i);
|
||||
await Task.Delay(TypingDelayMs);
|
||||
}
|
||||
|
||||
// 停顿一下
|
||||
await Task.Delay(200);
|
||||
|
||||
// 换行,光标移到第二行
|
||||
if (subtitleCursorBorder != null)
|
||||
{
|
||||
subtitleCursorBorder.IsVisible = false;
|
||||
}
|
||||
|
||||
// 打字机效果:互动信息看板
|
||||
var dashboardText = "互动信息看板";
|
||||
for (int i = 0; i <= dashboardText.Length; i++)
|
||||
{
|
||||
dashboardTextBlock.Text = dashboardText.Substring(0, i);
|
||||
await Task.Delay(TypingDelayMs);
|
||||
}
|
||||
|
||||
// 停顿一下后隐藏光标
|
||||
await Task.Delay(300);
|
||||
}
|
||||
|
||||
private async Task AnimateGradientFlowAsync(LinearGradientBrush? gradientBrush)
|
||||
{
|
||||
if (gradientBrush == null) return;
|
||||
|
||||
var stops = gradientBrush.GradientStops;
|
||||
if (stops.Count < 2) return;
|
||||
|
||||
// 获取原有的所有颜色
|
||||
var colors = new System.Collections.Generic.List<Color>();
|
||||
foreach (var stop in stops)
|
||||
{
|
||||
colors.Add(stop.Color);
|
||||
}
|
||||
|
||||
// 为了实现无缝循环流动,把第一个颜色追加到最后
|
||||
colors.Add(colors[0]);
|
||||
|
||||
// 重新分配 GradientStops
|
||||
stops.Clear();
|
||||
for (int i = 0; i < colors.Count; i++)
|
||||
{
|
||||
stops.Add(new GradientStop(colors[i], (double)i / (colors.Count - 1)));
|
||||
}
|
||||
|
||||
// 设置铺展模式,超出范围时重复
|
||||
gradientBrush.SpreadMethod = GradientSpreadMethod.Repeat;
|
||||
|
||||
double offset = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
offset -= 0.005; // 每次流动一小步,负数表示向右流动
|
||||
if (offset <= -1.0) offset = 0;
|
||||
|
||||
// 让渐变保持水平方向,但位置不断偏移,形成河流般的流动效果
|
||||
gradientBrush.StartPoint = new RelativePoint(offset, 0, RelativeUnit.Relative);
|
||||
gradientBrush.EndPoint = new RelativePoint(offset + 1, 0, RelativeUnit.Relative);
|
||||
|
||||
await Task.Delay(16); // 约60帧
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnStartButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
await NavigateToStep(2);
|
||||
}
|
||||
|
||||
// 主题选择页面按钮
|
||||
private async void OnThemeBackClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
await NavigateToStep(1);
|
||||
}
|
||||
|
||||
private async void OnThemeNextClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
await NavigateToStep(3);
|
||||
}
|
||||
|
||||
// 数据位置选择页面按钮
|
||||
private async void OnDataLocationBackClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
await NavigateToStep(2);
|
||||
}
|
||||
|
||||
private async void OnDataLocationNextClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
|
||||
// 应用数据位置选择
|
||||
if (!_isDebugMode)
|
||||
{
|
||||
_resolver.ApplyLocationChoice(_selectedDataLocationMode, null, _migrateExistingData);
|
||||
}
|
||||
|
||||
await NavigateToStep(4);
|
||||
}
|
||||
|
||||
// 隐私设置页面按钮
|
||||
private async void OnPrivacyBackClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
await NavigateToStep(3);
|
||||
}
|
||||
|
||||
private async void OnPrivacyNextClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
|
||||
// 保存隐私设置
|
||||
SavePrivacySettings();
|
||||
|
||||
await NavigateToStep(5);
|
||||
}
|
||||
|
||||
private void OnViewPrivacyPolicyClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
// 打开隐私政策窗口
|
||||
var privacyWindow = new PrivacyPolicyWindow
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
privacyWindow.ShowDialog(this);
|
||||
}
|
||||
|
||||
private void OnPrivacyAgreementChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
// 根据复选框状态控制遥测开关
|
||||
if (this.FindControl<CheckBox>("PrivacyAgreementCheckBox") is { } checkBox &&
|
||||
this.FindControl<ToggleSwitch>("CrashTelemetryToggle") is { } crashToggle &&
|
||||
this.FindControl<ToggleSwitch>("UsageTelemetryToggle") is { } usageToggle)
|
||||
{
|
||||
var isAgreed = checkBox.IsChecked == true;
|
||||
|
||||
// 如果用户不同意协议,禁用遥测开关并关闭它们
|
||||
crashToggle.IsEnabled = isAgreed;
|
||||
usageToggle.IsEnabled = isAgreed;
|
||||
|
||||
if (!isAgreed)
|
||||
{
|
||||
crashToggle.IsChecked = false;
|
||||
usageToggle.IsChecked = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 用户同意协议后,默认开启遥测(用户可以在开关中手动关闭)
|
||||
crashToggle.IsChecked = true;
|
||||
usageToggle.IsChecked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnEnterClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isTransitioning) return;
|
||||
_isTransitioning = true;
|
||||
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
|
||||
|
||||
try
|
||||
{
|
||||
await PlayExitAnimationAsync();
|
||||
_completionSource.TrySetResult(true);
|
||||
Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeWindow] Error during transition: {ex.Message}");
|
||||
Console.Error.WriteLine($"[OobeWindow] Error: {ex.Message}");
|
||||
_completionSource.TrySetResult(true);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeDataLocationStep()
|
||||
{
|
||||
if (this.FindControl<TextBlock>("SystemPathText") is { } systemPathText)
|
||||
{
|
||||
systemPathText.Text = _resolver.DefaultSystemDataPath;
|
||||
}
|
||||
|
||||
if (this.FindControl<TextBlock>("PortablePathText") is { } portablePathText)
|
||||
{
|
||||
portablePathText.Text = _resolver.DefaultPortableDataPath;
|
||||
}
|
||||
|
||||
var canWriteToAppRoot = _resolver.IsPortableModeAllowed();
|
||||
if (this.FindControl<RadioButton>("PortableRadio") is { } portableRadio)
|
||||
{
|
||||
portableRadio.IsEnabled = canWriteToAppRoot;
|
||||
}
|
||||
|
||||
if (!canWriteToAppRoot)
|
||||
{
|
||||
if (this.FindControl<Border>("AdminWarningBanner") is { } warningBanner)
|
||||
{
|
||||
warningBanner.IsVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (_resolver.HasExistingSystemData())
|
||||
{
|
||||
_migrateExistingData = true;
|
||||
if (this.FindControl<Border>("MigrationInfoBorder") is { } migrationInfo)
|
||||
{
|
||||
migrationInfo.IsVisible = true;
|
||||
}
|
||||
if (this.FindControl<TextBlock>("MigrationInfoText") is { } migrationText)
|
||||
{
|
||||
migrationText.Text = "检测到现有数据,选择便携模式时将自动迁移。";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectDataLocationMode(DataLocationMode mode)
|
||||
{
|
||||
_selectedDataLocationMode = mode;
|
||||
|
||||
if (this.FindControl<RadioButton>("SystemRadio") is { } systemRadio)
|
||||
{
|
||||
systemRadio.IsChecked = mode == DataLocationMode.System;
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("PortableRadio") is { } portableRadio)
|
||||
{
|
||||
portableRadio.IsChecked = mode == DataLocationMode.Portable;
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("SystemOptionBorder") is { } systemBorder)
|
||||
{
|
||||
systemBorder.BorderBrush = mode == DataLocationMode.System
|
||||
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
|
||||
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
|
||||
systemBorder.BorderThickness = mode == DataLocationMode.System
|
||||
? new Thickness(2)
|
||||
: new Thickness(1);
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("PortableOptionBorder") is { } portableBorder)
|
||||
{
|
||||
portableBorder.BorderBrush = mode == DataLocationMode.Portable
|
||||
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
|
||||
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
|
||||
portableBorder.BorderThickness = mode == DataLocationMode.Portable
|
||||
? new Thickness(2)
|
||||
: new Thickness(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 主题选择方法
|
||||
private void SelectThemeMode(Services.ThemeMode mode)
|
||||
{
|
||||
_selectedThemeMode = mode;
|
||||
|
||||
// 立即应用主题到启动器
|
||||
ThemeService.ApplyTheme(mode, _selectedAccentColor);
|
||||
|
||||
if (this.FindControl<RadioButton>("LightModeRadio") is { } lightModeRadio)
|
||||
{
|
||||
lightModeRadio.IsChecked = mode == Services.ThemeMode.Light;
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("DarkModeRadio") is { } darkModeRadio)
|
||||
{
|
||||
darkModeRadio.IsChecked = mode == Services.ThemeMode.Dark;
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("LightModeOption") is { } lightModeOption)
|
||||
{
|
||||
lightModeOption.BorderBrush = mode == Services.ThemeMode.Light
|
||||
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
|
||||
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
|
||||
lightModeOption.BorderThickness = mode == Services.ThemeMode.Light
|
||||
? new Thickness(2)
|
||||
: new Thickness(1);
|
||||
}
|
||||
|
||||
if (this.FindControl<Border>("DarkModeOption") is { } darkModeOption)
|
||||
{
|
||||
darkModeOption.BorderBrush = mode == Services.ThemeMode.Dark
|
||||
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
|
||||
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
|
||||
darkModeOption.BorderThickness = mode == Services.ThemeMode.Dark
|
||||
? new Thickness(2)
|
||||
: new Thickness(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectAccentColor(string colorName, string colorValue)
|
||||
{
|
||||
_selectedAccentColor = colorValue;
|
||||
|
||||
// 更新所有颜色圆圈边框
|
||||
var colorBorders = new[] { "BlueColor", "PurpleColor", "GreenColor", "OrangeColor", "PinkColor", "TealColor" };
|
||||
foreach (var name in colorBorders)
|
||||
{
|
||||
if (this.FindControl<Border>(name) is { } border)
|
||||
{
|
||||
var isSelected = name == colorName;
|
||||
border.BorderBrush = isSelected
|
||||
? Application.Current?.Resources["TextFillColorPrimaryBrush"] as IBrush
|
||||
: null;
|
||||
border.BorderThickness = isSelected ? new Thickness(3) : new Thickness(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectMonetSource(MonetSource source)
|
||||
{
|
||||
_selectedMonetSource = source;
|
||||
|
||||
if (this.FindControl<RadioButton>("MonetFromWallpaperRadio") is { } wallpaperRadio)
|
||||
{
|
||||
wallpaperRadio.IsChecked = source == MonetSource.Wallpaper;
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("MonetFromCustomRadio") is { } customRadio)
|
||||
{
|
||||
customRadio.IsChecked = source == MonetSource.Custom;
|
||||
}
|
||||
|
||||
if (this.FindControl<RadioButton>("MonetDisabledRadio") is { } disabledRadio)
|
||||
{
|
||||
disabledRadio.IsChecked = source == MonetSource.Disabled;
|
||||
}
|
||||
|
||||
UpdateMonetOptionBorder("MonetFromWallpaperOption", source == MonetSource.Wallpaper);
|
||||
UpdateMonetOptionBorder("MonetFromCustomOption", source == MonetSource.Custom);
|
||||
UpdateMonetOptionBorder("MonetDisabledOption", source == MonetSource.Disabled);
|
||||
}
|
||||
|
||||
private void UpdateMonetOptionBorder(string borderName, bool isSelected)
|
||||
{
|
||||
if (this.FindControl<Border>(borderName) is { } border)
|
||||
{
|
||||
border.BorderBrush = isSelected
|
||||
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
|
||||
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
|
||||
border.BorderThickness = isSelected ? new Thickness(2) : new Thickness(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NavigateToStep(int step)
|
||||
{
|
||||
if (_isTransitioning || step == _currentStep) return;
|
||||
_isTransitioning = true;
|
||||
|
||||
// 获取当前步骤的控件
|
||||
Grid? currentStepControl = _currentStep switch
|
||||
{
|
||||
1 => this.FindControl<Grid>("TypingStep"),
|
||||
2 => this.FindControl<Grid>("ThemeStep"),
|
||||
3 => this.FindControl<Grid>("DataLocationStep"),
|
||||
4 => this.FindControl<Grid>("PrivacyStep"),
|
||||
5 => this.FindControl<Grid>("WelcomeStep"),
|
||||
_ => null
|
||||
};
|
||||
|
||||
// 获取目标步骤的控件
|
||||
Grid? nextStepControl = step switch
|
||||
{
|
||||
1 => this.FindControl<Grid>("TypingStep"),
|
||||
2 => this.FindControl<Grid>("ThemeStep"),
|
||||
3 => this.FindControl<Grid>("DataLocationStep"),
|
||||
4 => this.FindControl<Grid>("PrivacyStep"),
|
||||
5 => this.FindControl<Grid>("WelcomeStep"),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (currentStepControl == null || nextStepControl == null)
|
||||
{
|
||||
_isTransitioning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await AnimateOpacityAsync(currentStepControl, 1, 0, AnimationDurationMs);
|
||||
currentStepControl.IsVisible = false;
|
||||
|
||||
nextStepControl.IsVisible = true;
|
||||
nextStepControl.Opacity = 0;
|
||||
await AnimateOpacityAsync(nextStepControl, 0, 1, AnimationDurationMs);
|
||||
|
||||
_currentStep = step;
|
||||
_isTransitioning = false;
|
||||
}
|
||||
|
||||
private async Task PlayExitAnimationAsync()
|
||||
{
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid != null)
|
||||
{
|
||||
await AnimateOpacityAsync(contentGrid, 1, 0, AnimationDurationMs);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AnimateOpacityAsync(Control element, double from, double to, int durationMs)
|
||||
{
|
||||
var steps = 20;
|
||||
var delay = durationMs / steps;
|
||||
|
||||
for (int i = 0; i <= steps; i++)
|
||||
{
|
||||
var progress = (double)i / steps;
|
||||
var eased = EaseOutCubic(progress);
|
||||
element.Opacity = from + (to - from) * eased;
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
private static double EaseOutCubic(double t) => 1 - Math.Pow(1 - t, 3);
|
||||
private static double EaseOutQuad(double t) => 1 - Math.Pow(1 - t, 2);
|
||||
private static double EaseOutBack(double t)
|
||||
{
|
||||
const double c1 = 1.70158;
|
||||
const double c3 = c1 + 1;
|
||||
var t1 = t - 1;
|
||||
return 1 + c3 * Math.Pow(t1, 3) + c1 * Math.Pow(t1, 2);
|
||||
}
|
||||
|
||||
private void InitializePrivacySettings()
|
||||
{
|
||||
// 生成隐私追踪 ID
|
||||
var telemetryId = Guid.NewGuid().ToString("N");
|
||||
if (this.FindControl<TextBox>("TelemetryIdTextBox") is { } telemetryIdTextBox)
|
||||
{
|
||||
telemetryIdTextBox.Text = telemetryId;
|
||||
}
|
||||
}
|
||||
|
||||
private void SavePrivacySettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
await Task.Delay(200);
|
||||
return;
|
||||
}
|
||||
var crashTelemetryEnabled = this.FindControl<ToggleSwitch>("CrashTelemetryToggle")?.IsChecked ?? true;
|
||||
var usageTelemetryEnabled = this.FindControl<ToggleSwitch>("UsageTelemetryToggle")?.IsChecked ?? true;
|
||||
var telemetryId = this.FindControl<TextBox>("TelemetryIdTextBox")?.Text ?? Guid.NewGuid().ToString("N");
|
||||
|
||||
var fadeOutAnimation = new Animation
|
||||
// 保存到启动器配置
|
||||
var privacyConfig = new PrivacyConfig
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(200),
|
||||
Easing = new CubicEaseIn(),
|
||||
Children =
|
||||
{
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 1.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(0)
|
||||
},
|
||||
new KeyFrame
|
||||
{
|
||||
Setters = { new Setter(OpacityProperty, 0.0) },
|
||||
KeyTime = TimeSpan.FromMilliseconds(200)
|
||||
}
|
||||
}
|
||||
CrashTelemetryEnabled = crashTelemetryEnabled,
|
||||
UsageTelemetryEnabled = usageTelemetryEnabled,
|
||||
TelemetryId = telemetryId
|
||||
};
|
||||
|
||||
await fadeOutAnimation.RunAsync(contentGrid);
|
||||
Console.WriteLine("[OobeWindow] Exit animation completed");
|
||||
var configPath = Path.Combine(_resolver.ResolveLauncherDataPath(), "privacy-config.json");
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(privacyConfig, AppJsonContext.Default.PrivacyConfig);
|
||||
File.WriteAllText(configPath, json);
|
||||
|
||||
// 保存隐私协议同意状态(带防篡改保护)
|
||||
var agreementService = new PrivacyAgreementService(_resolver.ResolveLauncherDataPath());
|
||||
var isAgreed = this.FindControl<CheckBox>("PrivacyAgreementCheckBox")?.IsChecked ?? false;
|
||||
|
||||
// 生成用户ID和设备ID
|
||||
var userId = telemetryId;
|
||||
var deviceId = GetDeviceIdentifier();
|
||||
|
||||
agreementService.SaveAgreement(isAgreed, userId, deviceId);
|
||||
|
||||
Logger.Info($"[OobeWindow] 隐私设置已保存: Crash={crashTelemetryEnabled}, Usage={usageTelemetryEnabled}, Agreement={isAgreed}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeWindow] Error playing exit animation: {ex.Message}");
|
||||
Logger.Warn($"[OobeWindow] 保存隐私设置失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveEntranceOffset()
|
||||
/// <summary>
|
||||
/// 获取设备标识符
|
||||
/// </summary>
|
||||
private string GetDeviceIdentifier()
|
||||
{
|
||||
var boundsHeight = Bounds.Height > 0 ? Bounds.Height : Height;
|
||||
var scaledOffset = boundsHeight * 0.05;
|
||||
return Math.Clamp(scaledOffset, 20, 48);
|
||||
try
|
||||
{
|
||||
// 使用机器名和用户名的组合作为设备标识
|
||||
var machineName = Environment.MachineName;
|
||||
var userName = Environment.UserName;
|
||||
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes($"{machineName}:{userName}"));
|
||||
return Convert.ToHexString(hash).Substring(0, 16);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "UnknownDevice";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 枚举定义(使用 Services 命名空间中的 ThemeMode)
|
||||
public enum MonetSource
|
||||
{
|
||||
Wallpaper,
|
||||
Custom,
|
||||
Disabled
|
||||
}
|
||||
|
||||
63
LanMountainDesktop.Launcher/Views/PrivacyPolicyWindow.axaml
Normal file
63
LanMountainDesktop.Launcher/Views/PrivacyPolicyWindow.axaml
Normal file
@@ -0,0 +1,63 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:md="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
mc:Ignorable="d"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.PrivacyPolicyWindow"
|
||||
x:DataType="views:PrivacyPolicyViewModel"
|
||||
Title="阑山桌面遥测隐私数据收集协议"
|
||||
Width="800"
|
||||
Height="600"
|
||||
MinWidth="600"
|
||||
MinHeight="400"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- 标题栏 -->
|
||||
<Border Grid.Row="0"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="24,16">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="阑山桌面遥测隐私数据收集协议"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="请仔细阅读以下协议内容,了解我们如何收集、使用和保护您的数据"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Markdown 内容区域 -->
|
||||
<Border Grid.Row="1"
|
||||
Margin="24,16"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}">
|
||||
<md:MarkdownScrollViewer x:Name="MarkdownViewer"
|
||||
Markdown="{Binding PrivacyPolicyMarkdown}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</Border>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<Border Grid.Row="2"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="24,16">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="12">
|
||||
<Button x:Name="CloseButton"
|
||||
Content="关闭"
|
||||
Theme="{DynamicResource AccentButtonTheme}"
|
||||
Width="100" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
121
LanMountainDesktop.Launcher/Views/PrivacyPolicyWindow.axaml.cs
Normal file
121
LanMountainDesktop.Launcher/Views/PrivacyPolicyWindow.axaml.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
public partial class PrivacyPolicyWindow : Window
|
||||
{
|
||||
private readonly PrivacyPolicyViewModel _viewModel;
|
||||
|
||||
public PrivacyPolicyWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_viewModel = new PrivacyPolicyViewModel();
|
||||
DataContext = _viewModel;
|
||||
|
||||
// 加载隐私政策内容
|
||||
LoadPrivacyPolicy();
|
||||
|
||||
// 绑定关闭按钮事件
|
||||
if (this.FindControl<Button>("CloseButton") is { } closeButton)
|
||||
{
|
||||
closeButton.Click += OnCloseClick;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
private void LoadPrivacyPolicy()
|
||||
{
|
||||
// 默认隐私政策内容(Markdown 格式)
|
||||
_viewModel.PrivacyPolicyMarkdown = @"# 阑山桌面遥测隐私数据收集协议
|
||||
|
||||
## 1. 概述
|
||||
|
||||
欢迎使用阑山桌面!本协议旨在向您说明我们在应用运行过程中收集哪些数据、如何使用这些数据以及如何保护您的隐私。
|
||||
|
||||
## 2. 我们收集的数据
|
||||
|
||||
### 2.1 崩溃报告(可选)
|
||||
|
||||
当应用发生崩溃时,我们可能会收集以下信息:
|
||||
|
||||
- **崩溃类型**:应用程序崩溃、无响应等异常情况的类型
|
||||
- **错误堆栈**:导致崩溃的代码路径(不包含文件内容或个人数据)
|
||||
- **设备信息**:操作系统版本、应用版本、.NET 运行时版本
|
||||
- **匿名设备标识符**:一个随机生成的唯一标识符,用于统计崩溃频率
|
||||
|
||||
**注意**:崩溃报告不包含您的个人文件、桌面组件内容、浏览历史或任何可识别个人身份的信息。
|
||||
|
||||
### 2.2 使用统计(可选)
|
||||
|
||||
如果您启用了使用统计,我们可能会收集:
|
||||
|
||||
- **功能使用频率**:各功能模块的使用次数(如设置打开次数、组件添加次数)
|
||||
- **性能指标**:应用启动时间、内存占用范围等性能数据
|
||||
- **匿名设备标识符**:用于统计独立用户数量
|
||||
|
||||
**注意**:使用统计不包含您的组件配置、个人设置或任何敏感信息。
|
||||
|
||||
## 3. 我们不收集的数据
|
||||
|
||||
我们明确**不会**收集以下信息:
|
||||
|
||||
- ❌ 您的姓名、邮箱、电话号码等个人身份信息
|
||||
- ❌ 您的桌面截图或壁纸内容
|
||||
- ❌ 您添加的组件的具体内容或配置详情
|
||||
- ❌ 您的文件系统浏览记录
|
||||
- ❌ 您的网络活动或浏览历史
|
||||
- ❌ 您的精确地理位置信息
|
||||
|
||||
## 4. 数据用途
|
||||
|
||||
我们收集的数据仅用于以下目的:
|
||||
|
||||
1. **改进应用稳定性**:通过分析崩溃报告,修复程序缺陷
|
||||
2. **优化产品体验**:了解功能使用情况,优先改进常用功能
|
||||
3. **性能优化**:识别性能瓶颈,提升应用运行效率
|
||||
|
||||
## 5. 数据存储与保护
|
||||
|
||||
- 所有数据通过**加密传输**(HTTPS)发送到我们的服务器
|
||||
- 数据存储在安全的服务器环境中,访问受到严格控制
|
||||
- 匿名设备标识符仅用于统计目的,无法关联到您的真实身份
|
||||
- 我们**不会**将数据出售或共享给任何第三方用于商业目的
|
||||
|
||||
## 6. 您的控制权
|
||||
|
||||
您拥有以下权利:
|
||||
|
||||
- **随时开启或关闭**:您可以在 OOBE 向导或设置中随时更改遥测选项
|
||||
- **数据删除**:如果您希望删除已收集的数据,请联系我们的支持团队
|
||||
- **知情权**:您有权了解我们收集了哪些数据(通过本协议)
|
||||
|
||||
## 7. 协议更新
|
||||
|
||||
我们可能会不时更新本协议。重大变更时,我们会在应用内通知您。继续使用本应用即表示您同意修订后的协议。
|
||||
|
||||
## 8. 联系我们
|
||||
|
||||
如果您对本协议有任何疑问,请通过以下方式联系我们:
|
||||
|
||||
- 项目主页:https://github.com/LanMountain/LanMountainDesktop
|
||||
- 问题反馈:在 GitHub 仓库提交 Issue
|
||||
|
||||
---
|
||||
|
||||
**最后更新日期**:2026年4月26日
|
||||
|
||||
感谢您信任并使用阑山桌面!";
|
||||
}
|
||||
}
|
||||
|
||||
public partial class PrivacyPolicyViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string _privacyPolicyMarkdown = string.Empty;
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None"
|
||||
WindowDecorations="None"
|
||||
Background="#0B0B0B"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
@@ -20,39 +20,29 @@
|
||||
<views:SplashWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
Background="#0B0B0B">
|
||||
<Grid Grid.Row="0">
|
||||
<Grid x:Name="CompactHero"
|
||||
Margin="24">
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="LanMountain Desktop"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="#F6F7FB" />
|
||||
</Grid>
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<!-- 背景图片 -->
|
||||
<Image x:Name="BackgroundImage"
|
||||
Grid.RowSpan="2"
|
||||
Stretch="UniformToFill"
|
||||
IsVisible="False"
|
||||
Opacity="0"/>
|
||||
|
||||
<Grid x:Name="FullscreenHero"
|
||||
IsVisible="False">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="24">
|
||||
<Border Width="240"
|
||||
Height="240"
|
||||
Background="Transparent">
|
||||
<Image Source="/Assets/logo_nightly.png"
|
||||
Stretch="Uniform" />
|
||||
</Border>
|
||||
<!-- 半透明遮罩层 -->
|
||||
<Border x:Name="BackgroundOverlay"
|
||||
Grid.RowSpan="2"
|
||||
Background="#0B0B0B"
|
||||
Opacity="0.85"/>
|
||||
|
||||
<TextBlock Text="LanMountain Desktop"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="26"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#F6F7FB" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Grid Grid.Row="0"
|
||||
Margin="24">
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="LanMountain Desktop"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="#F6F7FB" />
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
|
||||
@@ -7,7 +7,6 @@ using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
@@ -15,25 +14,14 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
{
|
||||
private const int DebugModeClickThreshold = 5;
|
||||
private static readonly TimeSpan FadeAnimationDuration = TimeSpan.FromMilliseconds(160);
|
||||
private static readonly TimeSpan SlideAnimationDuration = TimeSpan.FromMilliseconds(260);
|
||||
|
||||
private readonly StartupVisualMode _mode;
|
||||
private int _versionTextClickCount;
|
||||
private bool _isDebugModeOpened;
|
||||
private bool _isOpened;
|
||||
private bool _layoutConfigured;
|
||||
private bool _dismissed;
|
||||
private PixelPoint _targetPosition;
|
||||
private PixelPoint _slideHiddenPosition;
|
||||
|
||||
public SplashWindow()
|
||||
: this(StartupVisualMode.Fade)
|
||||
{
|
||||
}
|
||||
|
||||
public SplashWindow(StartupVisualMode mode)
|
||||
{
|
||||
_mode = mode;
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
@@ -41,12 +29,40 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
InitializeBackgroundImage();
|
||||
|
||||
if (this.FindControl<Border>("VersionTextBorder") is { } versionBorder)
|
||||
{
|
||||
versionBorder.PointerPressed += OnVersionTextClick;
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeBackgroundImage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var imageInfo = LauncherBackgroundService.LoadBackgroundImage();
|
||||
if (imageInfo is { IsValid: true, Bitmap: not null })
|
||||
{
|
||||
if (this.FindControl<Image>("BackgroundImage") is { } backgroundImage)
|
||||
{
|
||||
backgroundImage.Source = imageInfo.Bitmap;
|
||||
backgroundImage.IsVisible = true;
|
||||
backgroundImage.Opacity = 1;
|
||||
}
|
||||
Logger.Info("[SplashWindow] 背景图片加载成功");
|
||||
}
|
||||
else if (imageInfo is { Exists: true, IsValid: false })
|
||||
{
|
||||
Logger.Warn($"[SplashWindow] 背景图片校验失败: {imageInfo.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[SplashWindow] 加载背景图片失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isOpened)
|
||||
@@ -55,20 +71,9 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
}
|
||||
|
||||
_isOpened = true;
|
||||
ConfigureForVisualMode();
|
||||
|
||||
if (_mode == StartupVisualMode.Fade)
|
||||
{
|
||||
Opacity = 0d;
|
||||
await AnimateOpacityAsync(0d, 1d, FadeAnimationDuration).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
Opacity = 1d;
|
||||
if (_mode == StartupVisualMode.SlideSplash)
|
||||
{
|
||||
await AnimateWindowPositionAsync(_slideHiddenPosition, _targetPosition, SlideAnimationDuration, EaseOutCubic).ConfigureAwait(false);
|
||||
}
|
||||
Opacity = 0d;
|
||||
await AnimateOpacityAsync(0d, 1d, FadeAnimationDuration).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DismissAsync()
|
||||
@@ -79,25 +84,15 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
}
|
||||
|
||||
_dismissed = true;
|
||||
ConfigureForVisualMode();
|
||||
|
||||
if (_mode == StartupVisualMode.SlideSplash)
|
||||
if (!Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
var from = Position;
|
||||
await AnimateWindowPositionAsync(from, _slideHiddenPosition, SlideAnimationDuration, EaseInCubic).ConfigureAwait(false);
|
||||
}
|
||||
else if (_mode == StartupVisualMode.Fade)
|
||||
{
|
||||
await AnimateOpacityAsync(Opacity, 0d, FadeAnimationDuration).ConfigureAwait(false);
|
||||
await Dispatcher.UIThread.InvokeAsync(async () => await DismissAsync());
|
||||
return;
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (IsVisible)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
});
|
||||
await AnimateOpacityAsync(Opacity, 0d, FadeAnimationDuration).ConfigureAwait(false);
|
||||
Close();
|
||||
}
|
||||
|
||||
public void Report(string stage, string message)
|
||||
@@ -192,46 +187,6 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
UpdateStatus("[Debug Mode] Splash Preview");
|
||||
}
|
||||
|
||||
private void ConfigureForVisualMode()
|
||||
{
|
||||
if (_layoutConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_layoutConfigured = true;
|
||||
var compactHero = this.FindControl<Grid>("CompactHero");
|
||||
var fullscreenHero = this.FindControl<Grid>("FullscreenHero");
|
||||
|
||||
if (_mode == StartupVisualMode.Fade)
|
||||
{
|
||||
compactHero?.SetCurrentValue(IsVisibleProperty, true);
|
||||
fullscreenHero?.SetCurrentValue(IsVisibleProperty, false);
|
||||
Background = new SolidColorBrush(Color.Parse("#0B0B0B"));
|
||||
Width = 480;
|
||||
Height = 320;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
return;
|
||||
}
|
||||
|
||||
compactHero?.SetCurrentValue(IsVisibleProperty, false);
|
||||
fullscreenHero?.SetCurrentValue(IsVisibleProperty, true);
|
||||
Background = Brushes.Black;
|
||||
WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
|
||||
var screen = Screens?.Primary ?? Screens?.All.FirstOrDefault();
|
||||
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
|
||||
var scale = Math.Max(screen?.Scaling ?? 1d, 0.01d);
|
||||
|
||||
Width = workingArea.Width / scale;
|
||||
Height = workingArea.Height / scale;
|
||||
_targetPosition = new PixelPoint(workingArea.X, workingArea.Y);
|
||||
_slideHiddenPosition = new PixelPoint(workingArea.X + workingArea.Width, workingArea.Y);
|
||||
Position = _mode == StartupVisualMode.SlideSplash
|
||||
? _slideHiddenPosition
|
||||
: _targetPosition;
|
||||
}
|
||||
|
||||
private void OnVersionTextClick(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (_isDebugModeOpened)
|
||||
@@ -290,20 +245,6 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
}, duration, EaseOutCubic).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task AnimateWindowPositionAsync(
|
||||
PixelPoint from,
|
||||
PixelPoint to,
|
||||
TimeSpan duration,
|
||||
Func<double, double> easing)
|
||||
{
|
||||
await AnimateAsync(progress =>
|
||||
{
|
||||
var currentX = (int)Math.Round(from.X + ((to.X - from.X) * progress));
|
||||
var currentY = (int)Math.Round(from.Y + ((to.Y - from.Y) * progress));
|
||||
Position = new PixelPoint(currentX, currentY);
|
||||
}, duration, easing).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task AnimateAsync(Action<double> update, TimeSpan duration, Func<double, double> easing)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
@@ -345,6 +286,4 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
var inverse = 1d - value;
|
||||
return 1d - (inverse * inverse * inverse);
|
||||
}
|
||||
|
||||
private static double EaseInCubic(double value) => value * value * value;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
Height="320"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None"
|
||||
WindowDecorations="None"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>5.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.PluginIsolation.Contracts</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>5.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.PluginIsolation.Ipc</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
@@ -17,7 +17,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||
<PackageReference Include="dotnetCampus.Ipc" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>4.0.2</Version>
|
||||
<Version>5.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
@@ -19,13 +19,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="_build_verify_*\**\*.cs" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.320" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="FluentAvaloniaUI" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="dotnetCampus.Ipc" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginSdkInfo
|
||||
{
|
||||
public const string ApiVersion = "4.0.2";
|
||||
public const string ApiVersion = "5.0.0";
|
||||
public const string ManifestFileName = "plugin.json";
|
||||
public const string PackageFileExtension = ".laapp";
|
||||
public const string DataDirectoryName = "Data";
|
||||
|
||||
@@ -16,7 +16,7 @@ Official SDK package for LanMountainDesktop plugins.
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
|
||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"pluginSdkVersion": {
|
||||
"type": "parameter",
|
||||
"datatype": "text",
|
||||
"defaultValue": "4.0.2",
|
||||
"defaultValue": "5.0.0",
|
||||
"description": "LanMountainDesktop.PluginSdk package version.",
|
||||
"replaces": "__PLUGIN_SDK_VERSION__"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "__PLUGIN_DESCRIPTION__",
|
||||
"author": "__PLUGIN_AUTHOR__",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "4.0.2",
|
||||
"apiVersion": "5.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
|
||||
"sharedContracts": [],
|
||||
"runtime": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<Version>5.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.Shared.Contracts</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
@@ -17,7 +17,7 @@
|
||||
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>5.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.Shared.IPC</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
@@ -17,8 +17,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="dotnetCampus.Ipc" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -10,11 +10,10 @@ public sealed class ComponentLibraryCollapseStateTests
|
||||
public void CreateExpanded_InitializesExpandedStateAndHidesChip()
|
||||
{
|
||||
var margin = new Thickness(24, 24, 24, 100);
|
||||
var state = ComponentLibraryCollapseState.CreateExpanded(margin, 0.75);
|
||||
var state = ComponentLibraryCollapseState.CreateExpanded(margin);
|
||||
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, state.VisualState);
|
||||
Assert.Equal(margin, state.ExpandedMargin);
|
||||
Assert.Equal(0.75, state.ExpandedOpacity, 3);
|
||||
Assert.False(state.IsChipVisible);
|
||||
}
|
||||
|
||||
@@ -22,7 +21,7 @@ public sealed class ComponentLibraryCollapseStateTests
|
||||
public void WithVisualState_PreservesStableExpandedSnapshotAcrossTransitions()
|
||||
{
|
||||
var margin = new Thickness(20, 18, 20, 96);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 1);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin);
|
||||
|
||||
var collapsing = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true);
|
||||
var collapsed = collapsing.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true);
|
||||
@@ -36,24 +35,19 @@ public sealed class ComponentLibraryCollapseStateTests
|
||||
Assert.Equal(margin, collapsed.ExpandedMargin);
|
||||
Assert.Equal(margin, restoring.ExpandedMargin);
|
||||
|
||||
Assert.Equal(1, collapsing.ExpandedOpacity, 3);
|
||||
Assert.Equal(1, collapsed.ExpandedOpacity, 3);
|
||||
Assert.Equal(1, restoring.ExpandedOpacity, 3);
|
||||
|
||||
Assert.True(collapsing.IsChipVisible);
|
||||
Assert.True(collapsed.IsChipVisible);
|
||||
Assert.False(restoring.IsChipVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateExpanded_ProducesRestorableSnapshotEvenWhenOriginalOpacityIsLow()
|
||||
public void CreateExpanded_DoesNotCaptureTransientOpacityAsRestorableState()
|
||||
{
|
||||
var margin = new Thickness(18, 22, 18, 88);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 0.15);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin);
|
||||
var restored = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Expanded, isChipVisible: false);
|
||||
|
||||
Assert.Equal(margin, restored.ExpandedMargin);
|
||||
Assert.Equal(0.15, restored.ExpandedOpacity, 3);
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, restored.VisualState);
|
||||
Assert.False(restored.IsChipVisible);
|
||||
}
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ComponentPreviewImageServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_ExecutesWorkSeriallyAcrossKeys()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var executionOrder = new List<string>();
|
||||
var activeCount = 0;
|
||||
var maxActiveCount = 0;
|
||||
|
||||
Task<ComponentPreviewImageEntry> Queue(string componentTypeId)
|
||||
{
|
||||
var key = ComponentPreviewKey.ForComponentType(componentTypeId, widthCells: 2, heightCells: 2);
|
||||
return service.QueueGenerationAsync(
|
||||
key,
|
||||
visualSignature: $"sig:{componentTypeId}",
|
||||
async _ =>
|
||||
{
|
||||
var activeNow = Interlocked.Increment(ref activeCount);
|
||||
maxActiveCount = Math.Max(maxActiveCount, activeNow);
|
||||
lock (executionOrder)
|
||||
{
|
||||
executionOrder.Add(componentTypeId);
|
||||
}
|
||||
|
||||
await Task.Delay(40);
|
||||
Interlocked.Decrement(ref activeCount);
|
||||
return CreateImage();
|
||||
});
|
||||
}
|
||||
|
||||
var first = Queue("Clock");
|
||||
var second = Queue("Weather");
|
||||
var third = Queue("Calendar");
|
||||
|
||||
await Task.WhenAll(first, second, third);
|
||||
|
||||
Assert.Equal(1, maxActiveCount);
|
||||
Assert.Equal(["Clock", "Weather", "Calendar"], executionOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_DeduplicatesConcurrentRequestsForSameKey()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var generationCount = 0;
|
||||
var bitmap = CreateImage();
|
||||
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Task<IImage?> Generation(CancellationToken _)
|
||||
{
|
||||
Interlocked.Increment(ref generationCount);
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
var first = service.QueueGenerationAsync(key, "clock-sig", Generation);
|
||||
var second = service.QueueGenerationAsync(key, "clock-sig", Generation);
|
||||
|
||||
Assert.Same(first, second);
|
||||
|
||||
completion.SetResult(bitmap);
|
||||
var entry = await first;
|
||||
|
||||
Assert.Equal(1, generationCount);
|
||||
Assert.Equal(ComponentPreviewImageState.Ready, entry.State);
|
||||
Assert.Same(bitmap, entry.Bitmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_ResetsSingleKeyToPending()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var image = CreateDisposableImage();
|
||||
var stored = service.Store(key, image, "clock-sig");
|
||||
var previousRevision = stored.Revision;
|
||||
|
||||
var result = service.Invalidate(key);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, stored.State);
|
||||
Assert.Null(stored.Bitmap);
|
||||
Assert.True(image.IsDisposed);
|
||||
Assert.True(stored.Revision > previousRevision);
|
||||
Assert.Equal("clock-sig", stored.VisualSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovePlacementPreviews_RemovesOnlyMatchingPlacementEntries()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
|
||||
var removedClock = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2);
|
||||
var removedWeather = ComponentPreviewKey.ForPlacementInstance("Weather", "desk-1", widthCells: 4, heightCells: 2);
|
||||
var keptPlacement = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-2", widthCells: 2, heightCells: 2);
|
||||
var keptType = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var removedClockImage = CreateDisposableImage();
|
||||
var removedWeatherImage = CreateDisposableImage();
|
||||
var keptPlacementImage = CreateDisposableImage();
|
||||
var keptTypeImage = CreateDisposableImage();
|
||||
|
||||
service.Store(removedClock, removedClockImage, "sig-a");
|
||||
service.Store(removedWeather, removedWeatherImage, "sig-b");
|
||||
service.Store(keptPlacement, keptPlacementImage, "sig-c");
|
||||
service.Store(keptType, keptTypeImage, "sig-d");
|
||||
|
||||
var removedCount = service.RemovePlacementPreviews("desk-1");
|
||||
|
||||
Assert.Equal(2, removedCount);
|
||||
Assert.False(service.TryGetEntry(removedClock, out _));
|
||||
Assert.False(service.TryGetEntry(removedWeather, out _));
|
||||
Assert.True(service.TryGetEntry(keptPlacement, out _));
|
||||
Assert.True(service.TryGetEntry(keptType, out _));
|
||||
Assert.True(removedClockImage.IsDisposed);
|
||||
Assert.True(removedWeatherImage.IsDisposed);
|
||||
Assert.False(keptPlacementImage.IsDisposed);
|
||||
Assert.False(keptTypeImage.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidateVisualSignature_InvalidatesEveryMatchingEntry()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
const string matchingSignature = "shared-sig";
|
||||
const string otherSignature = "other-sig";
|
||||
|
||||
var first = service.Store(
|
||||
ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2),
|
||||
CreateImage(),
|
||||
matchingSignature);
|
||||
var second = service.Store(
|
||||
ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2),
|
||||
CreateImage(),
|
||||
matchingSignature);
|
||||
var third = service.Store(
|
||||
ComponentPreviewKey.ForComponentType("Weather", widthCells: 2, heightCells: 1),
|
||||
CreateImage(),
|
||||
otherSignature);
|
||||
|
||||
var invalidatedCount = service.InvalidateVisualSignature(matchingSignature);
|
||||
|
||||
Assert.Equal(2, invalidatedCount);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, first.State);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, second.State);
|
||||
Assert.Null(first.Bitmap);
|
||||
Assert.Null(second.Bitmap);
|
||||
Assert.Equal(ComponentPreviewImageState.Ready, third.State);
|
||||
Assert.NotNull(third.Bitmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_ReplacingBitmap_DisposesPreviousBitmap_WhenInstanceChanges()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var first = CreateDisposableImage();
|
||||
var second = CreateDisposableImage();
|
||||
|
||||
service.Store(key, first, "sig-a");
|
||||
service.Store(key, second, "sig-b");
|
||||
|
||||
Assert.True(first.IsDisposed);
|
||||
Assert.False(second.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_ReplacingBitmap_DoesNotDispose_WhenSameInstanceReused()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var image = CreateDisposableImage();
|
||||
|
||||
service.Store(key, image, "sig-a");
|
||||
service.Store(key, image, "sig-b");
|
||||
|
||||
Assert.False(image.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoreFailure_DisposesExistingBitmap()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var image = CreateDisposableImage();
|
||||
|
||||
service.Store(key, image, "sig-a");
|
||||
var entry = service.StoreFailure(key, "sig-a", "failed");
|
||||
|
||||
Assert.True(image.IsDisposed);
|
||||
Assert.Equal(ComponentPreviewImageState.Failed, entry.State);
|
||||
Assert.Null(entry.Bitmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_DisposesStaleGeneratedBitmap_WhenEntryWasInvalidated()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var stale = CreateDisposableImage();
|
||||
|
||||
var generationTask = service.QueueGenerationAsync(key, "sig-a", _ => completion.Task);
|
||||
_ = service.Invalidate(key);
|
||||
completion.SetResult(stale);
|
||||
var entry = await generationTask;
|
||||
|
||||
Assert.True(stale.IsDisposed);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, entry.State);
|
||||
Assert.Null(entry.Bitmap);
|
||||
}
|
||||
|
||||
private static IImage CreateImage() => new TestImage();
|
||||
private static DisposableTestImage CreateDisposableImage() => new();
|
||||
|
||||
private sealed class TestImage : IImage
|
||||
{
|
||||
public Size Size => new(1, 1);
|
||||
|
||||
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
||||
{
|
||||
_ = context;
|
||||
_ = sourceRect;
|
||||
_ = destRect;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DisposableTestImage : IImage, IDisposable
|
||||
{
|
||||
public Size Size => new(1, 1);
|
||||
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
IsDisposed = true;
|
||||
}
|
||||
|
||||
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
||||
{
|
||||
_ = context;
|
||||
_ = sourceRect;
|
||||
_ = destRect;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
@@ -34,10 +33,14 @@ public sealed class ComponentSettingsServiceTests
|
||||
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
|
||||
Assert.Single(snapshot.ImportedClassSchedules);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||
Assert.True(document.RootElement.TryGetProperty("defaultSettings", out var defaultSettings));
|
||||
Assert.Equal("Sweep", defaultSettings.GetProperty("desktopClockSecondHandMode").GetString());
|
||||
Assert.False(document.RootElement.TryGetProperty("DesktopClockSecondHandMode", out _));
|
||||
Assert.True(File.Exists(sandbox.DatabasePath));
|
||||
Assert.False(File.Exists(sandbox.SettingsPath));
|
||||
Assert.True(File.Exists(sandbox.SettingsBackupPath));
|
||||
|
||||
ComponentSettingsService.ResetCacheForTests();
|
||||
var reloadedService = sandbox.CreateService();
|
||||
var reloaded = reloadedService.Load();
|
||||
Assert.Equal("Sweep", reloaded.DesktopClockSecondHandMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,11 +75,16 @@ public sealed class ComponentSettingsServiceTests
|
||||
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
|
||||
Assert.True(pluginSettings.SampleFlag);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
|
||||
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out var clockSettings));
|
||||
Assert.Equal("Sweep", clockSettings.GetProperty("desktopClockSecondHandMode").GetString());
|
||||
Assert.False(document.RootElement.TryGetProperty("InstanceSettings", out _));
|
||||
Assert.True(File.Exists(sandbox.DatabasePath));
|
||||
Assert.False(File.Exists(sandbox.SettingsPath));
|
||||
Assert.True(File.Exists(sandbox.SettingsBackupPath));
|
||||
|
||||
ComponentSettingsService.ResetCacheForTests();
|
||||
var reloadedService = sandbox.CreateService();
|
||||
var reloadedSnapshot = reloadedService.LoadForComponent("DesktopClock", "clock-2x2");
|
||||
var reloadedPluginSettings = reloadedService.LoadPluginSettings<SamplePluginSettings>("DesktopClock", "clock-2x2");
|
||||
Assert.Equal("Sweep", reloadedSnapshot.DesktopClockSecondHandMode);
|
||||
Assert.True(reloadedPluginSettings.SampleFlag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,12 +140,7 @@ public sealed class ComponentSettingsServiceTests
|
||||
Assert.True(pluginSettings.SampleFlag);
|
||||
Assert.Equal("schedule-settings", pluginSettings.Title);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
|
||||
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out _));
|
||||
Assert.True(instanceSettings.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
|
||||
Assert.True(document.RootElement.TryGetProperty("pluginSettings", out var pluginSettingsNode));
|
||||
Assert.True(pluginSettingsNode.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
|
||||
Assert.True(File.Exists(sandbox.DatabasePath));
|
||||
}
|
||||
|
||||
private sealed class ComponentSettingsSandbox : IDisposable
|
||||
@@ -155,6 +158,10 @@ public sealed class ComponentSettingsServiceTests
|
||||
|
||||
public string SettingsPath => Path.Combine(_directoryPath, "component-settings.json");
|
||||
|
||||
public string SettingsBackupPath => $"{SettingsPath}.migrated.bak";
|
||||
|
||||
public string DatabasePath => Path.Combine(_directoryPath, "component-state.db");
|
||||
|
||||
public ComponentSettingsService CreateService()
|
||||
{
|
||||
return new ComponentSettingsService(_directoryPath);
|
||||
|
||||
135
LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs
Normal file
135
LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class DesktopComponentRenderModeTests
|
||||
{
|
||||
private const string ComponentId = "RenderModeProbe";
|
||||
|
||||
[Fact]
|
||||
public void DescriptorCreateControl_DefaultsToLiveRenderMode()
|
||||
{
|
||||
var descriptor = CreateDescriptor();
|
||||
var control = (ProbeControl)descriptor.CreateControl(
|
||||
cellSize: 64,
|
||||
CreateTimeZoneService(),
|
||||
CreateWeatherInfoService(),
|
||||
new RecommendationDataService(),
|
||||
new CalculatorDataService(),
|
||||
CreateSettingsFacade(),
|
||||
placementId: "desktop-placement");
|
||||
|
||||
Assert.Equal(DesktopComponentRenderMode.Live, control.RuntimeContext?.RenderMode);
|
||||
Assert.Equal("desktop-placement", control.RuntimeContext?.PlacementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescriptorCreateControl_CanCreateLibraryPreviewRenderModeWithoutPlacement()
|
||||
{
|
||||
var descriptor = CreateDescriptor();
|
||||
var control = (ProbeControl)descriptor.CreateControl(
|
||||
cellSize: 64,
|
||||
CreateTimeZoneService(),
|
||||
CreateWeatherInfoService(),
|
||||
new RecommendationDataService(),
|
||||
new CalculatorDataService(),
|
||||
CreateSettingsFacade(),
|
||||
placementId: null,
|
||||
renderMode: DesktopComponentRenderMode.LibraryPreview);
|
||||
|
||||
Assert.Equal(DesktopComponentRenderMode.LibraryPreview, control.RuntimeContext?.RenderMode);
|
||||
Assert.Null(control.RuntimeContext?.PlacementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComponentLibraryService_CreatesLibraryPreviewRenderMode()
|
||||
{
|
||||
var service = new ComponentLibraryService(
|
||||
CreateComponentRegistry(),
|
||||
CreateRuntimeRegistry());
|
||||
|
||||
var created = service.TryCreateControl(
|
||||
ComponentId,
|
||||
new ComponentLibraryCreateContext(
|
||||
64,
|
||||
CreateTimeZoneService(),
|
||||
CreateWeatherInfoService(),
|
||||
new RecommendationDataService(),
|
||||
new CalculatorDataService(),
|
||||
CreateSettingsFacade(),
|
||||
PlacementId: null,
|
||||
RenderMode: DesktopComponentRenderMode.LibraryPreview),
|
||||
out var control,
|
||||
out var exception);
|
||||
|
||||
Assert.True(created, exception?.ToString());
|
||||
var probe = Assert.IsType<ProbeControl>(control);
|
||||
Assert.Equal(DesktopComponentRenderMode.LibraryPreview, probe.RuntimeContext?.RenderMode);
|
||||
Assert.Null(probe.RuntimeContext?.PlacementId);
|
||||
}
|
||||
|
||||
private static DesktopComponentRuntimeDescriptor CreateDescriptor()
|
||||
{
|
||||
Assert.True(CreateRuntimeRegistry().TryGetDescriptor(ComponentId, out var descriptor));
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private static DesktopComponentRuntimeRegistry CreateRuntimeRegistry()
|
||||
{
|
||||
return new DesktopComponentRuntimeRegistry(
|
||||
CreateComponentRegistry(),
|
||||
[
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
ComponentId,
|
||||
displayNameLocalizationKey: null,
|
||||
_ => new ProbeControl(),
|
||||
cornerRadiusResolver: (System.Func<double, double>?)null)
|
||||
]);
|
||||
}
|
||||
|
||||
private static ComponentRegistry CreateComponentRegistry()
|
||||
{
|
||||
return new ComponentRegistry(
|
||||
[
|
||||
new DesktopComponentDefinition(
|
||||
ComponentId,
|
||||
"Render Mode Probe",
|
||||
"Apps",
|
||||
"Test",
|
||||
MinWidthCells: 1,
|
||||
MinHeightCells: 1,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true)
|
||||
]);
|
||||
}
|
||||
|
||||
private static ISettingsFacadeService CreateSettingsFacade()
|
||||
{
|
||||
return HostSettingsFacadeProvider.GetOrCreate();
|
||||
}
|
||||
|
||||
private static TimeZoneService CreateTimeZoneService()
|
||||
{
|
||||
return CreateSettingsFacade().Region.GetTimeZoneService();
|
||||
}
|
||||
|
||||
private static IWeatherInfoService CreateWeatherInfoService()
|
||||
{
|
||||
return CreateSettingsFacade().Weather.GetWeatherInfoService();
|
||||
}
|
||||
|
||||
private sealed class ProbeControl : Control, IComponentRuntimeContextAware
|
||||
{
|
||||
public DesktopComponentRuntimeContext? RuntimeContext { get; private set; }
|
||||
|
||||
public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context)
|
||||
{
|
||||
RuntimeContext = context;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -118,7 +118,7 @@ public sealed class OobeStateServiceTests : IDisposable
|
||||
executionSnapshot: executionSnapshot ?? new LauncherExecutionSnapshot(false, "tester", "S-1-5-test"));
|
||||
}
|
||||
|
||||
private string GetStatePath() => Path.Combine(_tempRoot, ".launcher", "state", "oobe-state.json");
|
||||
private string GetStatePath() => Path.Combine(_tempRoot, "Launcher", "state", "oobe-state.json");
|
||||
|
||||
private string GetLegacyMarkerPath() => Path.Combine(_tempRoot, ".launcher", "state", "first_run_completed");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
@@ -14,7 +14,6 @@ using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using AvaloniaWebView;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
@@ -78,7 +77,6 @@ public partial class App : Application
|
||||
private TransparentOverlayWindow? _transparentOverlayWindow;
|
||||
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
|
||||
private bool _mainWindowClosed;
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
private DesktopShellHost? _desktopShellHost;
|
||||
private PublicIpcHostService? _publicIpcHostService;
|
||||
private LoadingStateManager? _loadingStateManager;
|
||||
@@ -150,6 +148,37 @@ public partial class App : Application
|
||||
|
||||
_settingsFacade.Settings.Changed += OnSettingsChanged;
|
||||
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
|
||||
|
||||
// 监听系统主题变化
|
||||
PropertyChanged += OnAppPropertyChanged;
|
||||
}
|
||||
|
||||
private void OnAppPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.Property == ActualThemeVariantProperty)
|
||||
{
|
||||
// 系统主题变化时,检查是否需要更新
|
||||
var themeMode = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ThemeMode;
|
||||
if (string.Equals(themeMode, ThemeAppearanceValues.ThemeModeFollowSystem, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var newThemeVariant = (ThemeVariant?)e.NewValue;
|
||||
var isDark = newThemeVariant == ThemeVariant.Dark;
|
||||
|
||||
// 同步到设置
|
||||
var currentThemeState = _settingsFacade.Theme.Get();
|
||||
if (currentThemeState.IsNightMode != isDark)
|
||||
{
|
||||
_settingsFacade.Theme.Save(currentThemeState with { IsNightMode = isDark });
|
||||
}
|
||||
|
||||
// 应用主题
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
ApplyThemeFromSettings();
|
||||
RefreshTrayIconContent();
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Initialize()
|
||||
@@ -163,8 +192,6 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigureWebViewUserDataFolder();
|
||||
AvaloniaWebViewBuilder.Initialize(default);
|
||||
ApplyThemeFromSettings();
|
||||
ApplyCurrentCultureFromSettings();
|
||||
EnsureSettingsWindowService();
|
||||
@@ -181,8 +208,7 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
AppLogger.Info("App", "Framework initialization completed.");
|
||||
|
||||
RegisterUiUnhandledExceptionGuard();
|
||||
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePublicIpc();
|
||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||
@@ -501,43 +527,8 @@ public partial class App : Application
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
{
|
||||
// Get an array of plugins to remove
|
||||
var dataValidationPluginsToRemove =
|
||||
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
|
||||
|
||||
// remove each entry found
|
||||
foreach (var plugin in dataValidationPluginsToRemove)
|
||||
{
|
||||
BindingPlugins.DataValidators.Remove(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureWebViewUserDataFolder()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string userDataFolderEnvVar = "WEBVIEW2_USER_DATA_FOLDER";
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(userDataFolderEnvVar)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var userDataFolder = WebView2RuntimeProbe.ResolveUserDataFolder();
|
||||
Environment.SetEnvironmentVariable(
|
||||
userDataFolderEnvVar,
|
||||
userDataFolder,
|
||||
EnvironmentVariableTarget.Process);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep startup resilient if user profile folders are unavailable.
|
||||
AppLogger.Warn("WebView2", "Failed to configure WebView2 user data folder.", ex);
|
||||
}
|
||||
// Avalonia 12 中 BindingPlugins 已移除,数据验证插件不再需要手动禁用
|
||||
// 编译型绑定默认开启,数据注解验证行为已改变
|
||||
}
|
||||
|
||||
private void InitializePluginRuntime()
|
||||
@@ -762,9 +753,30 @@ public partial class App : Application
|
||||
private void ApplyThemeFromSettings()
|
||||
{
|
||||
var snapshot = _appearanceThemeService.GetCurrent();
|
||||
RequestedThemeVariant = snapshot.IsNightMode
|
||||
? ThemeVariant.Dark
|
||||
: ThemeVariant.Light;
|
||||
var themeMode = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ThemeMode;
|
||||
|
||||
// 处理跟随系统主题模式
|
||||
if (string.Equals(themeMode, ThemeAppearanceValues.ThemeModeFollowSystem, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// 使用 Avalonia 的系统主题检测
|
||||
var systemTheme = ActualThemeVariant;
|
||||
RequestedThemeVariant = systemTheme;
|
||||
|
||||
// 同步 IsNightMode 到设置
|
||||
var isSystemDark = systemTheme == ThemeVariant.Dark;
|
||||
var currentThemeState = _settingsFacade.Theme.Get();
|
||||
if (currentThemeState.IsNightMode != isSystemDark)
|
||||
{
|
||||
_settingsFacade.Theme.Save(currentThemeState with { IsNightMode = isSystemDark });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
RequestedThemeVariant = snapshot.IsNightMode
|
||||
? ThemeVariant.Dark
|
||||
: ThemeVariant.Light;
|
||||
}
|
||||
|
||||
ApplyAdaptiveThemeResources();
|
||||
}
|
||||
|
||||
@@ -1054,6 +1066,7 @@ public partial class App : Application
|
||||
var themeChanged =
|
||||
refreshAll ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeMode), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) ||
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
@@ -1125,43 +1138,6 @@ public partial class App : Application
|
||||
_appearanceThemeService.ApplyThemeResources(Resources);
|
||||
}
|
||||
|
||||
private void RegisterUiUnhandledExceptionGuard()
|
||||
{
|
||||
if (_uiUnhandledExceptionHooked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.UnhandledException += OnUiThreadUnhandledException;
|
||||
_uiUnhandledExceptionHooked = true;
|
||||
}
|
||||
|
||||
private void OnUiThreadUnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
if (!IsKnownWebViewStartupException(e.Exception))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
AppLogger.Warn(
|
||||
"WebView2",
|
||||
"Suppressed a known WebView startup exception from AvaloniaWebView.Navigate to keep the host process alive.",
|
||||
e.Exception);
|
||||
}
|
||||
|
||||
private static bool IsKnownWebViewStartupException(Exception exception)
|
||||
{
|
||||
if (exception is not NullReferenceException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var stackTrace = exception.StackTrace ?? string.Empty;
|
||||
return stackTrace.Contains("AvaloniaWebView.WebView.Navigate", StringComparison.Ordinal) &&
|
||||
stackTrace.Contains("AvaloniaWebView.WebView.OnAttachedToVisualTree", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private void ReleaseSingleInstanceAfterExit(string source)
|
||||
{
|
||||
if (_singleInstanceReleased)
|
||||
@@ -1906,4 +1882,3 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 999 KiB |
BIN
LanMountainDesktop/Assets/about_banner_dark.png
Normal file
BIN
LanMountainDesktop/Assets/about_banner_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 682 KiB |
BIN
LanMountainDesktop/Assets/about_banner_light.png
Normal file
BIN
LanMountainDesktop/Assets/about_banner_light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
internal static class ComponentPreviewRuntimeQuiescer
|
||||
{
|
||||
private static readonly BindingFlags TimerMemberFlags =
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
public static void Attach(Control control)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(control);
|
||||
|
||||
control.IsHitTestVisible = false;
|
||||
control.Focusable = false;
|
||||
control.AttachedToVisualTree += (_, _) =>
|
||||
Dispatcher.UIThread.Post(() => Quiesce(control), DispatcherPriority.Background);
|
||||
control.DetachedFromVisualTree += (_, _) => Quiesce(control);
|
||||
Quiesce(control);
|
||||
}
|
||||
|
||||
public static void Detach(Control control)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(control);
|
||||
|
||||
Quiesce(control);
|
||||
}
|
||||
|
||||
public static void Quiesce(Control control)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(control);
|
||||
|
||||
foreach (var candidate in EnumerateControls(control))
|
||||
{
|
||||
StopDispatcherTimers(candidate);
|
||||
candidate.IsHitTestVisible = false;
|
||||
candidate.Focusable = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Control> EnumerateControls(Control root)
|
||||
{
|
||||
yield return root;
|
||||
|
||||
foreach (var descendant in root.GetVisualDescendants().OfType<Control>())
|
||||
{
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
|
||||
private static void StopDispatcherTimers(object target)
|
||||
{
|
||||
var type = target.GetType();
|
||||
foreach (var field in type.GetFields(TimerMemberFlags))
|
||||
{
|
||||
if (typeof(DispatcherTimer).IsAssignableFrom(field.FieldType) &&
|
||||
field.GetValue(target) is DispatcherTimer timer)
|
||||
{
|
||||
timer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in type.GetProperties(TimerMemberFlags))
|
||||
{
|
||||
if (!property.CanRead ||
|
||||
property.GetIndexParameters().Length != 0 ||
|
||||
!typeof(DispatcherTimer).IsAssignableFrom(property.PropertyType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (property.GetValue(target) is DispatcherTimer timer)
|
||||
{
|
||||
timer.Stop();
|
||||
}
|
||||
}
|
||||
catch (TargetInvocationException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public enum DesktopComponentRenderMode
|
||||
{
|
||||
Live = 0,
|
||||
LibraryPreview = 1
|
||||
}
|
||||
@@ -13,4 +13,5 @@ public sealed record DesktopComponentRuntimeContext(
|
||||
IAppearanceThemeService AppearanceTheme,
|
||||
ComponentChromeContext Chrome,
|
||||
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore);
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore,
|
||||
DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live);
|
||||
|
||||
@@ -108,7 +108,7 @@ public partial class SettingsOptionCard : UserControl
|
||||
"Info" => Symbol.Info,
|
||||
"ArrowSync" => Symbol.ArrowSync,
|
||||
"Alert" => Symbol.Alert,
|
||||
"Bell" => Symbol.Alert, // Bell也映射到Alert图标
|
||||
"Bell" => Symbol.AlertOn,
|
||||
_ => Symbol.Settings
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ public partial class SettingsSectionCard : UserControl
|
||||
"PuzzlePiece" => Symbol.PuzzlePiece,
|
||||
"Info" => Symbol.Info,
|
||||
"ArrowSync" => Symbol.ArrowSync,
|
||||
"Bell" => Symbol.AlertOn,
|
||||
_ => Symbol.Settings
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
{
|
||||
private static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(150);
|
||||
private static readonly Easing TransitionEasing = new CubicEaseOut();
|
||||
private const double StableOpacityThreshold = 0.01;
|
||||
|
||||
private readonly Border _componentLibraryWindow;
|
||||
private readonly Border _collapsedChipHost;
|
||||
@@ -37,9 +36,7 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
_collapsedChipIcon = collapsedChipIcon;
|
||||
|
||||
EnsureTransforms();
|
||||
_state = ComponentLibraryCollapseState.CreateExpanded(
|
||||
_componentLibraryWindow.Margin,
|
||||
_componentLibraryWindow.Opacity <= 0 ? 1 : _componentLibraryWindow.Opacity);
|
||||
_state = ComponentLibraryCollapseState.CreateExpanded(_componentLibraryWindow.Margin);
|
||||
ApplyExpandedSnapshot();
|
||||
_collapsedChipHost.IsVisible = false;
|
||||
_collapsedChipHost.IsHitTestVisible = false;
|
||||
@@ -50,19 +47,16 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
|
||||
public ComponentLibraryCollapseVisualState VisualState => _state.VisualState;
|
||||
|
||||
public void SyncExpandedState(Thickness margin, double opacity)
|
||||
public void SyncExpandedState(Thickness margin)
|
||||
{
|
||||
var hasStableOpacity = IsStableExpandedOpacity(opacity);
|
||||
var nextExpandedOpacity = hasStableOpacity ? Math.Clamp(opacity, 0, 1) : _state.ExpandedOpacity;
|
||||
_state = _state with
|
||||
{
|
||||
ExpandedMargin = margin,
|
||||
ExpandedOpacity = nextExpandedOpacity
|
||||
ExpandedMargin = margin
|
||||
};
|
||||
|
||||
if (_state.VisualState is ComponentLibraryCollapseVisualState.Expanded or ComponentLibraryCollapseVisualState.Restoring)
|
||||
{
|
||||
ApplyExpandedSnapshot(applyOpacity: hasStableOpacity);
|
||||
ApplyExpandedSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +116,7 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
return;
|
||||
}
|
||||
|
||||
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
|
||||
_componentLibraryWindow.Opacity = 1;
|
||||
_windowTranslate.Y = 0;
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
@@ -190,14 +184,10 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyExpandedSnapshot(bool applyOpacity = true)
|
||||
private void ApplyExpandedSnapshot()
|
||||
{
|
||||
_componentLibraryWindow.Margin = _state.ExpandedMargin;
|
||||
if (applyOpacity)
|
||||
{
|
||||
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
|
||||
}
|
||||
|
||||
_componentLibraryWindow.Opacity = 1;
|
||||
_componentLibraryWindow.IsVisible = true;
|
||||
_componentLibraryWindow.IsHitTestVisible = true;
|
||||
_windowTranslate.Y = 0;
|
||||
@@ -270,11 +260,4 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
_componentLibraryWindow.Opacity = 0;
|
||||
_windowTranslate.Y = 28;
|
||||
}
|
||||
|
||||
private static bool IsStableExpandedOpacity(double opacity)
|
||||
{
|
||||
return !double.IsNaN(opacity) &&
|
||||
!double.IsInfinity(opacity) &&
|
||||
opacity > StableOpacityThreshold;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,13 @@ internal enum ComponentLibraryCollapseVisualState
|
||||
internal readonly record struct ComponentLibraryCollapseState(
|
||||
ComponentLibraryCollapseVisualState VisualState,
|
||||
Thickness ExpandedMargin,
|
||||
double ExpandedOpacity,
|
||||
bool IsChipVisible)
|
||||
{
|
||||
public static ComponentLibraryCollapseState CreateExpanded(Thickness expandedMargin, double expandedOpacity)
|
||||
public static ComponentLibraryCollapseState CreateExpanded(Thickness expandedMargin)
|
||||
{
|
||||
return new(
|
||||
ComponentLibraryCollapseVisualState.Expanded,
|
||||
expandedMargin,
|
||||
expandedOpacity,
|
||||
IsChipVisible: false);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,44 +41,42 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.12">
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Controls.WebView" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<!--Condition below is needed to remove developer tools support from build output in Release configuration.-->
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
||||
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
||||
<PackageReference Include="Downloader" Version="4.1.1" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.320" />
|
||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" />
|
||||
<PackageReference Include="Material.Avalonia" Version="3.13.4" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
||||
<PackageReference Include="ClassIsland.Markdown.Avalonia" Version="11.0.3.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||
<PackageReference Include="MudTools.OfficeInterop" Version="2.0.8" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.Word" Version="2.0.8" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.Excel" Version="2.0.8" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.8" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" />
|
||||
<PackageReference Include="Downloader" />
|
||||
<PackageReference Include="FluentAvaloniaUI" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" />
|
||||
<PackageReference Include="Material.Avalonia" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" />
|
||||
<PackageReference Include="ClassIsland.Markdown.Avalonia" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<PackageReference Include="MudTools.OfficeInterop" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.Word" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.Excel" />
|
||||
<PackageReference Include="MudTools.OfficeInterop.PowerPoint" />
|
||||
|
||||
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
||||
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
|
||||
<PackageReference Include="PostHog" Version="2.4.0" />
|
||||
<PackageReference Include="Sentry" Version="4.0.0" />
|
||||
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
||||
<PackageReference Include="PortAudioSharp2" />
|
||||
<PackageReference Include="MaterialColorUtilities" />
|
||||
<PackageReference Include="PostHog" />
|
||||
<PackageReference Include="Sentry" />
|
||||
<PackageReference Include="System.Runtime.WindowsRuntime" />
|
||||
<PackageReference Include="System.Drawing.Common" />
|
||||
|
||||
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||
<PackageReference Include="log4net" Version="3.3.0" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" />
|
||||
<PackageReference Include="log4net" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Launcher 构建目标已移除 - Launcher 现在是独立应用,由 CI/CD 单独构建 -->
|
||||
|
||||
@@ -349,6 +349,11 @@
|
||||
"settings.appearance.title": "Appearance",
|
||||
"settings.appearance.description": "Adjust theme source, system material, and window chrome.",
|
||||
"settings.appearance.theme_header": "Theme",
|
||||
"settings.appearance.theme_mode_label": "Theme mode",
|
||||
"settings.appearance.theme_mode_desc": "Choose light, dark, or follow system theme.",
|
||||
"settings.appearance.theme_mode.light": "Light",
|
||||
"settings.appearance.theme_mode.dark": "Dark",
|
||||
"settings.appearance.theme_mode.follow_system": "Follow system",
|
||||
"settings.color.enable_night_mode_toggle": "Enable night mode",
|
||||
"settings.color.use_system_chrome_toggle": "Use system window chrome",
|
||||
"settings.color.theme_color_label": "Theme accent color",
|
||||
|
||||
@@ -292,6 +292,11 @@
|
||||
"settings.appearance.title": "外観",
|
||||
"settings.appearance.description": "テーマソース、システムマテリアル、ウィンドウクロームを調整します。",
|
||||
"settings.appearance.theme_header": "テーマ",
|
||||
"settings.appearance.theme_mode_label": "テーマモード",
|
||||
"settings.appearance.theme_mode_desc": "ライト、ダーク、またはシステムに従うを選択してください。",
|
||||
"settings.appearance.theme_mode.light": "ライト",
|
||||
"settings.appearance.theme_mode.dark": "ダーク",
|
||||
"settings.appearance.theme_mode.follow_system": "システムに従う",
|
||||
"settings.color.enable_night_mode_toggle": "夜モードを有効にする",
|
||||
"settings.color.use_system_chrome_toggle": "システムのウィンドウクロームを使用",
|
||||
"settings.color.theme_color_label": "テーマのアクセントカラー",
|
||||
|
||||
@@ -338,6 +338,11 @@
|
||||
"settings.appearance.title": "외관",
|
||||
"settings.appearance.description": "테마 소스, 시스템 소재 및 창 외관을 조정합니다.",
|
||||
"settings.appearance.theme_header": "테마",
|
||||
"settings.appearance.theme_mode_label": "테마 모드",
|
||||
"settings.appearance.theme_mode_desc": "라이트, 다크 또는 시스템 설정 따르기를 선택하세요.",
|
||||
"settings.appearance.theme_mode.light": "라이트",
|
||||
"settings.appearance.theme_mode.dark": "다크",
|
||||
"settings.appearance.theme_mode.follow_system": "시스템 설정 따르기",
|
||||
"settings.color.enable_night_mode_toggle": "야간 모드 활성화",
|
||||
"settings.color.use_system_chrome_toggle": "시스템 창 제목 표시줄 사용",
|
||||
"settings.color.theme_color_label": "테마 강조 색상",
|
||||
|
||||
@@ -344,6 +344,11 @@
|
||||
"settings.appearance.title": "外观",
|
||||
"settings.appearance.description": "调整主题来源、系统材质与窗口外观。",
|
||||
"settings.appearance.theme_header": "主题",
|
||||
"settings.appearance.theme_mode_label": "主题模式",
|
||||
"settings.appearance.theme_mode_desc": "选择日间、夜间或跟随系统主题。",
|
||||
"settings.appearance.theme_mode.light": "日间",
|
||||
"settings.appearance.theme_mode.dark": "夜间",
|
||||
"settings.appearance.theme_mode.follow_system": "跟随系统",
|
||||
"settings.color.enable_night_mode_toggle": "启用夜间模式",
|
||||
"settings.color.use_system_chrome_toggle": "使用系统窗口标题栏",
|
||||
"settings.color.theme_color_label": "主题强调色",
|
||||
|
||||
@@ -27,6 +27,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string? SelectedWallpaperSeed { get; set; }
|
||||
|
||||
public string ThemeMode { get; set; } = "light";
|
||||
|
||||
public string? WallpaperPath { get; set; }
|
||||
|
||||
public string WallpaperType { get; set; } = "Image";
|
||||
|
||||
@@ -3,7 +3,6 @@ using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.WebView.Desktop;
|
||||
using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Plugins;
|
||||
@@ -22,6 +21,7 @@ public sealed class Program
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
AppLogger.Initialize();
|
||||
AppDataPathProvider.Initialize(args);
|
||||
DevPluginOptions.Parse(args);
|
||||
RegisterGlobalExceptionLogging();
|
||||
var restartParentProcessId = LauncherRuntimeMetadata.GetRestartParentProcessId(args);
|
||||
@@ -110,7 +110,6 @@ public sealed class Program
|
||||
{
|
||||
var builder = AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.UseDesktopWebView()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
|
||||
|
||||
66
LanMountainDesktop/Services/AppDataPathProvider.cs
Normal file
66
LanMountainDesktop/Services/AppDataPathProvider.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppDataPathProvider
|
||||
{
|
||||
private static string? _overriddenDataRoot;
|
||||
|
||||
public static void Initialize(string[] args)
|
||||
{
|
||||
var dataRoot = ResolveDataRootFromArgs(args);
|
||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||
{
|
||||
_overriddenDataRoot = Path.GetFullPath(dataRoot);
|
||||
AppLogger.Info("AppDataPath", $"Data root overridden by launcher: '{_overriddenDataRoot}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var envDataRoot = Environment.GetEnvironmentVariable("LMD_DATA_ROOT");
|
||||
if (!string.IsNullOrWhiteSpace(envDataRoot))
|
||||
{
|
||||
_overriddenDataRoot = Path.GetFullPath(envDataRoot);
|
||||
AppLogger.Info("AppDataPath", $"Data root overridden by environment variable: '{_overriddenDataRoot}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetDataRoot()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_overriddenDataRoot))
|
||||
{
|
||||
return _overriddenDataRoot;
|
||||
}
|
||||
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
}
|
||||
|
||||
public static string GetSettingsDirectory()
|
||||
{
|
||||
return GetDataRoot();
|
||||
}
|
||||
|
||||
public static string GetPluginMarketDirectory()
|
||||
{
|
||||
return Path.Combine(GetDataRoot(), "PluginMarket");
|
||||
}
|
||||
|
||||
public static string GetWallpapersDirectory()
|
||||
{
|
||||
return Path.Combine(GetDataRoot(), "Wallpapers");
|
||||
}
|
||||
|
||||
private static string? ResolveDataRootFromArgs(string[] args)
|
||||
{
|
||||
const string prefix = "--data-root=";
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return arg[prefix.Length..];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,7 @@ public sealed class AppDatabaseService
|
||||
|
||||
public AppDatabaseService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var dataDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
var dataDirectory = AppDataPathProvider.GetDataRoot();
|
||||
_databasePath = Path.Combine(dataDirectory, "app.db");
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,7 @@ public sealed class AppSettingsService
|
||||
|
||||
public AppSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
var settingsDirectory = AppDataPathProvider.GetSettingsDirectory();
|
||||
_settingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,8 @@ internal sealed class ComponentLibraryService : IComponentLibraryService
|
||||
context.RecommendationInfoService,
|
||||
context.CalculatorDataService,
|
||||
context.SettingsFacade,
|
||||
context.PlacementId);
|
||||
context.PlacementId,
|
||||
context.RenderMode);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class ComponentPreviewImageService : IComponentPreviewImageService
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<ComponentPreviewKey, ComponentPreviewImageEntry> _entries = new(ComponentPreviewKeyComparer.Instance);
|
||||
private readonly Dictionary<ComponentPreviewKey, Task<ComponentPreviewImageEntry>> _inFlightRequests = new(ComponentPreviewKeyComparer.Instance);
|
||||
private Task _queueTail = Task.CompletedTask;
|
||||
|
||||
public ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = new ComponentPreviewImageEntry(key, visualSignature);
|
||||
_entries[key] = created;
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
entry = existing;
|
||||
return true;
|
||||
}
|
||||
|
||||
entry = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<ComponentPreviewImageEntry> GetEntriesSnapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _entries.Values.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ComponentPreviewImageEntry> QueueGenerationAsync(
|
||||
ComponentPreviewKey key,
|
||||
string visualSignature,
|
||||
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(generationWork);
|
||||
|
||||
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||
lock (_gate)
|
||||
{
|
||||
var entry = GetOrCreateEntryCore(key);
|
||||
|
||||
if (entry.State == ComponentPreviewImageState.Ready &&
|
||||
entry.Bitmap is not null &&
|
||||
StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature))
|
||||
{
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
if (_inFlightRequests.TryGetValue(key, out var inFlight))
|
||||
{
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
var expectedRevision = entry.BeginGeneration(normalizedSignature);
|
||||
var previousTask = _queueTail;
|
||||
var queuedTask = RunGenerationAsync(
|
||||
previousTask,
|
||||
key,
|
||||
entry,
|
||||
expectedRevision,
|
||||
normalizedSignature,
|
||||
generationWork,
|
||||
cancellationToken);
|
||||
|
||||
_inFlightRequests[key] = queuedTask;
|
||||
_queueTail = queuedTask.ContinueWith(
|
||||
static _ => { },
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
return queuedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||
lock (_gate)
|
||||
{
|
||||
var entry = GetOrCreateEntryCore(key);
|
||||
entry.StoreBitmap(bitmap, normalizedSignature);
|
||||
_inFlightRequests.Remove(key);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null)
|
||||
{
|
||||
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||
lock (_gate)
|
||||
{
|
||||
var entry = GetOrCreateEntryCore(key);
|
||||
entry.StoreFailure(normalizedSignature, errorMessage);
|
||||
_inFlightRequests.Remove(key);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Invalidate(ComponentPreviewKey key, string? visualSignature = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.Invalidate(visualSignature);
|
||||
_inFlightRequests.Remove(key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public int RemovePlacementPreviews(string placementId)
|
||||
{
|
||||
var normalizedPlacementId = NormalizeRequired(placementId, nameof(placementId));
|
||||
lock (_gate)
|
||||
{
|
||||
var entriesToRemove = _entries
|
||||
.Where(static pair => pair.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
|
||||
.Where(pair => StringComparer.OrdinalIgnoreCase.Equals(pair.Key.PlacementId, normalizedPlacementId))
|
||||
.ToArray();
|
||||
|
||||
foreach (var pair in entriesToRemove)
|
||||
{
|
||||
pair.Value.DisposeBitmap();
|
||||
_entries.Remove(pair.Key);
|
||||
_inFlightRequests.Remove(pair.Key);
|
||||
}
|
||||
|
||||
return entriesToRemove.Length;
|
||||
}
|
||||
}
|
||||
|
||||
public int InvalidateVisualSignature(string visualSignature)
|
||||
{
|
||||
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||
lock (_gate)
|
||||
{
|
||||
var entriesToInvalidate = _entries.Values
|
||||
.Where(entry => StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature))
|
||||
.ToArray();
|
||||
|
||||
foreach (var entry in entriesToInvalidate)
|
||||
{
|
||||
entry.Invalidate(normalizedSignature);
|
||||
_inFlightRequests.Remove(entry.Key);
|
||||
}
|
||||
|
||||
return entriesToInvalidate.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ComponentPreviewImageEntry> RunGenerationAsync(
|
||||
Task previousTask,
|
||||
ComponentPreviewKey key,
|
||||
ComponentPreviewImageEntry entry,
|
||||
long expectedRevision,
|
||||
string visualSignature,
|
||||
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
await previousTask.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep serial queue processing even if previous work faulted.
|
||||
}
|
||||
|
||||
IImage? bitmap;
|
||||
try
|
||||
{
|
||||
bitmap = await generationWork(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
entry.TryApplyFailure(expectedRevision, visualSignature, ex.Message);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (bitmap is null)
|
||||
{
|
||||
entry.TryApplyFailure(expectedRevision, visualSignature, "Preview generation returned no bitmap.");
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.TryApplyGeneratedBitmap(expectedRevision, bitmap, visualSignature);
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_inFlightRequests.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ComponentPreviewImageEntry GetOrCreateEntryCore(ComponentPreviewKey key)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = new ComponentPreviewImageEntry(key);
|
||||
_entries[key] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Media;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public enum ComponentPreviewKeyKind
|
||||
{
|
||||
ComponentType = 0,
|
||||
PlacementInstance = 1
|
||||
}
|
||||
|
||||
public readonly record struct ComponentPreviewKey
|
||||
{
|
||||
private ComponentPreviewKey(
|
||||
ComponentPreviewKeyKind kind,
|
||||
string componentTypeId,
|
||||
string? placementId,
|
||||
int widthCells,
|
||||
int heightCells)
|
||||
{
|
||||
Kind = kind;
|
||||
ComponentTypeId = NormalizeRequired(componentTypeId, nameof(componentTypeId));
|
||||
PlacementId = kind == ComponentPreviewKeyKind.PlacementInstance
|
||||
? NormalizeRequired(placementId, nameof(placementId))
|
||||
: null;
|
||||
WidthCells = NormalizeSpan(widthCells, nameof(widthCells));
|
||||
HeightCells = NormalizeSpan(heightCells, nameof(heightCells));
|
||||
}
|
||||
|
||||
public ComponentPreviewKeyKind Kind { get; }
|
||||
|
||||
public string ComponentTypeId { get; }
|
||||
|
||||
public string? PlacementId { get; }
|
||||
|
||||
public int WidthCells { get; }
|
||||
|
||||
public int HeightCells { get; }
|
||||
|
||||
public static ComponentPreviewKey ForComponentType(string componentTypeId, int widthCells, int heightCells)
|
||||
{
|
||||
return new ComponentPreviewKey(ComponentPreviewKeyKind.ComponentType, componentTypeId, null, widthCells, heightCells);
|
||||
}
|
||||
|
||||
public static ComponentPreviewKey ForPlacementInstance(string componentTypeId, string placementId, int widthCells, int heightCells)
|
||||
{
|
||||
return new ComponentPreviewKey(
|
||||
ComponentPreviewKeyKind.PlacementInstance,
|
||||
componentTypeId,
|
||||
placementId,
|
||||
widthCells,
|
||||
heightCells);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Kind == ComponentPreviewKeyKind.ComponentType
|
||||
? $"Type:{ComponentTypeId}[{WidthCells}x{HeightCells}]"
|
||||
: $"Placement:{ComponentTypeId}@{PlacementId}[{WidthCells}x{HeightCells}]";
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static int NormalizeSpan(int value, string paramName)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(paramName, value, "Span must be greater than zero.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ComponentPreviewImageState
|
||||
{
|
||||
Pending = 0,
|
||||
Ready = 1,
|
||||
Failed = 2
|
||||
}
|
||||
|
||||
public sealed class ComponentPreviewImageEntry : ObservableObject
|
||||
{
|
||||
private IImage? _bitmap;
|
||||
private ComponentPreviewImageState _state = ComponentPreviewImageState.Pending;
|
||||
private string _visualSignature = string.Empty;
|
||||
private string? _errorMessage;
|
||||
private long _revision;
|
||||
private DateTimeOffset _lastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
public ComponentPreviewImageEntry(ComponentPreviewKey key, string? visualSignature = null)
|
||||
{
|
||||
Key = key;
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
}
|
||||
|
||||
public ComponentPreviewKey Key { get; }
|
||||
|
||||
public IImage? Bitmap
|
||||
{
|
||||
get => _bitmap;
|
||||
private set => SetProperty(ref _bitmap, value);
|
||||
}
|
||||
|
||||
public ComponentPreviewImageState State
|
||||
{
|
||||
get => _state;
|
||||
private set => SetProperty(ref _state, value);
|
||||
}
|
||||
|
||||
public string VisualSignature
|
||||
{
|
||||
get => _visualSignature;
|
||||
private set => SetProperty(ref _visualSignature, value);
|
||||
}
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set => SetProperty(ref _errorMessage, value);
|
||||
}
|
||||
|
||||
public long Revision
|
||||
{
|
||||
get => _revision;
|
||||
private set => SetProperty(ref _revision, value);
|
||||
}
|
||||
|
||||
public DateTimeOffset LastUpdatedUtc
|
||||
{
|
||||
get => _lastUpdatedUtc;
|
||||
private set => SetProperty(ref _lastUpdatedUtc, value);
|
||||
}
|
||||
|
||||
internal long BeginGeneration(string visualSignature)
|
||||
{
|
||||
var normalizedVisualSignature = NormalizeSignature(visualSignature);
|
||||
var nextRevision = Revision + 1;
|
||||
Revision = nextRevision;
|
||||
VisualSignature = normalizedVisualSignature;
|
||||
State = ComponentPreviewImageState.Pending;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
return nextRevision;
|
||||
}
|
||||
|
||||
internal bool TryApplyGeneratedBitmap(long expectedRevision, IImage bitmap, string visualSignature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
if (Revision != expectedRevision)
|
||||
{
|
||||
DisposeIfNeeded(bitmap);
|
||||
return false;
|
||||
}
|
||||
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Ready;
|
||||
ReplaceBitmap(bitmap);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool TryApplyFailure(long expectedRevision, string visualSignature, string? errorMessage)
|
||||
{
|
||||
if (Revision != expectedRevision)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Failed;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void StoreBitmap(IImage bitmap, string visualSignature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
Revision += 1;
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Ready;
|
||||
ReplaceBitmap(bitmap);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal void StoreFailure(string visualSignature, string? errorMessage)
|
||||
{
|
||||
Revision += 1;
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Failed;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal void Invalidate(string? visualSignature = null)
|
||||
{
|
||||
Revision += 1;
|
||||
if (visualSignature is not null)
|
||||
{
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
}
|
||||
|
||||
State = ComponentPreviewImageState.Pending;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal void DisposeBitmap()
|
||||
{
|
||||
ReplaceBitmap(null);
|
||||
}
|
||||
|
||||
private void ReplaceBitmap(IImage? bitmap)
|
||||
{
|
||||
var previous = _bitmap;
|
||||
if (ReferenceEquals(previous, bitmap))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Bitmap = bitmap;
|
||||
DisposeIfNeeded(previous);
|
||||
}
|
||||
|
||||
private static void DisposeIfNeeded(IImage? bitmap)
|
||||
{
|
||||
if (bitmap is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeSignature(string? visualSignature)
|
||||
{
|
||||
return visualSignature?.Trim() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ComponentPreviewKeyComparer : IEqualityComparer<ComponentPreviewKey>
|
||||
{
|
||||
public static ComponentPreviewKeyComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals(ComponentPreviewKey x, ComponentPreviewKey y)
|
||||
{
|
||||
return x.Kind == y.Kind &&
|
||||
StringComparer.OrdinalIgnoreCase.Equals(x.ComponentTypeId, y.ComponentTypeId) &&
|
||||
StringComparer.OrdinalIgnoreCase.Equals(x.PlacementId, y.PlacementId) &&
|
||||
x.WidthCells == y.WidthCells &&
|
||||
x.HeightCells == y.HeightCells;
|
||||
}
|
||||
|
||||
public int GetHashCode(ComponentPreviewKey obj)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(obj.Kind);
|
||||
hash.Add(obj.ComponentTypeId, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.PlacementId, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.WidthCells);
|
||||
hash.Add(obj.HeightCells);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ public sealed record ComponentLibraryCreateContext(
|
||||
IRecommendationInfoService RecommendationInfoService,
|
||||
ICalculatorDataService CalculatorDataService,
|
||||
ISettingsFacadeService SettingsFacade,
|
||||
string? PlacementId = null);
|
||||
string? PlacementId = null,
|
||||
DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live);
|
||||
|
||||
public interface IComponentLibraryService
|
||||
{
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IComponentPreviewImageService
|
||||
{
|
||||
ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null);
|
||||
|
||||
bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry);
|
||||
|
||||
IReadOnlyCollection<ComponentPreviewImageEntry> GetEntriesSnapshot();
|
||||
|
||||
Task<ComponentPreviewImageEntry> QueueGenerationAsync(
|
||||
ComponentPreviewKey key,
|
||||
string visualSignature,
|
||||
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature);
|
||||
|
||||
ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null);
|
||||
|
||||
bool Invalidate(ComponentPreviewKey key, string? visualSignature = null);
|
||||
|
||||
int RemovePlacementPreviews(string placementId);
|
||||
|
||||
int InvalidateVisualSignature(string visualSignature);
|
||||
}
|
||||
@@ -30,8 +30,7 @@ public sealed class LauncherSettingsService
|
||||
|
||||
public LauncherSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
var settingsDirectory = AppDataPathProvider.GetSettingsDirectory();
|
||||
_settingsPath = Path.Combine(settingsDirectory, "launcher-settings.json");
|
||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
@@ -88,8 +88,6 @@ public sealed class MonetColorService
|
||||
PixelFormat.Bgra8888,
|
||||
AlphaFormat.Premul);
|
||||
using var framebuffer = writeable.Lock();
|
||||
scaledBitmap.CopyPixels(framebuffer, AlphaFormat.Premul);
|
||||
|
||||
var byteCount = framebuffer.RowBytes * framebuffer.Size.Height;
|
||||
if (byteCount <= 0 || framebuffer.Address == IntPtr.Zero)
|
||||
{
|
||||
@@ -97,6 +95,11 @@ public sealed class MonetColorService
|
||||
}
|
||||
|
||||
var pixelBuffer = new byte[byteCount];
|
||||
scaledBitmap.CopyPixels(
|
||||
new PixelRect(scaledBitmap.PixelSize),
|
||||
framebuffer.Address,
|
||||
byteCount,
|
||||
framebuffer.RowBytes);
|
||||
Marshal.Copy(framebuffer.Address, pixelBuffer, 0, byteCount);
|
||||
|
||||
var argbPixels = new List<uint>(framebuffer.Size.Width * framebuffer.Size.Height);
|
||||
|
||||
@@ -65,7 +65,7 @@ public interface INotificationService
|
||||
{
|
||||
void Show(NotificationContent content);
|
||||
|
||||
Task<ContentDialogResult> ShowDialogAsync(NotificationContent content);
|
||||
Task<FAContentDialogResult> ShowDialogAsync(NotificationContent content);
|
||||
|
||||
void ShowInfo(string title, string? message = null,
|
||||
NotificationPosition position = NotificationPosition.TopRight);
|
||||
@@ -79,17 +79,17 @@ public interface INotificationService
|
||||
void ShowError(string title, string? message = null,
|
||||
NotificationPosition position = NotificationPosition.TopRight);
|
||||
|
||||
Task<ContentDialogResult> ShowDialogInfoAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消");
|
||||
Task<FAContentDialogResult> ShowDialogInfoAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel");
|
||||
|
||||
Task<ContentDialogResult> ShowDialogSuccessAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消");
|
||||
Task<FAContentDialogResult> ShowDialogSuccessAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel");
|
||||
|
||||
Task<ContentDialogResult> ShowDialogWarningAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消");
|
||||
Task<FAContentDialogResult> ShowDialogWarningAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel");
|
||||
|
||||
Task<ContentDialogResult> ShowDialogErrorAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消");
|
||||
Task<FAContentDialogResult> ShowDialogErrorAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel");
|
||||
}
|
||||
|
||||
internal sealed class NotificationService : INotificationService
|
||||
@@ -105,20 +105,17 @@ internal sealed class NotificationService : INotificationService
|
||||
|
||||
public void Show(NotificationContent content)
|
||||
{
|
||||
// 检查通知开关是否启用
|
||||
if (!IsNotificationEnabled())
|
||||
{
|
||||
return; // 通知已禁用,不显示
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a dialog notification (center position), show as dialog window
|
||||
if (content.IsDialogNotification)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => ShowDialogWindow(content), DispatcherPriority.Normal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, show as toast notification
|
||||
Dispatcher.UIThread.Post(() => ShowCore(content), DispatcherPriority.Normal);
|
||||
}
|
||||
|
||||
@@ -153,37 +150,35 @@ internal sealed class NotificationService : INotificationService
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<ContentDialogResult> ShowDialogAsync(NotificationContent content)
|
||||
public async Task<FAContentDialogResult> ShowDialogAsync(NotificationContent content)
|
||||
{
|
||||
// 检查通知开关是否启用
|
||||
if (!IsNotificationEnabled())
|
||||
{
|
||||
return ContentDialogResult.None; // 通知已禁用,不显示
|
||||
return FAContentDialogResult.None;
|
||||
}
|
||||
|
||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowDialogCoreAsync(content));
|
||||
}
|
||||
|
||||
private async Task<ContentDialogResult> ShowDialogCoreAsync(NotificationContent content)
|
||||
private async Task<FAContentDialogResult> ShowDialogCoreAsync(NotificationContent content)
|
||||
{
|
||||
// Get the main window as the dialog host
|
||||
var mainWindow = GetMainWindow();
|
||||
if (mainWindow is null)
|
||||
{
|
||||
AppLogger.Warn("Notification", "Cannot show dialog notification: main window not found");
|
||||
return ContentDialogResult.None;
|
||||
return FAContentDialogResult.None;
|
||||
}
|
||||
|
||||
var dialog = new ContentDialog
|
||||
var dialog = new FAContentDialog
|
||||
{
|
||||
Title = content.Title,
|
||||
Content = content.Message ?? string.Empty,
|
||||
PrimaryButtonText = content.PrimaryButtonText,
|
||||
SecondaryButtonText = content.SecondaryButtonText,
|
||||
CloseButtonText = content.CloseButtonText,
|
||||
DefaultButton = !string.IsNullOrEmpty(content.PrimaryButtonText) ? ContentDialogButton.Primary :
|
||||
!string.IsNullOrEmpty(content.SecondaryButtonText) ? ContentDialogButton.Secondary :
|
||||
ContentDialogButton.Close
|
||||
DefaultButton = !string.IsNullOrEmpty(content.PrimaryButtonText) ? FAContentDialogButton.Primary :
|
||||
!string.IsNullOrEmpty(content.SecondaryButtonText) ? FAContentDialogButton.Secondary :
|
||||
FAContentDialogButton.Close
|
||||
};
|
||||
|
||||
var result = await dialog.ShowAsync(mainWindow);
|
||||
@@ -191,10 +186,10 @@ internal sealed class NotificationService : INotificationService
|
||||
// Execute callbacks based on result
|
||||
switch (result)
|
||||
{
|
||||
case ContentDialogResult.Primary:
|
||||
case FAContentDialogResult.Primary:
|
||||
content.OnPrimaryButtonClick?.Invoke();
|
||||
break;
|
||||
case ContentDialogResult.Secondary:
|
||||
case FAContentDialogResult.Secondary:
|
||||
content.OnSecondaryButtonClick?.Invoke();
|
||||
break;
|
||||
}
|
||||
@@ -206,14 +201,13 @@ internal sealed class NotificationService : INotificationService
|
||||
{
|
||||
try
|
||||
{
|
||||
// 从全局设置服务中读取通知开关状态
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
var snapshot = settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(PluginSdk.SettingsScope.App);
|
||||
return snapshot.NotificationEnabled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果读取失败,默认启用通知
|
||||
// 濠电姷顣介埀顒€鍟块埀顒€缍婇幃妯诲緞鐏炴儳鐝伴柣鐘叉处瑜板啰寰婇崹顕呯唵闁诡垱澹嗙花鍧楁偡濞嗘瑧鐣甸柡浣哥Т閻f繈宕熼鐐殿偧闂佽崵濮抽梽宥夊磹閺囥垹绠氶幖娣妽閸嬨劑鏌曟繛鐐澒闁稿鎸搁~婵囨綇閳轰礁缁?
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -286,8 +280,8 @@ internal sealed class NotificationService : INotificationService
|
||||
Show(new NotificationContent(title, message, Severity: NotificationSeverity.Error, Position: position));
|
||||
}
|
||||
|
||||
public Task<ContentDialogResult> ShowDialogInfoAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消")
|
||||
public Task<FAContentDialogResult> ShowDialogInfoAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel")
|
||||
{
|
||||
return ShowDialogAsync(new NotificationContent(
|
||||
title,
|
||||
@@ -298,8 +292,8 @@ internal sealed class NotificationService : INotificationService
|
||||
CloseButtonText: closeButtonText));
|
||||
}
|
||||
|
||||
public Task<ContentDialogResult> ShowDialogSuccessAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消")
|
||||
public Task<FAContentDialogResult> ShowDialogSuccessAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel")
|
||||
{
|
||||
return ShowDialogAsync(new NotificationContent(
|
||||
title,
|
||||
@@ -310,8 +304,8 @@ internal sealed class NotificationService : INotificationService
|
||||
CloseButtonText: closeButtonText));
|
||||
}
|
||||
|
||||
public Task<ContentDialogResult> ShowDialogWarningAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消")
|
||||
public Task<FAContentDialogResult> ShowDialogWarningAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel")
|
||||
{
|
||||
return ShowDialogAsync(new NotificationContent(
|
||||
title,
|
||||
@@ -322,8 +316,8 @@ internal sealed class NotificationService : INotificationService
|
||||
CloseButtonText: closeButtonText));
|
||||
}
|
||||
|
||||
public Task<ContentDialogResult> ShowDialogErrorAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "确定", string? closeButtonText = "取消")
|
||||
public Task<FAContentDialogResult> ShowDialogErrorAsync(string title, string? message = null,
|
||||
string? primaryButtonText = "OK", string? closeButtonText = "Cancel")
|
||||
{
|
||||
return ShowDialogAsync(new NotificationContent(
|
||||
title,
|
||||
@@ -357,7 +351,7 @@ internal sealed class NotificationWindowManager
|
||||
var position = viewModel.Position;
|
||||
var windows = _windowsByPosition[position];
|
||||
|
||||
// 从设置中读取最大通知数量
|
||||
// 濠电偛顕慨鏉戭潩閿曞偆鏁婇柡鍥╁Х绾剧偓銇勯弮鈧Σ鎺楀储椤掑嫭鍋i柛銉憾閸ゆ瑧鎲搁弶鎸庡枠鐎殿喚鏁婚崺鈧い鎺嶇缁剁偟鎲搁弮鍫濈劦妞ゆ帊鐒﹂惃鎴︽煟閹垮嫮绡€鐎殿噮鍋呯€靛ジ寮堕幋鐑嗕画
|
||||
var maxNotifications = GetMaxNotificationsPerPosition();
|
||||
|
||||
if (windows.Count >= maxNotifications)
|
||||
@@ -395,14 +389,13 @@ internal sealed class NotificationWindowManager
|
||||
{
|
||||
try
|
||||
{
|
||||
// 从全局设置服务中读取最大通知数量
|
||||
// 濠电偛顕慨瀛橆殽閹间礁鐭楅煫鍥ㄦ磻濞岊亪鏌嶈閸撴盯骞忕€n喖绀堢憸蹇涘几閸岀偞鐓涢柛顐g箘瀛濇繝娈垮枤閸犳劗绮欐径鎰垫晣闁宠棄妫楀▓娲⒑閸涘﹦鎳勯柣妤侇殔閵嗘帡鎳滈棃娑氱獮閻熸粍妫冮崺鈧い鎺嶇劍閻ㄦ垿鏌i幙鍕瘈鐎殿噮鍋呯€靛ジ寮堕幋鐑嗕画
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
var snapshot = settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(PluginSdk.SettingsScope.App);
|
||||
return snapshot.NotificationMaxPerPosition > 0 ? snapshot.NotificationMaxPerPosition : 5;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果读取失败,返回默认值
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,7 @@ internal sealed class SqliteComponentDomainStorage :
|
||||
public SqliteComponentDomainStorage(string? settingsRoot = null)
|
||||
{
|
||||
_settingsRoot = string.IsNullOrWhiteSpace(settingsRoot)
|
||||
? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop")
|
||||
? AppDataPathProvider.GetDataRoot()
|
||||
: settingsRoot.Trim();
|
||||
_dbPath = Path.Combine(_settingsRoot, "component-state.db");
|
||||
_layoutJsonPath = Path.Combine(_settingsRoot, "desktop-layout-settings.json");
|
||||
|
||||
@@ -33,7 +33,8 @@ public sealed record ThemeAppearanceSettingsState(
|
||||
string CornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle,
|
||||
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
|
||||
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
|
||||
string? SelectedWallpaperSeed = null);
|
||||
string? SelectedWallpaperSeed = null,
|
||||
string ThemeMode = ThemeAppearanceValues.ThemeModeLight);
|
||||
public sealed record StatusBarSettingsState(
|
||||
IReadOnlyList<string> TopStatusComponentIds,
|
||||
IReadOnlyList<string> PinnedTaskbarActions,
|
||||
|
||||
@@ -167,10 +167,7 @@ internal sealed class WallpaperMediaService : IWallpaperMediaService
|
||||
|
||||
public WallpaperMediaService()
|
||||
{
|
||||
var appDataRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
_wallpapersDirectory = Path.Combine(appDataRoot, "Wallpapers");
|
||||
_wallpapersDirectory = AppDataPathProvider.GetWallpapersDirectory();
|
||||
}
|
||||
|
||||
public WallpaperMediaType DetectMediaType(string? path)
|
||||
@@ -269,7 +266,21 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
cornerRadiusStyle,
|
||||
ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
|
||||
ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
|
||||
snapshot.SelectedWallpaperSeed);
|
||||
snapshot.SelectedWallpaperSeed,
|
||||
NormalizeThemeMode(snapshot.ThemeMode));
|
||||
}
|
||||
|
||||
private static string NormalizeThemeMode(string? value)
|
||||
{
|
||||
if (string.Equals(value, ThemeAppearanceValues.ThemeModeDark, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ThemeAppearanceValues.ThemeModeDark;
|
||||
}
|
||||
if (string.Equals(value, ThemeAppearanceValues.ThemeModeFollowSystem, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ThemeAppearanceValues.ThemeModeFollowSystem;
|
||||
}
|
||||
return ThemeAppearanceValues.ThemeModeLight;
|
||||
}
|
||||
|
||||
public void Save(ThemeAppearanceSettingsState state)
|
||||
@@ -326,6 +337,13 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.SelectedWallpaperSeed));
|
||||
}
|
||||
|
||||
var normalizedThemeMode = NormalizeThemeMode(state.ThemeMode);
|
||||
if (!string.Equals(snapshot.ThemeMode, normalizedThemeMode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
snapshot.ThemeMode = normalizedThemeMode;
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.ThemeMode));
|
||||
}
|
||||
|
||||
if (changedKeys.Count == 0)
|
||||
{
|
||||
return;
|
||||
@@ -1026,10 +1044,7 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
|
||||
{
|
||||
_pluginRuntimeService = pluginRuntimeService;
|
||||
|
||||
var dataRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"PluginMarket");
|
||||
var dataRoot = AppDataPathProvider.GetPluginMarketDirectory();
|
||||
var cacheService = new AirAppMarketCacheService(dataRoot);
|
||||
_indexService = new AirAppMarketIndexService(cacheService);
|
||||
if (_pluginRuntimeService is not null)
|
||||
@@ -1049,10 +1064,7 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
|
||||
return;
|
||||
}
|
||||
|
||||
var dataRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"PluginMarket");
|
||||
var dataRoot = AppDataPathProvider.GetPluginMarketDirectory();
|
||||
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,7 @@ internal sealed class SettingsService : ISettingsService
|
||||
|
||||
public SettingsService()
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop");
|
||||
var root = AppDataPathProvider.GetDataRoot();
|
||||
_pluginSettingsPath = Path.Combine(root, "plugin-settings.json");
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ public static class ThemeAppearanceValues
|
||||
public const string ColorSchemeFollowSystem = "follow_system";
|
||||
public const string ColorSchemeNative = "native";
|
||||
|
||||
public const string ThemeModeLight = "light";
|
||||
public const string ThemeModeDark = "dark";
|
||||
public const string ThemeModeFollowSystem = "follow_system";
|
||||
|
||||
public const string MaterialNone = "none";
|
||||
public const string MaterialMica = "mica";
|
||||
public const string MaterialAcrylic = "acrylic";
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class WebView2RuntimeProbe
|
||||
|
||||
public static WebView2RuntimeAvailability GetAvailability()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return new WebView2RuntimeAvailability(
|
||||
IsAvailable: true,
|
||||
@@ -27,6 +27,14 @@ public static class WebView2RuntimeProbe
|
||||
Message: string.Empty);
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return new WebView2RuntimeAvailability(
|
||||
IsAvailable: false,
|
||||
Version: null,
|
||||
Message: "Embedded browser is currently unavailable on this platform.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var version = TryGetVersionFromWebView2Api();
|
||||
|
||||
@@ -70,11 +70,25 @@
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NumberBox">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Style Selector="ui|FANumberBox">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|FANumberBox /template/ Button#PART_SpinUp">
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="4,4,0,0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|FANumberBox /template/ Button#PART_SpinDown">
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0,0,4,4" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="CheckBox">
|
||||
@@ -125,7 +139,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=".settings-scope ui|NumberBox">
|
||||
<Style Selector=".settings-scope ui|FANumberBox">
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<Setter Property="MinHeight" Value="34" />
|
||||
</Style>
|
||||
@@ -152,7 +166,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=".settings-scope ui|NavigationView, .settings-scope ui|NavigationViewItem, .settings-scope ui|SettingsExpander, .settings-scope ui|InfoBar, .settings-scope ListBoxItem">
|
||||
<Style Selector=".settings-scope ui|FANavigationView, .settings-scope ui|FANavigationViewItem, .settings-scope ui|FASettingsExpander, .settings-scope ui|FAInfoBar, .settings-scope ListBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
@@ -169,9 +183,9 @@
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
半透明表面样式类
|
||||
注意:这些样式使用纯色半透明画刷模拟玻璃效果,并非真正的 Mica/Acrylic 模糊材质。
|
||||
真正的 Mica/Acrylic 效果仅通过 WindowTransparencyLevel 在独立窗口上应用。
|
||||
鍗婇€忔槑琛ㄩ潰鏍峰紡绫?
|
||||
娉ㄦ剰锛氳繖浜涙牱寮忎娇鐢ㄧ函鑹插崐閫忔槑鐢诲埛妯℃嫙鐜荤拑鏁堟灉锛屽苟闈炵湡姝g殑 Mica/Acrylic 妯$硦鏉愯川銆?
|
||||
鐪熸鐨?Mica/Acrylic 鏁堟灉浠呴€氳繃 WindowTransparencyLevel 鍦ㄧ嫭绔嬬獥鍙d笂搴旂敤銆?
|
||||
-->
|
||||
|
||||
<Style Selector="Border.surface-translucent-panel">
|
||||
@@ -221,7 +235,7 @@
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
|
||||
</Style>
|
||||
|
||||
<!-- 向后兼容的旧样式类(已弃用) -->
|
||||
<!-- 鍚戝悗鍏煎鐨勬棫鏍峰紡绫伙紙宸插純鐢級 -->
|
||||
<Style Selector="Border.glass-panel">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent">
|
||||
xmlns:fi="using:FluentIcons.Avalonia">
|
||||
|
||||
<Styles.Resources>
|
||||
<x:Double x:Key="PaneToggleButtonWidth">40</x:Double>
|
||||
@@ -115,7 +115,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationView.settings-navigation-view">
|
||||
<Style Selector="ui|FANavigationView.settings-navigation-view">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.2" Easing="0.05,0.75,0.10,1.00" />
|
||||
@@ -123,7 +123,7 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationView.settings-navigation-view /template/ Border#NavigationViewBorder">
|
||||
<Style Selector="ui|FANavigationView.settings-navigation-view /template/ Border#NavigationViewBorder">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.167" Easing="0.05,0.75,0.10,1.00" />
|
||||
@@ -131,7 +131,7 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationViewItem.settings-nav-item">
|
||||
<Style Selector="ui|FANavigationViewItem.settings-nav-item">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
|
||||
@@ -140,11 +140,11 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationViewItem.settings-nav-item:pointerover">
|
||||
<Style Selector="ui|FANavigationViewItem.settings-nav-item:pointerover">
|
||||
<Setter Property="RenderTransform" Value="scale(1.01)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationViewItem.settings-nav-item:pressed">
|
||||
<Style Selector="ui|FANavigationViewItem.settings-nav-item:pressed">
|
||||
<Setter Property="RenderTransform" Value="scale(0.99)" />
|
||||
</Style>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:behaviors="using:LanMountainDesktop.Behaviors">
|
||||
|
||||
<Style Selector="StackPanel.settings-page-container">
|
||||
@@ -162,59 +162,59 @@
|
||||
<Setter Property="ColumnSpacing" Value="12" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander">
|
||||
<Style Selector="ui|FASettingsExpander">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander ComboBox, ui|SettingsExpander TextBox, ui|SettingsExpander NumericUpDown">
|
||||
<Style Selector="ui|FASettingsExpander ComboBox, ui|FASettingsExpander TextBox, ui|FASettingsExpander NumericUpDown">
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpanderItem ComboBox, ui|SettingsExpanderItem TextBox, ui|SettingsExpanderItem NumericUpDown">
|
||||
<Style Selector="ui|FASettingsExpanderItem ComboBox, ui|FASettingsExpanderItem TextBox, ui|FASettingsExpanderItem NumericUpDown">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander ToggleSwitch, ui|SettingsExpanderItem ToggleSwitch">
|
||||
<Style Selector="ui|FASettingsExpander ToggleSwitch, ui|FASettingsExpanderItem ToggleSwitch">
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card">
|
||||
<Setter Property="Margin" Value="0,0,0,14" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card /template/ ContentPresenter#FooterContentPresenter">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card /template/ ContentPresenter#FooterContentPresenter">
|
||||
<Setter Property="Margin" Value="0,6,0,2" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card /template/ ContentPresenter#ContentPresenter">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card /template/ ContentPresenter#ContentPresenter">
|
||||
<Setter Property="Margin" Value="0,14,0,0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card ComboBox, .settings-section-card ComboBox, .settings-option-card ComboBox">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card ComboBox, .settings-section-card ComboBox, .settings-option-card ComboBox">
|
||||
<Setter Property="MinWidth" Value="220" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card TextBox, .settings-section-card TextBox, .settings-option-card TextBox">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card TextBox, .settings-section-card TextBox, .settings-option-card TextBox">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="MinHeight" Value="38" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card NumericUpDown, .settings-section-card NumericUpDown, .settings-option-card NumericUpDown">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card NumericUpDown, .settings-section-card NumericUpDown, .settings-option-card NumericUpDown">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="MinHeight" Value="38" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|SettingsExpander.settings-expander-card ToggleSwitch, .settings-option-card ToggleSwitch, .settings-list-item ToggleSwitch">
|
||||
<Style Selector="ui|FASettingsExpander.settings-expander-card ToggleSwitch, .settings-option-card ToggleSwitch, .settings-list-item ToggleSwitch">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="OnContent" Value="{x:Null}" />
|
||||
<Setter Property="OffContent" Value="{x:Null}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=".settings-section-card Button, .settings-option-card Button, .settings-list-item Button, ui|SettingsExpander.settings-expander-card Button">
|
||||
<Style Selector=".settings-section-card Button, .settings-option-card Button, .settings-list-item Button, ui|FASettingsExpander.settings-expander-card Button">
|
||||
<Setter Property="MinHeight" Value="36" />
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
</Style>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<linker>
|
||||
<!-- Avalonia and UI framework assemblies that should not be trimmed -->
|
||||
<assembly fullname="Avalonia" preserve="all" />
|
||||
<assembly fullname="Avalonia.Controls" preserve="all" />
|
||||
<assembly fullname="Avalonia.Core" preserve="all" />
|
||||
<assembly fullname="Avalonia.Dialogs" preserve="all" />
|
||||
<assembly fullname="Avalonia.Desktop" preserve="all" />
|
||||
<assembly fullname="Avalonia.Themes.Fluent" preserve="all" />
|
||||
<assembly fullname="Avalonia.Fonts.Inter" preserve="all" />
|
||||
|
||||
<!-- FluentUI packages -->
|
||||
<assembly fullname="FluentAvaloniaUI" preserve="all" />
|
||||
<assembly fullname="FluentIcons.Avalonia" preserve="all" />
|
||||
<assembly fullname="FluentIcons.Avalonia.Fluent" preserve="all" />
|
||||
|
||||
<!-- Media and rendering -->
|
||||
<assembly fullname="LibVLCSharp" preserve="all" />
|
||||
<assembly fullname="LibVLCSharp.Avalonia" preserve="all" />
|
||||
<assembly fullname="WebView.Avalonia" preserve="all" />
|
||||
<assembly fullname="WebView.Avalonia.Desktop" preserve="all" />
|
||||
|
||||
<!-- MVVM and utilities -->
|
||||
<assembly fullname="CommunityToolkit.Mvvm" preserve="all" />
|
||||
<assembly fullname="YamlDotNet" preserve="all" />
|
||||
<assembly fullname="DotNetCampus.AvaloniaInkCanvas" preserve="all" />
|
||||
<assembly fullname="PortAudioSharp2" preserve="all" />
|
||||
|
||||
<!-- System assemblies with reflection usage -->
|
||||
<assembly fullname="System.Drawing.Common" preserve="all" />
|
||||
<assembly fullname="System.Runtime.WindowsRuntime" preserve="all" />
|
||||
<assembly fullname="System.ComponentModel.TypeConverter" preserve="all" />
|
||||
<assembly fullname="System.Reflection" preserve="all" />
|
||||
<assembly fullname="System.Reflection.Emit" preserve="all" />
|
||||
<assembly fullname="System.Reflection.Emit.Lightweight" preserve="all" />
|
||||
</linker>
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using LanMountainDesktop.Services;
|
||||
using Avalonia.Controls;
|
||||
using FluentIcons.Common;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
@@ -55,33 +54,20 @@ public sealed class ComponentLibraryCategoryViewModel
|
||||
public sealed class ComponentLibraryItemViewModel
|
||||
: ObservableObject
|
||||
{
|
||||
private readonly string _loadingPreviewText;
|
||||
private readonly string _previewUnavailableText;
|
||||
private string _displayName;
|
||||
private string? _description;
|
||||
private ComponentPreviewKey _previewKey;
|
||||
private ComponentPreviewImageEntry? _previewImageEntry;
|
||||
private ComponentPreviewImageState _previewState;
|
||||
private string? _previewErrorMessage;
|
||||
private string _previewStatusText;
|
||||
private Control? _previewControl;
|
||||
|
||||
public ComponentLibraryItemViewModel(
|
||||
string componentId,
|
||||
string displayName,
|
||||
ComponentPreviewKey previewKey,
|
||||
string? description = null,
|
||||
string loadingPreviewText = "Loading preview...",
|
||||
string previewUnavailableText = "Preview unavailable",
|
||||
ComponentPreviewImageEntry? previewImageEntry = null)
|
||||
Control? previewControl = null)
|
||||
{
|
||||
ComponentId = componentId;
|
||||
_displayName = displayName;
|
||||
_description = description;
|
||||
_previewKey = previewKey;
|
||||
_loadingPreviewText = loadingPreviewText;
|
||||
_previewUnavailableText = previewUnavailableText;
|
||||
_previewStatusText = loadingPreviewText;
|
||||
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: false);
|
||||
_previewControl = previewControl;
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
@@ -98,98 +84,10 @@ public sealed class ComponentLibraryItemViewModel
|
||||
set => SetProperty(ref _description, value);
|
||||
}
|
||||
|
||||
public ComponentPreviewKey PreviewKey
|
||||
public Control? PreviewControl
|
||||
{
|
||||
get => _previewKey;
|
||||
set => SetProperty(ref _previewKey, value);
|
||||
get => _previewControl;
|
||||
set => SetProperty(ref _previewControl, value);
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry? PreviewImageEntry => _previewImageEntry;
|
||||
|
||||
public object? PreviewBitmap => _previewImageEntry?.Bitmap;
|
||||
|
||||
public ComponentPreviewImageState PreviewState => _previewState;
|
||||
|
||||
public bool IsPreviewPending => _previewState == ComponentPreviewImageState.Pending;
|
||||
|
||||
public bool IsPreviewReady => _previewState == ComponentPreviewImageState.Ready && _previewImageEntry?.Bitmap is not null;
|
||||
|
||||
public bool IsPreviewFailed => _previewState == ComponentPreviewImageState.Failed;
|
||||
|
||||
public string? PreviewErrorMessage => _previewErrorMessage;
|
||||
|
||||
public string PreviewStatusText => _previewStatusText;
|
||||
|
||||
public void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry)
|
||||
{
|
||||
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: true);
|
||||
}
|
||||
|
||||
private void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry, bool raiseEntryChanged)
|
||||
{
|
||||
if (raiseEntryChanged && ReferenceEquals(_previewImageEntry, previewImageEntry))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_previewImageEntry is not null)
|
||||
{
|
||||
_previewImageEntry.PropertyChanged -= OnPreviewImageEntryPropertyChanged;
|
||||
}
|
||||
|
||||
_previewImageEntry = previewImageEntry;
|
||||
_previewState = previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
|
||||
_previewErrorMessage = previewImageEntry?.ErrorMessage;
|
||||
|
||||
_previewStatusText = _previewState switch
|
||||
{
|
||||
ComponentPreviewImageState.Ready => string.Empty,
|
||||
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
|
||||
? _previewUnavailableText
|
||||
: _previewErrorMessage!,
|
||||
_ => _loadingPreviewText
|
||||
};
|
||||
|
||||
if (_previewImageEntry is not null)
|
||||
{
|
||||
_previewImageEntry.PropertyChanged += OnPreviewImageEntryPropertyChanged;
|
||||
}
|
||||
|
||||
RaisePreviewDependentProperties();
|
||||
}
|
||||
|
||||
private void OnPreviewImageEntryPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
if (string.IsNullOrWhiteSpace(e.PropertyName) ||
|
||||
e.PropertyName is nameof(ComponentPreviewImageEntry.Bitmap) or
|
||||
nameof(ComponentPreviewImageEntry.State) or
|
||||
nameof(ComponentPreviewImageEntry.ErrorMessage))
|
||||
{
|
||||
_previewState = _previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
|
||||
_previewErrorMessage = _previewImageEntry?.ErrorMessage;
|
||||
_previewStatusText = _previewState switch
|
||||
{
|
||||
ComponentPreviewImageState.Ready => string.Empty,
|
||||
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
|
||||
? _previewUnavailableText
|
||||
: _previewErrorMessage!,
|
||||
_ => _loadingPreviewText
|
||||
};
|
||||
|
||||
RaisePreviewDependentProperties();
|
||||
}
|
||||
}
|
||||
|
||||
private void RaisePreviewDependentProperties()
|
||||
{
|
||||
OnPropertyChanged(nameof(PreviewImageEntry));
|
||||
OnPropertyChanged(nameof(PreviewBitmap));
|
||||
OnPropertyChanged(nameof(PreviewState));
|
||||
OnPropertyChanged(nameof(IsPreviewPending));
|
||||
OnPropertyChanged(nameof(IsPreviewReady));
|
||||
OnPropertyChanged(nameof(IsPreviewFailed));
|
||||
OnPropertyChanged(nameof(PreviewErrorMessage));
|
||||
OnPropertyChanged(nameof(PreviewStatusText));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user