# Velopack + Supabase Storage 自动更新复用指南

本文档用于在桌面程序中复用“Velopack 生成安装包和更新包，Supabase Storage 托管更新源”的自动更新流程。示例均使用占位符，不绑定任何具体项目。

## 方案概览

更新链路分为两部分：

1. 发布端使用 Velopack CLI 将程序发布目录打包成安装包、全量包、差分包和 `releases.win.json`。
2. 上传端将这些产物放到 Supabase Storage 的公开桶目录中。
3. 客户端运行时只读取公开更新源 URL，调用 Velopack 检查、下载并应用更新。

客户端不需要 Supabase key。Supabase key 只应出现在发布脚本、CI/CD 或受信任的维护机器中。

## Supabase 配置

### 1. 创建 Storage 桶

推荐使用一个专门存放发布包的公开 File bucket：

| 配置项 | 推荐值 | 说明 |
| --- | --- | --- |
| Bucket name | `app-releases` | 可按组织统一命名 |
| Public bucket | `true` | 客户端需要匿名下载 `releases.win.json` 和安装包 |
| File size limit | 按安装包大小设置 | 例如 `512MB`、`1GB` 或更高 |
| Allowed MIME types | 留空或允许二进制 | Velopack 产物包含 `.exe`、`.nupkg`、`.json` 等 |

Dashboard 路径通常是：

```text
Supabase Dashboard -> Storage -> New bucket
```

也可以用 SQL 创建桶：

```sql
insert into storage.buckets (
  id,
  name,
  public,
  file_size_limit,
  allowed_mime_types
)
values (
  'app-releases',
  'app-releases',
  true,
  1073741824,
  null
)
on conflict (id) do update
set
  public = excluded.public,
  file_size_limit = excluded.file_size_limit,
  allowed_mime_types = excluded.allowed_mime_types;
```

### 2. 目录规划

建议每个应用、平台和发布通道使用独立目录：

```text
app-releases/
  my-app/
    windows/
      stable/
        releases.win.json
        RELEASES
        MyApp-win-Setup.exe
        MyApp-1.2.3-full.nupkg
        MyApp-1.2.3-delta.nupkg
      beta/
        releases.win.json
        ...
```

对应的 Velopack 更新源根地址为：

```text
https://<project-ref>.supabase.co/storage/v1/object/public/app-releases/my-app/windows/stable
```

Velopack 会在该根地址下读取 `releases.win.json`，所以不要把 `releases.win.json` 再放到额外子目录里。

### 3. 权限策略

推荐做法：

- 桶设为 public，只开放匿名读取。
- 发布上传使用 `service_role` key，并且只在 CI/CD 或维护机器环境变量中保存。
- 不给 `anon` 角色配置 `INSERT`、`UPDATE`、`DELETE` 权限。
- 不在客户端配置、日志、提交信息或文档中写入真实 key。

Supabase 的 public bucket 只代表对象可以通过公开 URL 读取；上传、覆盖、删除仍受访问控制影响。使用 `service_role` key 上传时会绕过 RLS，适合发布流程。

如果必须用非 service role 的登录用户上传，需要在 `storage.objects` 上创建受限策略。下面示例只允许已登录用户写入指定目录，实际项目应再结合用户、角色或后端校验收紧权限：

```sql
create policy "release upload insert"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'app-releases'
  and (storage.foldername(name))[1] = 'my-app'
);

create policy "release upload select"
on storage.objects
for select
to authenticated
using (
  bucket_id = 'app-releases'
  and (storage.foldername(name))[1] = 'my-app'
);

create policy "release upload update"
on storage.objects
for update
to authenticated
using (
  bucket_id = 'app-releases'
  and (storage.foldername(name))[1] = 'my-app'
)
with check (
  bucket_id = 'app-releases'
  and (storage.foldername(name))[1] = 'my-app'
);
```

覆盖已有文件需要 `SELECT` 和 `UPDATE` 权限。发布流程更推荐使用 service role，而不是给普通客户端发放写权限。

## 应用配置文件

建议在应用中放一个独立配置文件，例如 `Config/update_config.json`：

```json
{
  "enable_auto_update": true,
  "update_server_type": "supabase",
  "update_server_url": "https://<project-ref>.supabase.co/storage/v1/object/public/app-releases/my-app/windows/stable",
  "update_check_interval_hours": 24,
  "silent_install": false,
  "supabase": {
    "project_url": "https://<project-ref>.supabase.co",
    "bucket_name": "app-releases",
    "directory": "my-app/windows/stable"
  }
}
```

字段说明：

| 字段 | 用途 |
| --- | --- |
| `enable_auto_update` | 是否启用自动更新 |
| `update_server_type` | 可固定为 `supabase`，用于程序内部区分更新源 |
| `update_server_url` | 客户端 Velopack 更新源根地址 |
| `update_check_interval_hours` | 自动检查间隔；手动检查可忽略该间隔 |
| `silent_install` | 是否下载后延迟到下次启动自动应用 |
| `supabase.project_url` | 发布脚本上传时使用的 Supabase 项目 URL |
| `supabase.bucket_name` | 发布包所在 bucket |
| `supabase.directory` | bucket 内目录 |

`supabase` 节只给发布脚本使用。客户端运行时检查更新只需要 `update_server_url`。

## 客户端集成

### 1. 安装 NuGet 包

```powershell
dotnet add <YourApp.csproj> package Velopack
```

### 2. 在程序入口尽早初始化

Velopack 初始化应尽量放在应用启动最早位置：

```csharp
using Velopack;

internal static class Program
{
    [STAThread]
    public static void Main(string[] args)
    {
        VelopackApp.Build()
            .SetArgs(args)
            .SetAutoApplyOnStartup(true)
            .Run();

        StartApplication(args);
    }
}
```

`SetAutoApplyOnStartup(true)` 用于在下次启动时自动应用已下载但尚未安装的更新。

### 3. 检查、下载并应用更新

下面是最小可复用逻辑：

```csharp
using Velopack;

public sealed class AppUpdateService
{
    private readonly string _updateServerUrl;

    public AppUpdateService(string updateServerUrl)
    {
        _updateServerUrl = updateServerUrl;
    }

    public async Task<bool> CheckDownloadAndRestartAsync(
        IProgress<int>? progress = null,
        CancellationToken cancellationToken = default)
    {
        var manager = new UpdateManager(_updateServerUrl);
        if (!manager.IsInstalled)
        {
            return false;
        }

        var updateInfo = await manager.CheckForUpdatesAsync().ConfigureAwait(false);
        if (updateInfo?.TargetFullRelease is null)
        {
            return false;
        }

        await manager.DownloadUpdatesAsync(
            updateInfo,
            value => progress?.Report(Math.Clamp(value, 0, 100)),
            cancellationToken).ConfigureAwait(false);

        manager.ApplyUpdatesAndRestart(updateInfo.TargetFullRelease);
        return true;
    }
}
```

建议在 UI 层：

- 启动后延迟 1 到 3 秒检查，避免影响首屏。
- 发现新版本后先提示用户，再下载并重启。
- 手动“检查更新”按钮忽略检查间隔。
- 开发环境或直接运行 `dotnet run` 时，`manager.IsInstalled` 通常为 `false`，应跳过更新检查。

## 打包发布

### 1. 安装 Velopack CLI

```powershell
dotnet tool install -g vpk
```

### 2. 提升版本号

更新项目文件中的版本号，确保新版本高于线上版本：

```xml
<Version>1.2.3</Version>
```

### 3. 发布应用目录

```powershell
dotnet publish .\src\MyApp\MyApp.csproj `
  -c Release `
  -r win-x64 `
  --self-contained false `
  -o .\artifacts\publish\my-app
```

如果目标机器不保证安装 .NET Runtime，可以改用 self-contained 发布，或按 Velopack 文档为安装包配置所需 framework。

### 4. 生成 Velopack 包

```powershell
vpk pack `
  --packId MyCompany.MyApp `
  --packVersion 1.2.3 `
  --packDir .\artifacts\publish\my-app `
  --mainExe MyApp.exe `
  --packTitle "My App" `
  --packAuthors "MyCompany" `
  --outputDir .\artifacts\releases\my-app
```

输出目录应至少包含：

```text
MyApp-win-Setup.exe
MyCompany.MyApp-1.2.3-full.nupkg
MyCompany.MyApp-1.2.3-delta.nupkg
releases.win.json
RELEASES
```

首次发布可能没有 delta 包；从第二次发布开始应包含 delta 包。

## 上传到 Supabase Storage

发布脚本应读取配置文件中的 `supabase.project_url`、`supabase.bucket_name` 和 `supabase.directory`，并从环境变量读取服务端 key：

```powershell
$env:SUPABASE_SERVICE_ROLE_KEY = "<service-role-key>"
```

通用上传脚本示例：

```powershell
param(
    [Parameter(Mandatory = $true)]
    [string]$ConfigPath,

    [Parameter(Mandatory = $true)]
    [string]$ReleasePath,

    [string]$ServiceRoleKey = $env:SUPABASE_SERVICE_ROLE_KEY
)

$ErrorActionPreference = "Stop"

