mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-09 15:58:41 +00:00
Add Studio web update banner and release version display (#5308)
Some checks are pending
Core / Core (HF=default + TRL=default) (push) Waiting to run
Core / Core (HF=4.57.6 + TRL<1) (push) Waiting to run
Core / Core (HF=latest + TRL=latest) (push) Waiting to run
Core / llama.cpp build + smoke (push) Waiting to run
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
MLX CI on Mac M1 / dispatch (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Studio API CI / Studio API & Auth Tests (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Backend CI / (Python 3.10) (push) Waiting to run
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.12) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Studio GGUF CI / Tool calling Tests (push) Waiting to run
Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio API CI / Studio API & Auth Tests (push) Waiting to run
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Mac Studio GGUF CI / Tool calling Tests (push) Waiting to run
Mac Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio UI CI / Chat UI Tests (push) Waiting to run
Mac Studio Update CI / Studio Updating Tests (push) Waiting to run
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Studio UI CI / Chat UI Tests (push) Waiting to run
Studio Update CI / Studio Updating Tests (push) Waiting to run
Windows Studio API CI / Studio API & Auth Tests (push) Waiting to run
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Windows Studio GGUF CI / Tool calling Tests (push) Waiting to run
Windows Studio GGUF CI / JSON, images (push) Waiting to run
Windows Studio UI CI / Chat UI Tests (push) Waiting to run
Windows Studio Update CI / Studio Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Some checks are pending
Core / Core (HF=default + TRL=default) (push) Waiting to run
Core / Core (HF=4.57.6 + TRL<1) (push) Waiting to run
Core / Core (HF=latest + TRL=latest) (push) Waiting to run
Core / llama.cpp build + smoke (push) Waiting to run
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
MLX CI on Mac M1 / dispatch (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Studio API CI / Studio API & Auth Tests (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Backend CI / (Python 3.10) (push) Waiting to run
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.12) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Studio GGUF CI / Tool calling Tests (push) Waiting to run
Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio API CI / Studio API & Auth Tests (push) Waiting to run
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Mac Studio GGUF CI / Tool calling Tests (push) Waiting to run
Mac Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio UI CI / Chat UI Tests (push) Waiting to run
Mac Studio Update CI / Studio Updating Tests (push) Waiting to run
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Studio UI CI / Chat UI Tests (push) Waiting to run
Studio Update CI / Studio Updating Tests (push) Waiting to run
Windows Studio API CI / Studio API & Auth Tests (push) Waiting to run
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Windows Studio GGUF CI / Tool calling Tests (push) Waiting to run
Windows Studio GGUF CI / JSON, images (push) Waiting to run
Windows Studio UI CI / Chat UI Tests (push) Waiting to run
Windows Studio Update CI / Studio Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
* Add Studio web update and release version display * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Show package version in Studio settings * Break training unload guard barrel cycle --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
This commit is contained in:
parent
a6462876de
commit
23cebfaf98
14 changed files with 1426 additions and 72 deletions
31
build.sh
31
build.sh
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
set -euo pipefail
|
||||
|
||||
# PyPI/Studio release publishing must use `./build.sh publish` (or an
|
||||
# equivalent stamp -> build -> verify-dist -> upload flow) so packaged Studio
|
||||
# artifacts include the display-only Studio release version.
|
||||
|
||||
# 1. Build frontend (Vite outputs to dist/)
|
||||
cd studio/frontend
|
||||
|
||||
|
|
@ -70,10 +74,33 @@ cd ../..
|
|||
# 2. Clean old artifacts
|
||||
rm -rf build dist *.egg-info
|
||||
|
||||
# 3. Build wheel
|
||||
# 3. Stamp display-only Studio release metadata for packaged builds.
|
||||
_STUDIO_BUILD_INFO="studio/backend/utils/_studio_release_build.py"
|
||||
_STUDIO_BUILD_INFO_BACKUP="$(mktemp)"
|
||||
cp "$_STUDIO_BUILD_INFO" "$_STUDIO_BUILD_INFO_BACKUP"
|
||||
_restore_studio_build_info() {
|
||||
cp "$_STUDIO_BUILD_INFO_BACKUP" "$_STUDIO_BUILD_INFO" 2>/dev/null || true
|
||||
rm -f "$_STUDIO_BUILD_INFO_BACKUP"
|
||||
}
|
||||
trap _restore_studio_build_info EXIT
|
||||
|
||||
if [ "${1:-}" = "publish" ]; then
|
||||
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py --require-release)"
|
||||
else
|
||||
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)"
|
||||
fi
|
||||
|
||||
# 4. Build wheel/sdist
|
||||
python -m build
|
||||
|
||||
# 4. Optionally publish
|
||||
if [ "${1:-}" = "publish" ]; then
|
||||
python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION"
|
||||
fi
|
||||
|
||||
_restore_studio_build_info
|
||||
trap - EXIT
|
||||
|
||||
# 5. Optionally publish
|
||||
if [ "${1:-}" = "publish" ]; then
|
||||
python -m twine upload dist/*
|
||||
fi
|
||||
|
|
|
|||
257
scripts/stamp_studio_release.py
Executable file
257
scripts/stamp_studio_release.py
Executable file
|
|
@ -0,0 +1,257 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Stamp and verify display-only Studio release metadata for builds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
BUILD_INFO_PATH = (
|
||||
REPO_ROOT / "studio" / "backend" / "utils" / "_studio_release_build.py"
|
||||
)
|
||||
BUILD_INFO_SUFFIX = "studio/backend/utils/_studio_release_build.py"
|
||||
VERSION_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$")
|
||||
GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$")
|
||||
MAX_VERSION_LENGTH = 64
|
||||
PLACEHOLDER = """# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
\"\"\"Build-stamped Studio release metadata.
|
||||
|
||||
Release builds may rewrite this module in the build workspace before creating
|
||||
Python artifacts. Keep the committed value neutral so source checkouts do not
|
||||
accidentally report a stale release tag.
|
||||
\"\"\"
|
||||
|
||||
STUDIO_RELEASE_VERSION = None
|
||||
"""
|
||||
|
||||
|
||||
def is_valid_version(value: object) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
version = value.strip()
|
||||
if not version or len(version) > MAX_VERSION_LENGTH:
|
||||
return False
|
||||
if version.endswith("-dirty") or GIT_DESCRIBE_SUFFIX_RE.search(version):
|
||||
return False
|
||||
return VERSION_RE.fullmatch(version) is not None
|
||||
|
||||
|
||||
def _exact_git_tag() -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"describe",
|
||||
"--tags",
|
||||
"--exact-match",
|
||||
"--match",
|
||||
"v[0-9]*",
|
||||
"HEAD",
|
||||
],
|
||||
cwd = REPO_ROOT,
|
||||
check = False,
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.DEVNULL,
|
||||
text = True,
|
||||
timeout = 2.0,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
tag = result.stdout.strip()
|
||||
return tag if is_valid_version(tag) else None
|
||||
|
||||
|
||||
def _git_worktree_is_dirty() -> bool:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd = REPO_ROOT,
|
||||
check = False,
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.DEVNULL,
|
||||
text = True,
|
||||
timeout = 2.0,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return True
|
||||
if result.returncode != 0:
|
||||
return True
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def _github_tag() -> str | None:
|
||||
if os.environ.get("GITHUB_REF_TYPE") != "tag":
|
||||
return None
|
||||
github_ref = os.environ.get("GITHUB_REF_NAME", "").strip()
|
||||
return github_ref or None
|
||||
|
||||
|
||||
def resolve_version() -> tuple[str | None, str]:
|
||||
env_version = os.environ.get("UNSLOTH_STUDIO_RELEASE_VERSION", "").strip()
|
||||
if env_version:
|
||||
return (env_version, "UNSLOTH_STUDIO_RELEASE_VERSION")
|
||||
|
||||
github_ref = _github_tag()
|
||||
if github_ref:
|
||||
return (github_ref, "GITHUB_REF_NAME")
|
||||
|
||||
git_tag = _exact_git_tag()
|
||||
if git_tag:
|
||||
return (git_tag, "exact git tag")
|
||||
|
||||
return (None, "none")
|
||||
|
||||
|
||||
def build_info_source(version: str | None) -> str:
|
||||
literal = repr(version) if version is not None else "None"
|
||||
return f'''# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Build-stamped Studio release metadata."""
|
||||
|
||||
STUDIO_RELEASE_VERSION = {literal}
|
||||
'''
|
||||
|
||||
|
||||
def _env_version_conflicts(version: str) -> list[tuple[str, str]]:
|
||||
conflicts: list[tuple[str, str]] = []
|
||||
github_ref = _github_tag()
|
||||
if github_ref and is_valid_version(github_ref) and github_ref != version:
|
||||
conflicts.append(("GITHUB_REF_NAME", github_ref))
|
||||
|
||||
git_tag = _exact_git_tag()
|
||||
if git_tag and git_tag != version:
|
||||
conflicts.append(("exact git tag", git_tag))
|
||||
|
||||
return conflicts
|
||||
|
||||
|
||||
def stamp(require_release: bool) -> int:
|
||||
version, source = resolve_version()
|
||||
if version is not None and not is_valid_version(version):
|
||||
print(
|
||||
f"Invalid Studio release version from {source}: {version!r}",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
if version is not None and source == "UNSLOTH_STUDIO_RELEASE_VERSION":
|
||||
conflicts = _env_version_conflicts(version)
|
||||
if conflicts:
|
||||
details = ", ".join(f"{name}={value!r}" for name, value in conflicts)
|
||||
print(
|
||||
"UNSLOTH_STUDIO_RELEASE_VERSION does not match available "
|
||||
f"release tag metadata: {details}",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
if require_release and source == "exact git tag" and _git_worktree_is_dirty():
|
||||
print(
|
||||
"Refusing to publish from a dirty exact-tag checkout. Set "
|
||||
"UNSLOTH_STUDIO_RELEASE_VERSION explicitly from release automation "
|
||||
"or publish from a clean tag checkout.",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
if version is None:
|
||||
if require_release:
|
||||
print(
|
||||
"No Studio release version available. Set "
|
||||
"UNSLOTH_STUDIO_RELEASE_VERSION, build from a GitHub tag, "
|
||||
"or run from an exact local Studio release tag.",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return 2
|
||||
BUILD_INFO_PATH.write_text(PLACEHOLDER, encoding = "utf-8")
|
||||
print("dev")
|
||||
return 0
|
||||
|
||||
BUILD_INFO_PATH.write_text(build_info_source(version), encoding = "utf-8")
|
||||
print(f"Stamping Studio release version {version} from {source}", file = sys.stderr)
|
||||
print(version)
|
||||
return 0
|
||||
|
||||
|
||||
def _read_wheel_member(path: Path) -> str | None:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
for name in archive.namelist():
|
||||
if name.endswith(BUILD_INFO_SUFFIX):
|
||||
return archive.read(name).decode("utf-8")
|
||||
return None
|
||||
|
||||
|
||||
def _read_sdist_member(path: Path) -> str | None:
|
||||
with tarfile.open(path) as archive:
|
||||
for member in archive.getmembers():
|
||||
if member.name.endswith(BUILD_INFO_SUFFIX):
|
||||
extracted = archive.extractfile(member)
|
||||
if extracted is None:
|
||||
return None
|
||||
return extracted.read().decode("utf-8")
|
||||
return None
|
||||
|
||||
|
||||
def verify_dist(expected: str, dist_dir: Path) -> int:
|
||||
if not is_valid_version(expected):
|
||||
print(f"Invalid expected Studio release version: {expected!r}", file = sys.stderr)
|
||||
return 2
|
||||
|
||||
artifacts = list(dist_dir.glob("*.whl")) + list(dist_dir.glob("*.tar.gz"))
|
||||
if not artifacts:
|
||||
print(f"No wheel or sdist artifacts found in {dist_dir}", file = sys.stderr)
|
||||
return 2
|
||||
|
||||
expected_line = f"STUDIO_RELEASE_VERSION = {expected!r}"
|
||||
failures: list[str] = []
|
||||
for artifact in artifacts:
|
||||
if artifact.suffix == ".whl":
|
||||
content = _read_wheel_member(artifact)
|
||||
else:
|
||||
content = _read_sdist_member(artifact)
|
||||
if content is None:
|
||||
failures.append(f"{artifact.name}: missing {BUILD_INFO_SUFFIX}")
|
||||
elif expected_line not in content:
|
||||
failures.append(f"{artifact.name}: Studio release version mismatch")
|
||||
|
||||
if failures:
|
||||
for failure in failures:
|
||||
print(failure, file = sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"Verified Studio release version {expected} in {len(artifacts)} artifact(s)")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description = __doc__)
|
||||
parser.add_argument("--require-release", action = "store_true")
|
||||
parser.add_argument("--verify-dist", type = Path)
|
||||
parser.add_argument("--expected")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verify_dist is not None:
|
||||
if not args.expected:
|
||||
parser.error("--verify-dist requires --expected")
|
||||
return verify_dist(args.expected, args.verify_dist)
|
||||
|
||||
return stamp(require_release = args.require_release)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -134,6 +134,11 @@ import utils.hardware.hardware as _hw_module
|
|||
|
||||
from utils.cache_cleanup import clear_unsloth_compiled_cache
|
||||
from utils.native_path_leases import native_path_leases_supported
|
||||
from utils.update_status import (
|
||||
get_studio_install_source_status,
|
||||
get_studio_update_status,
|
||||
)
|
||||
from utils.studio_version import get_studio_version
|
||||
|
||||
|
||||
def get_unsloth_version() -> str:
|
||||
|
|
@ -155,6 +160,7 @@ def get_unsloth_version() -> str:
|
|||
|
||||
|
||||
UNSLOTH_VERSION = get_unsloth_version()
|
||||
STUDIO_VERSION = get_studio_version()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -296,6 +302,7 @@ async def health_check():
|
|||
"timestamp": datetime.now().isoformat(),
|
||||
"service": "Unsloth UI Backend",
|
||||
"version": UNSLOTH_VERSION,
|
||||
"studio_version": STUDIO_VERSION,
|
||||
"device_type": device_type,
|
||||
"chat_only": _hw_module.CHAT_ONLY,
|
||||
"desktop_protocol_version": 1,
|
||||
|
|
@ -308,6 +315,18 @@ async def health_check():
|
|||
}
|
||||
|
||||
|
||||
@app.get("/api/studio/install-source")
|
||||
def studio_install_source(_current_subject: str = Depends(get_current_subject)):
|
||||
"""Return source-aware install metadata without remote update checks."""
|
||||
return get_studio_install_source_status(UNSLOTH_VERSION)
|
||||
|
||||
|
||||
@app.get("/api/studio/update-status")
|
||||
def studio_update_status(_current_subject: str = Depends(get_current_subject)):
|
||||
"""Return source-aware manual update status for browser-served Studio."""
|
||||
return get_studio_update_status(UNSLOTH_VERSION)
|
||||
|
||||
|
||||
@app.post("/api/shutdown")
|
||||
async def shutdown_server(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ typer
|
|||
fastapi
|
||||
uvicorn
|
||||
pydantic
|
||||
packaging
|
||||
matplotlib
|
||||
pandas
|
||||
nest_asyncio
|
||||
|
|
|
|||
11
studio/backend/utils/_studio_release_build.py
Normal file
11
studio/backend/utils/_studio_release_build.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Build-stamped Studio release metadata.
|
||||
|
||||
Release builds may rewrite this module in the build workspace before creating
|
||||
Python artifacts. Keep the committed value neutral so source checkouts do not
|
||||
accidentally report a stale release tag.
|
||||
"""
|
||||
|
||||
STUDIO_RELEASE_VERSION = None
|
||||
92
studio/backend/utils/studio_version.py
Normal file
92
studio/backend/utils/studio_version.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Network-free Studio release version resolution for display-only UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from utils import _studio_release_build
|
||||
|
||||
_DEV_VERSION = "dev"
|
||||
_GIT_TIMEOUT_SECONDS = 1.0
|
||||
_STUDIO_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$")
|
||||
_GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$")
|
||||
_MAX_VERSION_LENGTH = 64
|
||||
|
||||
|
||||
def is_valid_studio_release_version(value: object) -> bool:
|
||||
"""Return True for Studio release tags such as ``v0.1.39-beta``."""
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
version = value.strip()
|
||||
if not version or len(version) > _MAX_VERSION_LENGTH:
|
||||
return False
|
||||
if version.endswith("-dirty") or _GIT_DESCRIBE_SUFFIX_RE.search(version):
|
||||
return False
|
||||
return _STUDIO_TAG_RE.fullmatch(version) is not None
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _path_is_in_site_packages(path: Path) -> bool:
|
||||
return any(part in {"site-packages", "dist-packages"} for part in path.parts)
|
||||
|
||||
|
||||
def _is_source_checkout(repo_root: Path) -> bool:
|
||||
return (repo_root / ".git").exists() and not _path_is_in_site_packages(
|
||||
Path(__file__).resolve()
|
||||
)
|
||||
|
||||
|
||||
def _exact_git_studio_tag(repo_root: Path) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"describe",
|
||||
"--tags",
|
||||
"--exact-match",
|
||||
"--match",
|
||||
"v[0-9]*",
|
||||
"HEAD",
|
||||
],
|
||||
cwd = repo_root,
|
||||
check = False,
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.DEVNULL,
|
||||
text = True,
|
||||
timeout = _GIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
tag = result.stdout.strip()
|
||||
return tag if is_valid_studio_release_version(tag) else None
|
||||
|
||||
|
||||
def get_studio_version(repo_root: Path | None = None) -> str:
|
||||
"""Return the installed Studio release tag for display, or ``dev``.
|
||||
|
||||
This value is intentionally separate from the PyPI ``unsloth`` package
|
||||
version used by update checks. It never performs network requests.
|
||||
"""
|
||||
resolved_repo_root = repo_root or _repo_root()
|
||||
|
||||
if _is_source_checkout(resolved_repo_root):
|
||||
git_tag = _exact_git_studio_tag(resolved_repo_root)
|
||||
return git_tag if git_tag is not None else _DEV_VERSION
|
||||
|
||||
stamped_version = _studio_release_build.STUDIO_RELEASE_VERSION
|
||||
if is_valid_studio_release_version(stamped_version):
|
||||
return stamped_version.strip()
|
||||
|
||||
return _DEV_VERSION
|
||||
374
studio/backend/utils/update_status.py
Normal file
374
studio/backend/utils/update_status.py
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Web update status helpers for browser-served Unsloth Studio.
|
||||
|
||||
This module is intentionally side-effect light: no network work happens at
|
||||
import time or from /api/health. The PyPI check is lazy, cached, and only used
|
||||
for normal PyPI-managed installs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from importlib.metadata import PackageNotFoundError, distribution
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
PACKAGE_NAME = "unsloth"
|
||||
PYPI_JSON_URL = "https://pypi.org/pypi/unsloth/json"
|
||||
PYPI_TIMEOUT_SECONDS = 3
|
||||
PYPI_RESPONSE_MAX_BYTES = 5 * 1024 * 1024
|
||||
PYPI_SUCCESS_TTL_SECONDS = 12 * 60 * 60
|
||||
PYPI_FAILURE_TTL_SECONDS = 60 * 60
|
||||
RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog"
|
||||
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_UPDATE_CHECK"
|
||||
|
||||
LOCAL_INSTALL_SOURCES = {"editable", "local_path", "vcs", "local_repo"}
|
||||
|
||||
|
||||
@dataclass(frozen = True)
|
||||
class LatestVersionResult:
|
||||
latest_version: str | None
|
||||
checked_at: str
|
||||
reason: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _LatestVersionCacheEntry:
|
||||
result: LatestVersionResult
|
||||
expires_at: float
|
||||
|
||||
|
||||
_cache_condition = threading.Condition()
|
||||
_latest_version_cache: _LatestVersionCacheEntry | None = None
|
||||
_latest_version_fetching = False
|
||||
|
||||
|
||||
def reset_update_status_cache() -> None:
|
||||
"""Clear the in-process PyPI cache. Intended for tests."""
|
||||
global _latest_version_cache, _latest_version_fetching
|
||||
with _cache_condition:
|
||||
_latest_version_cache = None
|
||||
_latest_version_fetching = False
|
||||
_cache_condition.notify_all()
|
||||
|
||||
|
||||
def detect_install_source() -> str:
|
||||
"""Return a coarse install source without exposing local paths.
|
||||
|
||||
Sources are intentionally conservative. PEP 610 local/vcs metadata wins.
|
||||
Legacy source installs are treated as local only when package files resolve
|
||||
outside site-packages/dist-packages and under a Git checkout.
|
||||
"""
|
||||
try:
|
||||
dist = distribution(PACKAGE_NAME)
|
||||
except PackageNotFoundError:
|
||||
return (
|
||||
"local_repo"
|
||||
if _path_has_git_parent(_repo_root_from_this_file())
|
||||
else "unknown"
|
||||
)
|
||||
|
||||
try:
|
||||
direct_url = dist.read_text("direct_url.json")
|
||||
except Exception:
|
||||
return "unknown"
|
||||
if direct_url:
|
||||
return _source_from_direct_url(direct_url)
|
||||
|
||||
for package_path in _distribution_package_paths(dist):
|
||||
if not _path_is_under_python_package_dir(package_path) and _path_has_git_parent(
|
||||
package_path
|
||||
):
|
||||
return "local_repo"
|
||||
|
||||
return "pypi"
|
||||
|
||||
|
||||
def get_studio_install_source_status(current_version: str) -> dict[str, Any]:
|
||||
"""Return install-source metadata without remote update checks."""
|
||||
install_source = detect_install_source()
|
||||
reason = None
|
||||
if install_source in LOCAL_INSTALL_SOURCES:
|
||||
reason = "local_source"
|
||||
elif install_source == "unknown":
|
||||
reason = "unknown_source"
|
||||
|
||||
return _status_response(
|
||||
current_version = current_version,
|
||||
latest_version = None,
|
||||
install_source = install_source,
|
||||
reason = reason,
|
||||
)
|
||||
|
||||
|
||||
def get_studio_update_status(current_version: str) -> dict[str, Any]:
|
||||
"""Return public, read-only update status for the web UI."""
|
||||
install_source = detect_install_source()
|
||||
|
||||
if os.environ.get(DISABLE_ENV_VAR) == "1":
|
||||
return _status_response(
|
||||
current_version = current_version,
|
||||
latest_version = None,
|
||||
install_source = install_source,
|
||||
reason = "disabled",
|
||||
)
|
||||
|
||||
if install_source in LOCAL_INSTALL_SOURCES:
|
||||
return _status_response(
|
||||
current_version = current_version,
|
||||
latest_version = None,
|
||||
install_source = install_source,
|
||||
reason = "local_source",
|
||||
)
|
||||
|
||||
if install_source != "pypi":
|
||||
return _status_response(
|
||||
current_version = current_version,
|
||||
latest_version = None,
|
||||
install_source = install_source,
|
||||
reason = "unknown_source",
|
||||
)
|
||||
|
||||
current = _parse_current_version(current_version)
|
||||
if current is None:
|
||||
return _status_response(
|
||||
current_version = current_version,
|
||||
latest_version = None,
|
||||
install_source = install_source,
|
||||
reason = "invalid_current_version"
|
||||
if current_version != "dev"
|
||||
else "dev_build",
|
||||
)
|
||||
latest_result = get_latest_pypi_version()
|
||||
if latest_result.latest_version is None:
|
||||
return _status_response(
|
||||
current_version = current_version,
|
||||
latest_version = None,
|
||||
install_source = install_source,
|
||||
reason = latest_result.reason or "offline",
|
||||
error = latest_result.error,
|
||||
checked_at = latest_result.checked_at,
|
||||
)
|
||||
|
||||
try:
|
||||
latest = Version(latest_result.latest_version)
|
||||
except InvalidVersion:
|
||||
return _status_response(
|
||||
current_version = current_version,
|
||||
latest_version = latest_result.latest_version,
|
||||
install_source = install_source,
|
||||
reason = "invalid_latest_version",
|
||||
error = "PyPI returned an invalid version.",
|
||||
checked_at = latest_result.checked_at,
|
||||
)
|
||||
|
||||
if latest > current:
|
||||
return _status_response(
|
||||
current_version = current_version,
|
||||
latest_version = latest_result.latest_version,
|
||||
install_source = install_source,
|
||||
update_available = True,
|
||||
can_show_web_notification = True,
|
||||
checked_at = latest_result.checked_at,
|
||||
)
|
||||
|
||||
return _status_response(
|
||||
current_version = current_version,
|
||||
latest_version = latest_result.latest_version,
|
||||
install_source = install_source,
|
||||
reason = "current_not_older",
|
||||
checked_at = latest_result.checked_at,
|
||||
)
|
||||
|
||||
|
||||
def get_latest_pypi_version() -> LatestVersionResult:
|
||||
"""Return the latest PyPI version using a small in-process TTL cache."""
|
||||
global _latest_version_cache, _latest_version_fetching
|
||||
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
with _cache_condition:
|
||||
if _latest_version_cache and _latest_version_cache.expires_at > now:
|
||||
return _latest_version_cache.result
|
||||
if not _latest_version_fetching:
|
||||
_latest_version_fetching = True
|
||||
break
|
||||
_cache_condition.wait(timeout = PYPI_TIMEOUT_SECONDS + 1)
|
||||
|
||||
try:
|
||||
result = _fetch_latest_pypi_version()
|
||||
except Exception:
|
||||
result = LatestVersionResult(
|
||||
latest_version = None,
|
||||
checked_at = _utc_now_iso(),
|
||||
reason = "offline",
|
||||
error = "Could not check PyPI update metadata.",
|
||||
)
|
||||
|
||||
ttl = (
|
||||
PYPI_SUCCESS_TTL_SECONDS if result.latest_version else PYPI_FAILURE_TTL_SECONDS
|
||||
)
|
||||
with _cache_condition:
|
||||
_latest_version_cache = _LatestVersionCacheEntry(
|
||||
result = result,
|
||||
expires_at = time.monotonic() + ttl,
|
||||
)
|
||||
_latest_version_fetching = False
|
||||
_cache_condition.notify_all()
|
||||
return result
|
||||
|
||||
|
||||
def _fetch_latest_pypi_version() -> LatestVersionResult:
|
||||
checked_at = _utc_now_iso()
|
||||
request = urllib.request.Request(
|
||||
PYPI_JSON_URL,
|
||||
headers = {"User-Agent": "unsloth-studio-update-check"},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout = PYPI_TIMEOUT_SECONDS) as response:
|
||||
body = response.read(PYPI_RESPONSE_MAX_BYTES + 1)
|
||||
if len(body) > PYPI_RESPONSE_MAX_BYTES:
|
||||
return LatestVersionResult(
|
||||
latest_version = None,
|
||||
checked_at = checked_at,
|
||||
reason = "malformed_response",
|
||||
error = "PyPI returned oversized update metadata.",
|
||||
)
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return LatestVersionResult(
|
||||
latest_version = None,
|
||||
checked_at = checked_at,
|
||||
reason = "malformed_response",
|
||||
error = "PyPI returned malformed update metadata.",
|
||||
)
|
||||
except OSError:
|
||||
return LatestVersionResult(
|
||||
latest_version = None,
|
||||
checked_at = checked_at,
|
||||
reason = "offline",
|
||||
error = "Could not reach PyPI for update metadata.",
|
||||
)
|
||||
|
||||
latest = (
|
||||
payload.get("info", {}).get("version") if isinstance(payload, dict) else None
|
||||
)
|
||||
if not isinstance(latest, str) or not latest.strip():
|
||||
return LatestVersionResult(
|
||||
latest_version = None,
|
||||
checked_at = checked_at,
|
||||
reason = "malformed_response",
|
||||
error = "PyPI update metadata did not include a version.",
|
||||
)
|
||||
|
||||
return LatestVersionResult(latest_version = latest.strip(), checked_at = checked_at)
|
||||
|
||||
|
||||
def _status_response(
|
||||
*,
|
||||
current_version: str,
|
||||
latest_version: str | None,
|
||||
install_source: str,
|
||||
reason: str | None = None,
|
||||
error: str | None = None,
|
||||
update_available: bool = False,
|
||||
can_show_web_notification: bool = False,
|
||||
checked_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"current_version": current_version,
|
||||
"latest_version": latest_version,
|
||||
"update_available": update_available,
|
||||
"install_source": install_source,
|
||||
"can_show_web_notification": can_show_web_notification,
|
||||
"release_notes_url": RELEASE_NOTES_URL,
|
||||
"checked_at": checked_at or _utc_now_iso(),
|
||||
"reason": reason,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def _source_from_direct_url(direct_url: str) -> str:
|
||||
try:
|
||||
payload = json.loads(direct_url)
|
||||
except json.JSONDecodeError:
|
||||
return "unknown"
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return "unknown"
|
||||
|
||||
dir_info = payload.get("dir_info")
|
||||
if isinstance(dir_info, dict) and dir_info.get("editable") is True:
|
||||
return "editable"
|
||||
|
||||
if isinstance(payload.get("vcs_info"), dict):
|
||||
return "vcs"
|
||||
|
||||
url = payload.get("url")
|
||||
if isinstance(url, str) and url.startswith("file:"):
|
||||
return "local_path"
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _distribution_package_paths(dist: Any) -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
files = getattr(dist, "files", None) or []
|
||||
for file in files:
|
||||
text = str(file)
|
||||
if not text.startswith(("unsloth/", "unsloth_cli/", "studio/")):
|
||||
continue
|
||||
try:
|
||||
paths.append(Path(dist.locate_file(file)).resolve())
|
||||
except OSError:
|
||||
continue
|
||||
return paths
|
||||
|
||||
|
||||
def _path_is_under_python_package_dir(path: Path) -> bool:
|
||||
return any(part in {"site-packages", "dist-packages"} for part in path.parts)
|
||||
|
||||
|
||||
def _path_has_git_parent(path: Path) -> bool:
|
||||
for candidate in (path, *path.parents):
|
||||
if (candidate / ".git").exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _repo_root_from_this_file() -> Path:
|
||||
# update_status.py -> utils -> backend -> studio -> repo root
|
||||
try:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
except IndexError:
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _parse_current_version(current_version: str) -> Version | None:
|
||||
if current_version == "dev":
|
||||
return None
|
||||
try:
|
||||
return Version(current_version)
|
||||
except InvalidVersion:
|
||||
return None
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return (
|
||||
datetime.now(timezone.utc)
|
||||
.replace(microsecond = 0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
shouldUseCustomWindowTitlebar,
|
||||
} from "@/components/tauri/window-titlebar";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { WebUpdateBanner } from "@/components/web/update-banner";
|
||||
import { getTauriAuthFailure, tauriAutoAuth } from "@/features/auth";
|
||||
import { NativeIntentDrain } from "@/features/native-intents/native-intent-drain";
|
||||
import { useTauriBackend, type BackendStatus } from "@/hooks/use-tauri-backend";
|
||||
|
|
@ -154,6 +155,13 @@ const HIDDEN_TITLEBAR_SIDEBAR_ROUTES = new Set([
|
|||
"/signup",
|
||||
]);
|
||||
|
||||
const WEB_UPDATE_HIDDEN_ROUTES = new Set([
|
||||
"/onboarding",
|
||||
"/login",
|
||||
"/change-password",
|
||||
"/signup",
|
||||
]);
|
||||
|
||||
function TauriWrapper({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const {
|
||||
|
|
@ -234,7 +242,14 @@ function TauriWrapper({ children }: { children: ReactNode }) {
|
|||
return () => { disposed = true; };
|
||||
}, [status, desktopAuthRetry]);
|
||||
|
||||
if (!isTauri) return <>{children}</>;
|
||||
if (!isTauri) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<WebUpdateBanner enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const showApp = status === "running" && desktopAuthReady;
|
||||
const startupStatus = status === "running" ? "starting" : status;
|
||||
|
|
|
|||
136
studio/frontend/src/components/web/update-banner.tsx
Normal file
136
studio/frontend/src/components/web/update-banner.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||
|
||||
const STUDIO_UPDATE_CMD = "unsloth studio update";
|
||||
const RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog";
|
||||
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
|
||||
|
||||
interface WebUpdateBannerProps {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function WebUpdateBanner({
|
||||
enabled = true,
|
||||
}: WebUpdateBannerProps): ReactElement | null {
|
||||
const { status, dismiss } = useWebUpdateCheck({ enabled });
|
||||
const [copiedVersion, setCopiedVersion] = useState<string | null>(null);
|
||||
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dismissTimerRef.current) {
|
||||
clearTimeout(dismissTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isTauri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleCopyCommand() {
|
||||
if (!(await copyToClipboard(STUDIO_UPDATE_CMD))) {
|
||||
return;
|
||||
}
|
||||
setCopiedVersion(status?.latestVersion ?? null);
|
||||
if (dismissTimerRef.current) {
|
||||
clearTimeout(dismissTimerRef.current);
|
||||
}
|
||||
dismissTimerRef.current = setTimeout(() => dismiss(), 900);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{status ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -12, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.97 }}
|
||||
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
|
||||
className="fixed top-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[380px]"
|
||||
>
|
||||
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-5 py-4 shadow-lg backdrop-blur-md">
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="absolute top-3 right-3 flex size-6 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Dismiss update notification"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11 3L3 11M3 3l8 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-2 pr-5">
|
||||
<span className="text-lg" aria-hidden="true">
|
||||
🦥
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Package update available: {status.latestVersion}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
Installed package: {status.currentVersion}. To update Studio,
|
||||
run this in your terminal, then restart Studio.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="corner-squircle"
|
||||
onClick={handleCopyCommand}
|
||||
>
|
||||
{copiedVersion === status.latestVersion
|
||||
? "Copied"
|
||||
: "Copy command"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="corner-squircle"
|
||||
asChild={true}
|
||||
>
|
||||
<a
|
||||
href={RELEASE_NOTES_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Release notes
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="corner-squircle"
|
||||
onClick={dismiss}
|
||||
>
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
|
|
@ -14,11 +14,42 @@ const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
|
|||
"curl -fsSL https://unsloth.ai/install.sh | sh";
|
||||
const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD =
|
||||
"irm https://unsloth.ai/install.ps1 | iex";
|
||||
const STUDIO_LOCAL_PULL_CMD = "git pull --ff-only";
|
||||
const STUDIO_LOCAL_UPDATE_CMD = "unsloth studio update --local";
|
||||
const STUDIO_LOCAL_FALLBACK_UNIX_CMD = "./install.sh --local";
|
||||
const STUDIO_LOCAL_FALLBACK_WINDOWS_CMD = ".\\install.ps1 --local";
|
||||
|
||||
export type UpdateShell = "windows" | "unix";
|
||||
export type UpdateInstallSource =
|
||||
| "pypi"
|
||||
| "editable"
|
||||
| "local_path"
|
||||
| "vcs"
|
||||
| "local_repo"
|
||||
| "unknown";
|
||||
type UpdateInstallSourceState = UpdateInstallSource | "loading";
|
||||
|
||||
function getStudioUpdateInstructionLine(shell: UpdateShell): string {
|
||||
return shell === "windows" ? "Open PowerShell and run:" : "Open Terminal and run:";
|
||||
return shell === "windows"
|
||||
? "Open PowerShell and run:"
|
||||
: "Open Terminal and run:";
|
||||
}
|
||||
|
||||
function isLocalInstallSource(
|
||||
installSource?: UpdateInstallSourceState | null,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
installSource &&
|
||||
installSource !== "pypi" &&
|
||||
installSource !== "unknown" &&
|
||||
installSource !== "loading",
|
||||
);
|
||||
}
|
||||
|
||||
function isUnknownInstallSource(
|
||||
installSource?: UpdateInstallSourceState | null,
|
||||
): boolean {
|
||||
return installSource === "unknown";
|
||||
}
|
||||
|
||||
function CopyableCommand({
|
||||
|
|
@ -54,7 +85,7 @@ function CopyableCommand({
|
|||
<div className="flex min-w-0 items-stretch overflow-hidden rounded-md border border-border bg-muted/40">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
readOnly={true}
|
||||
value={command}
|
||||
className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none"
|
||||
title={command}
|
||||
|
|
@ -68,7 +99,10 @@ function CopyableCommand({
|
|||
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
|
||||
>
|
||||
{copied ? (
|
||||
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-emerald-600" />
|
||||
<HugeiconsIcon
|
||||
icon={Tick02Icon}
|
||||
className="size-4 text-emerald-600"
|
||||
/>
|
||||
) : (
|
||||
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
|
||||
)}
|
||||
|
|
@ -77,24 +111,38 @@ function CopyableCommand({
|
|||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: keep source-specific update guidance in one component so the command matrix stays visible.
|
||||
export function UpdateStudioInstructions({
|
||||
className,
|
||||
defaultShell,
|
||||
installSource,
|
||||
showTitle = true,
|
||||
}: {
|
||||
className?: string;
|
||||
defaultShell: UpdateShell;
|
||||
installSource?: UpdateInstallSourceState | null;
|
||||
showTitle?: boolean;
|
||||
}): ReactElement {
|
||||
const [shell, setShell] = useState<UpdateShell>(defaultShell);
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const windows = shell === "windows";
|
||||
const localInstallSource = isLocalInstallSource(installSource);
|
||||
const checkoutInstallSource =
|
||||
installSource === "editable" || installSource === "local_repo";
|
||||
const packagedSourceInstall =
|
||||
installSource === "vcs" || installSource === "local_path";
|
||||
const loadingInstallSource = installSource === "loading";
|
||||
const unknownInstallSource = isUnknownInstallSource(installSource);
|
||||
const fadeTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.16, ease: [0.165, 0.84, 0.44, 1] as const };
|
||||
const fadeInitial = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 2 };
|
||||
const fadeInitial = prefersReducedMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 0, y: 2 };
|
||||
const fadeAnimate = { opacity: 1, y: 0 };
|
||||
const fadeExit = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: -2 };
|
||||
const fadeExit = prefersReducedMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 0, y: -2 };
|
||||
|
||||
useEffect(() => {
|
||||
setShell(defaultShell);
|
||||
|
|
@ -133,9 +181,9 @@ export function UpdateStudioInstructions({
|
|||
onClick={() => setShell("unix")}
|
||||
className={cn(
|
||||
"px-0.5 py-0.5 font-medium transition-colors",
|
||||
!windows
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-emerald-600",
|
||||
windows
|
||||
? "text-muted-foreground hover:text-emerald-600"
|
||||
: "text-foreground",
|
||||
)}
|
||||
aria-pressed={!windows}
|
||||
>
|
||||
|
|
@ -143,43 +191,157 @@ export function UpdateStudioInstructions({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.p
|
||||
key={`instruction-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
transition={fadeTransition}
|
||||
className="text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{getStudioUpdateInstructionLine(shell)}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
<CopyableCommand command={STUDIO_UPDATE_CMD} copyLabel="update command" />
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
If that fails or unsloth studio update is unavailable, run:
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`fallback-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
transition={fadeTransition}
|
||||
>
|
||||
{loadingInstallSource ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Checking how Studio was installed…
|
||||
</p>
|
||||
) : localInstallSource ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Source or local install detected. To avoid replacing it with PyPI,
|
||||
update from the checkout or source you originally installed from.
|
||||
</p>
|
||||
{checkoutInstallSource ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Pull latest changes from your Unsloth repo checkout, then update
|
||||
Studio locally:
|
||||
</p>
|
||||
<CopyableCommand
|
||||
command={STUDIO_LOCAL_PULL_CMD}
|
||||
copyLabel="git pull command"
|
||||
/>
|
||||
<CopyableCommand
|
||||
command={STUDIO_LOCAL_UPDATE_CMD}
|
||||
copyLabel="local update command"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
If the Studio update command is unavailable, run the local
|
||||
installer from that checkout:
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`local-fallback-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
transition={fadeTransition}
|
||||
>
|
||||
<CopyableCommand
|
||||
command={
|
||||
windows
|
||||
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
|
||||
}
|
||||
copyLabel="local installer command"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</>
|
||||
) : null}
|
||||
{packagedSourceInstall ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
This looks like a source or VCS package install. Reinstall from
|
||||
the original local path or Git URL you used.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
If you still have the Unsloth repo checkout, run the local
|
||||
installer from that checkout:
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`source-fallback-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
transition={fadeTransition}
|
||||
>
|
||||
<CopyableCommand
|
||||
command={
|
||||
windows
|
||||
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
|
||||
}
|
||||
copyLabel="local installer command"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Restart Studio after updating for changes to take effect.
|
||||
</p>
|
||||
</>
|
||||
) : unknownInstallSource ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Studio could not detect how it was installed. Check how you
|
||||
installed Studio first, then choose the matching update path.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
For curl or PyPI installs, run:
|
||||
</p>
|
||||
<CopyableCommand
|
||||
command={
|
||||
windows
|
||||
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
|
||||
}
|
||||
copyLabel="fallback command"
|
||||
command={STUDIO_UPDATE_CMD}
|
||||
copyLabel="update command"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Restart Studio after updating for changes to take effect.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
For local checkout installs, update from that checkout instead and
|
||||
use the local update command:
|
||||
</p>
|
||||
<CopyableCommand
|
||||
command={STUDIO_LOCAL_UPDATE_CMD}
|
||||
copyLabel="local update command"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Restart Studio after updating for changes to take effect.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.p
|
||||
key={`instruction-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
transition={fadeTransition}
|
||||
className="text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{getStudioUpdateInstructionLine(shell)}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
<CopyableCommand
|
||||
command={STUDIO_UPDATE_CMD}
|
||||
copyLabel="update command"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
If that fails or unsloth studio update is unavailable, run:
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`fallback-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
transition={fadeTransition}
|
||||
>
|
||||
<CopyableCommand
|
||||
command={
|
||||
windows
|
||||
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
|
||||
}
|
||||
copyLabel="fallback command"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Restart Studio after updating for changes to take effect.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShutdownDialog } from "@/components/shutdown-dialog";
|
||||
import { UpdateStudioInstructions } from "../components/update-studio-instructions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
|
||||
import { getAuthToken } from "@/features/auth";
|
||||
import { removeTrainingUnloadGuard } from "@/features/training";
|
||||
import { apiUrl, isTauri } from "@/lib/api-base";
|
||||
import {
|
||||
ArrowUpRight01Icon,
|
||||
Book03Icon,
|
||||
|
|
@ -18,28 +18,108 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
|||
import { useEffect, useState } from "react";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
import {
|
||||
type UpdateInstallSource,
|
||||
UpdateStudioInstructions,
|
||||
} from "../components/update-studio-instructions";
|
||||
|
||||
type ApiObject = Record<string, unknown>;
|
||||
|
||||
const INSTALL_SOURCE_KEY = "install_source";
|
||||
|
||||
const UPDATE_INSTALL_SOURCES = new Set<UpdateInstallSource>([
|
||||
"pypi",
|
||||
"editable",
|
||||
"local_path",
|
||||
"vcs",
|
||||
"local_repo",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
function isUpdateInstallSource(value: unknown): value is UpdateInstallSource {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
UPDATE_INSTALL_SOURCES.has(value as UpdateInstallSource)
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchStudioVersions(): Promise<{
|
||||
packageVersion: string | null;
|
||||
studioVersion: string | null;
|
||||
}> {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/health"));
|
||||
if (!res.ok) {
|
||||
return { packageVersion: null, studioVersion: null };
|
||||
}
|
||||
const data = (await res.json()) as ApiObject;
|
||||
const packageVersion = data.version;
|
||||
const studioVersion = data.studio_version;
|
||||
return {
|
||||
packageVersion:
|
||||
typeof packageVersion === "string" ? packageVersion : null,
|
||||
studioVersion: typeof studioVersion === "string" ? studioVersion : null,
|
||||
};
|
||||
} catch {
|
||||
return { packageVersion: null, studioVersion: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInstallSource(): Promise<UpdateInstallSource> {
|
||||
if (isTauri) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = new Headers();
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
const res = await fetch(apiUrl("/api/studio/install-source"), { headers });
|
||||
if (!res.ok) {
|
||||
return "unknown";
|
||||
}
|
||||
const data = (await res.json()) as ApiObject;
|
||||
const installSource = data[INSTALL_SOURCE_KEY];
|
||||
return isUpdateInstallSource(installSource) ? installSource : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function AboutTab() {
|
||||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const defaultShell = deviceType === "windows" ? "windows" : "unix";
|
||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||
const [version, setVersion] = useState("dev");
|
||||
const [packageVersion, setPackageVersion] = useState("dev");
|
||||
const [studioVersion, setStudioVersion] = useState("dev");
|
||||
const [installSource, setInstallSource] = useState<
|
||||
UpdateInstallSource | "loading"
|
||||
>("loading");
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/health"));
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { version?: string };
|
||||
if (!canceled && data.version) {
|
||||
setVersion(data.version);
|
||||
}
|
||||
} catch {
|
||||
// fall back to dev label
|
||||
fetchStudioVersions().then((nextVersions) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
})();
|
||||
if (nextVersions.packageVersion) {
|
||||
setPackageVersion(nextVersions.packageVersion);
|
||||
}
|
||||
if (nextVersions.studioVersion) {
|
||||
setStudioVersion(nextVersions.studioVersion);
|
||||
}
|
||||
});
|
||||
|
||||
fetchInstallSource().then((nextInstallSource) => {
|
||||
if (!canceled) {
|
||||
setInstallSource(nextInstallSource);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
|
|
@ -56,14 +136,25 @@ export function AboutTab() {
|
|||
</header>
|
||||
|
||||
<SettingsSection title="Studio">
|
||||
<SettingsRow label="Version">
|
||||
<code className="font-mono text-xs text-muted-foreground">{version}</code>
|
||||
<SettingsRow label="Studio Version">
|
||||
<code className="font-mono text-xs text-muted-foreground">
|
||||
{studioVersion}
|
||||
</code>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Package Version">
|
||||
<code className="font-mono text-xs text-muted-foreground">
|
||||
{packageVersion}
|
||||
</code>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Updates">
|
||||
<div className="py-2">
|
||||
<UpdateStudioInstructions defaultShell={defaultShell} showTitle={false} />
|
||||
<UpdateStudioInstructions
|
||||
defaultShell={defaultShell}
|
||||
installSource={isTauri ? null : installSource}
|
||||
showTitle={false}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
|
|
@ -99,7 +190,10 @@ export function AboutTab() {
|
|||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={MessageNotification01Icon} className="size-3.5" />
|
||||
<HugeiconsIcon
|
||||
icon={MessageNotification01Icon}
|
||||
className="size-3.5"
|
||||
/>
|
||||
Report an issue
|
||||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
|
|
@ -108,7 +202,7 @@ export function AboutTab() {
|
|||
|
||||
<SettingsSection title="Danger zone">
|
||||
<SettingsRow
|
||||
destructive
|
||||
destructive={true}
|
||||
label="Shut down Unsloth Studio"
|
||||
description="Stops the Studio server process and ends your session."
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
|
||||
|
||||
let currentHandler: ((e: BeforeUnloadEvent) => void) | null = null;
|
||||
|
||||
|
|
@ -13,14 +13,18 @@ let currentHandler: ((e: BeforeUnloadEvent) => void) | null = null;
|
|||
export function useTrainingUnloadGuard() {
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (!useTrainingRuntimeStore.getState().isTrainingRunning) return;
|
||||
if (!useTrainingRuntimeStore.getState().isTrainingRunning) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
currentHandler = handler;
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => {
|
||||
if (currentHandler === handler) currentHandler = null;
|
||||
if (currentHandler === handler) {
|
||||
currentHandler = null;
|
||||
}
|
||||
window.removeEventListener("beforeunload", handler);
|
||||
};
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-st
|
|||
export { uploadTrainingDataset } from "./api/datasets-api";
|
||||
export { listLocalModels } from "./api/models-api";
|
||||
export type { LocalModelInfo } from "./api/models-api";
|
||||
export type { TrainingPhase, TrainingViewData, TrainingSeriesPoint } from "./types/runtime";
|
||||
export type {
|
||||
TrainingPhase,
|
||||
TrainingViewData,
|
||||
TrainingSeriesPoint,
|
||||
} from "./types/runtime";
|
||||
export type {
|
||||
TrainingRunSummary,
|
||||
TrainingRunListResponse,
|
||||
|
|
|
|||
158
studio/frontend/src/hooks/use-web-update-check.ts
Normal file
158
studio/frontend/src/hooks/use-web-update-check.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { getAuthToken } from "@/features/auth";
|
||||
import { apiUrl, isTauri } from "@/lib/api-base";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const WEB_UPDATE_CHECK_DELAY_MS = 5000;
|
||||
const DISMISS_PREFIX = "unsloth_web_update_dismissed";
|
||||
const CAN_SHOW_KEY = "can_show_web_notification";
|
||||
const UPDATE_AVAILABLE_KEY = "update_available";
|
||||
const INSTALL_SOURCE_KEY = "install_source";
|
||||
const LATEST_VERSION_KEY = "latest_version";
|
||||
const CURRENT_VERSION_KEY = "current_version";
|
||||
const CHECKED_AT_KEY = "checked_at";
|
||||
|
||||
type ApiObject = Record<string, unknown>;
|
||||
|
||||
export type WebUpdateInstallSource =
|
||||
| "pypi"
|
||||
| "editable"
|
||||
| "local_path"
|
||||
| "vcs"
|
||||
| "local_repo"
|
||||
| "unknown";
|
||||
|
||||
export interface WebUpdateStatus {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
installSource: "pypi";
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
interface UseWebUpdateCheckOptions {
|
||||
enabled?: boolean;
|
||||
delayMs?: number;
|
||||
}
|
||||
|
||||
function stringField(value: ApiObject, key: string): string | null {
|
||||
const field = value[key];
|
||||
return typeof field === "string" ? field : null;
|
||||
}
|
||||
|
||||
function toDisplayableUpdateStatus(value: unknown): WebUpdateStatus | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = value as ApiObject;
|
||||
const latestVersion = stringField(status, LATEST_VERSION_KEY);
|
||||
const currentVersion = stringField(status, CURRENT_VERSION_KEY);
|
||||
const checkedAt = stringField(status, CHECKED_AT_KEY);
|
||||
if (
|
||||
status[CAN_SHOW_KEY] !== true ||
|
||||
status[UPDATE_AVAILABLE_KEY] !== true ||
|
||||
status[INSTALL_SOURCE_KEY] !== "pypi" ||
|
||||
!latestVersion ||
|
||||
!currentVersion ||
|
||||
!checkedAt
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
installSource: "pypi",
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function dismissalKey(status: WebUpdateStatus): string {
|
||||
return `${DISMISS_PREFIX}:${status.installSource}:${status.latestVersion}`;
|
||||
}
|
||||
|
||||
function isDismissed(status: WebUpdateStatus): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(dismissalKey(status)) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markDismissed(status: WebUpdateStatus): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(dismissalKey(status), String(Date.now()));
|
||||
} catch {
|
||||
// Ignore storage failures; the banner can still be dismissed in-memory.
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDisplayableUpdateStatus(): Promise<WebUpdateStatus | null> {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
const res = await fetch(apiUrl("/api/studio/update-status"), { headers });
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toDisplayableUpdateStatus(await res.json());
|
||||
}
|
||||
|
||||
export function useWebUpdateCheck({
|
||||
enabled = true,
|
||||
delayMs = WEB_UPDATE_CHECK_DELAY_MS,
|
||||
}: UseWebUpdateCheckOptions = {}) {
|
||||
const [status, setStatus] = useState<WebUpdateStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTauri || !enabled || !getAuthToken()) {
|
||||
const clearTimer = window.setTimeout(() => setStatus(null), 0);
|
||||
return () => window.clearTimeout(clearTimer);
|
||||
}
|
||||
|
||||
let canceled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
fetchDisplayableUpdateStatus()
|
||||
.then((nextStatus) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
setStatus(nextStatus && !isDismissed(nextStatus) ? nextStatus : null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!canceled) {
|
||||
setStatus(null);
|
||||
}
|
||||
});
|
||||
}, delayMs);
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [delayMs, enabled]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setStatus((current) => {
|
||||
if (current) {
|
||||
markDismissed(current);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { status: enabled && !isTauri ? status : null, dismiss };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue