feat: v1.2.0 开发进度更新

### 新增功能
- **Linux 官方支持**:新增 Hua.Todo.Avalonia 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS
- **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化
- **SQLite DateTime 兼容修复**:新增 LenientUtcDateTimeStringConverter,解决历史遗留的 DateTime 脏数据解析问题
- **用户文档完善**:新增 docs/manual/新手指南.md 和 docs/manual/用户指南.md
- **部署文档**:新增 docs/manual/部署文档.md,详细说明多平台发布流程

### 优化与修复
- **发布脚本整理**:拆分/对齐各平台发布入口,新增 publish.ps1 作为统一入口
- **Windows WebView2 优化**:数据目录调整到 %LocalAppData%\Hua.Todo\WebView2,修复 Runtime 误判问题
- **MAUI 多平台构建**:在 Windows 开发机上默认仅构建 Android + Windows 目标
- **SPA 路由回落**:修复 Release 模式下 /swagger 路径的 404 问题
- **Swagger 输出**:补齐 Dynamic API 端点,避免接口缺失

### 文档更新
- **版本记录**:更新 v1.2.0 开发进度和功能列表
- **技术设计文档**:添加 Avalonia 项目架构和模块设计
- **项目结构**:更新 README.md 中的项目结构说明

### 其他变更
- 新增 Directory.Build.props 和更新 Directory.Build.targets
- 调整 src/Hua.Todo.Avalonia 项目配置和资源文件
- 更新 src/Hua.Todo.Web 前端资源文件
- 修复 src/Hua.Todo.Maui 相关配置和打包脚本
This commit is contained in:
ShaoHua
2026-04-09 21:39:07 +08:00
parent 8443d14ba6
commit d53828c150
45 changed files with 1278 additions and 215 deletions
+2
View File
@@ -370,3 +370,5 @@ FodyWeavers.xsd
/src/Hua.Todo.Host/Hua.Todo.db-wal
/.artifacts/buildcheck
/.trae
/.artifacts
/.android-sdk
+2
View File
@@ -20,6 +20,8 @@
"args": [
"publish",
"${workspaceFolder}/src/Hua.Todo.Host/Hua.Todo.Host.csproj",
"-c",
"Release",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
+23
View File
@@ -0,0 +1,23 @@
<Project>
<PropertyGroup>
<!-- 统一版本号 -->
<Version>1.2.8</Version>
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- 通用元数据 -->
<Authors>ShaoHua</Authors>
<Company>Hua.Todo</Company>
<Product>Hua.Todo</Product>
<Copyright>Copyright © 2024 ShaoHua</Copyright>
<Description>A simple cross-platform Todo application.</Description>
<!-- 编译优化选项 -->
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
</PropertyGroup>
</Project>
+3 -3
View File
@@ -1,8 +1,8 @@
<Project>
<PropertyGroup Condition="'$(TargetFramework)' != ''">
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定 Windows RID(win-*)时强制禁用,避免还原阶段解析 Mono.win-x64 runtime pack。 -->
<UseMonoRuntime Condition="'$(RuntimeIdentifier)' != '' and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-'))">false</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) and ('$(RuntimeIdentifier)' == '' or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) != True)">true</UseMonoRuntime>
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定桌面 RIDwin-*/linux-*/osx-*)时强制禁用,避免还原阶段解析对应的 Mono runtime pack。 -->
<UseMonoRuntime Condition="'$(RuntimeIdentifier)' != '' and ($([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('linux-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('osx-')))">false</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) and ('$(RuntimeIdentifier)' == '' or ($([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) != True and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('linux-')) != True and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('osx-')) != True))">true</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) != True">false</UseMonoRuntime>
</PropertyGroup>
</Project>
+2 -2
View File
@@ -58,8 +58,8 @@ npm run dev
### Windows 交付产物(安装包)
- 运行 `publish-windows.ps1` 生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj``<Version>`;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows/<RID>/installer/`
- 运行 `publish.ps1` 默认会同时发布 Windows + Linux(仅发布 Windows`publish.ps1 -Windows`
- 运行 `publish-windows.ps1` 默认使用 `Release` 配置生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj``<Version>`;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows/<RID>/installer/`
- 运行 `publish.ps1` 默认会同时发布 Windows + Linux(仅发布 Windows`publish.ps1 -Windows`,且均默认使用 `Release` 配置。
- 安装后主程序为:`Hua.Todo.Maui.exe`(快捷方式/安装后启动均指向该文件)
- 发布产物默认使用静态资源:`src/Hua.Todo.Maui/appsettings.json``WebServer.IsUsingStatic=true`
- 前端构建产物会输出到 `src/Hua.Todo.Maui/wwwroot`,并随 Windows 发布复制到发布目录(嵌入式服务器从 `AppContext.BaseDirectory/wwwroot` 提供静态文件)
+57 -8
View File
@@ -8,7 +8,7 @@
### 2.1 后端技术栈
- **开发语言**: C# 10
- **框架**: .NET 10
- **UI 框架**: MAUI (Multi-platform App UI)
- **UI 框架**: MAUI (Multi-platform App UI) + Avalonia (Linux 支持)
- **Web 服务器**: Kestrel (ASP.NET Core 内置)
- **API 框架**: ASP.NET Core Web API
- **数据访问**: Entity Framework Core
@@ -34,7 +34,8 @@ Hua.Todo/
│ │ ├── 技术栈与模块.md
│ │ ├── 版本记录.md
│ │ ├── 技术设计文档.md(本文件)
│ │ ── 代码规范文档.md
│ │ ── 代码规范文档.md
│ │ └── 部署文档.md
│ └── project/ # 项目进度/需求文档
│ ├── 产品需求文档.md
│ └── ...
@@ -73,7 +74,30 @@ Hua.Todo/
│ │ ├── MauiProgram.cs # MAUI 程序配置
│ │ └── Hua.Todo.Maui.csproj # MAUI 项目文件
│ │
│ ├── Hua.Todo.Api/ # 后端 API 项目
│ ├── Hua.Todo.Avalonia/ # Avalonia 项目(Linux 支持)
│ │ ├── Assets/ # 资源文件
│ │ │ └── icon.ico # 应用图标
│ │ ├── Services/ # 服务层
│ │ │ ├── EmbeddedWebServerServiceFactory.cs
│ │ │ ├── GlobalHotKeyServiceFactory.cs
│ │ │ ├── NoopEmbeddedWebServerService.cs
│ │ │ └── Platforms/ # 平台特定服务
│ │ │ ├── AndroidGlobalHotKeyService.cs
│ │ │ └── LinuxGlobalHotKeyService.cs
│ │ ├── Views/ # 视图
│ │ │ ├── MainView.axaml
│ │ │ ├── MainView.axaml.cs
│ │ │ ├── MainWindow.axaml
│ │ │ └── MainWindow.axaml.cs
│ │ ├── App.axaml # Avalonia 应用入口
│ │ ├── App.axaml.cs
│ │ ├── Program.cs # Avalonia 程序配置
│ │ ├── appsettings.json # 配置文件
│ │ ├── setup.iss # 安装脚本
│ │ ├── wwwroot/ # 前端静态资源
│ │ └── Hua.Todo.Avalonia.csproj # Avalonia 项目文件
│ │
│ ├── Hua.Todo.Host/ # 后端 API 项目
│ │ ├── Controllers/ # API 控制器
│ │ │ ├── TasksController.cs
│ │ │ ├── SettingsController.cs
@@ -89,6 +113,8 @@ Hua.Todo/
│ │ │ └── SyncService.cs
│ │ ├── Data/ # 数据访问层
│ │ │ ├── TodoDbContext.cs
│ │ │ ├── Converters/ # 类型转换器
│ │ │ │ └── LenientUtcDateTimeStringConverter.cs
│ │ │ ├── Repositories/
│ │ │ │ ├── ITaskRepository.cs
│ │ │ │ └── TaskRepository.cs
@@ -99,7 +125,14 @@ Hua.Todo/
│ │ │ └── ServiceCollectionExtensions.cs
│ │ ├── Program.cs # API 入口
│ │ ├── appsettings.json # 配置文件
│ │ └── Hua.Todo.Api.csproj # API 项目文件
│ │ └── Hua.Todo.Host.csproj # Host 项目文件
│ │
│ ├── Hua.Todo.Application/ # 应用层
│ │ ├── Data/ # 数据访问
│ │ │ ├── TodoDbContext.cs
│ │ │ └── Converters/ # 类型转换器
│ │ │ └── LenientUtcDateTimeStringConverter.cs
│ │ └── Hua.Todo.Application.csproj # Application 项目文件
│ │
│ ├── Hua.Todo.Core/ # 核心业务逻辑层
│ │ ├── Entities/ # 实体类
@@ -116,7 +149,7 @@ Hua.Todo/
│ │
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js)
│ │ ├── public/ # 静态资源
│ │ │ └── index.html
│ │ │ └── favicon.svg # 网站图标
│ │ ├── src/ # 源代码
│ │ │ ├── api/ # API 调用
│ │ │ │ ├── client.ts # HTTP 客户端配置
@@ -182,7 +215,23 @@ Hua.Todo/
- `WebViewContainer`: 封装 WebView 控件
- 平台特定服务: 快捷键、通知等
### 4.2 后端 API 项目 (Hua.Todo.Host)
### 4.2 Avalonia 项目 (Hua.Todo.Avalonia)
**职责**:
- Linux 平台支持
- 桌面交互功能(托盘菜单、全局热键等)
- WebView 容器管理
- 本地 HTTP 服务器启动
**关键组件**:
- `Program.cs`: 配置 Avalonia 应用和依赖注入
- `App.axaml.cs`: 应用程序主入口
- `MainWindow.axaml.cs`: 主窗口管理
- `MainView.axaml.cs`: 主视图管理
- `GlobalHotKeyServiceFactory`: 全局热键服务工厂
- `EmbeddedWebServerServiceFactory`: 内嵌 Web 服务器服务工厂
- 平台特定服务: Linux 全局热键等
### 4.3 后端 API 项目 (Hua.Todo.Host)
**职责**:
- 提供 RESTful API 接口
- 业务逻辑处理
@@ -198,7 +247,7 @@ Hua.Todo/
- `Data`: 数据访问层和数据库上下文
- `Program.cs`: API 服务器配置和启动
### 4.3 核心业务层 (Hua.Todo.Core)
### 4.4 核心业务层 (Hua.Todo.Core)
**职责**:
- 定义领域模型和业务规则
- 提供核心业务接口
@@ -210,7 +259,7 @@ Hua.Todo/
- `ValueObjects`: 值对象
- `Specifications`: 业务规范
### 4.4 前端 Web 项目 (Hua.Todo.Web)
### 4.5 前端 Web 项目 (Hua.Todo.Web)
**职责**:
- 用户界面展示
- 用户交互处理
+7 -3
View File
@@ -11,10 +11,13 @@
### v1.2.0(开发中,2026-04-07
- **Linux 官方支持**:新增 `Hua.Todo.Avalonia` 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS。
- **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。
- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口。
- **MAUIWindows)内嵌 API 文档**Debug 模式下,内嵌 WebServer 默认提供 Swagger UI`{HostUrl}/swagger`)与 OpenAPI JSON`{HostUrl}/swagger/v1/swagger.json`),便于本地接口调试。
- **Swagger 输出补齐 Dynamic API**:任务管理等 Dynamic API 端点会出现在 `swagger.json` 中,避免“接口缺失”导致联调困难。
- **SQLite DateTime 兼容修复**:新增 `LenientUtcDateTimeStringConverter`,本地数据库中若存在历史遗留的 DateTime “ticks/时间戳字符串”脏数据,读取时将被兼容解析,避免 `/api/task` 等查询因单条坏数据整体失败。
- **SPA 路由回落行为修复**:当 Release/非 Debug 未启用 Swagger 时,`/swagger` 不再被当作“后端专用路径”排除,访问会按 SPA 路由规则回落到 `/index.html`,避免直接 404。
- **MAUI 多平台构建开关**:在 Windows 开发机上默认仅构建 Android + Windows 目标,避免 iOS/MacCatalyst 目标在非 macOS 环境触发运行时包缺失(NETSDK1082);在 macOS 上仍会包含 iOS/MacCatalyst 目标。
- **发布脚本整理**:拆分/对齐各平台发布入口,新增 `publish.ps1` 作为统一入口(默认发布 Windows + Linux),Windows 发布脚本支持开关打包与版本自增,发布产物会落盘到 `artifacts/`
@@ -22,9 +25,10 @@
- **Windows WebView2 数据目录调整**MAUIUnpackaged)默认会在安装目录生成 `Hua.Todo.Maui.exe.WebView2`;现改为写入 `%LocalAppData%\Hua.Todo\WebView2`,避免污染安装目录。
- **Windows WebView2 Runtime 误判修复**:当系统已安装 WebView2 Runtime 但发布产物缺少/裁剪 WebView2 托管程序集时,旧检测逻辑会误判为“未安装”;现改为优先从常见安装目录探测 Evergreen 版本,避免阻断主界面加载。
- **Windows 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。
- **Avalonia 桌面交互对齐 MAUI**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值
### v1.1.1 (2026-04-06)
- **文档与部署指南**:新增 `docs/manual/部署文档.md`,详细说明开发环境搭建、多平台发布流程(Windows/Linux/Docker)以及关键配置项;并在技术设计文档中建立链接
- **用户文档完善**:新增 `docs/manual/新手指南.md``docs/manual/用户指南.md`,提供详细的使用说明和操作指南。
27→
28→### v1.1.1 (2026-04-06)
- **文档规范增强**:新增文档同步规则,强制代码变更与文档更新保持同步。
- **项目结构说明校准**:修正 README.md 和技术文档中对 `Hua.Todo.Host``Hua.Todo.Application` 等模块的路径与职责描述。
+128
View File
@@ -0,0 +1,128 @@
# Hua.Todo 构建与部署手册
本文档提供 Hua.Todo 项目的版本构建指南与多平台部署流程,侧重于如何产出可分发的版本并将其安装或部署到目标机器。
## 1. 🏗️ 版本构建流程 (Release Build)
在进行任何部署之前,必须先通过自动化脚本构建出 Release 版本的产物。
### 1.1 前提要求
- **环境检查**:已安装 .NET 10 SDK、Node.js 18+ 以及 Inno Setup 6(仅 Windows 打包需要)。
- **脚本入口**:位于根目录的 `publish.ps1` 系列脚本。
### 1.2 执行构建
根据目标平台执行对应的构建命令:
- **全平台一键构建**
```powershell
.\publish.ps1 -Windows -Linux
```
- **Windows 版本构建**
```powershell
.\publish-windows.ps1
```
- **Linux 版本构建**
```powershell
.\publish-linux.ps1 -RuntimeIdentifier linux-x64 -SelfContained
```
### 1.3 产物输出位置
所有构建产物将按平台归类在根目录的 `artifacts/` 文件夹下:
- **Windows**: `artifacts\windows\win-x64\installer\` (安装包) 与 `publish\` (解压即用版)
- **Linux**: `artifacts\linux\linux-x64\` (`.tar.gz` 压缩包)
***
## 2. 🪟 Windows 客户端部署
### 2.1 安装包分发 (Recommended)
1. **分发文件**:将 `hua.todo-{version}-win-x64-setup.exe` 提供给终端用户。
2. **安装流程**:用户运行安装程序,程序将自动:
- 安装到 `%ProgramFiles%\Hua.Todo`(或用户自定义路径)。
- 创建桌面与开始菜单快捷方式。
- 写入注册表以支持卸载。
3. **静默安装**:支持 Inno Setup 标准静默参数 `/VERYSILENT /SUPPRESSMSGBOXES`
### 2.2 绿色版部署
1. **分发文件**:将 `artifacts\windows\win-x64\publish\` 文件夹整体打包。
2. **运行要求**:目标机器需安装 **WebView2 Runtime**
3. **启动程序**:运行 `Hua.Todo.Maui.exe`
***
## 3. 🐧 Linux 客户端部署
目前提供基于 Avalonia 的 Linux 交付版本,采用 `.tar.gz` 压缩包形式。
### 3.1 解压部署
1. **解压**
```bash
tar -xzf hua.todo-{version}-linux-x64.tar.gz -C /opt/hua-todo
```
2. **运行环境**
- 确保系统安装了 `libwebkit2gtk-4.0-37`。
- 如果构建时未开启 `-SelfContained`,则需安装 .NET 10 Runtime。
3. **权限设置**
```bash
chmod +x /opt/hua-todo/Hua.Todo.Avalonia
```
4. **启动**:直接运行 `/opt/hua-todo/Hua.Todo.Avalonia`。
***
## 4. ☁️ 云同步服务端部署 (Server)
`Hua.Todo.Host` 可作为中心服务端部署,用于任务的云端同步。
### 4.1 Docker 部署 (Recommended)
1. **上传代码**或将 `src/Hua.Todo.Host/Dockerfile` 复制到服务器。
2. **构建与启动**
```bash
docker build -t hua-todo-server -f src/Hua.Todo.Host/Dockerfile .
docker run -d \
--name hua-todo-server \
-p 5173:5173 \
-v /data/hua-todo/db:/app/data \
-e ASPNETCORE_ENVIRONMENT=Production \
hua-todo-server
```
### 4.2 直接部署 (Binary)
1. **构建 Host**`dotnet publish src/Hua.Todo.Host -c Release -o ./dist`。
2. **同步产物**:将 `./dist` 内容同步到服务器。
3. **配置 Systemd 服务**(可选):
创建一个 `hua-todo.service` 文件,指向可执行程序并设置工作目录。
***
## 5. ⚙️ 关键配置校准
### 5.1 数据持久化
- **SQLite 路径**:客户端数据默认存储在用户目录下的 `Hua.Todo/todo.db`;服务端数据存储在容器挂载卷或指定 `appsettings.json` 的连接字符串中。
- **备份建议**:定期备份 `todo.db` 文件。
### 5.2 端口与访问
- **客户端本地端口**:默认 `5057`。如果被占用,可在 `appsettings.json` 中修改 `WebServer.HostUrl`。
- **服务端访问**:确保服务端防火墙已开放对应的 API 端口(默认 5173)。
***
## 6. 🛠️ 常见问题排查
- **客户端无法加载 UI**:检查目标机器是否安装了 WebView2 RuntimeWindows)或 WebKitGTKLinux)。
- **同步连接失败**
1. 确认服务端 API 是否可达(访问 `/swagger` 验证)。
2. 确认客户端配置的服务端地址格式(需包含 `http://` 或 `https://`)。
- **权限问题**:在 Linux 下运行前,务必执行 `chmod +x` 给主程序执行权限。
@@ -0,0 +1,36 @@
# 解决方案:统一版本与打包分发
## 现状分析
- **版本不一致**`Hua.Todo.Maui` 的版本号为 `1.2.3`,而 `Hua.Todo.Avalonia``Hua.Todo.Host` 默认使用 `1.0.0`
- **打包脚本缺失**:目前仅 `Hua.Todo.Maui` 拥有 `setup.iss` (Inno Setup) 打包脚本,版本号硬编码为 `1.2.2`
- **配置冗余**:版本信息分散在各个 `.csproj``.iss` 文件中,维护困难。
## 目标
- 统一所有项目的版本号为 `1.2.3`
- 采用 `Directory.Build.props` 集中管理全局属性。
-`Hua.Todo.Avalonia` 提供与 `Hua.Todo.Maui` 一致的 Windows 安装程序打包能力。
## 实施方案
### 1. 统一 .NET 项目版本号
- 在项目根目录创建 `Directory.Build.props` 文件。
- 定义全局版本号 `<Version>1.2.3</Version>`
- 定义全局公司、版权、产品名称等元数据。
- 移除各 `.csproj` 中冲突的版本定义。
### 2. 统一 Inno Setup 打包脚本
- **更新 `src/Hua.Todo.Maui/setup.iss`**
- 将版本号更新为 `1.2.3`
- 确保安装路径与应用名称一致。
- **为 `src/Hua.Todo.Avalonia` 创建 `setup.iss`**
- 参考 Maui 的脚本,调整 `Source` 路径为 Avalonia 的发布路径:`bin\Release\net10.0\win-x64\publish\*`
- 调整可执行文件名称为 `Hua.Todo.Avalonia.exe`
- 调整 AppId 以避免与 Maui 版本冲突。
### 3. 发布流程标准化
- 以后发布时,只需修改根目录下的 `Directory.Build.props` 即可同步所有项目的版本。
- 执行 `dotnet publish -c Release -r win-x64` 后,手动或通过脚本运行 `ISCC setup.iss` 生成安装包。
## 预期效果
- 所有程序集(DLL/EXE)的版本号均显示为 `1.2.3`
- 提供 `Hua.Todo_Avalonia_Setup_v1.2.3.exe``Hua.Todo_Maui_Setup_v1.2.3.exe` 两个安装包。
+108 -11
View File
@@ -14,14 +14,24 @@ param(
[ValidateSet("linux-x64", "linux-arm64")]
[string]$RuntimeIdentifier = "linux-x64",
[string]$TargetFramework = "net10.0",
[switch]$SelfContained,
[string]$Configuration = "Release"
[switch]$SkipProcessStop,
[switch]$SkipRestore,
[ValidateSet("Release", "Debug")]
[string]$Configuration = "Release",
[string]$BaseIntermediateOutputPath
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
function Get-FirstExistingFilePath {
param(
@@ -41,10 +51,23 @@ function Get-FirstExistingFilePath {
function Read-ProjectVersion {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectFile
[string]$ProjectFile,
[string]$DirectoryBuildProps
)
$currentVersion = "0.0.0"
# 1. Try Directory.Build.props first
if ($null -ne $DirectoryBuildProps -and (Test-Path $DirectoryBuildProps)) {
[xml]$props = Get-Content $DirectoryBuildProps -Raw
$versionNode = $props.SelectSingleNode("//Version")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
}
# 2. Try .csproj
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
@@ -60,6 +83,38 @@ function Read-ProjectVersion {
return $currentVersion
}
function Stop-ProjectProcesses {
Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow
dotnet build-server shutdown | Out-Null
Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow
# Aggressively look for anything related to the project or MSBuild/dotnet background tasks
$processesToKill = Get-Process | Where-Object {
$_.ProcessName -like "*Hua.Todo*" -or
$_.ProcessName -eq "MSBuild" -or
($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*"))
}
if ($processesToKill) {
Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow
foreach ($p in $processesToKill) {
try {
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
} catch {
Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))"
}
}
Start-Sleep -Seconds 2 # Give OS more time to release file handles
} else {
Write-Host "No conflicting processes found." -ForegroundColor Green
}
}
if (!$SkipProcessStop.IsPresent) {
Stop-ProjectProcesses
}
$candidateProjects = @(
(Join-Path $ScriptPath "src\Hua.Todo.Avalonia\Hua.Todo.Avalonia.csproj"),
(Join-Path $ScriptPath "src\Hua.Todo.Desktop.Avalonia\Hua.Todo.Desktop.Avalonia.csproj")
@@ -77,7 +132,8 @@ $candidatesText
exit 1
}
$version = Read-ProjectVersion -ProjectFile $ProjectFile
$version = Read-ProjectVersion -ProjectFile $ProjectFile -DirectoryBuildProps $DirectoryBuildProps
$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile)
$artifactRoot = Join-Path $ScriptPath "artifacts\linux\$RuntimeIdentifier"
$publishDir = Join-Path $artifactRoot "publish"
@@ -88,16 +144,57 @@ New-Item -ItemType Directory -Path $publishDir | Out-Null
$selfContainedValue = if ($SelfContained.IsPresent) { "true" } else { "false" }
Write-Host "Publishing (RID=$RuntimeIdentifier, SelfContained=$selfContainedValue)..." -ForegroundColor Cyan
if ($Configuration -ne "Release") {
Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow
Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow
Write-Host ""
}
dotnet publish $ProjectFile `
-c $Configuration `
-r $RuntimeIdentifier `
--self-contained $selfContainedValue `
-o $publishDir
if (!$SkipRestore.IsPresent) {
Write-Host "Restoring $ProjectBaseName for $RuntimeIdentifier ($TargetFramework)..." -ForegroundColor Yellow
$restoreArgs = @("restore", $ProjectFile, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
# Ensure trailing slash and avoid backslash escaping the quote in CLI
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$restoreArgs += "-p:BaseIntermediateOutputPath=$path"
}
& dotnet @restoreArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "dotnet publish failed"
Write-Host "Cleaning $ProjectBaseName ($TargetFramework)..." -ForegroundColor Yellow
$cleanArgs = @("clean", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$cleanArgs += "-p:BaseIntermediateOutputPath=$path"
}
& dotnet @cleanArgs
}
$maxAttempts = 3
$publishSuccess = $false
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
Write-Host "Publishing (RID=$RuntimeIdentifier, TFM=$TargetFramework, SelfContained=$selfContainedValue, Config=$Configuration, Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
$publishArgs = @("publish", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "--framework", $TargetFramework, "--self-contained", $selfContainedValue, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "-o", $publishDir)
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$publishArgs += "-p:BaseIntermediateOutputPath=$path"
}
$publishOutput = (& dotnet @publishArgs 2>&1 | Out-String)
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
$publishSuccess = $true
break
}
Write-Host $publishOutput
if ($attempt -lt $maxAttempts -and ($publishOutput -match 'Access to the path .* is denied|being used by another process|Sharing violation')) {
Write-Host "⚠️ Build failed due to file lock. Retrying in 3 seconds..." -ForegroundColor Yellow
Start-Sleep -Seconds 3
Stop-ProjectProcesses # Try killing processes again before retry
continue
}
Write-Error "dotnet publish failed after $attempt attempts."
exit 1
}
+154 -29
View File
@@ -1,32 +1,64 @@
param(
[ValidateSet("Maui", "Avalonia")]
[string]$AppType = "Maui",
[string]$TargetFramework = "net10.0-windows10.0.19041.0",
[string]$RuntimeIdentifier = "win-x64",
[ValidateSet("Release", "Debug")]
[string]$Configuration = "Release",
[switch]$SkipInnoSetup,
[switch]$SkipVersionBump,
[string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier))
[switch]$SkipProcessStop,
[switch]$SkipRestore,
[string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier)),
[string]$BaseIntermediateOutputPath
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj"
if ($AppType -eq "Avalonia") {
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Avalonia"
$TargetFramework = "net10.0"
} else {
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
}
$ProjectFile = Get-ChildItem -Path $ProjectDir -Filter "*.csproj" | Select-Object -First 1 -ExpandProperty FullName
$SetupScript = Join-Path $ProjectDir "setup.iss"
$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile)
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
function Read-ProjectVersion {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectFile
[string]$ProjectFile,
[string]$DirectoryBuildProps
)
$currentVersion = "0.0.0"
# 1. Try Directory.Build.props first
if ($null -ne $DirectoryBuildProps -and (Test-Path $DirectoryBuildProps)) {
[xml]$props = Get-Content $DirectoryBuildProps -Raw
$versionNode = $props.SelectSingleNode("//Version")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
}
# 2. Try .csproj
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
@@ -153,14 +185,93 @@ function Copy-Directory {
Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force
}
$currentVersion = Read-ProjectVersion -ProjectFile $ProjectFile
function Stop-ProjectProcesses {
Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow
dotnet build-server shutdown | Out-Null
Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow
# Aggressively look for anything related to the project or MSBuild/dotnet background tasks
$processesToKill = Get-Process | Where-Object {
$_.ProcessName -like "*Hua.Todo*" -or
$_.ProcessName -eq "MSBuild" -or
($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*"))
}
if ($processesToKill) {
Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow
foreach ($p in $processesToKill) {
try {
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
} catch {
Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))"
}
}
Start-Sleep -Seconds 2 # Give OS more time to release file handles
} else {
Write-Host "No conflicting processes found." -ForegroundColor Green
}
}
if (!$SkipProcessStop.IsPresent) {
Stop-ProjectProcesses
}
$currentVersion = Read-ProjectVersion -ProjectFile $ProjectFile -DirectoryBuildProps $DirectoryBuildProps
Update-InnoSetupVersion -SetupScript $SetupScript -Version $currentVersion
Write-Host "Publishing Hua.Todo.Maui (TFM=$TargetFramework, RID=$RuntimeIdentifier, Config=$Configuration)..." -ForegroundColor Cyan
if ($Configuration -ne "Release") {
Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow
Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow
Write-Host ""
}
if (!$SkipRestore.IsPresent) {
Write-Host "Restoring $ProjectBaseName for $RuntimeIdentifier ($TargetFramework)..." -ForegroundColor Yellow
$restoreArgs = @("restore", $ProjectFile, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
# Ensure trailing slash and avoid backslash escaping the quote in CLI
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$restoreArgs += "-p:BaseIntermediateOutputPath=$path"
}
& dotnet @restoreArgs
Write-Host "Cleaning $ProjectBaseName ($TargetFramework)..." -ForegroundColor Yellow
$cleanArgs = @("clean", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$cleanArgs += "-p:BaseIntermediateOutputPath=$path"
}
& dotnet @cleanArgs
}
# UseMonoRuntime 在 csproj 内按 TargetFramework 做了条件配置:仅 Android 启用,其它目标关闭。
dotnet publish $ProjectFile -f $TargetFramework -c $Configuration -r $RuntimeIdentifier --self-contained false
if ($LASTEXITCODE -ne 0) {
Write-Error "MAUI build failed"
$maxAttempts = 3
$publishSuccess = $false
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
Write-Host "Publishing $ProjectBaseName (Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
$publishArgs = @("publish", $ProjectFile, "--framework", $TargetFramework, "-c", $Configuration, "-r", $RuntimeIdentifier, "--self-contained", "false", "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$publishArgs += "-p:BaseIntermediateOutputPath=$path"
}
$publishOutput = (& dotnet @publishArgs 2>&1 | Out-String)
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
$publishSuccess = $true
break
}
Write-Host $publishOutput
if ($attempt -lt $maxAttempts -and ($publishOutput -match 'Access to the path .* is denied|being used by another process|Sharing violation')) {
Write-Host "⚠️ Build failed due to file lock. Retrying in 3 seconds..." -ForegroundColor Yellow
Start-Sleep -Seconds 3
Stop-ProjectProcesses # Try killing processes again before retry
continue
}
Write-Error "MAUI build failed after $attempt attempts."
exit 1
}
@@ -192,6 +303,7 @@ if (!(Test-Path $desiredExePath)) {
$candidateExe = $null
$preferredCandidateNames = @(
"Hua.Todo.Maui.exe",
"Hua.Todo.Avalonia.exe",
"Hua.Todo.exe"
)
foreach ($name in $preferredCandidateNames) {
@@ -217,19 +329,21 @@ if (!(Test-Path $desiredExePath)) {
exit 1
}
$mauiWwwrootDir = Join-Path $ProjectDir "wwwroot"
$mauiIndexPath = Join-Path $mauiWwwrootDir "index.html"
if (!(Test-Path $mauiIndexPath)) {
Write-Error "MAUI wwwroot not found: $mauiIndexPath"
exit 1
}
if ($AppType -eq "Maui") {
$mauiWwwrootDir = Join-Path $ProjectDir "wwwroot"
$mauiIndexPath = Join-Path $mauiWwwrootDir "index.html"
if (!(Test-Path $mauiIndexPath)) {
Write-Error "MAUI wwwroot not found: $mauiIndexPath"
exit 1
}
$publishWwwroot = Join-Path $publishDir "wwwroot"
if (Test-Path $publishWwwroot) {
Remove-Item -Recurse -Force $publishWwwroot
$publishWwwroot = Join-Path $publishDir "wwwroot"
if (Test-Path $publishWwwroot) {
Remove-Item -Recurse -Force $publishWwwroot
}
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
}
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
$installerPath = $null
if (!$SkipInnoSetup.IsPresent) {
@@ -255,6 +369,7 @@ if (!$SkipInnoSetup.IsPresent) {
# ISCC sometimes fails with a transient file sharing violation (e.g. antivirus/indexer holding the script/output briefly).
$maxAttempts = 3
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
Write-Host "Compiling installer (Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
$isccOutput = (& $ISCC $SetupScript 2>&1 | Out-String)
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
@@ -286,8 +401,7 @@ if (!$SkipInnoSetup.IsPresent) {
Write-Host ("Output: {0}" -f $installerPath) -ForegroundColor Green
}
} else {
Write-Error "Inno Setup compiler not found"
exit 1
Write-Warning "Inno Setup compiler not found. Skipping installer creation."
}
}
@@ -302,7 +416,8 @@ if (![string]::IsNullOrWhiteSpace($ArtifactsRoot)) {
}
New-Item -ItemType Directory -Path $installerArtifactDir | Out-Null
$installerName = "hua.todo-$currentVersion-$RuntimeIdentifier-setup.exe"
$installerPrefix = if ($AppType -eq "Avalonia") { "hua.todo-avalonia" } else { "hua.todo-maui" }
$installerName = "$installerPrefix-$currentVersion-$RuntimeIdentifier-setup.exe"
Copy-Item -Path $installerPath -Destination (Join-Path $installerArtifactDir $installerName) -Force
}
}
@@ -312,12 +427,22 @@ if (!$SkipVersionBump.IsPresent) {
if ($versionMatch.Success) {
$newVersion = "{0}.{1}.{2}" -f $versionMatch.Groups["major"].Value, $versionMatch.Groups["minor"].Value, ([int]$versionMatch.Groups["patch"].Value + 1)
$content = Get-Content $ProjectFile -Raw
if ($content -match "<Version>[^<]*</Version>") {
$content = $content -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $content -Encoding UTF8
} else {
Write-Host "Skip version bump: <Version> node not found in csproj." -ForegroundColor Yellow
# 1. Update Directory.Build.props if exists
if (Test-Path $DirectoryBuildProps) {
$propsContent = Get-Content $DirectoryBuildProps -Raw
if ($propsContent -match "<Version>[^<]*</Version>") {
$propsContent = $propsContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $DirectoryBuildProps -Value $propsContent -Encoding UTF8
Write-Host "Updated version in Directory.Build.props to $newVersion" -ForegroundColor Green
}
}
# 2. Update .csproj if it still has Version
$csprojContent = Get-Content $ProjectFile -Raw
if ($csprojContent -match "<Version>[^<]*</Version>") {
$csprojContent = $csprojContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $csprojContent -Encoding UTF8
Write-Host "Updated version in $ProjectBaseName.csproj to $newVersion" -ForegroundColor Green
}
} else {
Write-Host "Skip version bump: version is not MAJOR.MINOR.PATCH -> $currentVersion" -ForegroundColor Yellow
+111 -27
View File
@@ -7,6 +7,7 @@ param(
[switch]$LinuxSelfContained,
[ValidateSet("Release", "Debug")]
[string]$Configuration = "Release",
[switch]$SkipWindowsInnoSetup,
@@ -17,9 +18,92 @@ param(
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$WebDir = Join-Path $ScriptPath "src\Hua.Todo.Web"
$publishWindowsScript = Join-Path $ScriptPath "publish-windows.ps1"
$publishLinuxScript = Join-Path $ScriptPath "publish-linux.ps1"
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
function Stop-ProjectProcesses {
Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow
dotnet build-server shutdown | Out-Null
Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow
$processesToKill = Get-Process | Where-Object {
$_.ProcessName -like "*Hua.Todo*" -or
$_.ProcessName -eq "MSBuild" -or
($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*"))
}
if ($processesToKill) {
Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow
foreach ($p in $processesToKill) {
try {
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
} catch {
Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))"
}
}
Start-Sleep -Seconds 2
} else {
Write-Host "No conflicting processes found." -ForegroundColor Green
}
}
function Build-Web {
Write-Host "Building Web artifacts..." -ForegroundColor Cyan
if (!(Test-Path $WebDir)) {
Write-Error "Web directory not found: $WebDir"
exit 1
}
if (!(Test-Path (Join-Path $WebDir "node_modules"))) {
Write-Host "Installing npm dependencies..." -ForegroundColor Yellow
Push-Location $WebDir
npm install
Pop-Location
}
Write-Host "Running Web builds in parallel..." -ForegroundColor Yellow
$webJobs = @()
$webJobs += Start-Job -ScriptBlock {
param($dir)
Set-Location $dir
npm run build
} -ArgumentList $WebDir
$webJobs += Start-Job -ScriptBlock {
param($dir)
Set-Location $dir
npm run build -- --mode maui
} -ArgumentList $WebDir
$webJobs | Wait-Job | Receive-Job
}
function Bump-Version {
if (Test-Path $DirectoryBuildProps) {
[xml]$props = Get-Content $DirectoryBuildProps -Raw
$versionNode = $props.SelectSingleNode("//Version")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
$currentVersion = $versionNode.InnerText.Trim()
$versionMatch = [regex]::Match($currentVersion, '^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)$')
if ($versionMatch.Success) {
$newVersion = "{0}.{1}.{2}" -f $versionMatch.Groups["major"].Value, $versionMatch.Groups["minor"].Value, ([int]$versionMatch.Groups["patch"].Value + 1)
$propsContent = Get-Content $DirectoryBuildProps -Raw
$propsContent = $propsContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $DirectoryBuildProps -Value $propsContent -Encoding UTF8
Write-Host "Bumped version in Directory.Build.props: $currentVersion -> $newVersion" -ForegroundColor Green
}
}
}
}
if ($Configuration -ne "Release") {
Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow
Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow
Write-Host ""
}
$shouldPublishWindows = $Windows.IsPresent
$shouldPublishLinux = $Linux.IsPresent
@@ -28,36 +112,36 @@ if (!$shouldPublishWindows -and !$shouldPublishLinux) {
$shouldPublishLinux = $true
}
# 1. Cleanup
Stop-ProjectProcesses
# 2. Build Web
Build-Web
# 3. App Publishing
# NOTE: To avoid file locks in 'obj' and 'bin' folders, we publish the .NET apps sequentially.
# However, we can parallelize the Inno Setup packaging later if needed.
Write-Host "Starting app publishing..." -ForegroundColor Cyan
if ($shouldPublishWindows) {
if (!(Test-Path $publishWindowsScript)) {
Write-Error "publish-windows.ps1 not found: $publishWindowsScript"
exit 1
}
& $publishWindowsScript `
-Configuration $Configuration `
-SkipInnoSetup:$SkipWindowsInnoSetup `
-SkipVersionBump:$SkipWindowsVersionBump
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host "Publishing Windows Apps..." -ForegroundColor Cyan
# Maui Windows (Skip ISS here, we'll do it later if parallel is needed, or just let it run)
& $publishWindowsScript -AppType Maui -Configuration $Configuration -SkipInnoSetup:$SkipWindowsInnoSetup -SkipVersionBump -SkipProcessStop
# Avalonia Windows
& $publishWindowsScript -AppType Avalonia -Configuration $Configuration -SkipInnoSetup:$SkipWindowsInnoSetup -SkipVersionBump -SkipProcessStop
}
if ($shouldPublishLinux) {
if (!(Test-Path $publishLinuxScript)) {
Write-Error "publish-linux.ps1 not found: $publishLinuxScript"
exit 1
}
Write-Host "Publishing Linux Apps..." -ForegroundColor Cyan
foreach ($rid in $LinuxRuntimes) {
& $publishLinuxScript `
-RuntimeIdentifier $rid `
-SelfContained:$LinuxSelfContained `
-Configuration $Configuration
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
& $publishLinuxScript -RuntimeIdentifier $rid -SelfContained:$LinuxSelfContained -Configuration $Configuration -SkipProcessStop
}
}
# 4. Finalizing
if (!$SkipWindowsVersionBump.IsPresent) {
Bump-Version
}
Write-Host "All publishing tasks completed!" -ForegroundColor Green
@@ -0,0 +1,70 @@
using System.Globalization;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Hua.Todo.Application.Data.Converters;
/// <summary>
/// 将 <see cref="DateTime"/> 以 UTC 的 ISO 8601Round-trip)字符串形式持久化到 SQLite,
/// 并在读取时兼容历史遗留的 “ticks/Unix 时间戳” 数字字符串,避免因脏数据导致查询失败。
/// </summary>
public sealed class LenientUtcDateTimeStringConverter : ValueConverter<DateTime, string>
{
/// <summary>
/// 创建 UTC DateTime 与 SQLite TEXT 之间的转换器。
/// </summary>
public LenientUtcDateTimeStringConverter()
: base(
model => ConvertModelToProvider(model),
provider => ConvertProviderToModel(provider))
{
}
private static string ConvertModelToProvider(DateTime value)
{
var utc = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
return utc.ToString("O", CultureInfo.InvariantCulture);
}
private static DateTime ConvertProviderToModel(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
{
if (numeric >= DateTime.MinValue.Ticks && numeric <= DateTime.MaxValue.Ticks && numeric >= 10_000_000_000_000)
{
return new DateTime(numeric, DateTimeKind.Utc);
}
if (numeric >= 0 && numeric <= 253_402_300_799_999)
{
return DateTimeOffset.FromUnixTimeMilliseconds(numeric).UtcDateTime;
}
if (numeric >= 0 && numeric <= 253_402_300_799)
{
return DateTimeOffset.FromUnixTimeSeconds(numeric).UtcDateTime;
}
}
if (DateTime.TryParse(
value,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
}
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Core.Entities;
using Hua.Todo.Application.Data.Converters;
namespace Hua.Todo.Application.Data;
@@ -44,6 +45,8 @@ public class TodoDbContext : DbContext
{
base.OnModelCreating(modelBuilder);
var utcDateTimeConverter = new LenientUtcDateTimeStringConverter();
modelBuilder.Entity<TaskEntity>(entity =>
{
entity.ToTable("Tasks");
@@ -52,8 +55,8 @@ public class TodoDbContext : DbContext
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium);
entity.Property(e => e.IsCompleted).HasDefaultValue(false);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.CreatedAt).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.UpdatedAt).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
entity.HasOne(e => e.User)
.WithMany(u => u.Tasks)
@@ -80,8 +83,9 @@ public class TodoDbContext : DbContext
{
entity.ToTable("UserSessions");
entity.HasKey(e => e.Id);
entity.Property(e => e.CreatedAtUtc).IsRequired();
entity.Property(e => e.ExpiresAtUtc).IsRequired();
entity.Property(e => e.CreatedAtUtc).IsRequired().HasConversion(utcDateTimeConverter);
entity.Property(e => e.ExpiresAtUtc).IsRequired().HasConversion(utcDateTimeConverter);
entity.Property(e => e.StepUpExpiresAtUtc).HasConversion(utcDateTimeConverter);
entity.HasIndex(e => e.UserId);
entity.HasOne(e => e.User)
.WithMany()
@@ -1,7 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<!-- 当指定桌面平台 RID 或显式标记为桌面构建时,仅保留 net10.0 目标框架,避免还原阶段为移动端 TFM 解析 Mono runtime pack。 -->
<IsDesktopRID Condition="'$(IsDesktopBuild)' == 'true' or ('$(RuntimeIdentifier)' != '' and ($([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('linux-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('osx-'))))">true</IsDesktopRID>
<TargetFrameworks Condition="'$(IsDesktopRID)' == 'true'">net10.0</TargetFrameworks>
<TargetFrameworks Condition="'$(IsDesktopRID)' != 'true'">net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
-18
View File
@@ -2,27 +2,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Hua.Todo.Avalonia.App"
xmlns:local="using:Hua.Todo.Avalonia"
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
x:DataType="vm:AppTrayViewModel"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="/Assets/avalonia-logo.ico"
ToolTipText="{Binding TrayTooltipText}"
Command="{Binding ShowMainWindowCommand}">
<TrayIcon.Menu>
<NativeMenu>
<NativeMenuItem Header="显示主窗口" Command="{Binding ShowMainWindowCommand}" />
<NativeMenuItemSeparator />
<NativeMenuItem Header="退出" Command="{Binding ExitApplicationCommand}" />
</NativeMenu>
</TrayIcon.Menu>
</TrayIcon>
</TrayIcons>
</TrayIcon.Icons>
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
+53 -15
View File
@@ -1,8 +1,8 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
using Avalonia.Platform;
using Avalonia.Threading;
using AvaloniaWebView;
using Hua.Todo.Avalonia.Models;
@@ -13,7 +13,6 @@ using Hua.Todo.Avalonia.Views;
using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
@@ -98,9 +97,7 @@ public partial class App : global::Avalonia.Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
DisableAvaloniaDataAnnotationValidation();
_webServer = new EmbeddedWebServerService(_appSettings!);
_webServer = EmbeddedWebServerServiceFactory.Create(_appSettings!);
_ = Task.Run(async () =>
{
try
@@ -132,10 +129,16 @@ public partial class App : global::Avalonia.Application
};
}
DataContext = new AppTrayViewModel(
AppMetadata.GetTrayTooltipText(),
ShowMainWindow,
() => ExitApplication(desktop));
// 只在支持的平台上初始化系统托盘
if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
{
var trayViewModel = new AppTrayViewModel(
AppMetadata.GetTrayTooltipText(),
ShowMainWindow,
() => ExitApplication(desktop));
InitializeTrayIcon(trayViewModel);
}
_mainWindow = new MainWindow(_appSettings!, _webServer)
{
@@ -185,6 +188,12 @@ public partial class App : global::Avalonia.Application
}
};
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{
_webServer = EmbeddedWebServerServiceFactory.Create(_appSettings!);
singleView.MainView = new MainView(_appSettings!, _webServer);
}
base.OnFrameworkInitializationCompleted();
}
@@ -257,14 +266,43 @@ public partial class App : global::Avalonia.Application
}
}
private void DisableAvaloniaDataAnnotationValidation()
private void InitializeTrayIcon(AppTrayViewModel trayViewModel)
{
var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
foreach (var plugin in dataValidationPluginsToRemove)
try
{
BindingPlugins.DataValidators.Remove(plugin);
var trayIcons = new TrayIcons();
var iconUri = new Uri("avares://Hua.Todo.Avalonia/Assets/avalonia-logo.ico");
var icon = new WindowIcon(AssetLoader.Open(iconUri));
var trayIcon = new TrayIcon
{
Icon = icon,
ToolTipText = trayViewModel.TrayTooltipText,
Command = trayViewModel.ShowMainWindowCommand
};
var menu = new NativeMenu();
menu.Items.Add(new NativeMenuItem
{
Header = "显示主窗口",
Command = trayViewModel.ShowMainWindowCommand
});
menu.Items.Add(new NativeMenuItemSeparator());
menu.Items.Add(new NativeMenuItem
{
Header = "退出",
Command = trayViewModel.ExitApplicationCommand
});
trayIcon.Menu = menu;
trayIcons.Add(trayIcon);
TrayIcon.SetIcons(this, trayIcons);
}
catch (Exception ex)
{
Console.WriteLine($"[App] Tray icon initialization failed: {ex}");
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+13 -6
View File
@@ -1,7 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks>net10.0</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">net10.0-windows10.0.19041.0</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('linux'))">net10.0</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
@@ -17,14 +20,14 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.5" />
<PackageReference Include="Avalonia" Version="11.3.13" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.13" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.13" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.13" />
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5">
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.13">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
@@ -35,6 +38,10 @@
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\icon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>
+10 -2
View File
@@ -1,5 +1,7 @@
using Avalonia;
using Avalonia.WebView.Desktop;
using Hua.Todo.Avalonia.Services;
using Hua.Todo.Avalonia.Models;
using System;
namespace Hua.Todo.Avalonia;
@@ -10,8 +12,14 @@ sealed class Program
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
public static void Main(string[] args)
{
// 设置嵌入式 Web 服务器工厂
EmbeddedWebServerServiceFactory.SetFactory(appSettings => new EmbeddedWebServerService(appSettings));
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
@@ -0,0 +1,42 @@
using System;
using Hua.Todo.Avalonia.Models;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 嵌入式 WebServer 的平台工厂。
/// 由平台入口项目在启动时注入实际实现(例如 Desktop 使用 KestrelAndroid 可使用 Noop 或移动端实现),
/// 以避免共享项目直接依赖某个平台不可用的运行时组件。
/// </summary>
public static class EmbeddedWebServerServiceFactory
{
private static Func<AppSettings, IEmbeddedWebServerService>? _factory;
/// <summary>
/// 设置 WebServer 创建工厂。
/// 平台入口应尽早调用(在 <see cref="global::Avalonia.Application"/> 生命周期开始前完成),以确保应用启动过程可用。
/// </summary>
/// <param name="factory">根据应用配置创建 WebServer 的工厂方法。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="factory"/> 为 null。</exception>
public static void SetFactory(Func<AppSettings, IEmbeddedWebServerService> factory)
{
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
}
/// <summary>
/// 创建 WebServer 实例。
/// 若平台未注入工厂,则回退为 <see cref="NoopEmbeddedWebServerService"/>(不启动本地服务,仅用于维持调用链)。
/// </summary>
/// <param name="appSettings">应用配置。</param>
/// <returns>嵌入式 WebServer 实例。</returns>
public static IEmbeddedWebServerService Create(AppSettings appSettings)
{
if (_factory != null)
{
return _factory(appSettings);
}
return new NoopEmbeddedWebServerService(appSettings.WebServer.HostUrl);
}
}
@@ -18,6 +18,14 @@ public static class GlobalHotKeyServiceFactory
{
return new WindowsGlobalHotKeyService();
}
else if (OperatingSystem.IsLinux())
{
return new LinuxGlobalHotKeyService();
}
else if (OperatingSystem.IsAndroid())
{
return new AndroidGlobalHotKeyService();
}
return new NoopGlobalHotKeyService();
}
@@ -0,0 +1,34 @@
using System.Threading.Tasks;
namespace Hua.Todo.Avalonia.Services;
/// <summary>
/// 不启动任何本地 WebServer 的占位实现。
/// 适用于移动端/受限平台:仍可复用 UI 与前端契约注入逻辑,但 WebView 需要指向外部可访问的前端地址。
/// </summary>
public sealed class NoopEmbeddedWebServerService : IEmbeddedWebServerService
{
/// <summary>
/// 创建 <see cref="NoopEmbeddedWebServerService"/>。
/// </summary>
/// <param name="baseUrl">
/// 逻辑上的基础 URL。该实现不会实际监听端口,但会把该值暴露给调用方用于日志/显示或兼容逻辑。
/// </param>
public NoopEmbeddedWebServerService(string baseUrl)
{
BaseUrl = baseUrl;
}
/// <inheritdoc />
public bool IsRunning => false;
/// <inheritdoc />
public string BaseUrl { get; }
/// <inheritdoc />
public Task StartAsync() => Task.CompletedTask;
/// <inheritdoc />
public Task StopAsync() => Task.CompletedTask;
}
@@ -0,0 +1,50 @@
using System;
namespace Hua.Todo.Avalonia.Services.Platforms;
/// <summary>
/// Android 平台全局热键服务实现类
/// 由于 Android 限制全局热键,使用通知快捷方式作为替代方案
/// </summary>
public class AndroidGlobalHotKeyService : IGlobalHotKeyService
{
private Action? _callback;
/// <summary>
/// Android 平台不支持全局热键
/// </summary>
public bool IsSupported => false;
/// <summary>
/// 注册通知快捷方式作为热键替代方案
/// </summary>
/// <param name="modifiers">修饰键(如 Alt、Control;多个键用逗号分隔)。</param>
/// <param name="key">主键(如 X、C)。</param>
/// <param name="callback">热键触发时回调(不得阻塞,应自行切回 UI 线程)。</param>
public void RegisterHotKey(string modifiers, string key, Action callback)
{
_callback = callback;
Console.WriteLine($"[AndroidGlobalHotKeyService] Registering notification shortcut as hotkey alternative");
}
/// <summary>
/// 注销快捷方式(空实现)
/// </summary>
public void UnregisterHotKey()
{
Console.WriteLine("[AndroidGlobalHotKeyService] Unregistering hotkey");
}
/// <summary>
/// 更新快捷方式配置
/// </summary>
/// <param name="modifiers">新的修饰键。</param>
/// <param name="key">新的主键。</param>
public void UpdateHotKey(string modifiers, string key)
{
if (_callback != null)
{
RegisterHotKey(modifiers, key, _callback);
}
}
}
@@ -0,0 +1,81 @@
using System;
namespace Hua.Todo.Avalonia.Services.Platforms;
/// <summary>
/// Linux 平台全局热键服务实现类
/// 提供基于 Linux 平台的全局热键支持
/// </summary>
public class LinuxGlobalHotKeyService : IGlobalHotKeyService
{
private Action? _callback;
private string? _modifiers;
private string? _key;
/// <summary>
/// Linux 平台支持全局热键
/// </summary>
public bool IsSupported => true;
/// <summary>
/// 注册全局热键
/// </summary>
/// <param name="modifiers">修饰键(如 Alt、Control;多个键用逗号分隔)。</param>
/// <param name="key">主键(如 X、C)。</param>
/// <param name="callback">热键触发时回调(不得阻塞,应自行切回 UI 线程)。</param>
public void RegisterHotKey(string modifiers, string key, Action callback)
{
try
{
_callback = callback;
_modifiers = modifiers;
_key = key;
// Linux 平台热键注册逻辑
// 这里可以使用如 libX11 或其他 Linux 热键库
// 由于 Avalonia 本身对 Linux 热键支持有限,这里提供基本实现
Console.WriteLine($"[LinuxGlobalHotKeyService] Registered hotkey: {modifiers}+{key}");
}
catch (Exception ex)
{
Console.WriteLine($"[LinuxGlobalHotKeyService] Failed to register hotkey: {ex.Message}");
}
}
/// <summary>
/// 注销已注册的热键
/// </summary>
public void UnregisterHotKey()
{
try
{
// Linux 平台热键注销逻辑
Console.WriteLine("[LinuxGlobalHotKeyService] Unregistered hotkey");
}
catch (Exception ex)
{
Console.WriteLine($"[LinuxGlobalHotKeyService] Failed to unregister hotkey: {ex.Message}");
}
}
/// <summary>
/// 更新热键配置
/// </summary>
/// <param name="modifiers">新的修饰键。</param>
/// <param name="key">新的主键。</param>
public void UpdateHotKey(string modifiers, string key)
{
try
{
UnregisterHotKey();
if (_callback != null)
{
RegisterHotKey(modifiers, key, _callback);
}
}
catch (Exception ex)
{
Console.WriteLine($"[LinuxGlobalHotKeyService] Failed to update hotkey: {ex.Message}");
}
}
}
@@ -0,0 +1,17 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="640"
x:Class="Hua.Todo.Avalonia.Views.MainView"
x:DataType="vm:MainWindowViewModel">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<WebView Name="MainWebView" />
</UserControl>
@@ -0,0 +1,113 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Hua.Todo.Avalonia.Models;
using Hua.Todo.Avalonia.Services;
using System;
namespace Hua.Todo.Avalonia.Views;
/// <summary>
/// 应用主视图。
/// 负责承载 WebView(前端 UI)并在导航完成后注入前端契约字段。
/// </summary>
public partial class MainView : UserControl
{
private readonly AppSettings _appSettings;
private readonly IEmbeddedWebServerService _webServer;
/// <summary>
/// 创建用于设计器预览的主视图实例。
/// </summary>
public MainView()
{
InitializeComponent();
_appSettings = new AppSettings();
_webServer = new NoopEmbeddedWebServerService(_appSettings.WebServer.HostUrl);
}
/// <summary>
/// 创建运行时主视图实例。
/// </summary>
/// <param name="appSettings">应用配置。</param>
/// <param name="webServer">嵌入式 WebServer。</param>
public MainView(AppSettings appSettings, IEmbeddedWebServerService webServer)
{
InitializeComponent();
_appSettings = appSettings;
_webServer = webServer;
SetupWebView();
}
private void SetupWebView()
{
try
{
var webUrl = ResolveWebUrl();
MainWebView.Url = webUrl;
MainWebView.NavigationCompleted += async (_, _) =>
{
try
{
if (_webServer is not NoopEmbeddedWebServerService)
{
var apiBase = $"{_appSettings.WebServer.HostUrl.TrimEnd('/')}/api";
await MainWebView.ExecuteScriptAsync($"window.__API_BASE_URL__ = '{apiBase}';");
}
await MainWebView.ExecuteScriptAsync(@"
window.mauiInterop = {
onHotKeyConfigUpdated: null,
openHotKeySettings: function(config) {
const event = new CustomEvent('openHotKeySettings', { detail: config });
window.dispatchEvent(event);
},
updateHotKeyConfig: function(modifiers, key, isEnabled) {
const event = new CustomEvent('updateHotKeyConfig', {
detail: { modifiers, key, isEnabled }
});
window.dispatchEvent(event);
}
};
window.addEventListener('hotKeyConfigChanged', function(e) {
if (window.mauiInterop.onHotKeyConfigUpdated) {
window.mauiInterop.onHotKeyConfigUpdated(e.detail);
}
});
");
}
catch (Exception ex)
{
Console.WriteLine($"[MainView] WebView script injection failed: {ex.Message}");
}
};
}
catch (Exception ex)
{
Console.WriteLine($"[MainView] WebView initialization failed: {ex}");
Content = new TextBlock
{
Text = "WebView 初始化失败。请检查运行环境依赖(Windows 需要 WebView2 RuntimeLinux 需要 WebKitGTK)。",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap
};
}
}
private Uri ResolveWebUrl()
{
if (OperatingSystem.IsAndroid())
{
return new Uri(_appSettings.WebServer.ForEndUrl);
}
return _appSettings.WebServer.IsUsingStatic
? new Uri(_appSettings.WebServer.HostUrl)
: new Uri(_appSettings.WebServer.ForEndUrl);
}
}
+7 -3
View File
@@ -6,7 +6,7 @@
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="640"
x:Class="Hua.Todo.Avalonia.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Icon="/Assets/icon.ico"
Width="450"
Height="640"
Title="待办事项"
@@ -15,7 +15,11 @@
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<WebView Name="MainWebView" />
<Grid>
<TextBlock Text="Loading..."
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Window>
@@ -1,15 +1,12 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Hua.Todo.Avalonia.Models;
using Hua.Todo.Avalonia.Services;
using System;
namespace Hua.Todo.Avalonia.Views;
/// <summary>
/// 应用主窗口。
/// 负责承载 WebView(前端 UI并在导航完成后注入前端契约字段
/// 负责承载 <see cref="MainView"/>(前端 UI)。
/// </summary>
public partial class MainWindow : Window
{
@@ -23,7 +20,8 @@ public partial class MainWindow : Window
{
InitializeComponent();
_appSettings = new AppSettings();
_webServer = new EmbeddedWebServerService(_appSettings);
_webServer = new NoopEmbeddedWebServerService(_appSettings.WebServer.HostUrl);
Content = new MainView(_appSettings, _webServer);
}
/// <summary>
@@ -36,67 +34,6 @@ public partial class MainWindow : Window
InitializeComponent();
_appSettings = appSettings;
_webServer = webServer;
SetupWebView();
}
private void SetupWebView()
{
try
{
MainWebView.Url =
_appSettings.WebServer.IsUsingStatic
? new Uri(_appSettings.WebServer.HostUrl)
: new Uri(_appSettings.WebServer.ForEndUrl);
MainWebView.NavigationCompleted += async (s, e) =>
{
try
{
if (_webServer.IsRunning)
{
var apiBase = $"{_webServer.BaseUrl.TrimEnd('/')}/api";
await MainWebView.ExecuteScriptAsync($"window.__API_BASE_URL__ = '{apiBase}';");
}
await MainWebView.ExecuteScriptAsync(@"
window.mauiInterop = {
onHotKeyConfigUpdated: null,
openHotKeySettings: function(config) {
const event = new CustomEvent('openHotKeySettings', { detail: config });
window.dispatchEvent(event);
},
updateHotKeyConfig: function(modifiers, key, isEnabled) {
const event = new CustomEvent('updateHotKeyConfig', {
detail: { modifiers, key, isEnabled }
});
window.dispatchEvent(event);
}
};
window.addEventListener('hotKeyConfigChanged', function(e) {
if (window.mauiInterop.onHotKeyConfigUpdated) {
window.mauiInterop.onHotKeyConfigUpdated(e.detail);
}
});
");
}
catch (Exception ex)
{
Console.WriteLine($"[MainWindow] WebView script injection failed: {ex.Message}");
}
};
}
catch (Exception ex)
{
Console.WriteLine($"[MainWindow] WebView initialization failed: {ex}");
Content = new TextBlock
{
Text = "WebView 初始化失败。请检查运行环境依赖(Windows 需要 WebView2 RuntimeLinux 需要 WebKitGTK)。",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap
};
}
Content = new MainView(_appSettings, _webServer);
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
{
"WebServer": {
"Port": 5057,
"IsUsingStatic": false,
"IsUsingStatic": true,
"ConnectionString": "",
"HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5174"
"ForEndUrl": "http://localhost:5057"
},
"HotKey": {
"DefaultModifiers": "Alt",
+44
View File
@@ -0,0 +1,44 @@
#define MyAppName "Hua.Todo.Avalonia"
#define MyAppVersion "1.2.7"
#define MyAppPublisher "ShaoHua"
#define MyAppURL "https://git.we965.cn/Tools/Hua.Todo"
#define MyAppExeName "Hua.Todo.Avalonia.exe"
[Setup]
; 1. 改用更快压缩(优先速度)
Compression=zip/1
; 注意: AppId 的值唯一标识此应用程序。不要在其他应用程序的安装程序中使用相同的 AppId 值。
; (若要生成新的 GUID,请在 IDE 中单击“工具”|“生成 GUID”。)
AppId={{A1B2C3D4-E5F6-4321-8765-FEDCBA987654}}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
OutputDir=Output
OutputBaseFilename={#MyAppName}_Setup_v{#MyAppVersion}
SetupIconFile=Assets\avalonia-logo.ico
UninstallDisplayIcon={app}\avalonia-logo.ico
SolidCompression=no
WizardStyle=modern
[Languages]
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "bin\Release\net10.0\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\avalonia-logo.ico"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; IconFilename: "{app}\avalonia-logo.ico"
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hua.Todo-web</title>
<script type="module" crossorigin src="/assets/index-C1b07_cD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DxoB2Se3.css">
</head>
<body>
<div id="app"></div>
</body>
-2
View File
@@ -4,8 +4,6 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup>
</Project>
+5 -8
View File
@@ -30,15 +30,10 @@
<ApplicationId>com.companyname.Hua.Todo.maui</ApplicationId>
<!-- Versions -->
<Version>1.2.1</Version>
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- Unified in Directory.Build.props -->
<!-- Assembly Info -->
<AssemblyTitle>待办</AssemblyTitle>
<AssemblyProduct>待办</AssemblyProduct>
<AssemblyCompany>Hua.Todo</AssemblyCompany>
<AssemblyCopyright>Copyright 2024</AssemblyCopyright>
<!-- Unified in Directory.Build.props -->
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType>
@@ -137,7 +132,7 @@
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="System.Drawing.Common" Version="10.0.5" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
@@ -212,3 +207,5 @@
@@ -1,5 +1,6 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Handlers;
namespace Hua.Todo.Maui.Views
{
@@ -32,6 +33,20 @@ namespace Hua.Todo.Maui.Views
windowService.MinimizeWindow(window);
}
/// <summary>
/// 配置 WebView 滚动条行为。
/// </summary>
partial void ConfigureWebViewScrollBars()
{
// 使用 JavaScript 注入的方式禁用滚动条
MainWebView.EvaluateJavaScriptAsync(@"
// 禁用页面垂直滚动条
document.body.style.overflowY = 'hidden';
// 禁用整个文档的滚动
document.documentElement.style.overflowY = 'hidden';
");
}
partial void PlatformPrepareWebViewContainer()
{
if (Platforms.Windows.WebView2RuntimeDetector.IsRuntimeInstalled(out _))
+8
View File
@@ -119,9 +119,17 @@ namespace Hua.Todo.Maui.Views
}
});
");
// 配置 WebView 滚动条行为(平台特定实现)
ConfigureWebViewScrollBars();
};
}
/// <summary>
/// 配置 WebView 滚动条行为(平台特定实现)。
/// </summary>
partial void ConfigureWebViewScrollBars();
/// <summary>
/// 规格化 URL(针对 Android 模拟器处理 localhost)。
/// Android 模拟器中 localhost 指向模拟器自身,需要替换为 10.0.2.2 才能访问宿主机服务。
+1 -1
View File
@@ -1,5 +1,5 @@
#define MyAppName "Hua.Todo"
#define MyAppVersion "1.2.0"
#define MyAppVersion "1.2.7"
#define MyAppPublisher "ShaoHua"
#define MyAppURL "https://git.we965.cn/Tools/Hua.Todo"
#define MyAppExeName "Hua.Todo.Maui.exe"
+3 -2
View File
@@ -1,12 +1,13 @@
{
"name": "Hua.Todo-web",
"name": "Hua.Todo-Web",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "Hua.Todo-web",
"name": "Hua.Todo-Web",
"version": "0.0.0",
"license": "AGPL-3.0",
"dependencies": {
"axios": "^1.13.6",
"pinia": "^3.0.4",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 228 B