function Join-UrlPath {
    param(
        [string]$Left,
        [string]$Right
    )

    if ([string]::IsNullOrWhiteSpace($Left)) {
        return $Right
    }

    if ([string]::IsNullOrWhiteSpace($Right)) {
        return $Left.TrimEnd("/")
    }

    return $Left.TrimEnd("/") + "/" + $Right.TrimStart("/")
}

if ([string]::IsNullOrWhiteSpace($ServiceRoleKey)) {
    throw "SUPABASE_SERVICE_ROLE_KEY is empty."
}

$config = Get-Content -Encoding UTF8 $ConfigPath -Raw | ConvertFrom-Json
$baseUrl = $config.supabase.project_url.TrimEnd("/")
$bucketName = $config.supabase.bucket_name
$directory = $config.supabase.directory

if ([string]::IsNullOrWhiteSpace($baseUrl) -or
    [string]::IsNullOrWhiteSpace($bucketName) -or
    [string]::IsNullOrWhiteSpace($directory)) {
    throw "Supabase project_url, bucket_name, or directory is missing."
}

$files = Get-ChildItem -Path $ReleasePath -File | Sort-Object Name
if (-not $files) {
    throw "No release files were found."
}

$headers = @{
    "apikey" = $ServiceRoleKey
    "Authorization" = "Bearer $ServiceRoleKey"
}

foreach ($file in $files) {
    $objectPath = Join-UrlPath $directory $file.Name
    $uploadUrl = Join-UrlPath "$baseUrl/storage/v1/object/$bucketName" $objectPath

    $uploadHeaders = $headers.Clone()
    $uploadHeaders["Content-Type"] = "application/octet-stream"
    $uploadHeaders["x-upsert"] = "true"

    Invoke-RestMethod `
        -Uri $uploadUrl `
        -Method Post `
        -Headers $uploadHeaders `
        -InFile $file.FullName
}

$publicBaseUrl = Join-UrlPath "$baseUrl/storage/v1/object/public/$bucketName" $directory
Write-Host "Public feed root: $publicBaseUrl"
Write-Host "Velopack feed: $(Join-UrlPath $publicBaseUrl 'releases.win.json')"
```

上传命令：

```powershell
.\UploadReleaseToSupabase.ps1 `
  -ConfigPath .\Config\update_config.json `
  -ReleasePath .\artifacts\releases\my-app
```

## 发布后验证

### 1. 验证 feed 可访问

```powershell
$feedUrl = "https://<project-ref>.supabase.co/storage/v1/object/public/app-releases/my-app/windows/stable/releases.win.json"
$feed = Invoke-RestMethod -Uri $feedUrl -Method Get
$feed.Assets | Sort-Object {[version]$_.Version} -Descending | Select-Object -First 5 PackageId,Version,Type,FileName,Size
```

确认：

- 最新版本等于本次发布版本。
- 最新版本至少有 `Full` 资产。
- 非首次发布时应有 `Delta` 资产。

### 2. 验证安装包 URL

```powershell
curl.exe -I "https://<project-ref>.supabase.co/storage/v1/object/public/app-releases/my-app/windows/stable/MyApp-win-Setup.exe"
```

期望结果：

```text
HTTP/1.1 200 OK
```

### 3. 验证客户端更新

建议用两个版本验证：

1. 安装旧版本。
2. 上传新版本发布包。
3. 启动旧版本并触发检查更新。
4. 确认能发现新版本、下载进度正常、重启后版本号变为新版本。

## 复用检查清单

- Supabase Storage 已创建 public bucket。
- 更新包目录规划稳定，不同应用和通道互不覆盖。
- 客户端配置的 `update_server_url` 指向包含 `releases.win.json` 的目录。
- 发布脚本只在受信环境使用 `SUPABASE_SERVICE_ROLE_KEY`。
- 客户端没有保存 Supabase key。
- 每次发布前提高应用版本号。
- `vpk pack` 输出包含 `releases.win.json`、`Setup.exe` 和 `.nupkg`。
- 上传后 `releases.win.json` 可通过公开 URL 访问。
- 安装包 URL 返回 `200 OK`。
- 至少用旧版本安装包做一次真实升级测试。

## 参考文档

- [Velopack C# Getting Started](https://docs.velopack.io/getting-started/csharp)
- [Velopack Integrating Overview](https://docs.velopack.io/integrating/overview)
- [Supabase Storage Buckets](https://supabase.com/docs/guides/storage/buckets/fundamentals)
- [Supabase Creating Buckets](https://supabase.com/docs/guides/storage/buckets/creating-buckets)
- [Supabase Storage Access Control](https://supabase.com/docs/guides/storage/security/access-control)
- [Supabase Standard Uploads](https://supabase.com/docs/guides/storage/uploads/standard-uploads)
