mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
8 Commits
v0.8.6.2
...
launcher-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05ffadd1a0 | ||
|
|
5b4b9f32b5 | ||
|
|
8b8c7d1e7f | ||
|
|
43c0ee6c06 | ||
|
|
403cf280bb | ||
|
|
ad3648a0b8 | ||
|
|
28f41cd27c | ||
|
|
9de93d2a4d |
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`
|
||||
@@ -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,9 @@ 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))]
|
||||
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);
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" Version="1.1.250403001" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||
</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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
270
LanMountainDesktop.Launcher/Services/DataLocationResolver.cs
Normal file
270
LanMountainDesktop.Launcher/Services/DataLocationResolver.cs
Normal file
@@ -0,0 +1,270 @@
|
||||
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");
|
||||
|
||||
/// <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();
|
||||
if (config is null)
|
||||
{
|
||||
return _defaultSystemDataPath;
|
||||
}
|
||||
|
||||
if (string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var portablePath = !string.IsNullOrWhiteSpace(config.PortableDataPath)
|
||||
? config.PortableDataPath
|
||||
: _defaultSystemDataPath;
|
||||
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(ResolveLauncherDataPath(), 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
|
||||
{
|
||||
var configPath = ResolveConfigPath();
|
||||
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 = ResolveLauncherDataPath();
|
||||
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
|
||||
{
|
||||
Directory.CreateDirectory(ResolveLauncherDataPath());
|
||||
Directory.CreateDirectory(ResolveDesktopDataPath());
|
||||
}
|
||||
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
|
||||
{
|
||||
|
||||
@@ -21,9 +21,9 @@ 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");
|
||||
}
|
||||
@@ -208,14 +208,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))
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
153
LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml
Normal file
153
LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml
Normal file
@@ -0,0 +1,153 @@
|
||||
<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:ui="using:FluentAvalonia.UI.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="520"
|
||||
d:DesignHeight="480"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.DataLocationPromptWindow"
|
||||
x:DataType="views:DataLocationPromptWindow"
|
||||
Title="选择数据保存位置"
|
||||
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="选择数据保存位置"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="决定将应用数据保存在哪里,可随时在设置中更改"
|
||||
FontSize="13"
|
||||
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">
|
||||
<ui:SymbolIcon Symbol="Important"
|
||||
FontSize="16"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
<TextBlock Text="无法保存到应用目录"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="当前安装目录需要管理员权限才能写入,数据将自动保存到系统用户目录。"
|
||||
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="保存在系统用户目录(推荐)"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="数据与当前 Windows 用户绑定,重装应用或更新后数据不会丢失"
|
||||
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="保存在应用安装目录"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="便于携带,可随应用文件夹整体移动到其他电脑"
|
||||
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">
|
||||
<ui:SymbolIcon Symbol="Message"
|
||||
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="取消"
|
||||
Theme="{DynamicResource ButtonTheme}"
|
||||
IsVisible="False" />
|
||||
<Button x:Name="ConfirmButton"
|
||||
Content="确认"
|
||||
Theme="{DynamicResource AccentButtonTheme}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,310 @@
|
||||
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.Checked += OnSelectionChanged;
|
||||
systemRadio.Unchecked += OnSelectionChanged;
|
||||
}
|
||||
|
||||
if (portableRadio is not null)
|
||||
{
|
||||
portableRadio.Checked += OnSelectionChanged;
|
||||
portableRadio.Unchecked += 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 = "检测到系统用户目录已有应用数据。如果选择保存在应用安装目录,将自动迁移现有数据。";
|
||||
}
|
||||
}
|
||||
|
||||
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,13 +4,14 @@
|
||||
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:DesignWidth="700"
|
||||
d:DesignHeight="500"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
|
||||
x:DataType="views:OobeWindow"
|
||||
Title="欢迎使用阑山桌面"
|
||||
Width="600"
|
||||
Width="700"
|
||||
Height="500"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
@@ -21,59 +22,599 @@
|
||||
<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,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 x:Name="AdminWarningBanner"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="12,10"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<PathIcon Data="M12,2 L1,21 L23,21 Z M11,9 L13,9 L13,15 L11,15 Z M11,17 L13,17 L13,19 L11,19 Z"
|
||||
Width="16"
|
||||
Height="16"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
<TextBlock Text="无法保存到应用目录"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="当前安装目录需要管理员权限才能写入,数据将自动保存到系统用户目录。"
|
||||
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"
|
||||
Cursor="Hand">
|
||||
<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="保存在系统用户目录(推荐)"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="数据与当前 Windows 用户绑定,重装应用或更新后数据不会丢失"
|
||||
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"
|
||||
Cursor="Hand">
|
||||
<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="保存在应用安装目录"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<TextBlock Text="便于携带,可随应用文件夹整体移动到其他电脑"
|
||||
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">
|
||||
<PathIcon Data="M9,16.17 L4.83,12 L3.41,13.41 L9,19 L21,7 L19.59,5.59 Z"
|
||||
Width="16"
|
||||
Height="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="12"
|
||||
Margin="0,24,0,0">
|
||||
<Button x:Name="DataLocationBackButton"
|
||||
Content="返回"
|
||||
Theme="{DynamicResource ButtonTheme}" />
|
||||
<Button x:Name="DataLocationNextButton"
|
||||
Content="下一步"
|
||||
Theme="{DynamicResource AccentButtonTheme}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 步骤 4: 欢迎完成页面 -->
|
||||
<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,709 @@
|
||||
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();
|
||||
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>("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 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>("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>("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()
|
||||
{
|
||||
try
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid != null)
|
||||
{
|
||||
var contentGrid = this.FindControl<Grid>("ContentGrid");
|
||||
if (contentGrid is null)
|
||||
{
|
||||
await Task.Delay(200);
|
||||
return;
|
||||
}
|
||||
|
||||
var fadeOutAnimation = new Animation
|
||||
{
|
||||
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)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fadeOutAnimation.RunAsync(contentGrid);
|
||||
Console.WriteLine("[OobeWindow] Exit animation completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeWindow] Error playing exit animation: {ex.Message}");
|
||||
await AnimateOpacityAsync(contentGrid, 1, 0, AnimationDurationMs);
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveEntranceOffset()
|
||||
private static async Task AnimateOpacityAsync(Control element, double from, double to, int durationMs)
|
||||
{
|
||||
var boundsHeight = Bounds.Height > 0 ? Bounds.Height : Height;
|
||||
var scaledOffset = boundsHeight * 0.05;
|
||||
return Math.Clamp(scaledOffset, 20, 48);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 枚举定义(使用 Services 命名空间中的 ThemeMode)
|
||||
public enum MonetSource
|
||||
{
|
||||
Wallpaper,
|
||||
Custom,
|
||||
Disabled
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
@@ -150,6 +150,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()
|
||||
@@ -762,9 +793,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 +1106,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) &&
|
||||
|
||||
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 |
@@ -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";
|
||||
|
||||
@@ -22,6 +22,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);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,10 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
@@ -576,10 +579,36 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||
RefreshLocalizedText();
|
||||
ThemeColorModes = CreateThemeColorModes();
|
||||
ThemeModeOptions = CreateThemeModeOptions();
|
||||
|
||||
_isInitializing = true;
|
||||
Load();
|
||||
_isInitializing = false;
|
||||
|
||||
}
|
||||
|
||||
partial void OnSelectedThemeModeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据选择的主题模式更新夜间模式状态
|
||||
var newIsNightMode = value.Value switch
|
||||
{
|
||||
ThemeAppearanceValues.ThemeModeDark => true,
|
||||
ThemeAppearanceValues.ThemeModeLight => false,
|
||||
ThemeAppearanceValues.ThemeModeFollowSystem => Application.Current?.ActualThemeVariant == ThemeVariant.Dark,
|
||||
_ => IsNightMode
|
||||
};
|
||||
|
||||
if (IsNightMode != newIsNightMode)
|
||||
{
|
||||
IsNightMode = newIsNightMode;
|
||||
}
|
||||
|
||||
PersistCurrentState(restartRequired: false);
|
||||
}
|
||||
|
||||
public event Action<string>? RestartRequested;
|
||||
@@ -595,6 +624,27 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _themeColor = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<SelectionOption> _themeModeOptions = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedThemeMode = new(ThemeAppearanceValues.ThemeModeLight, "Light");
|
||||
|
||||
[ObservableProperty]
|
||||
private string _themeModeLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _themeModeDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _themeModeLightText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _themeModeDarkText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _themeModeFollowSystemText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private Color _customSeedPickerValue = DefaultSeedColor;
|
||||
|
||||
@@ -797,16 +847,6 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
UpdatePreview(theme);
|
||||
}
|
||||
|
||||
partial void OnIsNightModeChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PersistCurrentState(restartRequired: false);
|
||||
}
|
||||
|
||||
partial void OnUseSystemChromeChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
@@ -887,7 +927,11 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
PageTitle = L("settings.appearance.title", "Appearance");
|
||||
PageDescription = L("settings.appearance.description", "Adjust theme source, material background, and window chrome.");
|
||||
ThemeHeader = L("settings.appearance.theme_header", "Theme");
|
||||
NightModeLabel = L("settings.color.enable_night_mode_toggle", "Enable night mode");
|
||||
ThemeModeLabel = L("settings.appearance.theme_mode_label", "Theme mode");
|
||||
ThemeModeDescription = L("settings.appearance.theme_mode_desc", "Choose light, dark, or follow system preference.");
|
||||
ThemeModeLightText = L("settings.appearance.theme_mode.light", "Light");
|
||||
ThemeModeDarkText = L("settings.appearance.theme_mode.dark", "Dark");
|
||||
ThemeModeFollowSystemText = L("settings.appearance.theme_mode.follow_system", "Follow system");
|
||||
UseSystemChromeLabel = L("settings.color.use_system_chrome_toggle", "Use system window chrome");
|
||||
ThemeColorLabel = L("settings.color.theme_color_label", "Theme Accent Color");
|
||||
ThemeColorModeLabel = L("settings.appearance.theme_color_mode_label", "Theme color source");
|
||||
@@ -957,6 +1001,26 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
SelectedSystemMaterialMode = SystemMaterialModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, savedSystemMaterialMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? SystemMaterialModes[0];
|
||||
|
||||
// 应用主题模式设置
|
||||
var savedThemeMode = NormalizeThemeMode(theme.ThemeMode);
|
||||
SelectedThemeMode = ThemeModeOptions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, savedThemeMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? ThemeModeOptions.FirstOrDefault(o => o.Value == ThemeAppearanceValues.ThemeModeLight)
|
||||
?? new SelectionOption(ThemeAppearanceValues.ThemeModeLight, ThemeModeLightText);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private void PersistCurrentState(bool restartRequired)
|
||||
@@ -984,6 +1048,16 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateThemeModeOptions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption(ThemeAppearanceValues.ThemeModeLight, ThemeModeLightText),
|
||||
new SelectionOption(ThemeAppearanceValues.ThemeModeDark, ThemeModeDarkText),
|
||||
new SelectionOption(ThemeAppearanceValues.ThemeModeFollowSystem, ThemeModeFollowSystemText)
|
||||
];
|
||||
}
|
||||
|
||||
private ThemeAppearanceSettingsState BuildPendingState(bool usePickerSeed)
|
||||
{
|
||||
var themeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(SelectedThemeColorMode?.Value, ThemeColor);
|
||||
@@ -998,7 +1072,8 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusStyle(CornerRadiusStyle),
|
||||
themeColorMode,
|
||||
ThemeAppearanceValues.NormalizeSystemMaterialMode(SelectedSystemMaterialMode?.Value),
|
||||
_selectedWallpaperSeed);
|
||||
_selectedWallpaperSeed,
|
||||
SelectedThemeMode?.Value ?? ThemeAppearanceValues.ThemeModeLight);
|
||||
}
|
||||
|
||||
private void UpdatePreview(ThemeAppearanceSettingsState pendingState)
|
||||
|
||||
@@ -6,6 +6,19 @@
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.AboutSettingsPage"
|
||||
x:DataType="vm:AboutSettingsPageViewModel">
|
||||
<UserControl.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
<ResourceDictionary x:Key="Default">
|
||||
<ImageBrush x:Key="AboutBannerBrush" Source="/Assets/about_banner_light.png" Stretch="Uniform" AlignmentX="Center" AlignmentY="Center" />
|
||||
</ResourceDictionary>
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<ImageBrush x:Key="AboutBannerBrush" Source="/Assets/about_banner_dark.png" Stretch="Uniform" AlignmentX="Center" AlignmentY="Center" />
|
||||
</ResourceDictionary>
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
</ResourceDictionary>
|
||||
</UserControl.Resources>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="StackPanel.about-page-container">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
@@ -38,10 +51,7 @@
|
||||
Classes="about-hero-card"
|
||||
Height="240"
|
||||
PointerPressed="OnAboutHeroCardPointerPressed">
|
||||
<Image Source="/Assets/about_banner.png"
|
||||
Stretch="Uniform"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
<Panel Background="{DynamicResource AboutBannerBrush}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settings-subsection-title"
|
||||
|
||||
@@ -13,12 +13,21 @@
|
||||
Text="{Binding ThemeHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<ui:SettingsExpander Header="{Binding NightModeLabel}">
|
||||
<ui:SettingsExpander Header="{Binding ThemeModeLabel}"
|
||||
Description="{Binding ThemeModeDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="WeatherMoon" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding IsNightMode}" />
|
||||
<ComboBox Width="200"
|
||||
ItemsSource="{Binding ThemeModeOptions}"
|
||||
SelectedItem="{Binding SelectedThemeMode}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user