feat: align release version naming
This commit is contained in:
29
.github/workflows/release-stable.yml
vendored
29
.github/workflows/release-stable.yml
vendored
@@ -22,15 +22,32 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
display_version: ${{ steps.ver.outputs.display_version }}
|
||||
tag: ${{ steps.ver.outputs.tag }}
|
||||
steps:
|
||||
- id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#stable-v}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "### Stable version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
python3 <<'PY' > version.env
|
||||
import os
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
tag = os.environ["GITHUB_REF_NAME"]
|
||||
version = tag.removeprefix("stable-v")
|
||||
match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})", version)
|
||||
if not match:
|
||||
raise SystemExit(f"Stable tags must use stable-vYYYY.M.D, got {tag}")
|
||||
|
||||
date(*map(int, match.groups()))
|
||||
print(f"version={version}")
|
||||
print(f"display_version={version}")
|
||||
print(f"tag={tag}")
|
||||
PY
|
||||
|
||||
cat version.env >> "$GITHUB_OUTPUT"
|
||||
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
|
||||
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build each architecture in parallel
|
||||
@@ -211,7 +228,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: Tolaria ${{ needs.version.outputs.version }}
|
||||
name: Tolaria ${{ needs.version.outputs.display_version }}
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -275,7 +292,7 @@ jobs:
|
||||
</head>
|
||||
<body>
|
||||
<h1>Tolaria Release History</h1>
|
||||
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vX.Y.Z tag is promoted.</p>
|
||||
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.</p>
|
||||
<div id="releases"></div>
|
||||
<script>
|
||||
fetch('releases.json').then(r=>r.json()).then(releases=>{
|
||||
|
||||
76
.github/workflows/release.yml
vendored
76
.github/workflows/release.yml
vendored
@@ -16,13 +16,14 @@ concurrency:
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 1: Compute the alpha version string once
|
||||
# Alpha builds are prereleases of the next stable patch version.
|
||||
# Alpha builds use calendar semver and stay newer than the latest stable tag.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
version:
|
||||
name: Compute alpha version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
display_version: ${{ steps.ver.outputs.display_version }}
|
||||
tag: ${{ steps.ver.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -32,22 +33,63 @@ jobs:
|
||||
- id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
LAST_STABLE_TAG=$(git tag --list 'stable-v*' --sort=-version:refname | head -n 1)
|
||||
if [ -z "$LAST_STABLE_TAG" ]; then
|
||||
BASE_VERSION="0.1.0"
|
||||
else
|
||||
BASE_VERSION="${LAST_STABLE_TAG#stable-v}"
|
||||
fi
|
||||
python3 <<'PY' > version.env
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<EOF
|
||||
$BASE_VERSION
|
||||
EOF
|
||||
NEXT_PATCH_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
def lines(command: list[str]) -> list[str]:
|
||||
output = subprocess.check_output(command, text=True).strip()
|
||||
return [line for line in output.splitlines() if line]
|
||||
|
||||
VERSION="${NEXT_PATCH_VERSION}-alpha.$(date -u +%Y%m%d%H%M).${GITHUB_RUN_NUMBER}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=alpha-v$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "### Alpha version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
existing_tags = [
|
||||
tag for tag in lines(["git", "tag", "--points-at", "HEAD"])
|
||||
if tag.startswith("alpha-v")
|
||||
]
|
||||
|
||||
if existing_tags:
|
||||
tag = existing_tags[0]
|
||||
version = tag.removeprefix("alpha-v")
|
||||
else:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
stable_date = None
|
||||
stable_pattern = re.compile(r"^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$")
|
||||
|
||||
for stable_tag in lines(["git", "tag", "--list", "stable-v*", "--sort=-version:refname"]):
|
||||
match = stable_pattern.fullmatch(stable_tag)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
year, month, day = map(int, match.groups())
|
||||
try:
|
||||
stable_date = datetime(year, month, day, tzinfo=timezone.utc).date()
|
||||
except ValueError:
|
||||
continue
|
||||
break
|
||||
|
||||
alpha_date = today if stable_date is None or today > stable_date else stable_date + timedelta(days=1)
|
||||
calendar_version = f"{alpha_date.year}.{alpha_date.month}.{alpha_date.day}"
|
||||
sequence = len(lines(["git", "tag", "--list", f"alpha-v{calendar_version}-alpha.*"])) + 1
|
||||
|
||||
version = f"{calendar_version}-alpha.{sequence}"
|
||||
tag = f"alpha-v{version}"
|
||||
|
||||
display_match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)", version)
|
||||
display_version = (
|
||||
f"Alpha {int(display_match.group(1))}.{int(display_match.group(2))}.{int(display_match.group(3))}.{int(display_match.group(4))}"
|
||||
if display_match
|
||||
else version
|
||||
)
|
||||
|
||||
print(f"version={version}")
|
||||
print(f"display_version={display_version}")
|
||||
print(f"tag={tag}")
|
||||
PY
|
||||
|
||||
cat version.env >> "$GITHUB_OUTPUT"
|
||||
VERSION=$(grep '^version=' version.env | cut -d= -f2-)
|
||||
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
|
||||
echo "### Alpha version: \`$DISPLAY_VERSION\` (\`$VERSION\`)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build each architecture in parallel
|
||||
@@ -225,7 +267,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: Tolaria ${{ needs.version.outputs.version }} (Alpha)
|
||||
name: Tolaria ${{ needs.version.outputs.display_version }}
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: true
|
||||
@@ -288,7 +330,7 @@ jobs:
|
||||
</head>
|
||||
<body>
|
||||
<h1>Tolaria Release History</h1>
|
||||
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vX.Y.Z tag is promoted.</p>
|
||||
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.</p>
|
||||
<div id="releases"></div>
|
||||
<script>
|
||||
fetch('releases.json').then(r=>r.json()).then(releases=>{
|
||||
|
||||
@@ -621,12 +621,13 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
|
||||
|
||||
### Rust
|
||||
- **`src-tauri/src/app_updater.rs`** — Chooses the correct update endpoint (`alpha/latest.json` or `stable/latest.json`) and adapts Tauri updater results into frontend-friendly payloads.
|
||||
- **`src-tauri/src/commands/version.rs`** — Formats app build/version labels for the status bar, including calendar alpha labels and legacy release compatibility.
|
||||
|
||||
### Tauri Commands
|
||||
- **`check_for_app_update`** — Channel-aware update manifest lookup.
|
||||
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
|
||||
|
||||
### CI/CD
|
||||
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main`. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-v*` tags. Publishes `stable/latest.json`.
|
||||
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`.
|
||||
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.
|
||||
|
||||
@@ -689,7 +689,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `trigger_menu_command` | Emit a native menu command ID for deterministic shortcut QA |
|
||||
| `update_current_window_min_size` | Update the active Tauri window's minimum size and optionally grow it to fit restored panes |
|
||||
|
||||
`get_build_number` feeds the bottom status bar label. It preserves legacy `bNNN` date-build labels, renders local `0.1.0` / `0.0.0` builds as `dev`, and formats semver alpha prereleases as `alpha <version>` so signed alpha builds never fall back to `?`.
|
||||
`get_build_number` feeds the bottom status bar label. It preserves legacy `bNNN` date-build labels, renders local `0.1.0` / `0.0.0` builds as `dev`, formats calendar alpha builds as `Alpha YYYY.M.D.N`, strips any calendar `-stable.N` suffix back to `YYYY.M.D`, and keeps legacy semver releases readable instead of falling back to `?`.
|
||||
|
||||
## Mock Layer
|
||||
|
||||
@@ -766,14 +766,15 @@ Every push to `main` triggers `.github/workflows/release.yml`:
|
||||
|
||||
```
|
||||
push to main
|
||||
→ version job: read latest stable-vX.Y.Z tag
|
||||
→ compute next patch prerelease X.Y.(Z+1)-alpha.UTCSTAMP.RUN_NUMBER
|
||||
→ version job: compute calendar alpha version YYYY.M.D-alpha.N
|
||||
→ use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today
|
||||
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
|
||||
→ build job:
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
|
||||
→ upload signed .app.tar.gz + .sig and .dmg artifacts
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
|
||||
→ upload signed .app.tar.gz + .sig updater artifacts
|
||||
→ release job:
|
||||
→ generate alpha-latest.json
|
||||
→ publish GitHub prerelease alpha-v<version>
|
||||
→ publish GitHub prerelease alpha-v<version> named Tolaria Alpha YYYY.M.D.N
|
||||
→ pages job:
|
||||
→ build static HTML release history page
|
||||
→ publish alpha/latest.json
|
||||
@@ -785,14 +786,14 @@ push to main
|
||||
Stable promotions trigger `.github/workflows/release-stable.yml`:
|
||||
|
||||
```
|
||||
push stable-vX.Y.Z tag
|
||||
→ version job: use X.Y.Z from the tag
|
||||
push stable-vYYYY.M.D tag
|
||||
→ version job: validate YYYY.M.D from the tag
|
||||
→ build job:
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
|
||||
→ upload signed .app.tar.gz + .sig and .dmg artifacts
|
||||
→ release job:
|
||||
→ generate stable-latest.json
|
||||
→ publish GitHub release Tolaria X.Y.Z
|
||||
→ publish GitHub release Tolaria YYYY.M.D
|
||||
→ pages job:
|
||||
→ publish stable/latest.json
|
||||
→ preserve alpha/latest.json
|
||||
@@ -801,10 +802,11 @@ push stable-vX.Y.Z tag
|
||||
|
||||
### Versioning
|
||||
|
||||
- Stable promotions use git tags in the form `stable-vX.Y.Z`.
|
||||
- Alpha builds are prereleases of the next stable patch version, for example stable `1.2.3` → alpha `1.2.4-alpha.202604122135.7`.
|
||||
- Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`.
|
||||
- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`.
|
||||
- If the latest stable tag already uses today's date, alpha advances to the next calendar day before assigning `-alpha.N` so Alpha remains semver-newer than Stable across channel switches.
|
||||
- The workflows stamp the computed version into `tauri.conf.json` and `Cargo.toml` at build time.
|
||||
- This keeps semver monotonic when a user switches between Stable and Alpha.
|
||||
- This keeps display strings clean while preserving semver monotonicity when a user switches between Stable and Alpha.
|
||||
|
||||
### In-App Updates
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0066"
|
||||
title: "Calendar-semver versioning for alpha and stable releases"
|
||||
status: active
|
||||
date: 2026-04-16
|
||||
supersedes: "0057"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0057 kept Tolaria on two updater channels and used "next stable patch" semver for alpha builds. That preserved ordering, but it no longer matched the agreed product naming:
|
||||
|
||||
- Alpha should display as `Alpha YYYY.M.D.N`
|
||||
- Alpha should ship the technical version `YYYY.M.D-alpha.N`
|
||||
- Stable should ship and display as `YYYY.M.D`
|
||||
|
||||
The naming change still needs to stay semver-safe when users switch between Stable and Alpha. A pure same-day calendar alpha would become older than a same-day stable promotion, so the workflow needs a monotonicity guard in addition to cleaner display strings.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria keeps exactly two updater channels (`stable` and `alpha`), but both now use calendar-semver release numbers.** Stable promotions use `stable-vYYYY.M.D` tags and stamp the technical version `YYYY.M.D`. Every push to `main` publishes an alpha build with technical version `YYYY.M.D-alpha.N` and display label `Alpha YYYY.M.D.N`.
|
||||
|
||||
If the latest stable tag already uses the current UTC calendar date, the alpha workflow advances to the next calendar day before assigning `-alpha.N`. That keeps alpha semver-newer than the most recent stable build even after a same-day promotion.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Calendar semver with a next-day safeguard** (chosen): matches the agreed naming, keeps user-facing labels clean, and preserves updater ordering across channel switches.
|
||||
- **Calendar semver without a safeguard**: simplest display model, but alpha can become semver-older than Stable after a same-day promotion.
|
||||
- **Keep ADR-0057's next-patch prerelease numbering**: semver-safe, but it does not match the agreed release naming or the product surfaces that should show calendar-based versions.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Release workflows now compute both a technical version and a display version.
|
||||
- User-facing version surfaces strip technical prerelease noise into clean labels (`Alpha YYYY.M.D.N` or `YYYY.M.D`).
|
||||
- Stable promotions must use `stable-vYYYY.M.D` tags instead of patch-based semver tags.
|
||||
- Alpha sequence numbers are scoped to a calendar core date and remain compatible with the updater manifests.
|
||||
@@ -3,6 +3,7 @@ mod delete;
|
||||
mod git;
|
||||
mod system;
|
||||
mod vault;
|
||||
mod version;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -11,6 +12,7 @@ pub use delete::*;
|
||||
pub use git::*;
|
||||
pub use system::*;
|
||||
pub use vault::*;
|
||||
pub use version::*;
|
||||
|
||||
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
|
||||
/// Returns the original string unchanged if it doesn't start with `~` or if the
|
||||
@@ -29,17 +31,6 @@ pub fn expand_tilde(path: &str) -> Cow<'_, str> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_build_label(version: &str) -> String {
|
||||
let version = version.trim();
|
||||
if version.is_empty() {
|
||||
return "b?".to_string();
|
||||
}
|
||||
|
||||
parse_legacy_build_label(version)
|
||||
.or_else(|| parse_semver_build_label(version))
|
||||
.unwrap_or_else(|| "b?".to_string())
|
||||
}
|
||||
|
||||
fn is_numeric_version_part(part: &str) -> bool {
|
||||
!part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
@@ -56,30 +47,6 @@ fn parse_legacy_build_label(version: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_semver_build_label(version: &str) -> Option<String> {
|
||||
let semver = version.split_once('+').map_or(version, |(base, _)| base);
|
||||
let (core, prerelease) = semver
|
||||
.split_once('-')
|
||||
.map_or((semver, None), |(base, suffix)| (base, Some(suffix)));
|
||||
let parts: Vec<&str> = core.split('.').collect();
|
||||
let [major, minor, patch] = parts.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
if ![major, minor, patch]
|
||||
.iter()
|
||||
.all(|part| is_numeric_version_part(part))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
match prerelease {
|
||||
Some(suffix) if suffix.starts_with("alpha.") => Some(format!("alpha {}", version)),
|
||||
Some(_) => Some(format!("v{}", version)),
|
||||
None if version == "0.1.0" || version == "0.0.0" => Some("dev".to_string()),
|
||||
None => Some(format!("v{}", version)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -115,35 +82,4 @@ mod tests {
|
||||
let result = expand_tilde("/home/~user/path");
|
||||
assert_eq!(result, "/home/~user/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_release_version() {
|
||||
assert_eq!(parse_build_label("0.20260303.281"), "b281");
|
||||
assert_eq!(parse_build_label("0.20251215.42"), "b42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_semver_releases() {
|
||||
assert_eq!(parse_build_label("1.2.3"), "v1.2.3");
|
||||
assert_eq!(
|
||||
parse_build_label("1.2.4-alpha.202604122135.7"),
|
||||
"alpha 1.2.4-alpha.202604122135.7"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_build_label("1.2.4-alpha.202604122135.7+darwin"),
|
||||
"alpha 1.2.4-alpha.202604122135.7+darwin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_dev_version() {
|
||||
assert_eq!(parse_build_label("0.1.0"), "dev");
|
||||
assert_eq!(parse_build_label("0.0.0"), "dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_malformed() {
|
||||
assert_eq!(parse_build_label("invalid"), "b?");
|
||||
assert_eq!(parse_build_label(""), "b?");
|
||||
}
|
||||
}
|
||||
|
||||
121
src-tauri/src/commands/version.rs
Normal file
121
src-tauri/src/commands/version.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use super::{is_numeric_version_part, parse_legacy_build_label};
|
||||
|
||||
pub fn parse_build_label(version: &str) -> String {
|
||||
let version = version.trim();
|
||||
if version.is_empty() {
|
||||
return "b?".to_string();
|
||||
}
|
||||
|
||||
parse_legacy_build_label(version)
|
||||
.or_else(|| parse_calendar_build_label(version))
|
||||
.or_else(|| parse_semver_build_label(version))
|
||||
.unwrap_or_else(|| "b?".to_string())
|
||||
}
|
||||
|
||||
fn strip_build_metadata(version: &str) -> &str {
|
||||
version.split_once('+').map_or(version, |(base, _)| base)
|
||||
}
|
||||
|
||||
fn parse_calendar_build_label(version: &str) -> Option<String> {
|
||||
let semver = strip_build_metadata(version);
|
||||
let (core, prerelease) = semver
|
||||
.split_once('-')
|
||||
.map_or((semver, None), |(base, suffix)| (base, Some(suffix)));
|
||||
let [year, month, day] = split_numeric_version_parts(core)?;
|
||||
if year.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let core_version = format!(
|
||||
"{}.{}.{}",
|
||||
year.parse::<u32>().ok()?,
|
||||
month.parse::<u32>().ok()?,
|
||||
day.parse::<u32>().ok()?
|
||||
);
|
||||
|
||||
match prerelease {
|
||||
Some(suffix) if suffix.starts_with("alpha.") => suffix
|
||||
.strip_prefix("alpha.")
|
||||
.map(|sequence| format!("Alpha {}.{}", core_version, sequence)),
|
||||
Some(suffix) if suffix.starts_with("stable.") => Some(core_version),
|
||||
Some(_) => None,
|
||||
None => Some(core_version),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_semver_build_label(version: &str) -> Option<String> {
|
||||
let semver = strip_build_metadata(version);
|
||||
let (core, prerelease) = semver
|
||||
.split_once('-')
|
||||
.map_or((semver, None), |(base, suffix)| (base, Some(suffix)));
|
||||
split_numeric_version_parts(core)?;
|
||||
|
||||
match prerelease {
|
||||
Some(suffix) if suffix.starts_with("alpha.") => Some(format!("Alpha {}", semver)),
|
||||
Some(_) => Some(format!("v{}", semver)),
|
||||
None if semver == "0.1.0" || semver == "0.0.0" => Some("dev".to_string()),
|
||||
None => Some(format!("v{}", semver)),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_numeric_version_parts(version: &str) -> Option<[&str; 3]> {
|
||||
let parts: Vec<&str> = version.split('.').collect();
|
||||
let [major, minor, patch] = parts.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
if ![major, minor, patch]
|
||||
.iter()
|
||||
.all(|part| is_numeric_version_part(part))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some([major, minor, patch])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_build_label;
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_release_version() {
|
||||
assert_eq!(parse_build_label("0.20260303.281"), "b281");
|
||||
assert_eq!(parse_build_label("0.20251215.42"), "b42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_calendar_versions() {
|
||||
assert_eq!(parse_build_label("2026.4.16"), "2026.4.16");
|
||||
assert_eq!(parse_build_label("2026.4.16-stable.1"), "2026.4.16");
|
||||
assert_eq!(parse_build_label("2026.4.16-alpha.3"), "Alpha 2026.4.16.3");
|
||||
assert_eq!(
|
||||
parse_build_label("2026.4.16-alpha.3+darwin"),
|
||||
"Alpha 2026.4.16.3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_legacy_semver_releases() {
|
||||
assert_eq!(parse_build_label("1.2.3"), "v1.2.3");
|
||||
assert_eq!(
|
||||
parse_build_label("1.2.4-alpha.202604122135.7"),
|
||||
"Alpha 1.2.4-alpha.202604122135.7"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_build_label("1.2.4-alpha.202604122135.7+darwin"),
|
||||
"Alpha 1.2.4-alpha.202604122135.7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_dev_version() {
|
||||
assert_eq!(parse_build_label("0.1.0"), "dev");
|
||||
assert_eq!(parse_build_label("0.0.0"), "dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_malformed() {
|
||||
assert_eq!(parse_build_label("invalid"), "b?");
|
||||
assert_eq!(parse_build_label(""), "b?");
|
||||
}
|
||||
}
|
||||
@@ -21,64 +21,93 @@ function makeActions(overrides?: Partial<UpdateActions>): UpdateActions {
|
||||
}
|
||||
}
|
||||
|
||||
function makeAvailableStatus(overrides?: Partial<Extract<UpdateStatus, { state: 'available' }>>): UpdateStatus {
|
||||
return {
|
||||
state: 'available',
|
||||
version: '2026.4.16',
|
||||
displayVersion: '2026.4.16',
|
||||
notes: undefined,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeDownloadingStatus(overrides?: Partial<Extract<UpdateStatus, { state: 'downloading' }>>): UpdateStatus {
|
||||
return {
|
||||
state: 'downloading',
|
||||
version: '2026.4.16-alpha.3',
|
||||
displayVersion: 'Alpha 2026.4.16.3',
|
||||
progress: 0.65,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeReadyStatus(overrides?: Partial<Extract<UpdateStatus, { state: 'ready' }>>): UpdateStatus {
|
||||
return {
|
||||
state: 'ready',
|
||||
version: '2026.4.16',
|
||||
displayVersion: '2026.4.16',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderBanner(status: UpdateStatus, actions = makeActions()) {
|
||||
const view = render(<UpdateBanner status={status} actions={actions} />)
|
||||
return { ...view, actions }
|
||||
}
|
||||
|
||||
describe('UpdateBanner', () => {
|
||||
it('renders nothing when idle', () => {
|
||||
const status: UpdateStatus = { state: 'idle' }
|
||||
const { container } = render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
const { container } = renderBanner(status)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders nothing on error state', () => {
|
||||
const status: UpdateStatus = { state: 'error' }
|
||||
const { container } = render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
const { container } = renderBanner(status)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('shows version and action buttons when update is available', () => {
|
||||
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: 'Bug fixes' }
|
||||
const actions = makeActions()
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
renderBanner(makeAvailableStatus({
|
||||
version: '2026.4.16-alpha.3',
|
||||
displayVersion: 'Alpha 2026.4.16.3',
|
||||
notes: 'Bug fixes',
|
||||
}))
|
||||
|
||||
expect(screen.getByTestId('update-banner')).toBeTruthy()
|
||||
expect(screen.getByText(/Tolaria 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText('is available')).toBeTruthy()
|
||||
expect(screen.getByText(/Tolaria Alpha 2026\.4\.16\.3/)).toBeTruthy()
|
||||
expect(screen.getByText(/is available/)).toBeTruthy()
|
||||
expect(screen.getByTestId('update-now-btn')).toBeTruthy()
|
||||
expect(screen.getByTestId('update-release-notes')).toBeTruthy()
|
||||
expect(screen.getByTestId('update-dismiss')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('"Update Now" calls startDownload', () => {
|
||||
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: undefined }
|
||||
const actions = makeActions()
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
const { actions } = renderBanner(makeAvailableStatus())
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-now-btn'))
|
||||
expect(actions.startDownload).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('"Release Notes" link calls openReleaseNotes', () => {
|
||||
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: undefined }
|
||||
const actions = makeActions()
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
const { actions } = renderBanner(makeAvailableStatus())
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-release-notes'))
|
||||
expect(actions.openReleaseNotes).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('dismiss button calls dismiss action', () => {
|
||||
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: undefined }
|
||||
const actions = makeActions()
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
const { actions } = renderBanner(makeAvailableStatus())
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-dismiss'))
|
||||
expect(actions.dismiss).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows progress bar during download', () => {
|
||||
const status: UpdateStatus = { state: 'downloading', version: '1.5.0', progress: 0.65 }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
renderBanner(makeDownloadingStatus())
|
||||
|
||||
expect(screen.getByText(/Downloading Tolaria 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText(/Downloading Tolaria Alpha 2026\.4\.16\.3/)).toBeTruthy()
|
||||
expect(screen.getByText('65%')).toBeTruthy()
|
||||
|
||||
const progressBar = screen.getByTestId('update-progress')
|
||||
@@ -86,8 +115,11 @@ describe('UpdateBanner', () => {
|
||||
})
|
||||
|
||||
it('shows 0% at start of download', () => {
|
||||
const status: UpdateStatus = { state: 'downloading', version: '2.0.0', progress: 0 }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
renderBanner(makeDownloadingStatus({
|
||||
version: '2026.4.16',
|
||||
displayVersion: '2026.4.16',
|
||||
progress: 0,
|
||||
}))
|
||||
|
||||
expect(screen.getByText('0%')).toBeTruthy()
|
||||
const progressBar = screen.getByTestId('update-progress')
|
||||
@@ -95,18 +127,16 @@ describe('UpdateBanner', () => {
|
||||
})
|
||||
|
||||
it('shows restart button when update is ready', () => {
|
||||
const status: UpdateStatus = { state: 'ready', version: '1.5.0' }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
renderBanner(makeReadyStatus())
|
||||
|
||||
expect(screen.getByText(/Tolaria 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText(/Tolaria 2026\.4\.16/)).toBeTruthy()
|
||||
expect(screen.getByText(/restart to apply/)).toBeTruthy()
|
||||
expect(screen.getByTestId('update-restart-btn')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('restart button calls restartApp', async () => {
|
||||
const { restartApp } = await import('../hooks/useUpdater')
|
||||
const status: UpdateStatus = { state: 'ready', version: '1.5.0' }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
renderBanner(makeReadyStatus())
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-restart-btn'))
|
||||
expect(restartApp).toHaveBeenCalled()
|
||||
|
||||
@@ -1,145 +1,166 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Download, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import type { UpdateStatus, UpdateActions } from '../hooks/useUpdater'
|
||||
import { restartApp } from '../hooks/useUpdater'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
interface UpdateBannerProps {
|
||||
status: UpdateStatus
|
||||
actions: UpdateActions
|
||||
}
|
||||
|
||||
type VisibleUpdateStatus = Exclude<UpdateStatus, { state: 'idle' } | { state: 'error' }>
|
||||
|
||||
const bannerStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '6px 12px',
|
||||
background: '#1a56db',
|
||||
borderBottom: 'none',
|
||||
fontSize: 13,
|
||||
color: '#fff',
|
||||
flexShrink: 0,
|
||||
} satisfies CSSProperties
|
||||
|
||||
const iconStyle = {
|
||||
color: '#fff',
|
||||
flexShrink: 0,
|
||||
} satisfies CSSProperties
|
||||
|
||||
const primaryActionStyle = {
|
||||
marginLeft: 'auto',
|
||||
padding: '3px 10px',
|
||||
background: 'var(--primary)',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
} satisfies CSSProperties
|
||||
|
||||
const dismissButtonStyle = {
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
padding: 2,
|
||||
} satisfies CSSProperties
|
||||
|
||||
const progressTrackStyle = {
|
||||
flex: 1,
|
||||
maxWidth: 200,
|
||||
height: 4,
|
||||
background: 'rgba(255,255,255,0.3)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
} satisfies CSSProperties
|
||||
|
||||
const progressTextStyle = {
|
||||
fontSize: 11,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
} satisfies CSSProperties
|
||||
|
||||
const readyIconStyle = {
|
||||
color: 'var(--accent-green, #0F7B0F)',
|
||||
flexShrink: 0,
|
||||
} satisfies CSSProperties
|
||||
|
||||
function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'available' }>, actions: UpdateActions) {
|
||||
return (
|
||||
<>
|
||||
<Download size={14} style={iconStyle} />
|
||||
<span>
|
||||
<strong>Tolaria {status.displayVersion}</strong> is available
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="xs"
|
||||
data-testid="update-release-notes"
|
||||
onClick={actions.openReleaseNotes}
|
||||
style={{ color: '#fff', padding: 0, height: 'auto' }}
|
||||
>
|
||||
Release Notes <ExternalLink size={11} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
data-testid="update-now-btn"
|
||||
onClick={actions.startDownload}
|
||||
style={primaryActionStyle}
|
||||
>
|
||||
Update Now
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
data-testid="update-dismiss"
|
||||
onClick={actions.dismiss}
|
||||
style={dismissButtonStyle}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDownloadingContent(status: Extract<VisibleUpdateStatus, { state: 'downloading' }>) {
|
||||
return (
|
||||
<>
|
||||
<RefreshCw size={14} style={{ ...iconStyle, animation: 'spin 1s linear infinite' }} />
|
||||
<span>Downloading Tolaria {status.displayVersion}...</span>
|
||||
<div style={progressTrackStyle}>
|
||||
<div
|
||||
data-testid="update-progress"
|
||||
style={{
|
||||
width: `${Math.round(status.progress * 100)}%`,
|
||||
height: '100%',
|
||||
background: 'var(--primary)',
|
||||
borderRadius: 2,
|
||||
transition: 'width 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={progressTextStyle}>{Math.round(status.progress * 100)}%</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderReadyContent(status: Extract<VisibleUpdateStatus, { state: 'ready' }>) {
|
||||
return (
|
||||
<>
|
||||
<RefreshCw size={14} style={readyIconStyle} />
|
||||
<span>
|
||||
<strong>Tolaria {status.displayVersion}</strong> is ready - restart to apply
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
data-testid="update-restart-btn"
|
||||
onClick={restartApp}
|
||||
style={{
|
||||
...primaryActionStyle,
|
||||
background: 'var(--accent-green, #0F7B0F)',
|
||||
}}
|
||||
>
|
||||
Restart Now
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderBannerContent(status: VisibleUpdateStatus, actions: UpdateActions) {
|
||||
switch (status.state) {
|
||||
case 'available':
|
||||
return renderAvailableContent(status, actions)
|
||||
case 'downloading':
|
||||
return renderDownloadingContent(status)
|
||||
case 'ready':
|
||||
return renderReadyContent(status)
|
||||
}
|
||||
}
|
||||
|
||||
export function UpdateBanner({ status, actions }: UpdateBannerProps) {
|
||||
if (status.state === 'idle' || status.state === 'error') return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="update-banner"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '6px 12px',
|
||||
background: '#1a56db',
|
||||
borderBottom: 'none',
|
||||
fontSize: 13,
|
||||
color: '#fff',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{status.state === 'available' && (
|
||||
<>
|
||||
<Download size={14} style={{ color: '#fff', flexShrink: 0 }} />
|
||||
<span>
|
||||
<strong>Tolaria {status.version}</strong> is available
|
||||
</span>
|
||||
<button
|
||||
data-testid="update-release-notes"
|
||||
onClick={actions.openReleaseNotes}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
padding: 0,
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Release Notes <ExternalLink size={11} />
|
||||
</button>
|
||||
<button
|
||||
data-testid="update-now-btn"
|
||||
onClick={actions.startDownload}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '3px 10px',
|
||||
background: 'var(--primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 5,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Update Now
|
||||
</button>
|
||||
<button
|
||||
data-testid="update-dismiss"
|
||||
onClick={actions.dismiss}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
padding: 2,
|
||||
}}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status.state === 'downloading' && (
|
||||
<>
|
||||
<RefreshCw size={14} style={{ color: '#fff', flexShrink: 0, animation: 'spin 1s linear infinite' }} />
|
||||
<span>Downloading Tolaria {status.version}...</span>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: 200,
|
||||
height: 4,
|
||||
background: 'rgba(255,255,255,0.3)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="update-progress"
|
||||
style={{
|
||||
width: `${Math.round(status.progress * 100)}%`,
|
||||
height: '100%',
|
||||
background: 'var(--primary)',
|
||||
borderRadius: 2,
|
||||
transition: 'width 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>
|
||||
{Math.round(status.progress * 100)}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status.state === 'ready' && (
|
||||
<>
|
||||
<RefreshCw size={14} style={{ color: 'var(--accent-green, #0F7B0F)', flexShrink: 0 }} />
|
||||
<span>
|
||||
<strong>Tolaria {status.version}</strong> is ready — restart to apply
|
||||
</span>
|
||||
<button
|
||||
data-testid="update-restart-btn"
|
||||
onClick={restartApp}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '3px 10px',
|
||||
background: 'var(--accent-green, #0F7B0F)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 5,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Restart Now
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return <div data-testid="update-banner" style={bannerStyle}>{renderBannerContent(status, actions)}</div>
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ type DownloadArgs = {
|
||||
|
||||
function makeUpdate(overrides: Partial<AppUpdateMetadata> = {}): AppUpdateMetadata {
|
||||
return {
|
||||
currentVersion: '1.0.0',
|
||||
version: '1.2.0',
|
||||
currentVersion: '2026.4.15',
|
||||
version: '2026.4.16',
|
||||
body: 'Bug fixes and improvements',
|
||||
...overrides,
|
||||
}
|
||||
@@ -148,12 +148,13 @@ describe('useUpdater', () => {
|
||||
it('transitions to available when an alpha update is found', async () => {
|
||||
const { result } = await performManualCheck(
|
||||
'alpha',
|
||||
makeUpdate({ version: '1.2.4-alpha.202604121830.10' }),
|
||||
makeUpdate({ version: '2026.4.16-alpha.3' }),
|
||||
)
|
||||
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '1.2.4-alpha.202604121830.10',
|
||||
version: '2026.4.16-alpha.3',
|
||||
displayVersion: 'Alpha 2026.4.16.3',
|
||||
notes: 'Bug fixes and improvements',
|
||||
})
|
||||
|
||||
@@ -178,11 +179,26 @@ describe('useUpdater', () => {
|
||||
expect(outcome).toBe('available')
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '1.2.0',
|
||||
version: '2026.4.16',
|
||||
displayVersion: '2026.4.16',
|
||||
notes: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('strips stable prerelease suffixes from the display version', async () => {
|
||||
const { result } = await performManualCheck(
|
||||
'stable',
|
||||
makeUpdate({ version: '2026.4.16-stable.1' }),
|
||||
)
|
||||
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '2026.4.16-stable.1',
|
||||
displayVersion: '2026.4.16',
|
||||
notes: 'Bug fixes and improvements',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error when the update check fails', async () => {
|
||||
const { result, outcome } = await performManualCheck(
|
||||
'stable',
|
||||
@@ -231,7 +247,7 @@ describe('useUpdater', () => {
|
||||
checkResult: makeUpdate(),
|
||||
downloadImpl: async (args) => {
|
||||
expect(args.releaseChannel).toBe('stable')
|
||||
expect(args.expectedVersion).toBe('1.2.0')
|
||||
expect(args.expectedVersion).toBe('2026.4.16')
|
||||
args.onEvent.onmessage({ event: 'Started', data: { contentLength: 1000 } })
|
||||
args.onEvent.onmessage({ event: 'Progress', data: { chunkLength: 500 } })
|
||||
args.onEvent.onmessage({ event: 'Progress', data: { chunkLength: 500 } })
|
||||
@@ -249,7 +265,11 @@ describe('useUpdater', () => {
|
||||
await result.current.actions.startDownload()
|
||||
})
|
||||
|
||||
expect(result.current.status).toEqual({ state: 'ready', version: '1.2.0' })
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'ready',
|
||||
version: '2026.4.16',
|
||||
displayVersion: '2026.4.16',
|
||||
})
|
||||
})
|
||||
|
||||
it('transitions to error when download fails', async () => {
|
||||
|
||||
@@ -9,12 +9,18 @@ import {
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/tolaria/'
|
||||
const CALENDAR_VERSION_PATTERN = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:-(alpha|stable)\.(\d+))?$/
|
||||
|
||||
interface UpdateVersionInfo {
|
||||
version: string
|
||||
displayVersion: string
|
||||
}
|
||||
|
||||
export type UpdateStatus =
|
||||
| { state: 'idle' }
|
||||
| { state: 'available'; version: string; notes: string | undefined }
|
||||
| { state: 'downloading'; version: string; progress: number }
|
||||
| { state: 'ready'; version: string }
|
||||
| ({ state: 'available'; notes: string | undefined } & UpdateVersionInfo)
|
||||
| ({ state: 'downloading'; progress: number } & UpdateVersionInfo)
|
||||
| ({ state: 'ready' } & UpdateVersionInfo)
|
||||
| { state: 'error' }
|
||||
|
||||
export type UpdateCheckResult = 'up-to-date' | 'available' | 'error'
|
||||
@@ -26,16 +32,41 @@ export interface UpdateActions {
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
function formatReleaseDisplayVersion(version: string): string {
|
||||
const normalizedVersion = version.trim()
|
||||
if (!normalizedVersion) return normalizedVersion
|
||||
|
||||
const baseVersion = normalizedVersion.split('+')[0]
|
||||
const match = baseVersion.match(CALENDAR_VERSION_PATTERN)
|
||||
if (!match) return baseVersion
|
||||
|
||||
const [, year, month, day, channel, sequence] = match
|
||||
const calendarVersion = `${Number(year)}.${Number(month)}.${Number(day)}`
|
||||
|
||||
if (channel === 'alpha' && sequence) {
|
||||
return `Alpha ${calendarVersion}.${Number(sequence)}`
|
||||
}
|
||||
|
||||
return calendarVersion
|
||||
}
|
||||
|
||||
function createVersionInfo(version: string): UpdateVersionInfo {
|
||||
return {
|
||||
version,
|
||||
displayVersion: formatReleaseDisplayVersion(version),
|
||||
}
|
||||
}
|
||||
|
||||
function toAvailableStatus(update: AppUpdateMetadata): UpdateStatus {
|
||||
return {
|
||||
state: 'available',
|
||||
version: update.version,
|
||||
...createVersionInfo(update.version),
|
||||
notes: update.body ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function createDownloadProgressHandler(
|
||||
version: string,
|
||||
versionInfo: UpdateVersionInfo,
|
||||
setStatus: Dispatch<SetStateAction<UpdateStatus>>,
|
||||
): (event: AppUpdateDownloadEvent) => void {
|
||||
let totalBytes = 0
|
||||
@@ -50,11 +81,11 @@ function createDownloadProgressHandler(
|
||||
if (event.event === 'Progress') {
|
||||
downloadedBytes += event.data.chunkLength
|
||||
const progress = totalBytes > 0 ? Math.min(downloadedBytes / totalBytes, 1) : 0
|
||||
setStatus({ state: 'downloading', version, progress })
|
||||
setStatus({ state: 'downloading', ...versionInfo, progress })
|
||||
return
|
||||
}
|
||||
|
||||
setStatus({ state: 'ready', version })
|
||||
setStatus({ state: 'ready', ...versionInfo })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,17 +125,18 @@ export function useUpdater(
|
||||
const update = updateRef.current
|
||||
if (!update) return
|
||||
|
||||
setStatus({ state: 'downloading', version: update.version, progress: 0 })
|
||||
const versionInfo = createVersionInfo(update.version)
|
||||
setStatus({ state: 'downloading', ...versionInfo, progress: 0 })
|
||||
|
||||
try {
|
||||
await downloadAndInstallAppUpdate(
|
||||
releaseChannel,
|
||||
update.version,
|
||||
createDownloadProgressHandler(update.version, setStatus),
|
||||
createDownloadProgressHandler(versionInfo, setStatus),
|
||||
)
|
||||
|
||||
// If Finished wasn't emitted via callback, set ready after await resolves
|
||||
setStatus((prev) => (prev.state === 'downloading' ? { state: 'ready', version: update.version } : prev))
|
||||
setStatus((prev) => (prev.state === 'downloading' ? { state: 'ready', ...versionInfo } : prev))
|
||||
} catch {
|
||||
console.warn('[updater] Download failed')
|
||||
setStatus({ state: 'error' })
|
||||
|
||||
Reference in New Issue
Block a user