diff --git a/.github/scripts/tauri-release.py b/.github/scripts/tauri-release.py index 7cd0a907..f5ea9d97 100755 --- a/.github/scripts/tauri-release.py +++ b/.github/scripts/tauri-release.py @@ -68,6 +68,17 @@ "nsis": ("-setup.exe",), "msi": (".msi",), } +# Bundle kind -> the installer tauri-plugin-updater names in its `{os}-{arch}-{installer}` platform +# key (read from the bundle type the bundler patches into each package's binary). A dmg is not an +# updater format. +UPDATER_INSTALLERS = { + "deb": "deb", + "rpm": "rpm", + "appimage": "appimage", + "nsis": "nsis", + "msi": "msi", + "app": "app", +} # Arch token Tauri itself uses in dmg/nsis/msi file names, reused when a bundle # name would otherwise collide across targets (macOS `.app.tar.gz`). ARCH_FILE_TOKEN = {"x86_64": "x64", "aarch64": "aarch64", "i686": "x86", "armv7": "armv7"} @@ -607,6 +618,17 @@ def cmd_updater_json(args: argparse.Namespace) -> None: if args.targets_file is not None: expected_platforms = {entry["updater_platform"] for entry in load_targets(args.targets_file)} + def platform_entry(key: str, name: str) -> dict: + signature_path = args.dist / f"{name}.sig" + if not signature_path.is_file(): + raise Failure(f"{key}: signature file {name}.sig is missing") + decode_signature(signature_path) + return { + "signature": signature_path.read_text(encoding="utf-8").strip(), + "url": f"https://github.com/{args.repo}/releases/download/{args.tag}/" + + quote(name, safe=""), + } + platforms: dict[str, dict] = {} for record in evidence: updater = record["updater"] @@ -619,15 +641,26 @@ def cmd_updater_json(args: argparse.Namespace) -> None: raise Failure(f"duplicate updater platform {key}") if key != updater_platform_for(record["target"]): raise Failure(f"{record['target']}: evidence updater_platform {key} is wrong") - signature_path = args.dist / updater["signature"] - if not signature_path.is_file(): - raise Failure(f"{key}: signature file {updater['signature']} is missing") - decode_signature(signature_path) - platforms[key] = { - "signature": signature_path.read_text(encoding="utf-8").strip(), - "url": f"https://github.com/{args.repo}/releases/download/{args.tag}/" - + quote(updater["name"], safe=""), - } + if updater["signature"] != f"{updater['name']}.sig": + raise Failure(f"{key}: updater signature {updater['signature']} does not belong to {updater['name']}") + platforms[key] = platform_entry(key, updater["name"]) + # tauri-plugin-updater asks for `{os}-{arch}-{installer}` first, where the installer is the + # one the bundler wrote into this package's binary, and falls back to `{os}-{arch}`: without + # these keys a copy installed from the .deb, the .rpm or the .msi would download the + # updater bundle above and refuse it as the wrong format. + per_kind: dict[str, str] = {} + for item in record["files"]: + installer = UPDATER_INSTALLERS.get(item["kind"]) + if installer is None or not item["signature"]: + continue + if installer in per_kind: + raise Failure( + f"{record['target']}: two signed {item['kind']} packages " + f"({per_kind[installer]}, {item['name']}); an installed copy could be either" + ) + per_kind[installer] = item["name"] + for installer, name in sorted(per_kind.items()): + platforms[f"{key}-{installer}"] = platform_entry(f"{key}-{installer}", name) if not platforms: raise Failure("no updater platforms collected; refusing to write an empty latest.json") diff --git a/.github/scripts/test-tauri-release.py b/.github/scripts/test-tauri-release.py index 36ad3927..a710205c 100755 --- a/.github/scripts/test-tauri-release.py +++ b/.github/scripts/test-tauri-release.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -"""Offline test of `tauri-release.py collect` against Lockra's own target manifest with the updater -off (Lockra sets no bundle.createUpdaterArtifacts): every leg collects what Tauri writes then, the -macOS legs their dmg (the .app stays a directory, which only an updater build archives), and a leg -whose bundles are missing fails. It also checks that deny.toml's [graph] targets are exactly the -shipped targets. Run by scripts/verify-all.sh and the CI config job. +"""Offline test of `tauri-release.py collect` and `updater-json` against Lockra's own target +manifest and its real updater configuration: every package of every leg is signed and collected, +the macOS legs archive the .app for the updater beside their dmg, a leg without a signature or an +archive fails, and latest.json carries one key per target plus one per installer, so a copy +installed from the .deb, the .rpm or the .msi updates through its own package. It also checks the +update settings in tauri.conf.json and that deny.toml's [graph] targets are exactly the shipped +targets. Run by scripts/verify-all.sh and the CI config job. Stdlib only and Python 3.9 or later, like tauri-release.py, so it runs wherever `make check` does (macOS ships Python 3.9); it needs no jq, GNU tool or TOML library. @@ -11,9 +13,9 @@ from __future__ import annotations +import base64 import json import re -import shutil import subprocess import sys import tempfile @@ -22,7 +24,10 @@ ROOT = Path(__file__).resolve().parents[2] SCRIPT = ROOT / ".github" / "scripts" / "tauri-release.py" MANIFEST = ROOT / ".github" / "release-targets.json" +TAURI_CONF = ROOT / "apps" / "desktop" / "src-tauri" / "tauri.conf.json" DENY = ROOT / "deny.toml" +REPO = "sunerpy/lockra" +ENDPOINT = f"https://github.com/{REPO}/releases/latest/download/latest.json" VERSION = "1.2.3" @@ -30,25 +35,55 @@ def fail(message: str) -> None: sys.exit(f"test-tauri-release: {message}") +def signature(name: str) -> str: + """A `.sig` as the Tauri CLI writes it: base64 of minisign text with a trusted comment.""" + text = ( + "untrusted comment: signature from tauri secret key\nRUQfixture\n" + f"trusted comment: timestamp:1790000000\tfile:{name}\tversion:{VERSION}\nZml4dHVyZQ==\n" + ) + return base64.b64encode(text.encode()).decode() + + +def check_update_config(config: dict) -> None: + """The settings the in-app update relies on (docs/release.md, "In-app updates").""" + if config.get("bundle", {}).get("createUpdaterArtifacts") is not True: + fail("tauri.conf.json: bundle.createUpdaterArtifacts must be true") + updater = config.get("plugins", {}).get("updater", {}) + try: + pubkey = base64.b64decode(updater.get("pubkey", ""), validate=True).decode() + except ValueError: + fail("tauri.conf.json: plugins.updater.pubkey is not base64") + if not pubkey.startswith("untrusted comment: minisign public key"): + fail("tauri.conf.json: plugins.updater.pubkey is not a minisign public key") + if updater.get("endpoints") != [ENDPOINT]: + fail(f"tauri.conf.json: plugins.updater.endpoints must be [{ENDPOINT}]") + if updater.get("requireSignedVersion") is not True: + fail("tauri.conf.json: plugins.updater.requireSignedVersion must be true") + if updater.get("windows", {}).get("installMode") != "passive": + fail("tauri.conf.json: plugins.updater.windows.installMode must be passive") + + class Fixture: """A Tauri project directory with the bundles each target's `tauri bundle` writes.""" - def __init__(self, root: Path) -> None: + def __init__(self, root: Path, config: dict) -> None: self.root = root repo = root / "repo" self.project = repo / "src-tauri" self.project.mkdir(parents=True) (repo / "package.json").write_text(json.dumps({"name": "lockra", "version": VERSION}), encoding="utf-8") - # The shape of Lockra's tauri.conf.json that collect reads: no updater artifacts, no plugin. - config = {"productName": "Lockra", "version": "../package.json", "identifier": "dev.lockra.desktop", - "bundle": {"active": True, "targets": "all"}} - (self.project / "tauri.conf.json").write_text(json.dumps(config), encoding="utf-8") + # The parts of Lockra's tauri.conf.json that collect reads, the updater block included. + fixture = {"productName": "Lockra", "version": "../package.json", "identifier": "dev.lockra.desktop", + "bundle": config["bundle"], "plugins": config["plugins"]} + (self.project / "tauri.conf.json").write_text(json.dumps(fixture), encoding="utf-8") self.runs = 0 - def bundle(self, target: str, directory: str, name: str) -> Path: + def bundle(self, target: str, directory: str, name: str, signed: bool = True) -> Path: path = self.project / "target" / target / "release" / "bundle" / directory / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text(f"bundle {target} {name}\n", encoding="utf-8") + if signed: + path.with_name(f"{name}.sig").write_text(signature(name), encoding="utf-8") return path def app(self, target: str) -> Path: @@ -89,24 +124,29 @@ def main() -> None: if sys.version_info < (3, 9): fail("needs Python 3.9 or later") targets = [entry["target"] for entry in json.loads(MANIFEST.read_text(encoding="utf-8"))["targets"]] + config = json.loads(TAURI_CONF.read_text(encoding="utf-8")) + check_update_config(config) with tempfile.TemporaryDirectory() as tmp: - fx = Fixture(Path(tmp)) - arm_dmg = fx.bundle("aarch64-apple-darwin", "dmg", f"Lockra_{VERSION}_aarch64.dmg") + fx = Fixture(Path(tmp), config) + arm_archive = fx.bundle("aarch64-apple-darwin", "macos", "Lockra.app.tar.gz") x64_msi = fx.bundle("x86_64-pc-windows-msvc", "msi", f"Lockra_{VERSION}_x64_en-US.msi") - expected = [ - arm_dmg, - x64_msi, - fx.bundle("x86_64-unknown-linux-gnu", "deb", f"Lockra_{VERSION}_amd64.deb"), - fx.bundle("x86_64-unknown-linux-gnu", "rpm", f"Lockra-{VERSION}-1.x86_64.rpm"), - fx.bundle("x86_64-unknown-linux-gnu", "appimage", f"Lockra_{VERSION}_amd64.AppImage"), - fx.bundle("aarch64-unknown-linux-gnu", "deb", f"Lockra_{VERSION}_arm64.deb"), - fx.bundle("aarch64-unknown-linux-gnu", "rpm", f"Lockra-{VERSION}-1.aarch64.rpm"), - fx.bundle("aarch64-unknown-linux-gnu", "appimage", f"Lockra_{VERSION}_aarch64.AppImage"), - fx.bundle("x86_64-apple-darwin", "dmg", f"Lockra_{VERSION}_x64.dmg"), - fx.bundle("x86_64-pc-windows-msvc", "nsis", f"Lockra_{VERSION}_x64-setup.exe"), - fx.bundle("aarch64-pc-windows-msvc", "nsis", f"Lockra_{VERSION}_arm64-setup.exe"), - ] + packages = { + "linux-x86_64-deb": fx.bundle("x86_64-unknown-linux-gnu", "deb", f"Lockra_{VERSION}_amd64.deb").name, + "linux-x86_64-rpm": fx.bundle("x86_64-unknown-linux-gnu", "rpm", f"Lockra-{VERSION}-1.x86_64.rpm").name, + "linux-x86_64-appimage": fx.bundle("x86_64-unknown-linux-gnu", "appimage", f"Lockra_{VERSION}_amd64.AppImage").name, + "linux-aarch64-deb": fx.bundle("aarch64-unknown-linux-gnu", "deb", f"Lockra_{VERSION}_arm64.deb").name, + "linux-aarch64-rpm": fx.bundle("aarch64-unknown-linux-gnu", "rpm", f"Lockra-{VERSION}-1.aarch64.rpm").name, + "linux-aarch64-appimage": fx.bundle("aarch64-unknown-linux-gnu", "appimage", f"Lockra_{VERSION}_aarch64.AppImage").name, + "darwin-aarch64-app": "Lockra_aarch64.app.tar.gz", + "darwin-x86_64-app": "Lockra_x64.app.tar.gz", + "windows-x86_64-nsis": fx.bundle("x86_64-pc-windows-msvc", "nsis", f"Lockra_{VERSION}_x64-setup.exe").name, + "windows-x86_64-msi": x64_msi.name, + "windows-aarch64-nsis": fx.bundle("aarch64-pc-windows-msvc", "nsis", f"Lockra_{VERSION}_arm64-setup.exe").name, + } + fx.bundle("x86_64-apple-darwin", "macos", "Lockra.app.tar.gz") + dmgs = [fx.bundle("aarch64-apple-darwin", "dmg", f"Lockra_{VERSION}_aarch64.dmg", signed=False).name, + fx.bundle("x86_64-apple-darwin", "dmg", f"Lockra_{VERSION}_x64.dmg", signed=False).name] fx.app("aarch64-apple-darwin") fx.app("x86_64-apple-darwin") @@ -116,26 +156,47 @@ def main() -> None: if run.returncode != 0: fail(f"collect failed for {target}: {run.stderr.strip()}") actual = sorted(path.name for path in out.iterdir()) - wanted = sorted(path.name for path in expected) + wanted = sorted([*packages.values(), *(f"{name}.sig" for name in packages.values()), *dmgs]) if actual != wanted: fail(f"collected {actual}, expected {wanted}") - for target in ("aarch64-apple-darwin", "x86_64-apple-darwin"): + for target in targets: record = json.loads((evidence / f"{target}.json").read_text(encoding="utf-8")) - if record["updater_enabled"] or record["updater"] is not None or [f["kind"] for f in record["files"]] != ["dmg"]: - fail(f"{target}: evidence is not the dmg alone: {record}") + if not record["updater_enabled"] or record["updater"] is None: + fail(f"{target}: the updater bundle was not recorded: {record}") if len(list(evidence.glob("*.json"))) != len(targets): fail("one evidence file per target expected") - # A macOS leg whose bundle step produced no .app, or no dmg, fails; so does any other leg - # with a bundle missing. - shutil.rmtree(fx.app("aarch64-apple-darwin")) - fx.expect_failure("macOS leg without its .app", "aarch64-apple-darwin") - fx.app("aarch64-apple-darwin") - arm_dmg.unlink() - fx.expect_failure("macOS leg without its dmg", "aarch64-apple-darwin") + latest = out / "latest.json" + run = subprocess.run([sys.executable, str(SCRIPT), "updater-json", "--dist", str(out), "--evidence-dir", str(evidence), + "--repo", REPO, "--tag", f"v{VERSION}", "--version", VERSION, "--targets-file", str(MANIFEST), + "--out", str(latest)], capture_output=True, text=True) + if run.returncode != 0: + fail(f"updater-json failed: {run.stderr.strip()}") + platforms = json.loads(latest.read_text(encoding="utf-8"))["platforms"] + generic = {"linux-x86_64": "linux-x86_64-appimage", "linux-aarch64": "linux-aarch64-appimage", + "darwin-aarch64": "darwin-aarch64-app", "darwin-x86_64": "darwin-x86_64-app", + "windows-x86_64": "windows-x86_64-nsis", "windows-aarch64": "windows-aarch64-nsis"} + if sorted(platforms) != sorted([*packages, *generic]): + fail(f"latest.json platforms {sorted(platforms)}, expected {sorted([*packages, *generic])}") + base = f"https://github.com/{REPO}/releases/download/v{VERSION}/" + for key, name in packages.items(): + entry = platforms[key] + if entry["url"] != base + name or entry["signature"] != (out / f"{name}.sig").read_text(encoding="utf-8").strip(): + fail(f"latest.json {key} does not point at {name} and its signature: {entry}") + for key, specific in generic.items(): + if platforms[key] != platforms[specific]: + fail(f"latest.json {key} is not its updater bundle {specific}") + + # A leg whose updater bundle has no signature fails, and so does a macOS leg whose bundle + # step archived no .app or produced no dmg. + (fx.project / "target/x86_64-unknown-linux-gnu/release/bundle/appimage" / f"Lockra_{VERSION}_amd64.AppImage.sig").unlink() + fx.expect_failure("Linux leg without the AppImage signature", "x86_64-unknown-linux-gnu") + arm_archive.unlink() + fx.expect_failure("macOS leg without its .app archive", "aarch64-apple-darwin") x64_msi.unlink() fx.expect_failure("Windows leg without its msi", "x86_64-pc-windows-msvc") - print(f"test-tauri-release: collect passed for {len(targets)} targets with the updater off") + print(f"test-tauri-release: collect and latest.json passed for {len(targets)} targets " + f"({len(packages) + len(generic)} updater platforms)") # cargo-deny checks the graph of exactly the targets that ship, so a target added to the # manifest cannot escape the licence, ban and advisory policy. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fa28888..25a7638a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,6 +253,14 @@ jobs: - name: shellcheck run: shellcheck scripts/*.sh .github/scripts/*.sh + # The one-line installers, offline: a fake GitHub and fake package managers for install.sh, + # and PowerShell 7 with the cmdlets that reach the network or the registry replaced for + # install.ps1. install-scripts.yml runs them for real on each system. + - name: Install scripts + run: | + scripts/test-install.sh + pwsh -NoProfile -File scripts/test-install.ps1 + deny: name: Dependency policy runs-on: ubuntu-latest diff --git a/.github/workflows/install-scripts.yml b/.github/workflows/install-scripts.yml new file mode 100644 index 00000000..ddd1f70d --- /dev/null +++ b/.github/workflows/install-scripts.yml @@ -0,0 +1,134 @@ +# The one-line installers against the real latest release, on the systems they serve: apt on +# Linux x64 and ARM64 (and the AppImage there), dnf in Fedora, the dmg on macOS, and the per-user +# installer on Windows x64 and ARM64, run the way `irm | iex` runs it. The offline tests +# (scripts/test-install.sh and .ps1, in ci.yml) cover the logic; this proves the commands the +# documentation prints. It runs when a script changes, weekly (a release's assets could change +# shape), and by hand; it is not a required check, because it depends on GitHub's downloads. +name: Install scripts + +on: + pull_request: + paths: + - "scripts/install.sh" + - "scripts/install.ps1" + - ".github/workflows/install-scripts.yml" + push: + branches: [main] + paths: + - "scripts/install.sh" + - "scripts/install.ps1" + - ".github/workflows/install-scripts.yml" + schedule: + - cron: "23 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: install-scripts-${{ github.ref }} + cancel-in-progress: true + +jobs: + apt: + name: Linux ${{ matrix.arch }} (apt and AppImage) + strategy: + fail-fast: false + matrix: + include: + - { arch: x64, runner: ubuntu-24.04 } + - { arch: ARM64, runner: ubuntu-24.04-arm } + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install the .deb + run: sh scripts/install.sh + + - name: Check the package + run: | + set -eu + dpkg -s lockra | grep -E '^(Status|Version):' + test -x /usr/bin/lockra-desktop + + - name: Install the AppImage + env: + LOCKRA_PACKAGE: appimage + LOCKRA_INSTALL_DIR: ${{ runner.temp }}/bin + run: | + set -eu + sh scripts/install.sh + test -x "$LOCKRA_INSTALL_DIR/Lockra.AppImage" + grep -q "Exec=\"$LOCKRA_INSTALL_DIR/Lockra.AppImage\"" "$HOME/.local/share/applications/lockra.desktop" + + dnf: + name: Linux x64 (dnf) + runs-on: ubuntu-24.04 + container: fedora:42 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install the .rpm + run: sh scripts/install.sh + + - name: Check the package + run: | + set -eu + rpm -q lockra + test -x /usr/bin/lockra-desktop + + macos: + name: macOS (dmg) + runs-on: macos-15 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install the dmg + env: + LOCKRA_INSTALL_DIR: ${{ runner.temp }}/Applications + run: | + set -eu + sh scripts/install.sh + test -x "$LOCKRA_INSTALL_DIR/Lockra.app/Contents/MacOS/lockra-desktop" + /usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$LOCKRA_INSTALL_DIR/Lockra.app/Contents/Info.plist" + + windows: + name: Windows ${{ matrix.arch }} (installer) + strategy: + fail-fast: false + matrix: + include: + - { arch: x64, runner: windows-2025 } + - { arch: ARM64, runner: windows-11-arm } + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Windows PowerShell 5.1, the script piped into Invoke-Expression: what `irm | iex` does. + - name: Install + shell: powershell + run: Get-Content -Raw scripts/install.ps1 | Invoke-Expression + + - name: Check the installed app and stop it + shell: powershell + run: | + $Exe = Join-Path $env:LOCALAPPDATA "Lockra\lockra-desktop.exe" + if (-not (Test-Path $Exe)) { throw "not installed at $Exe" } + (Get-Item $Exe).VersionInfo.ProductVersion + Get-Process -Name lockra-desktop -ErrorAction SilentlyContinue | Stop-Process -Force diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7b24088..e8d4c0fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,8 @@ make pre-ci # optional, Linux: the real app under Xvfb, the deb, the cross-bu ``` `make check` needs, besides the set-up above, Python ≥ 3.9, cargo-llvm-cov, cargo-deny, actionlint -and shellcheck; it names any that is missing before the first gate. +and shellcheck; it names any that is missing before the first gate. With PowerShell 7 (`pwsh`) +installed it also tests `scripts/install.ps1`; without it, CI does. - Write the test first for any change in behaviour, and see it fail for the right reason. - Never weaken, skip or delete a test to get green; fix the code. diff --git a/Cargo.lock b/Cargo.lock index 8a712876..ccd15c0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -678,6 +678,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -701,7 +711,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -714,7 +724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1018,7 +1028,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1231,7 +1241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1303,6 +1313,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1583,6 +1603,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1927,6 +1958,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.21" @@ -1946,9 +1992,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2278,6 +2326,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2522,6 +2600,7 @@ dependencies = [ "tauri-codegen", "tauri-plugin-dialog", "tauri-plugin-single-instance", + "tauri-plugin-updater", "tauri-runtime", "tauri-runtime-wry", "tauri-utils", @@ -2632,6 +2711,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2691,7 +2776,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2766,7 +2851,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3009,6 +3094,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3072,6 +3169,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -3095,7 +3198,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.21", ] [[package]] @@ -3668,15 +3785,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3712,6 +3834,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -3762,7 +3898,80 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -3814,6 +4023,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3897,7 +4115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4165,6 +4383,16 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -4196,7 +4424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4295,6 +4523,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.8" @@ -4358,6 +4592,27 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4379,7 +4634,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -4389,7 +4644,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -4422,6 +4677,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4445,7 +4711,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -4615,6 +4881,38 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.21", + "time", + "tokio", + "url", + "windows-sys 0.60.2", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -4625,7 +4923,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -4648,7 +4946,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4722,10 +5020,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4872,6 +5170,16 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -5130,7 +5438,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5170,7 +5478,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5242,6 +5550,12 @@ dependencies = [ "ctutils", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -5577,6 +5891,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5641,7 +5964,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5696,8 +6019,8 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -5768,6 +6091,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5777,6 +6111,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -5786,6 +6129,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -5795,6 +6147,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -6108,7 +6469,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -6172,6 +6533,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Makefile b/Makefile index 9cebdca0..a59d55b4 100644 --- a/Makefile +++ b/Makefile @@ -65,6 +65,10 @@ deny: ## Licences, bans and sources of the dependency graph smoke-desktop: ## Drive the real app under Xvfb; screenshots to docs/acceptance/screens/desktop scripts/smoke-desktop-linux.sh +.PHONY: smoke-update +smoke-update: ## An AppImage updates itself from a local manifest (two release builds, a throwaway key) + scripts/smoke-update-linux.sh + .PHONY: showcase showcase: ## Screenshot the component showcase in the four themes scripts/screenshot-showcase.sh diff --git a/README.md b/README.md index ef2fe162..c691b46d 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ --- Lockra is a TOTP/HOTP authenticator for Windows, macOS and Linux. Your accounts live in one -encrypted file on your computer, and Lockra never goes online. It imports from Google -Authenticator and Microsoft Authenticator, exports back to them, and keeps encrypted backups in a -folder you choose. +encrypted file on your computer, and Lockra goes online only to check for updates. It imports from +Google Authenticator and Microsoft Authenticator, exports back to them, and keeps encrypted backups +in a folder you choose. ![Lockra's codes page](./docs/acceptance/screens/desktop/codes-1280-light.png) @@ -42,12 +42,29 @@ folder you choose. newest few. Restore by merging or by replacing. - **Security**: Argon2id and XChaCha20-Poly1305, optional unlock with the system keychain, auto-lock, and screen-capture protection while a secret is shown (Windows and macOS). +- **Updates**: Settings › About checks for a new version when you ask (or once a day, if you turn + that on) and installs it only when the package carries Lockra's signature, through the package + Lockra was installed from (deb, rpm, AppImage, the Windows installer or the macOS app). - **Interface**: four themes, eight accents, Chinese and English, a command palette (`Ctrl K`) and keyboard shortcuts throughout. ## Install -1. **Download a package** from the [releases page](https://github.com/sunerpy/lockra/releases): +1. **One command**: the script picks the package for your computer, checks it against the + release's `SHA256SUMS` and installs it + ([install guide](https://firlab.app/lockra/guide/install#install-with-one-command)). + + ```bash + # Linux and macOS + curl -fsSL https://raw.githubusercontent.com/sunerpy/lockra/main/scripts/install.sh | sh + ``` + + ```powershell + # Windows (PowerShell) + irm https://raw.githubusercontent.com/sunerpy/lockra/main/scripts/install.ps1 | iex + ``` + +2. **Download a package** from the [releases page](https://github.com/sunerpy/lockra/releases): the Windows installer (x64 and ARM64), a dmg for Apple silicon or Intel Macs, or a deb, rpm or AppImage for Linux (x64 and ARM64). Every release carries `SHA256SUMS` and build attestations: @@ -59,7 +76,10 @@ folder you choose. The packages are not code-signed yet: Windows SmartScreen and macOS Gatekeeper ask before the first start ([platform notes](https://firlab.app/lockra/reference/platforms)). -2. **Build from source** ([Development](#development)). +3. **Build from source** ([Development](#development)). + +From 0.2.0, Lockra updates itself: **Settings › About › Check for updates** +([updates](https://firlab.app/lockra/guide/updates)). ## Quick start diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 5eca8bb8..1c1e607b 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -34,6 +34,12 @@ tauri = { version = "=2.11.6", features = [] } tauri-plugin-dialog = "=2.7.3" # One process writes the vault: a second start focuses the first window instead. tauri-plugin-single-instance = { version = "=2.4.3", default-features = false } +# In-app updates, driven from Rust only when the user asks (src/updater.rs): the webview has no +# permission for the plugin. rustls with the operating system's roots; the system proxy is honoured. +tauri-plugin-updater = { version = "=2.12.0", default-features = false, features = [ + "rustls-tls", + "system-proxy", +] } tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index f9a969b0..43f70386 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,19 +1,21 @@ //! Lockra desktop shell: Tauri commands → `lockra-bridge` → `lockra-core`. //! -//! The shell owns wiring only: the keychain and clipboard adapters, the native file dialogs, the -//! code stream channel, the event forwarding, drag and drop, and screen-capture protection. Every +//! The shell owns wiring only: the keychain, clipboard and updater adapters, the native file +//! dialogs, the code stream channel, the event forwarding, drag and drop, and screen-capture +//! protection. Every //! command is `async` (a sync command runs on the main thread and would freeze the webview while //! Argon2 works). Everything but [`run`] is generic over the Tauri runtime, so `tests/ipc.rs` //! drives the real command layer on `tauri::test::MockRuntime` without a window. pub mod clipboard; pub mod keychain; +pub mod updater; use std::path::PathBuf; use std::sync::Arc; use lockra_bridge::{UiCommand, dispatch}; -use lockra_core::ports::{Clipboard, CodeSink, MemorySecretStore, NoSecretStore, PortError, SecretStore, SystemClock}; +use lockra_core::ports::{Clipboard, CodeSink, MemorySecretStore, NoSecretStore, NoUpdater, PortError, SecretStore, SystemClock, Updater}; use lockra_core::ui::{CodesFrame, Phase, Platform, UI_EVENT_NAME, UiEvent}; use lockra_core::{Core, CoreConfig, CoreError, ErrorCode, KdfCost, Ports}; use serde_json::Value; @@ -56,11 +58,26 @@ pub struct ShellOptions { pub kdf: KdfCost, /// Refuse a second process (off in the mock-runtime tests: the plugin talks to D-Bus on Linux). pub single_instance: bool, + /// The update source (default: tauri-plugin-updater with [`Self::plugin_updates`], otherwise + /// none). + pub updater: Option>, + /// Register tauri-plugin-updater, which needs `plugins.updater` in the context: `run` does; the + /// mock-runtime tests' context has none. + pub plugin_updates: bool, } impl Default for ShellOptions { fn default() -> Self { - Self { secrets: None, clipboard: None, data_dir: None, config_dir: None, kdf: KdfCost::DEFAULT, single_instance: true } + Self { + secrets: None, + clipboard: None, + data_dir: None, + config_dir: None, + kdf: KdfCost::DEFAULT, + single_instance: true, + updater: None, + plugin_updates: false, + } } } @@ -260,6 +277,7 @@ pub fn build_app(builder: tauri::Builder, options: ShellOptions) } else { builder }; + let builder = if options.plugin_updates { builder.plugin(tauri_plugin_updater::Builder::new().build()) } else { builder }; builder .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ @@ -285,7 +303,12 @@ pub fn build_app(builder: tauri::Builder, options: ShellOptions) let clipboard = options.clipboard.clone().unwrap_or_else(|| Arc::new(clipboard::ArboardClipboard::spawn())); let config = CoreConfig { data_dir, config_dir, app_version: app.package_info().version.to_string(), kdf: options.kdf, platform: Platform::current() }; - let ports = Ports { secrets, clipboard, clock: Arc::new(SystemClock) }; + let updater: Arc = match options.updater.clone() { + Some(updater) => updater, + None if options.plugin_updates => Arc::new(updater::PluginUpdater::new(app.handle().clone(), updater::install_method())), + None => Arc::new(NoUpdater), + }; + let ports = Ports { secrets, clipboard, clock: Arc::new(SystemClock), updater }; // The core's scheduler is a tokio task: start it inside Tauri's runtime. let core = tauri::async_runtime::block_on(async move { Core::start(config, ports) }); app.manage(core.clone()); @@ -304,7 +327,8 @@ pub fn build_app(builder: tauri::Builder, options: ShellOptions) pub fn run() { let _ = tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "lockra=info".into())).try_init(); - let app = build_app(tauri::Builder::default(), ShellOptions::default()).run(tauri::generate_context!()); + let options = ShellOptions { plugin_updates: true, ..ShellOptions::default() }; + let app = build_app(tauri::Builder::default(), options).run(tauri::generate_context!()); if let Err(error) = app { tracing::error!(%error, "Lockra could not start"); std::process::exit(1); diff --git a/apps/desktop/src-tauri/src/updater.rs b/apps/desktop/src-tauri/src/updater.rs new file mode 100644 index 00000000..ed01d4f7 --- /dev/null +++ b/apps/desktop/src-tauri/src/updater.rs @@ -0,0 +1,227 @@ +//! The in-app update through tauri-plugin-updater: the core's [`Updater`] port (docs/security.md, +//! "Updates"). +//! +//! The plugin reads `plugins.updater` from tauri.conf.json: the release manifest's address and the +//! minisign public key. It asks the manifest, picks the package for the way this copy was installed +//! (`{os}-{arch}-{installer}`, then `{os}-{arch}`), downloads it and checks its signature, and the +//! version the signature was made for (`requireSignedVersion`), before anything is installed. +//! Nothing here runs unless the core asks. Failures are logged with their whole cause chain and +//! reach the core as an [`UpdateFailure`]: the webview only ever sees a code. + +use std::time::Duration; + +use lockra_core::ports::{Release, UpdateFailure, UpdateFuture, UpdateProgress, Updater}; +use lockra_core::ui::InstallMethod; +use parking_lot::Mutex; +use tauri::{AppHandle, Runtime}; +use tauri_plugin_updater::{Error as PluginError, Update, UpdaterExt as _}; + +/// How long opening a connection (TCP and TLS) to the update host may take, for the manifest and +/// the package alike. The plugin sets none, which leaves a check hanging for as long as the +/// operating system lets a connection attempt run. +pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +/// How long one read may stall before the step fails. +pub const READ_TIMEOUT: Duration = Duration::from_secs(30); + +/// How this copy was installed: the bundle type the bundler patched into the executable, or on +/// macOS the `.app` it runs from. `None` for an executable run from the build tree, which has +/// nothing to update and must not replace itself. +pub fn install_method() -> Option { + #[cfg(target_os = "macos")] + { + let exe = std::env::current_exe().ok()?; + exe.ancestors().any(|dir| dir.extension().is_some_and(|ext| ext == "app")).then_some(InstallMethod::App) + } + #[cfg(not(target_os = "macos"))] + { + use tauri::utils::config::BundleType; + use tauri::utils::platform::bundle_type; + match bundle_type()? { + BundleType::Deb => Some(InstallMethod::Deb), + BundleType::Rpm => Some(InstallMethod::Rpm), + BundleType::AppImage => Some(InstallMethod::Appimage), + BundleType::Nsis => Some(InstallMethod::Nsis), + BundleType::Msi => Some(InstallMethod::Msi), + _ => None, + } + } +} + +/// The update port on tauri-plugin-updater: the release `check` found and the package `download` +/// verified stay here until `install`. +pub struct PluginUpdater { + app: AppHandle, + method: Option, + found: Mutex>, + package: Mutex>>, +} + +impl PluginUpdater { + /// The port for `app`, whose context carries `plugins.updater`; `method` is how this copy was + /// installed ([`install_method`]). + pub fn new(app: AppHandle, method: Option) -> Self { + Self { app, method, found: Mutex::new(None), package: Mutex::new(None) } + } +} + +impl Updater for PluginUpdater { + fn method(&self) -> Option { + self.method + } + + fn check(&self) -> UpdateFuture<'_, Option> { + Box::pin(async move { + // The client settings travel with the `Update` the check returns, so they hold for the + // package download too. + let updater = self + .app + .updater_builder() + .configure_client(|client| client.connect_timeout(CONNECT_TIMEOUT).read_timeout(READ_TIMEOUT)) + .build() + .map_err(|e| failure("set up", &e))?; + let found = updater.check().await.map_err(|e| failure("check", &e))?; + let release = found.as_ref().map(|update| Release { + version: update.version.clone(), + notes: update.body.clone(), + date: update.raw_json.get("pub_date").and_then(serde_json::Value::as_str).map(str::to_owned), + }); + match &release { + Some(release) => tracing::info!(version = %release.version, "update available"), + None => tracing::info!("no update available"), + } + *self.package.lock() = None; + *self.found.lock() = found; + Ok(release) + }) + } + + fn download(&self, mut progress: UpdateProgress) -> UpdateFuture<'_, ()> { + Box::pin(async move { + let update = self.found.lock().clone().ok_or(UpdateFailure::Invalid)?; + let mut received = 0u64; + let bytes = update + .download( + |chunk, total| { + received = received.saturating_add(u64::try_from(chunk).unwrap_or(u64::MAX)); + progress(received, total); + }, + || {}, + ) + .await + .map_err(|e| failure("download", &e))?; + tracing::info!(version = %update.version, bytes = bytes.len(), "update downloaded and its signature verified"); + *self.package.lock() = Some(bytes); + Ok(()) + }) + } + + fn install(&self) -> UpdateFuture<'_, ()> { + Box::pin(async move { + let update = self.found.lock().clone().ok_or(UpdateFailure::Install)?; + let package = self.package.lock().take().ok_or(UpdateFailure::Install)?; + // Installing writes files and may wait for an administrator prompt (pkexec for a .deb or + // an .rpm): off the async runtime. On Windows the installer takes over and the process + // exits inside `install`. + tauri::async_runtime::spawn_blocking(move || update.install(package)) + .await + .map_err(|_| UpdateFailure::Install)? + .map_err(|e| failure("install", &e))?; + tracing::info!("update installed; restarting"); + self.app.request_restart(); + Ok(()) + }) + } +} + +/// Log a failed step with every cause, and classify it. +fn failure(step: &str, error: &PluginError) -> UpdateFailure { + tracing::warn!(step, error = %describe(error), "update step failed"); + classify(error) +} + +/// `error` followed by its causes: the first line alone rarely says why a request failed. +pub fn describe(error: &dyn std::error::Error) -> String { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + let text = cause.to_string(); + if !text.is_empty() && !message.contains(&text) { + message.push_str(": "); + message.push_str(&text); + } + source = cause.source(); + } + message +} + +/// Which kind of failure the plugin's error is. +pub fn classify(error: &PluginError) -> UpdateFailure { + use PluginError as E; + match error { + E::Reqwest(e) if e.is_decode() => UpdateFailure::Invalid, + E::Reqwest(_) | E::Network(_) | E::Http(_) | E::InvalidHeaderValue(_) | E::InvalidHeaderName(_) => UpdateFailure::Network, + E::Minisign(_) | E::Base64(_) | E::SignatureUtf8(_) | E::SignedVersionMismatch { .. } | E::MissingSignedVersion => UpdateFailure::Signature, + E::AuthenticationFailed => UpdateFailure::Cancelled, + E::Io(_) + | E::FailedToDetermineExtractPath + | E::TempDirNotOnSameMountPoint + | E::BinaryNotFoundInArchive + | E::TempDirNotFound + | E::DebInstallFailed + | E::PackageInstallFailed + | E::InvalidUpdaterFormat + | E::Tauri(_) => UpdateFailure::Install, + // No manifest (any status but success: `ReleaseNotFound`), no package for this computer, a + // manifest that does not parse, or a configuration the plugin refuses. + _ => UpdateFailure::Invalid, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failures_are_classified_by_what_the_user_can_do() { + assert_eq!(classify(&PluginError::Network("timed out".into())), UpdateFailure::Network); + assert_eq!(classify(&PluginError::ReleaseNotFound), UpdateFailure::Invalid); + assert_eq!(classify(&PluginError::TargetsNotFound(vec!["linux-x86_64-deb".into(), "linux-x86_64".into()])), UpdateFailure::Invalid); + assert_eq!(classify(&PluginError::SignatureUtf8("x".into())), UpdateFailure::Signature); + assert_eq!(classify(&PluginError::SignedVersionMismatch { signed: "0.1.0".into(), announced: "0.2.0".into() }), UpdateFailure::Signature); + assert_eq!(classify(&PluginError::MissingSignedVersion), UpdateFailure::Signature); + assert_eq!(classify(&PluginError::AuthenticationFailed), UpdateFailure::Cancelled); + assert_eq!(classify(&PluginError::InvalidUpdaterFormat), UpdateFailure::Install); + assert_eq!(classify(&PluginError::Io(std::io::Error::other("read-only"))), UpdateFailure::Install); + assert_eq!(classify(&PluginError::EmptyEndpoints), UpdateFailure::Invalid); + } + + /// An error with an optional cause, standing in for reqwest's and hyper's chain. + #[derive(Debug)] + struct Layer(&'static str, Option>); + + impl std::fmt::Display for Layer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } + } + + impl std::error::Error for Layer { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.1.as_deref().map(|cause| cause as &(dyn std::error::Error + 'static)) + } + } + + #[test] + fn the_log_keeps_every_cause() { + let chain = Layer("error sending request", Some(Box::new(Layer("client error (Connect)", Some(Box::new(Layer("dns error", None))))))); + assert_eq!(describe(&chain), "error sending request: client error (Connect): dns error"); + let repeated = Layer("operation timed out: deadline", Some(Box::new(Layer("deadline", Some(Box::new(Layer("", None))))))); + assert_eq!(describe(&repeated), "operation timed out: deadline"); + } + + #[test] + fn a_build_run_from_the_tree_cannot_update_itself() { + // `cargo test` runs an executable no bundler touched. + assert_eq!(install_method(), None); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 9b13184f..133af990 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -41,9 +41,18 @@ "publisher": "Lockra", "category": "Utility", "shortDescription": "A local, encrypted two-factor authenticator", - "longDescription": "Lockra keeps TOTP and HOTP secrets in an encrypted vault on your own computer, imports from Google Authenticator and Microsoft Authenticator, exports back to them, and backs up to a folder you choose. It never goes online.", + "longDescription": "Lockra keeps TOTP and HOTP secrets in an encrypted vault on your own computer, imports from Google Authenticator and Microsoft Authenticator, exports back to them, and backs up to a folder you choose. It goes online only to look for an update, when you ask or after you turn automatic checks on.", "copyright": "Copyright 2026 The Lockra Authors", "license": "Apache-2.0", - "windows": { "nsis": { "installMode": "currentUser" } } + "windows": { "nsis": { "installMode": "currentUser" } }, + "createUpdaterArtifacts": true + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgwMjZGRjA2Qzg4RDc1NQpSV1JWMTRoczhHOENDR0tLeDBnTFpYeXVEZU84VmcrM0xvcU9zYUloUmpwRjcyYkFodXRzbDdtSAo=", + "endpoints": ["https://github.com/sunerpy/lockra/releases/latest/download/latest.json"], + "requireSignedVersion": true, + "windows": { "installMode": "passive" } + } } } diff --git a/apps/desktop/src-tauri/tests/ipc.rs b/apps/desktop/src-tauri/tests/ipc.rs index 6f0271c7..09913b8d 100644 --- a/apps/desktop/src-tauri/tests/ipc.rs +++ b/apps/desktop/src-tauri/tests/ipc.rs @@ -8,8 +8,9 @@ use std::sync::Arc; use lockra_bridge::SHELL_COMMANDS; use lockra_core::KdfCost; -use lockra_core::fakes::FakeClipboard; -use lockra_core::ports::MemorySecretStore; +use lockra_core::fakes::{FakeClipboard, FakeUpdater}; +use lockra_core::ports::{MemorySecretStore, Updater}; +use lockra_core::ui::InstallMethod; use lockra_desktop_lib::{COMMANDS, ShellOptions, build_app, dev_memory_store_requested}; use serde_json::{Value, json}; use tauri::ipc::{CallbackFn, InvokeBody}; @@ -27,6 +28,11 @@ struct Shell { } fn shell() -> Shell { + shell_with(None) +} + +/// The shell with `updater` as its update source (none: this copy cannot update itself). +fn shell_with(updater: Option>) -> Shell { let dir = tempfile::tempdir().unwrap(); let options = ShellOptions { secrets: Some(Arc::new(MemorySecretStore::default())), @@ -35,6 +41,8 @@ fn shell() -> Shell { config_dir: Some(dir.path().join("config")), kdf: KdfCost::FAST_INSECURE, single_instance: false, + updater, + plugin_updates: false, }; let mut app = build_app(mock_builder(), options).build(mock_context(noop_assets())).unwrap(); let webview = WebviewWindowBuilder::new(&app, "main", Default::default()).build().unwrap(); @@ -126,6 +134,31 @@ fn the_password_is_checked_before_a_plain_export_opens_its_dialog() { assert_eq!(error["code"], "wrong_password"); } +#[test] +fn the_update_commands_run_through_lockra_dispatch() { + // A build without an update source refuses, with the core's code. + let shell = shell(); + let state = shell.dispatch(json!({ "command": "app_state" })).unwrap(); + assert_eq!(state["update"], json!({ "method": null, "status": { "state": "idle" } })); + assert_eq!(shell.dispatch(json!({ "command": "update_check" })).unwrap_err(), json!({ "code": "update_unavailable" })); + + let updater = Arc::new(FakeUpdater::installed(InstallMethod::Msi)); + *updater.check.lock() = Ok(Some(FakeUpdater::release("0.2.0"))); + let shell = shell_with(Some(updater.clone())); + assert_eq!(shell.dispatch(json!({ "command": "update_check" })).unwrap(), Value::Null); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let update = loop { + let update = shell.dispatch(json!({ "command": "app_state" })).unwrap()["update"].clone(); + if update["status"]["state"] == "available" || std::time::Instant::now() > deadline { + break update; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + }; + assert_eq!(update["method"], "msi"); + assert_eq!(update["status"]["version"], "0.2.0"); + assert_eq!(updater.calls(), ["check"]); +} + #[test] fn only_a_debug_build_may_keep_the_keychain_in_memory() { assert!(dev_memory_store_requested(Some("memory"), true)); diff --git a/apps/desktop/src-tauri/tests/update.rs b/apps/desktop/src-tauri/tests/update.rs new file mode 100644 index 00000000..866e4ce2 --- /dev/null +++ b/apps/desktop/src-tauri/tests/update.rs @@ -0,0 +1,163 @@ +//! The update adapter on the real tauri-plugin-updater, against a manifest server on 127.0.0.1: +//! nothing newer (204), a newer release whose package verifies, a signature made for another +//! version (`requireSignedVersion`), a package changed after signing, no manifest, no server. +//! Nothing is installed here: an executable `cargo test` built has no package to replace, and the +//! packaged app's update is checked by hand (docs/acceptance/updates.md). + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::io::{BufRead as _, BufReader, Write as _}; +use std::net::TcpListener; +use std::sync::{Arc, Mutex}; + +use lockra_core::ports::{Release, UpdateFailure, Updater}; +use lockra_core::ui::InstallMethod; +use lockra_desktop_lib::updater::PluginUpdater; +use serde_json::{Value, json}; +use tauri::App; +use tauri::test::{MockRuntime, mock_builder, mock_context, noop_assets}; + +/// A minisign key made for this test only (`minisign -G -W`), as tauri.conf.json carries one. +const PUBKEY: &str = + "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgRjA2QzlDQTg0MjFCQzY0ClJXUmt2Q0dFeXNrR0R3ZzF3NFRFSTdvQ1diMHVJeWpzWDRiUk1rL3BKUVlHeXpXdTk1cEl2QXA3Cg=="; +/// [`PACKAGE`] signed with that key, the trusted comment naming version 9.9.9 as the Tauri CLI +/// writes it. +const SIGNED_999: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVSa3ZDR0V5c2tHRHpraTVEbmJNdS9jMmdiZjZSUlduNW4veWFoY1NaK0U3a0VQbTZPSG9hSytjQnBqT0VWUDQvaExjSHFwdXBkK21ZN3gyZjRqN3FEaklhTGw2U2FJSXd3PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzkwMDAwMDAwCWZpbGU6TG9ja3JhXzkuOS45X2FtZDY0LkFwcEltYWdlCXZlcnNpb246OS45LjkKS0tYRGVDK21VaDB5NWhSdSt1YTAvUFZsUHVTZU5nMEN5WGRINllIcWFxTDhqakcya01vTkx4bDN3ZU91aEpjOGhvRG55S3ZZbytqUlFJcU9nSlJpQWc9PQo="; +/// The same package signed as version 9.9.8: valid, but not for the version announced. +const SIGNED_998: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVSa3ZDR0V5c2tHRHpraTVEbmJNdS9jMmdiZjZSUlduNW4veWFoY1NaK0U3a0VQbTZPSG9hSytjQnBqT0VWUDQvaExjSHFwdXBkK21ZN3gyZjRqN3FEaklhTGw2U2FJSXd3PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzkwMDAwMDAwCWZpbGU6TG9ja3JhXzkuOS45X2FtZDY0LkFwcEltYWdlCXZlcnNpb246OS45LjgKcUUyZ09wb2Z6YU8za1FMZXpSWVR1UXIzc05WcXdsQ3NBdTVadDJtcjFiRVM4K28zemkyR29mUHh0VlFyQjFYa1Zna21rS2VXcjBnZGRBeXFndGhFQlE9PQo="; +const PACKAGE: &[u8] = b"lockra update fixture\n"; + +/// Answers each path with a fixed status and body until the test process ends. +fn serve(listener: TcpListener, routes: Vec<(&'static str, u16, Vec)>) { + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request = String::new(); + if reader.read_line(&mut request).is_err() { + continue; + } + loop { + let mut header = String::new(); + if reader.read_line(&mut header).map_or(true, |n| n == 0) || header == "\r\n" { + break; + } + } + let path = request.split_whitespace().nth(1).unwrap_or_default(); + let (status, body) = routes.iter().find(|(p, ..)| *p == path).map_or((404, Vec::new()), |(_, s, b)| (*s, b.clone())); + let head = match status { + 204 => "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n".to_owned(), + _ => format!("HTTP/1.1 {status} X\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n", body.len()), + }; + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(&body); + } + }); +} + +/// A manifest announcing 9.9.9 with `signature` for every desktop target, in the layout +/// tauri-release.py writes. +fn manifest(base: &str, signature: &str) -> Vec { + let platform = json!({ "url": format!("{base}/package"), "signature": signature }); + let platforms: serde_json::Map = ["linux-x86_64", "linux-aarch64", "windows-x86_64", "windows-aarch64", "darwin-x86_64", "darwin-aarch64"] + .into_iter() + .map(|key| (key.to_owned(), platform.clone())) + .collect(); + serde_json::to_vec(&json!({ "version": "9.9.9", "notes": "## 9.9.9\n\n- What changed", "pub_date": "2026-10-02T08:00:00Z", "platforms": platforms })) + .unwrap() +} + +/// The app with the plugin, configured as tauri.conf.json does but for this server and key. +fn updater(base: &str) -> (App, PluginUpdater) { + let mut context = mock_context(noop_assets()); + context.config_mut().plugins.0.insert( + "updater".to_owned(), + json!({ + "pubkey": PUBKEY, + "endpoints": [format!("{base}/latest.json")], + "requireSignedVersion": true, + "dangerousInsecureTransportProtocol": true, + }), + ); + let app = mock_builder().plugin(tauri_plugin_updater::Builder::new().build()).build(context).unwrap(); + let port = PluginUpdater::new(app.handle().clone(), Some(InstallMethod::Appimage)); + (app, port) +} + +fn server(routes: impl FnOnce(&str) -> Vec<(&'static str, u16, Vec)>) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + serve(listener, routes(&base)); + base +} + +/// Bytes so far and the size, as the progress callback sees them. +type Progress = Vec<(u64, Option)>; + +fn download(port: &PluginUpdater) -> (Result<(), UpdateFailure>, Progress) { + let seen = Arc::new(Mutex::new(Vec::new())); + let record = Arc::clone(&seen); + let result = tauri::async_runtime::block_on(port.download(Box::new(move |received, total| record.lock().unwrap().push((received, total))))); + let progress = seen.lock().unwrap().clone(); + (result, progress) +} + +#[test] +fn nothing_newer_answers_none() { + let base = server(|_| vec![("/latest.json", 204, Vec::new())]); + let (_app, port) = updater(&base); + assert_eq!(port.method(), Some(InstallMethod::Appimage)); + assert_eq!(tauri::async_runtime::block_on(port.check()), Ok(None)); +} + +#[test] +fn a_newer_release_downloads_and_its_signature_verifies() { + let base = server(|base| vec![("/latest.json", 200, manifest(base, SIGNED_999)), ("/package", 200, PACKAGE.to_vec())]); + let (_app, port) = updater(&base); + let release = tauri::async_runtime::block_on(port.check()).unwrap(); + assert_eq!(release, Some(Release { version: "9.9.9".into(), notes: Some("## 9.9.9\n\n- What changed".into()), date: Some("2026-10-02T08:00:00Z".into()) })); + let (result, progress) = download(&port); + assert_eq!(result, Ok(())); + let size = u64::try_from(PACKAGE.len()).unwrap(); + assert_eq!(progress.last().map(|(received, _)| *received), Some(size), "progress reaches the package's size"); +} + +#[test] +fn security_a_signature_for_another_version_is_refused() { + // A genuine, older package offered as 9.9.9: the forced downgrade requireSignedVersion stops. + let base = server(|base| vec![("/latest.json", 200, manifest(base, SIGNED_998)), ("/package", 200, PACKAGE.to_vec())]); + let (_app, port) = updater(&base); + assert!(tauri::async_runtime::block_on(port.check()).unwrap().is_some()); + assert_eq!(download(&port).0, Err(UpdateFailure::Signature)); + assert_eq!(tauri::async_runtime::block_on(port.install()), Err(UpdateFailure::Install), "nothing verified, nothing to install"); +} + +#[test] +fn security_a_package_changed_after_signing_is_refused() { + let base = server(|base| vec![("/latest.json", 200, manifest(base, SIGNED_999)), ("/package", 200, b"lockra update fixture!\n".to_vec())]); + let (_app, port) = updater(&base); + assert!(tauri::async_runtime::block_on(port.check()).unwrap().is_some()); + assert_eq!(download(&port).0, Err(UpdateFailure::Signature)); +} + +#[test] +fn a_missing_manifest_is_invalid_and_no_server_is_a_network_failure() { + let base = server(|_| Vec::new()); + let (_app, port) = updater(&base); + assert_eq!(tauri::async_runtime::block_on(port.check()), Err(UpdateFailure::Invalid)); + + let closed = { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + format!("http://{}", listener.local_addr().unwrap()) + }; + let (_app, port) = updater(&closed); + assert_eq!(tauri::async_runtime::block_on(port.check()), Err(UpdateFailure::Network)); +} + +#[test] +fn nothing_is_downloaded_or_installed_before_a_check_finds_a_release() { + let base = server(|_| vec![("/latest.json", 204, Vec::new())]); + let (_app, port) = updater(&base); + assert_eq!(download(&port).0, Err(UpdateFailure::Invalid)); + assert_eq!(tauri::async_runtime::block_on(port.install()), Err(UpdateFailure::Install)); +} diff --git a/apps/desktop/src/offline-assets.test.ts b/apps/desktop/src/offline-assets.test.ts index ec60faa1..c5a5bbfc 100644 --- a/apps/desktop/src/offline-assets.test.ts +++ b/apps/desktop/src/offline-assets.test.ts @@ -3,7 +3,8 @@ import { resolve } from "node:path"; const here = resolve(import.meta.dirname, ".."); -/** Lockra never goes online: every asset ships in the bundle and the CSP grants no remote host. */ +/** The webview never goes online (only the Rust updater does, when asked): every asset ships in the + * bundle and the CSP grants no remote host. */ describe("offline assets", () => { it("index.html references nothing remote", () => { const html = readFileSync(resolve(here, "index.html"), "utf8"); diff --git a/apps/desktop/src/pages/settings/About.tsx b/apps/desktop/src/pages/settings/About.tsx index 224181d1..512620a3 100644 --- a/apps/desktop/src/pages/settings/About.tsx +++ b/apps/desktop/src/pages/settings/About.tsx @@ -1,6 +1,28 @@ -import { SettingsPane, SettingsRows, StatusRow, useT, useUiState } from "@lockra/ui"; +import { + errorText, + formatBytes, + formatDateTime, + relativeTime, + releaseNotesLines, +} from "@lockra/shared"; +import { + Button, + Progress, + SettingsPane, + SettingsRows, + StatusRow, + Toggle, + useBackend, + useLocale, + useNow, + useT, + useUiState, +} from "@lockra/ui"; +import type { ReactNode } from "react"; +import { useSubmit } from "../../app/dispatch"; +import { useUpdateSettings } from "../../app/settings"; -/** Settings › About: version, where the data lives, licences and credits. */ +/** Settings › About: version, the in-app update, where the data lives, licences and credits. */ export function About() { const t = useT(); const { app_version: version, data_dir: dataDir } = useUiState(); @@ -12,6 +34,8 @@ export function About() { Lockra {version} + + ); } + +/** The in-app update: what the updater is doing, and the one action that fits it. Lockra goes + * online here only when asked (or when automatic checks are on). */ +function Update() { + const t = useT(); + const locale = useLocale(); + const now = useNow(); + const { backend } = useBackend(); + const { update } = useUiState(); + const action = useSubmit(); + const { method, status } = update; + const label = t("settings.about.update.label"); + if (method === null) { + return ( + + ); + } + const check = () => void action.run(() => backend.dispatch({ command: "update_check" })); + const install = () => void action.run(() => backend.dispatch({ command: "update_install" })); + const checkButton = (text: string) => ( + + ); + const rowView = (): RowView => { + switch (status.state) { + case "idle": + return { + help: t("settings.about.update.idle"), + control: checkButton(t("settings.about.update.check")), + }; + case "checking": + return { + help: t("settings.about.update.checking"), + control: ( + + ), + }; + case "up_to_date": + return { + help: t("settings.about.update.upToDate"), + control: checkButton(t("settings.about.update.check")), + note: t("settings.about.update.checkedAt", { + when: relativeTime(t, status.checked_at_ms, now), + }), + }; + case "available": { + const available = t("settings.about.update.available", { version: status.version }); + const published = status.date === null ? Number.NaN : Date.parse(status.date); + return { + help: Number.isNaN(published) + ? available + : `${available} · ${t("settings.about.update.publishedOn", { + date: formatDateTime(locale, published, { dateStyle: "medium" }), + })}`, + control: ( + + ), + note: t(`settings.about.update.method.${method}`), + }; + } + case "downloading": { + const known = status.total !== null && status.total > 0; + return { + help: known + ? t("settings.about.update.downloading", { + version: status.version, + percent: Math.min(100, Math.floor((status.received / (status.total ?? 1)) * 100)), + }) + : t("settings.about.update.downloadingSize", { + version: status.version, + received: formatBytes(status.received), + }), + control: ( + + ), + }; + } + case "installing": + return { + help: t("settings.about.update.installing", { version: status.version }), + control: , + }; + case "failed": + return { + help: ( + + {t("settings.about.update.failed", { error: errorText(t, status.code) })} + + ), + control: checkButton(t("settings.about.update.retry")), + }; + } + }; + const rejected = + action.error === undefined ? undefined : ( + {errorText(t, action.error)} + ); + const view = rowView(); + return ( + <> + + {view.control} + + {status.state === "available" && status.notes !== null && ( + + )} + + ); +} + +interface RowView { + help: ReactNode; + control?: ReactNode; + note?: ReactNode; +} + +/** The release's notes, as text (never HTML). */ +function ReleaseNotes({ notes }: { notes: string }) { + const t = useT(); + const lines = releaseNotesLines(notes); + if (lines.length === 0) return null; + return ( +
+

{t("settings.about.update.notes")}

+
+ {lines.map((line, index) => + line === "" ? ( +
+
+ ); +} + +/** "Check for updates automatically": off unless turned on; only for a copy that can update. */ +function AutoCheck() { + const t = useT(); + const { settings, update } = useUiState(); + const updateSettings = useUpdateSettings(); + if (update.method === null) return null; + return ( + + {t("settings.about.update.autoHint")} {t("settings.about.update.network")} + + } + data-testid="update-auto"> + updateSettings({ auto_check_updates })} + ariaLabel={t("settings.about.update.auto")} + /> + + ); +} diff --git a/apps/desktop/src/pages/settings/Settings.test.tsx b/apps/desktop/src/pages/settings/Settings.test.tsx index 378e24d7..490c9990 100644 --- a/apps/desktop/src/pages/settings/Settings.test.tsx +++ b/apps/desktop/src/pages/settings/Settings.test.tsx @@ -153,5 +153,77 @@ describe("SettingsDialog", () => { expect(dialog.getByText("Apache-2.0")).toBeInTheDocument(); expect(dialog.getByText(/SIL OFL 1\.1/)).toBeInTheDocument(); expect(dialog.getByText(/Voltip/)).toBeInTheDocument(); + // The sample core cannot update itself: no check, no automatic check, a pointer elsewhere. + expect(dialog.getByTestId("update")).toHaveTextContent("不是通过安装包安装的"); + expect(dialog.queryByTestId("update-check")).not.toBeInTheDocument(); + expect(dialog.queryByTestId("update-auto")).not.toBeInTheDocument(); + }); + + it("checks for an update, shows what changed and installs it", async () => { + const { user, backend } = renderApp({ mock: { updateMethod: "deb" } }); + await ready(); + const dialog = await openSettings(user, "关于"); + const row = within(dialog.getByTestId("update")); + expect(row.getByText("尚未检查")).toBeInTheDocument(); + await user.click(row.getByRole("button", { name: "检查更新" })); + expect(backend.calls.at(-1)).toEqual({ command: "update_check" }); + expect(await row.findByText("已是最新版本")).toBeInTheDocument(); + expect(row.getByText(/上次检查/)).toBeInTheDocument(); + + backend.setRelease({ + version: "0.2.0", + notes: + "## [0.2.0](https://github.com/sunerpy/lockra/compare/v0.1.1...v0.2.0) (2026-10-02)\n\n### Features\n\n* **update:** install updates ([#9](https://github.com/sunerpy/lockra/issues/9))\n", + date: "2026-10-02T08:00:00Z", + size: 2048, + }); + await user.click(row.getByRole("button", { name: "检查更新" })); + expect(await row.findByText(/新版本 0\.2\.0 可用/)).toBeInTheDocument(); + expect(row.getByText(/安装时系统会要求输入管理员密码/)).toBeInTheDocument(); + const notes = dialog.getByTestId("update-notes"); + expect(notes).toHaveTextContent("Features"); + expect(notes).toHaveTextContent("• update: install updates"); + expect(notes).not.toHaveTextContent("https://"); + expect(notes.querySelector("a")).toBeNull(); + + await user.click(row.getByRole("button", { name: "下载并安装" })); + expect(backend.calls.at(-1)).toEqual({ command: "update_install" }); + expect(await row.findByText(/正在安装 0\.2\.0/)).toBeInTheDocument(); + expect(row.getByRole("progressbar")).toBeInTheDocument(); + }); + + it("shows why an update failed and offers a retry", async () => { + const { user, backend } = renderApp({ + mock: { + updateMethod: "nsis", + release: { version: "0.2.0", notes: null, date: null, size: 10 }, + updateFailure: { step: "download", code: "update_signature" }, + }, + }); + await ready(); + const dialog = await openSettings(user, "关于"); + const row = within(dialog.getByTestId("update")); + await user.click(row.getByRole("button", { name: "检查更新" })); + expect(await row.findByText("新版本 0.2.0 可用")).toBeInTheDocument(); + expect(dialog.queryByTestId("update-notes")).not.toBeInTheDocument(); + await user.click(row.getByRole("button", { name: "下载并安装" })); + expect(await row.findByText("安装包未使用 Lockra 的密钥签名,已拒绝安装")).toBeInTheDocument(); + await user.click(row.getByRole("button", { name: "重试" })); + expect(backend.calls.at(-1)).toEqual({ command: "update_check" }); + }); + + it("turns automatic update checks on and off", async () => { + const { user, backend } = renderApp({ mock: { updateMethod: "appimage" } }); + await ready(); + const dialog = await openSettings(user, "关于"); + const toggle = dialog.getByRole("switch", { name: "自动检查更新" }); + expect(toggle).not.toBeChecked(); + expect(dialog.getByTestId("update-auto")).toHaveTextContent("仅连接 GitHub"); + await user.click(toggle); + expect(backend.calls.at(-1)).toMatchObject({ + command: "settings_set", + settings: { auto_check_updates: true }, + }); + expect(dialog.getByRole("switch", { name: "自动检查更新" })).toBeChecked(); }); }); diff --git a/crates/lockra-bridge/src/lib.rs b/crates/lockra-bridge/src/lib.rs index 718be4db..e51116d5 100644 --- a/crates/lockra-bridge/src/lib.rs +++ b/crates/lockra-bridge/src/lib.rs @@ -153,11 +153,15 @@ pub enum UiCommand { }, /// The user did something. Activity, + /// Look for a newer release (the answer arrives in the state). + UpdateCheck, + /// Download, verify and install the newest release, then restart. + UpdateInstall, } /// Every [`UiCommand`] name, in declaration order; the TypeScript schema and the fixtures name /// exactly this set (checked by the contract test). -pub const COMMANDS: [&str; 30] = [ +pub const COMMANDS: [&str; 32] = [ "app_state", "vault_create", "vault_unlock", @@ -188,6 +192,8 @@ pub const COMMANDS: [&str; 30] = [ "restore_cancel", "settings_set", "activity", + "update_check", + "update_install", ]; /// The Tauri commands of the desktop shell: the dispatcher, the code stream, and the actions that @@ -255,6 +261,8 @@ pub async fn dispatch(core: &Core, command: UiCommand) -> Result unit(core.update_check())?, + UiCommand::UpdateInstall => unit(core.update_install())?, }) } diff --git a/crates/lockra-bridge/tests/contract.rs b/crates/lockra-bridge/tests/contract.rs index 53b9d349..b33cb1b3 100644 --- a/crates/lockra-bridge/tests/contract.rs +++ b/crates/lockra-bridge/tests/contract.rs @@ -11,11 +11,12 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use lockra_bridge::{COMMANDS, SHELL_COMMANDS, UiCommand, dispatch}; -use lockra_core::fakes::{FakeClipboard, FakeClock, FakeKeychain, RecordingSink}; +use lockra_core::fakes::{FakeClipboard, FakeClock, FakeKeychain, FakeUpdater, RecordingSink}; use lockra_core::settings::{AccentId, AutoBackup, Density, LocaleSetting, Settings, SortOrder, ThemeId}; use lockra_core::ui::{ BackupFailure, BackupView, CandidateAction, CandidateStatus, CandidateView, CodeView, CodesFrame, DeviceUnlockView, Excluded, ExportPage, ExportStarted, - ExportTarget, GoogleBatchView, ImportSource, ImportView, LockView, Notice, Phase, Platform, RestoreView, Revealed, UiEvent, UiState, + ExportTarget, GoogleBatchView, ImportSource, ImportView, InstallMethod, LockView, Notice, Phase, Platform, RestoreView, Revealed, UiEvent, UiState, + UpdateStatus, UpdateView, }; use lockra_core::{Core, CoreConfig, CoreError, EntryView, ErrorCode, ExportCompat, KdfCost, Outcome, Ports}; use lockra_otp::{Algorithm, Digits, OtpKind, Period}; @@ -114,9 +115,30 @@ fn settings() -> Settings { hide_codes: true, sort: SortOrder::Recent, auto_backup: AutoBackup { enabled: true, dir: Some("/home/user/Backups/Lockra".into()), keep: 7 }, + auto_check_updates: true, } } +/// Every update status, in the order a run goes through them. +fn update_statuses() -> Vec { + vec![ + UpdateStatus::Idle, + UpdateStatus::Checking, + UpdateStatus::UpToDate { checked_at_ms: T0 - 30_000 }, + UpdateStatus::Available { + version: "0.2.0".into(), + notes: Some("## [0.2.0](https://github.com/sunerpy/lockra/compare/v0.1.1...v0.2.0) (2026-10-02)\n\n### Features\n\n* **update:** check for updates and install them\n".into()), + date: Some("2026-10-02T08:00:00Z".into()), + checked_at_ms: T0 - 30_000, + }, + UpdateStatus::Available { version: "0.2.1".into(), notes: None, date: None, checked_at_ms: T0 }, + UpdateStatus::Downloading { version: "0.2.0".into(), received: 0, total: None }, + UpdateStatus::Downloading { version: "0.2.0".into(), received: 4_194_304, total: Some(11_508_084) }, + UpdateStatus::Installing { version: "0.2.0".into() }, + UpdateStatus::Failed { code: ErrorCode::UpdateSignature, at_ms: T0 - 5_000 }, + ] +} + fn import_view() -> ImportView { let candidate = |n: u32, source: ImportSource, origin: Origin, issuer: &str, account: &str, status: CandidateStatus, action: CandidateAction| CandidateView { @@ -190,6 +212,11 @@ fn state(phase: Phase) -> UiState { created_at_ms: T0 - 864_000_000, }), auto_lock_at_ms: unlocked.then_some(T0 + 600_000), + update: match phase { + Phase::Unlocked => UpdateView { method: Some(InstallMethod::Deb), status: update_statuses()[3].clone() }, + Phase::Locked => UpdateView { method: Some(InstallMethod::Nsis), status: update_statuses()[6].clone() }, + Phase::NoVault => UpdateView { method: None, status: UpdateStatus::Idle }, + }, } } @@ -207,6 +234,7 @@ fn notices() -> Vec { Notice::AutoLocked, Notice::ExportExpired { session: id(100) }, Notice::DeviceUnlockTurnedOff, + Notice::UpdateAvailable { version: "0.2.0".into() }, ] } @@ -247,6 +275,8 @@ fn commands() -> Vec { json!({"command": "restore_cancel"}), json!({"command": "settings_set", "settings": serde_json::to_value(settings()).unwrap()}), json!({"command": "activity"}), + json!({"command": "update_check"}), + json!({"command": "update_install"}), ] } @@ -255,6 +285,17 @@ fn state_fixtures() { check("state.json", &json!({ "unlocked": state(Phase::Unlocked), "locked": state(Phase::Locked), "no_vault": state(Phase::NoVault) })); } +#[test] +fn update_fixtures() { + let methods = [InstallMethod::Deb, InstallMethod::Rpm, InstallMethod::Appimage, InstallMethod::Nsis, InstallMethod::Msi, InstallMethod::App]; + let views: Vec = update_statuses() + .into_iter() + .zip(methods.iter().copied().map(Some).chain([None]).cycle()) + .map(|(status, method)| UpdateView { method, status }) + .collect(); + check("update.json", &views); +} + #[test] fn event_fixtures() { let mut events: Vec = vec![UiEvent::State { state: Box::new(state(Phase::Locked)) }]; @@ -361,7 +402,10 @@ async fn dispatch_answers_and_leaks_nothing() { kdf: KdfCost::FAST_INSECURE, platform: Platform::Linux, }; - let core = Core::start(config, Ports { secrets: keychain, clipboard: clipboard.clone(), clock: Arc::new(FakeClock::new(T0)) }); + let updater = Arc::new(FakeUpdater::installed(InstallMethod::Deb)); + *updater.check.lock() = Ok(Some(FakeUpdater::release("0.2.0"))); + let ports = Ports { secrets: keychain, clipboard: clipboard.clone(), clock: Arc::new(FakeClock::new(T0)), updater: updater.clone() }; + let core = Core::start(config, ports); let mut events = core.subscribe(); let sink = Arc::new(RecordingSink::default()); core.subscribe_codes(sink.clone()); @@ -406,6 +450,15 @@ async fn dispatch_answers_and_leaks_nothing() { ok(run(json!({"command": "export_close", "session": started["session"]})).await, &mut answers); let settings = run(json!({"command": "app_state"})).await.unwrap()["settings"].clone(); ok(run(json!({"command": "settings_set", "settings": settings})).await, &mut answers); + // The update runs in the background: its states join the events scanned below. + for command in [json!({"command": "update_check"}), json!({"command": "update_install"})] { + ok(run(command).await, &mut answers); + for _ in 0..50 { + tokio::task::yield_now().await; + } + } + assert_eq!(updater.calls(), ["check", "check", "download", "install"]); + assert_eq!(run(json!({"command": "app_state"})).await.unwrap()["update"]["status"]["state"], "installing"); assert_eq!(run(json!({"command": "restore_commit", "password": "x", "mode": "merge"})).await.unwrap_err().code, ErrorCode::NoRestore); assert_eq!(run(json!({"command": "backup_auto_now"})).await.unwrap_err().code, ErrorCode::BackupDirMissing); let mut texts = answers; diff --git a/crates/lockra-core/src/error.rs b/crates/lockra-core/src/error.rs index beafa92b..bbe25f5b 100644 --- a/crates/lockra-core/src/error.rs +++ b/crates/lockra-core/src/error.rs @@ -68,6 +68,20 @@ pub enum ErrorCode { BackupDirUnavailable, /// A file could not be read or written. IoFailed, + /// This copy cannot update itself (not installed from a package, or no update key). + UpdateUnavailable, + /// A check or an update is already running. + UpdateBusy, + /// The update server could not be reached, or answered with an error. + UpdateNetwork, + /// The update information could not be read, or has no package for this computer. + UpdateInvalid, + /// The downloaded package is not signed with Lockra's key (or for the version announced). + UpdateSignature, + /// The package could not be installed. + UpdateInstallFailed, + /// The administrator password prompt was cancelled. + UpdateCancelled, /// Anything else (a bug); details are in the log. Internal, } diff --git a/crates/lockra-core/src/fakes.rs b/crates/lockra-core/src/fakes.rs index bf2834f4..d3bb41d2 100644 --- a/crates/lockra-core/src/fakes.rs +++ b/crates/lockra-core/src/fakes.rs @@ -7,8 +7,11 @@ use parking_lot::Mutex; use tokio::time::Instant; use zeroize::Zeroizing; -use crate::ports::{Clipboard, ClipboardImage, Clock, CodeSink, KeychainStatus, MemorySecretStore, PortError, SecretStore}; -use crate::ui::CodesFrame; +use crate::ports::{ + Clipboard, ClipboardImage, Clock, CodeSink, KeychainStatus, MemorySecretStore, PortError, Release, SecretStore, UpdateFailure, UpdateFuture, + UpdateProgress, Updater, +}; +use crate::ui::{CodesFrame, InstallMethod}; /// Wall-clock time that moves with tokio's clock, so `tokio::time::advance` under /// `start_paused` moves both the timers and the codes. @@ -155,3 +158,96 @@ impl CodeSink for RecordingSink { Ok(()) } } + +/// An update source the test scripts: what `check`, `download` and `install` answer, the +/// progress the download reports, and every call in order. +#[derive(Debug)] +pub struct FakeUpdater { + /// How an update installs; `None`: this copy cannot update itself. + pub method: Mutex>, + /// What `check` answers. + pub check: Mutex, UpdateFailure>>, + /// The progress `download` reports, in order. + pub progress: Mutex)>>, + /// What `download` answers. + pub download: Mutex>, + /// What `install` answers. + pub install: Mutex>, + /// Every call so far: `check`, `download`, `install`. + pub calls: Mutex>, + /// Make `check` wait for [`Self::resume`], to watch a run in flight. + pub hold: AtomicBool, + resume: tokio::sync::Notify, +} + +impl Default for FakeUpdater { + fn default() -> Self { + Self { + method: Mutex::new(None), + check: Mutex::new(Ok(None)), + progress: Mutex::new(Vec::new()), + download: Mutex::new(Ok(())), + install: Mutex::new(Ok(())), + calls: Mutex::new(Vec::new()), + hold: AtomicBool::new(false), + resume: tokio::sync::Notify::new(), + } + } +} + +impl FakeUpdater { + /// A copy installed by `method`, with nothing newer out. + pub fn installed(method: InstallMethod) -> Self { + Self { method: Mutex::new(Some(method)), ..Self::default() } + } + + /// A release with notes and a date. + pub fn release(version: &str) -> Release { + Release { version: version.to_owned(), notes: Some(format!("## {version}\n\n- What changed")), date: Some("2026-10-02T08:00:00Z".to_owned()) } + } + + /// Let a held `check` answer. + pub fn resume(&self) { + self.resume.notify_one(); + } + + /// The calls so far. + pub fn calls(&self) -> Vec<&'static str> { + self.calls.lock().clone() + } +} + +impl Updater for FakeUpdater { + fn method(&self) -> Option { + *self.method.lock() + } + + fn check(&self) -> UpdateFuture<'_, Option> { + Box::pin(async move { + self.calls.lock().push("check"); + if self.hold.load(Ordering::SeqCst) { + self.resume.notified().await; + } + self.check.lock().clone() + }) + } + + fn download(&self, mut progress: UpdateProgress) -> UpdateFuture<'_, ()> { + Box::pin(async move { + self.calls.lock().push("download"); + let steps = self.progress.lock().clone(); + for (received, total) in steps { + progress(received, total); + tokio::task::yield_now().await; + } + *self.download.lock() + }) + } + + fn install(&self) -> UpdateFuture<'_, ()> { + Box::pin(async move { + self.calls.lock().push("install"); + *self.install.lock() + }) + } +} diff --git a/crates/lockra-core/src/lib.rs b/crates/lockra-core/src/lib.rs index 3bf41890..20bda97c 100644 --- a/crates/lockra-core/src/lib.rs +++ b/crates/lockra-core/src/lib.rs @@ -1,7 +1,8 @@ //! The application core of Lockra: the session state machine (no vault, locked, unlocked), the -//! entries, import, export, backup, restore and settings, behind ports for everything the platform -//! provides (keychain, clipboard, clock, the code stream). The Tauri shell only wires the real -//! ports in and forwards commands and events; the logic is tested here with the fakes. +//! entries, import, export, backup, restore, settings and the in-app update, behind ports for +//! everything the platform provides (keychain, clipboard, clock, the code stream, the updater). +//! The Tauri shell only wires the real ports in and forwards commands and events; the logic is +//! tested here with the fakes. #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] @@ -14,6 +15,7 @@ pub mod ports; mod session; pub mod settings; pub mod ui; +mod update; #[cfg(any(test, feature = "fakes"))] pub mod fakes; @@ -28,3 +30,4 @@ pub use export::EXPORT_IDLE; pub use import::{Choice, Outcome}; pub use lockra_vault::KdfCost; pub use session::{AUTO_BACKUP_DEBOUNCE, Core, CoreConfig, FREE_ATTEMPTS, MAX_IMPORT_BYTES, MIN_PASSWORD_CHARS, Ports, RestoreMode, VAULT_FILE}; +pub use update::{CHECK_INTERVAL, CHECK_RETRY, PROGRESS_MIN_STEP, STARTUP_CHECK_DELAY}; diff --git a/crates/lockra-core/src/ports.rs b/crates/lockra-core/src/ports.rs index 89a4ada0..1a086fa2 100644 --- a/crates/lockra-core/src/ports.rs +++ b/crates/lockra-core/src/ports.rs @@ -3,12 +3,14 @@ //! runtime's critical path. use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; use std::time::{SystemTime, UNIX_EPOCH}; use parking_lot::Mutex; use zeroize::Zeroizing; -use crate::ui::CodesFrame; +use crate::ui::{CodesFrame, InstallMethod}; /// A port failed; the core maps it to a code. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] @@ -72,6 +74,76 @@ pub trait CodeSink: Send + Sync { fn send(&self, frame: &CodesFrame) -> Result<(), PortError>; } +/// A newer release the update source announces. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Release { + /// Its version (SemVer, without a `v`). + pub version: String, + /// Its release notes (Markdown), when the release has any. + pub notes: Option, + /// When it was published (RFC 3339), when the release says. + pub date: Option, +} + +/// Why an update step failed; the core turns it into an error code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateFailure { + /// The update server could not be reached, or answered with an error. + Network, + /// The answer could not be read, or has no package for this computer. + Invalid, + /// The package is not signed with the key built into the app (or for another version). + Signature, + /// The package could not be installed. + Install, + /// The user cancelled the administrator prompt. + Cancelled, +} + +/// An update step that waits on the network or on an installer. +pub type UpdateFuture<'a, T> = Pin> + Send + 'a>>; + +/// Download progress: bytes so far, and the size when the server says. +pub type UpdateProgress = Box) + Send>; + +/// The update source: the shell's tauri-plugin-updater, which reads the release manifest and +/// checks every package against the public key built into the app. Nothing here runs unless the +/// core asks, and the core asks only when the user does (or turned automatic checks on). +pub trait Updater: Send + Sync { + /// How this copy installs an update; `None` when it cannot update itself. + fn method(&self) -> Option; + /// Ask for a newer release; `None` when this is the newest. + fn check(&self) -> UpdateFuture<'_, Option>; + /// Download the release the last `check` found and verify its signature. + fn download(&self, progress: UpdateProgress) -> UpdateFuture<'_, ()>; + /// Install the downloaded package and restart Lockra. On Windows the installer takes over and + /// this does not return. + fn install(&self) -> UpdateFuture<'_, ()>; +} + +/// A copy that cannot update itself: the tests' default, and the shell's when the build has no +/// update key or was not installed from a package. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoUpdater; + +impl Updater for NoUpdater { + fn method(&self) -> Option { + None + } + + fn check(&self) -> UpdateFuture<'_, Option> { + Box::pin(async { Err(UpdateFailure::Invalid) }) + } + + fn download(&self, _progress: UpdateProgress) -> UpdateFuture<'_, ()> { + Box::pin(async { Err(UpdateFailure::Invalid) }) + } + + fn install(&self) -> UpdateFuture<'_, ()> { + Box::pin(async { Err(UpdateFailure::Install) }) + } +} + /// The system clock. #[derive(Debug, Default, Clone, Copy)] pub struct SystemClock; @@ -161,4 +233,13 @@ mod tests { fn the_system_clock_is_after_2025() { assert!(SystemClock.now_ms() > 1_735_689_600_000); } + + #[tokio::test] + async fn no_updater_cannot_update() { + let updater = NoUpdater; + assert_eq!(updater.method(), None); + assert_eq!(updater.check().await, Err(UpdateFailure::Invalid)); + assert_eq!(updater.download(Box::new(|_, _| {})).await, Err(UpdateFailure::Invalid)); + assert_eq!(updater.install().await, Err(UpdateFailure::Install)); + } } diff --git a/crates/lockra-core/src/session.rs b/crates/lockra-core/src/session.rs index a0922992..79dae6fe 100644 --- a/crates/lockra-core/src/session.rs +++ b/crates/lockra-core/src/session.rs @@ -24,12 +24,13 @@ use crate::entry::{Entry, EntryDraft, EntryPatch, VaultData, clean_name}; use crate::error::{CoreError, CoreResult, ErrorCode}; use crate::export::{self, EXPORT_IDLE, ExportSession}; use crate::import::{AwaitingBackup, Choice, ImportSession, Outcome}; -use crate::ports::{Clipboard, Clock, CodeSink, KeychainStatus, SecretStore}; +use crate::ports::{Clipboard, Clock, CodeSink, KeychainStatus, SecretStore, Updater}; use crate::settings::{Settings, SettingsStore}; use crate::ui::{ BackupFailure, BackupView, CodeView, CodesFrame, DeviceUnlockView, ExportPage, ExportStarted, ExportTarget, ImportSource, LockView, Notice, Phase, - Platform, RestoreView, Revealed, UiEvent, UiState, + Platform, RestoreView, Revealed, UiEvent, UiState, UpdateStatus, UpdateView, }; +use crate::update::{CHECK_INTERVAL, CHECK_RETRY, ProgressGate, UpdateRun, UpdateState, failure_code}; /// The vault's file name in the data directory. pub const VAULT_FILE: &str = "vault.lockra"; @@ -67,6 +68,8 @@ pub struct Ports { pub clipboard: Arc, /// Wall-clock time. pub clock: Arc, + /// The in-app update. + pub updater: Arc, } /// How a backup is restored into an unlocked vault. @@ -112,6 +115,7 @@ struct State { auto_backup_at: Option, restore: Option, last_activity: Instant, + update: UpdateState, } enum PhaseState { @@ -147,6 +151,7 @@ impl Core { }, Err(_) => (PhaseState::NoVault, None, false), }; + let update = UpdateState::new(settings.auto_check_updates && ports.updater.method().is_some()); let state = State { phase, settings, @@ -163,6 +168,7 @@ impl Core { auto_backup_at: None, restore: None, last_activity: Instant::now(), + update, }; let (events, _) = broadcast::channel(64); let shared = Arc::new(Shared { config, ports, settings_store, state: Mutex::new(state), events, wake: Notify::new() }); @@ -920,6 +926,11 @@ impl Core { if !settings.auto_backup.enabled { st.auto_backup_at = None; } + if !settings.auto_check_updates { + st.update.next_check = None; + } else if !st.settings.auto_check_updates && self.shared.ports.updater.method().is_some() { + st.update.next_check = Some(Instant::now()); + } st.settings = settings; if backup_turned_on { self.schedule_auto_backup(&mut st); @@ -947,6 +958,107 @@ impl Core { self.lock().codes = None; } + // ---- updates -------------------------------------------------------------------------- + + /// Look for a newer release now; the answer arrives in the state (`update.status`). + pub fn update_check(&self) -> CoreResult<()> { + self.start_update(UpdateRun::Check { automatic: false }) + } + + /// Look for the newest release, download it, verify it and install it, then restart; the + /// progress arrives in the state. With nothing newer the run ends up to date. + pub fn update_install(&self) -> CoreResult<()> { + self.start_update(UpdateRun::Install) + } + + /// One run at a time, in the background. + fn start_update(&self, run: UpdateRun) -> CoreResult<()> { + if self.shared.ports.updater.method().is_none() { + return Err(ErrorCode::UpdateUnavailable.into()); + } + { + let mut st = self.lock(); + if st.update.busy { + return Err(ErrorCode::UpdateBusy.into()); + } + st.update.busy = true; + } + let core = self.clone(); + tokio::spawn(async move { core.run_update(run).await }); + Ok(()) + } + + async fn run_update(&self, run: UpdateRun) { + let outcome = self.update_steps(run).await; + let now = self.now_ms(); + let announce = { + let mut st = self.lock(); + st.update.busy = false; + let mut announce = None; + let failed = outcome.is_err(); + match outcome { + Ok(Some(status)) => { + if let (UpdateRun::Check { automatic: true }, UpdateStatus::Available { version, .. }) = (run, &status) + && st.update.announced.as_deref() != Some(version.as_str()) + { + st.update.announced = Some(version.clone()); + announce = Some(version.clone()); + } + st.update.status = status; + } + // Installed: the shell restarts Lockra. + Ok(None) => {} + Err(failure) => st.update.status = UpdateStatus::Failed { code: failure_code(failure), at_ms: now }, + } + if run == (UpdateRun::Check { automatic: true }) && st.settings.auto_check_updates { + st.update.next_check = Some(Instant::now() + if failed { CHECK_RETRY } else { CHECK_INTERVAL }); + } + announce + }; + if let Some(version) = announce { + self.notice(Notice::UpdateAvailable { version }); + } + self.changed(); + } + + /// The run's steps; the status it ends on, or `None` once the package is installed. + async fn update_steps(&self, run: UpdateRun) -> Result, crate::ports::UpdateFailure> { + let updater = Arc::clone(&self.shared.ports.updater); + self.set_update(UpdateStatus::Checking); + let checked_at_ms = self.now_ms(); + let Some(release) = updater.check().await? else { return Ok(Some(UpdateStatus::UpToDate { checked_at_ms })) }; + if run != UpdateRun::Install { + return Ok(Some(UpdateStatus::Available { version: release.version, notes: release.notes, date: release.date, checked_at_ms })); + } + let version = release.version; + self.set_update(UpdateStatus::Downloading { version: version.clone(), received: 0, total: None }); + let core = self.clone(); + let downloading = version.clone(); + let mut gate = ProgressGate::default(); + updater + .download(Box::new(move |received, total| { + if gate.step(received, total) { + core.set_update(UpdateStatus::Downloading { version: downloading.clone(), received, total }); + } + })) + .await?; + // The process ends with the install: a change still inside the backup debounce is backed + // up first. + if self.lock().auto_backup_at.is_some() { + let _ = self.run_auto_backup(); + } + self.set_update(UpdateStatus::Installing { version }); + updater.install().await?; + Ok(None) + } + + /// Record an intermediate status and send the state (no code frame: nothing about the codes + /// changed). + fn set_update(&self, status: UpdateStatus) { + self.lock().update.status = status; + let _ = self.shared.events.send(UiEvent::State { state: Box::new(self.state()) }); + } + // ---- internals ------------------------------------------------------------------------ fn lock(&self) -> MutexGuard<'_, State> { @@ -1074,6 +1186,7 @@ impl Core { backup: BackupView { last_backup_ms: st.last_backup_ms, last_auto_file: st.last_auto_file.clone(), last_auto_error: st.last_auto_error }, restore: st.restore.as_ref().map(|r| r.view.clone()), auto_lock_at_ms, + update: UpdateView { method: self.shared.ports.updater.method(), status: st.update.status.clone() }, } } @@ -1145,14 +1258,19 @@ impl Core { deadlines.extend(st.auto_backup_at); } deadlines.extend(st.clipboard.as_ref().map(|(_, at)| *at)); + deadlines.extend(st.update.next_check); deadlines.into_iter().min() } /// Everything that is due. fn on_timer(&self) { let now = Instant::now(); - let (auto_lock, clear, backup, expired) = { + let (auto_lock, clear, backup, expired, update_check) = { let mut st = self.lock(); + let update_check = st.update.next_check.is_some_and(|at| now >= at); + if update_check { + st.update.next_check = None; + } let auto_lock = matches!(st.phase, PhaseState::Unlocked(_)) && st.settings.auto_lock_minutes > 0 && now >= st.last_activity + Duration::from_secs(u64::from(st.settings.auto_lock_minutes) * 60); @@ -1169,7 +1287,7 @@ impl Core { keep }); } - (auto_lock, clear, backup, expired) + (auto_lock, clear, backup, expired, update_check) }; if let Some(code) = clear && self.shared.ports.clipboard.clear_if(&code).unwrap_or(false) @@ -1186,6 +1304,13 @@ impl Core { self.lock_vault(); self.notice(Notice::AutoLocked); } + if update_check && self.start_update(UpdateRun::Check { automatic: true }).is_err() { + // A check or an install the user started is running: it answers the same question. + let mut st = self.lock(); + if st.settings.auto_check_updates { + st.update.next_check = Some(now + CHECK_INTERVAL); + } + } self.push_codes(); } } diff --git a/crates/lockra-core/src/settings.rs b/crates/lockra-core/src/settings.rs index 3a760a29..cc4c8f74 100644 --- a/crates/lockra-core/src/settings.rs +++ b/crates/lockra-core/src/settings.rs @@ -129,6 +129,9 @@ pub struct Settings { pub sort: SortOrder, /// Automatic backups. pub auto_backup: AutoBackup, + /// Look for an update 10 seconds after start and then once a day. Off: Lockra goes online only + /// when the user asks it to check. + pub auto_check_updates: bool, } /// Font size bounds of Settings › Appearance. @@ -157,6 +160,7 @@ impl Default for Settings { hide_codes: false, sort: SortOrder::default(), auto_backup: AutoBackup::default(), + auto_check_updates: false, } } } @@ -290,5 +294,17 @@ mod tests { assert_eq!(json["locale"], "system"); assert_eq!(serde_json::to_value(LocaleSetting::ZhCn).unwrap(), "zh-cn"); assert_eq!(json["auto_backup"]["keep"], 10); + assert_eq!(json["auto_check_updates"], false); + } + + #[test] + fn automatic_update_checks_stay_off_until_turned_on() { + let dir = tempfile::tempdir().unwrap(); + // A settings file from before the setting existed. + fs::write(dir.path().join("settings.json"), r#"{"theme":"dark","auto_lock_minutes":15}"#).unwrap(); + let store = SettingsStore::new(dir.path()); + assert!(!store.load().auto_check_updates); + store.save(&Settings { auto_check_updates: true, ..Settings::default() }).unwrap(); + assert!(store.load().auto_check_updates); } } diff --git a/crates/lockra-core/src/tests.rs b/crates/lockra-core/src/tests.rs index 64ac60fe..aaa3bde0 100644 --- a/crates/lockra-core/src/tests.rs +++ b/crates/lockra-core/src/tests.rs @@ -13,7 +13,7 @@ use tokio::sync::broadcast; use uuid::Uuid; use zeroize::Zeroizing; -use crate::fakes::{FakeClipboard, FakeClock, FakeKeychain, RecordingSink}; +use crate::fakes::{FakeClipboard, FakeClock, FakeKeychain, FakeUpdater, RecordingSink}; use crate::ports::{ClipboardImage, SecretStore}; use crate::settings::{AutoBackup, Settings, ThemeId}; use crate::ui::{CandidateAction, CandidateStatus, ExportTarget, Notice, Phase, Platform, UiEvent}; @@ -24,10 +24,13 @@ const T0: u64 = 1_790_000_000_000; const MASTER: &str = "correct horse battery"; const SECRET: &str = "JBSWY3DPEHPK3PXP"; +mod update; + struct Harness { core: Core, keychain: Arc, clipboard: Arc, + updater: Arc, events: broadcast::Receiver, dir: tempfile::TempDir, } @@ -43,7 +46,7 @@ impl Harness { /// A second core on the same directories, as after a restart. fn restart(&self) -> Core { - start(self.dir.path(), Arc::clone(&self.keychain), Arc::clone(&self.clipboard)) + start(self.dir.path(), Arc::clone(&self.keychain), Arc::clone(&self.clipboard), Arc::clone(&self.updater)) } /// Every notice received since the last call. @@ -64,7 +67,7 @@ impl Harness { } } -fn start(root: &Path, keychain: Arc, clipboard: Arc) -> Core { +fn start(root: &Path, keychain: Arc, clipboard: Arc, updater: Arc) -> Core { let config = CoreConfig { data_dir: root.join("data"), config_dir: root.join("config"), @@ -72,16 +75,22 @@ fn start(root: &Path, keychain: Arc, clipboard: Arc kdf: KdfCost::FAST_INSECURE, platform: Platform::Linux, }; - Core::start(config, Ports { secrets: keychain, clipboard, clock: Arc::new(FakeClock::new(T0)) }) + Core::start(config, Ports { secrets: keychain, clipboard, clock: Arc::new(FakeClock::new(T0)), updater }) } fn harness() -> Harness { + harness_with(FakeUpdater::default()) +} + +/// A core whose update source is `updater`. +fn harness_with(updater: FakeUpdater) -> Harness { let dir = tempfile::tempdir().unwrap(); let keychain = Arc::new(FakeKeychain::default()); let clipboard = Arc::new(FakeClipboard::default()); - let core = start(dir.path(), Arc::clone(&keychain), Arc::clone(&clipboard)); + let updater = Arc::new(updater); + let core = start(dir.path(), Arc::clone(&keychain), Arc::clone(&clipboard), Arc::clone(&updater)); let events = core.subscribe(); - Harness { core, keychain, clipboard, events, dir } + Harness { core, keychain, clipboard, updater, events, dir } } fn pw(text: &str) -> Zeroizing { diff --git a/crates/lockra-core/src/tests/update.rs b/crates/lockra-core/src/tests/update.rs new file mode 100644 index 00000000..1a56a402 --- /dev/null +++ b/crates/lockra-core/src/tests/update.rs @@ -0,0 +1,237 @@ +//! The in-app update on the fake update source: what the state shows at each step, which requests +//! are refused, and when the automatic check runs. + +use std::sync::atomic::Ordering; +use std::time::Duration; + +use super::*; +use crate::fakes::FakeUpdater; +use crate::ports::UpdateFailure; +use crate::ui::{InstallMethod, UpdateStatus}; +use crate::{CHECK_INTERVAL, CHECK_RETRY, PROGRESS_MIN_STEP, STARTUP_CHECK_DELAY}; + +fn status(h: &Harness) -> UpdateStatus { + h.core.state().update.status +} + +/// The update statuses the state events carried since the last call, without repeats. +fn statuses(h: &mut Harness) -> Vec { + let mut out: Vec = Vec::new(); + while let Ok(event) = h.events.try_recv() { + if let UiEvent::State { state } = event + && out.last() != Some(&state.update.status) + { + out.push(state.update.status); + } + } + out +} + +/// Let a background run reach its last status (the fake yields between progress steps, more often +/// than `settle` does). +async fn finished(h: &Harness) { + for _ in 0..500 { + tokio::task::yield_now().await; + if matches!(status(h), UpdateStatus::UpToDate { .. } | UpdateStatus::Available { .. } | UpdateStatus::Installing { .. } | UpdateStatus::Failed { .. }) { + return; + } + } + panic!("the update run did not finish: {:?}", status(h)); +} + +fn automatic(on: bool) -> Settings { + Settings { auto_check_updates: on, ..Settings::default() } +} + +#[tokio::test(start_paused = true)] +async fn a_copy_that_cannot_update_itself_refuses_and_never_checks() { + let h = harness(); + let state = h.core.state(); + assert_eq!((state.update.method, state.update.status), (None, UpdateStatus::Idle)); + assert_eq!(code_err(h.core.update_check()), ErrorCode::UpdateUnavailable); + assert_eq!(code_err(h.core.update_install()), ErrorCode::UpdateUnavailable); + h.core.set_settings(automatic(true)).unwrap(); + advance(STARTUP_CHECK_DELAY + CHECK_INTERVAL).await; + assert!(h.updater.calls().is_empty()); + assert_eq!(status(&h), UpdateStatus::Idle); +} + +#[tokio::test(start_paused = true)] +async fn a_check_finds_nothing_newer_or_the_newer_release() { + let mut h = harness_with(FakeUpdater::installed(InstallMethod::Deb)); + assert_eq!(h.core.state().update.method, Some(InstallMethod::Deb)); + h.core.update_check().unwrap(); + settle().await; + assert_eq!(statuses(&mut h), [UpdateStatus::Checking, UpdateStatus::UpToDate { checked_at_ms: T0 }]); + + *h.updater.check.lock() = Ok(Some(FakeUpdater::release("0.2.0"))); + advance(Duration::from_secs(60)).await; + h.core.update_check().unwrap(); + settle().await; + let available = UpdateStatus::Available { + version: "0.2.0".into(), + notes: Some("## 0.2.0\n\n- What changed".into()), + date: Some("2026-10-02T08:00:00Z".into()), + checked_at_ms: T0 + 60_000, + }; + assert_eq!(statuses(&mut h), [UpdateStatus::Checking, available]); + assert_eq!(h.updater.calls(), ["check", "check"]); + // A check the user asked for answers in Settings › About, without a toast. + assert!(h.notices().is_empty()); +} + +#[tokio::test(start_paused = true)] +async fn one_run_at_a_time() { + let updater = FakeUpdater::installed(InstallMethod::Appimage); + updater.hold.store(true, Ordering::SeqCst); + let h = harness_with(updater); + h.core.update_check().unwrap(); + settle().await; + assert_eq!(status(&h), UpdateStatus::Checking); + assert_eq!(code_err(h.core.update_check()), ErrorCode::UpdateBusy); + assert_eq!(code_err(h.core.update_install()), ErrorCode::UpdateBusy); + + *h.updater.check.lock() = Ok(Some(FakeUpdater::release("0.2.0"))); + h.updater.hold.store(false, Ordering::SeqCst); + h.updater.resume(); + settle().await; + assert!(matches!(status(&h), UpdateStatus::Available { .. })); + h.core.update_check().unwrap(); + settle().await; + assert_eq!(h.updater.calls(), ["check", "check"]); +} + +#[tokio::test(start_paused = true)] +async fn install_downloads_with_progress_backs_up_and_installs() { + let updater = FakeUpdater::installed(InstallMethod::Nsis); + *updater.check.lock() = Ok(Some(FakeUpdater::release("0.3.0"))); + let total = 400 * PROGRESS_MIN_STEP; + // Every hundredth for the first tenth, half-hundredths in between, then the rest. + let mut steps: Vec<(u64, Option)> = (1..=20).map(|n| (n * total / 200, Some(total))).collect(); + steps.push((total, Some(total))); + *updater.progress.lock() = steps; + let mut h = harness_with(updater); + h.core.create_vault(pw(MASTER)).await.unwrap(); + // Automatic backups were just turned on: a backup is due within the debounce. + let backups = h.dir.path().join("backups"); + fs::create_dir_all(&backups).unwrap(); + let auto_backup = AutoBackup { enabled: true, dir: Some(backups.display().to_string()), keep: 10 }; + h.core.set_settings(Settings { auto_backup, ..Settings::default() }).unwrap(); + statuses(&mut h); + + h.core.update_install().unwrap(); + finished(&h).await; + settle().await; + let seen = statuses(&mut h); + assert_eq!(seen.first(), Some(&UpdateStatus::Checking)); + assert_eq!(seen.last(), Some(&UpdateStatus::Installing { version: "0.3.0".into() })); + let received: Vec = seen + .iter() + .filter_map(|s| match s { + UpdateStatus::Downloading { received, total: t, .. } => { + assert!(t.is_none() || *t == Some(total)); + Some(*received) + } + _ => None, + }) + .collect(); + let mut expected: Vec = vec![0]; + expected.extend((1..=10).map(|n| n * total / 100)); + expected.push(total); + assert_eq!(received, expected, "a state per hundredth and at the end, not per chunk"); + assert_eq!(h.updater.calls(), ["check", "download", "install"]); + assert_eq!(fs::read_dir(&backups).unwrap().count(), 1, "the pending backup was written before the install"); + assert_eq!(h.core.state().phase, Phase::Unlocked); +} + +#[tokio::test(start_paused = true)] +async fn install_with_nothing_newer_ends_up_to_date() { + let h = harness_with(FakeUpdater::installed(InstallMethod::Rpm)); + h.core.update_install().unwrap(); + settle().await; + assert_eq!(status(&h), UpdateStatus::UpToDate { checked_at_ms: T0 }); + assert_eq!(h.updater.calls(), ["check"]); +} + +#[tokio::test(start_paused = true)] +async fn a_failed_step_shows_its_code_and_frees_the_updater() { + let h = harness_with(FakeUpdater::installed(InstallMethod::App)); + *h.updater.check.lock() = Err(UpdateFailure::Network); + h.core.update_check().unwrap(); + settle().await; + assert_eq!(status(&h), UpdateStatus::Failed { code: ErrorCode::UpdateNetwork, at_ms: T0 }); + + *h.updater.check.lock() = Ok(Some(FakeUpdater::release("0.2.0"))); + *h.updater.download.lock() = Err(UpdateFailure::Signature); + h.core.update_install().unwrap(); + settle().await; + assert_eq!(status(&h), UpdateStatus::Failed { code: ErrorCode::UpdateSignature, at_ms: T0 }); + + *h.updater.download.lock() = Ok(()); + *h.updater.install.lock() = Err(UpdateFailure::Cancelled); + h.core.update_install().unwrap(); + settle().await; + assert_eq!(status(&h), UpdateStatus::Failed { code: ErrorCode::UpdateCancelled, at_ms: T0 }); + assert_eq!(h.updater.calls(), ["check", "check", "download", "check", "download", "install"]); +} + +#[tokio::test(start_paused = true)] +async fn automatic_checks_follow_the_setting_and_announce_each_release_once() { + let mut h = harness_with(FakeUpdater::installed(InstallMethod::Deb)); + *h.updater.check.lock() = Ok(Some(FakeUpdater::release("0.2.0"))); + // Off by default: nothing goes online, however long Lockra runs. + advance(STARTUP_CHECK_DELAY + CHECK_INTERVAL).await; + assert!(h.updater.calls().is_empty()); + + h.core.set_settings(automatic(true)).unwrap(); + settle().await; + assert_eq!(h.updater.calls(), ["check"], "turning it on checks at once"); + assert_eq!(h.notices(), [Notice::UpdateAvailable { version: "0.2.0".into() }]); + + advance(CHECK_INTERVAL).await; + assert_eq!(h.updater.calls().len(), 2, "then once a day"); + assert!(h.notices().is_empty(), "the same release is announced once"); + *h.updater.check.lock() = Ok(Some(FakeUpdater::release("0.2.1"))); + advance(CHECK_INTERVAL).await; + assert_eq!(h.notices(), [Notice::UpdateAvailable { version: "0.2.1".into() }]); + + h.core.set_settings(automatic(false)).unwrap(); + advance(CHECK_INTERVAL * 2).await; + assert_eq!(h.updater.calls().len(), 3, "off again: no more checks"); +} + +#[tokio::test(start_paused = true)] +async fn after_a_restart_the_check_waits_and_a_failure_retries_within_the_hour() { + let h = harness_with(FakeUpdater::installed(InstallMethod::Appimage)); + h.core.set_settings(automatic(true)).unwrap(); + settle().await; + + let updater = Arc::new(FakeUpdater::installed(InstallMethod::Appimage)); + *updater.check.lock() = Err(UpdateFailure::Network); + let core = start(h.dir.path(), Arc::clone(&h.keychain), Arc::clone(&h.clipboard), Arc::clone(&updater)); + advance(STARTUP_CHECK_DELAY - Duration::from_secs(1)).await; + assert!(updater.calls().is_empty(), "not before the start delay"); + advance(Duration::from_secs(1)).await; + assert_eq!(updater.calls(), ["check"]); + assert!(matches!(core.state().update.status, UpdateStatus::Failed { code: ErrorCode::UpdateNetwork, .. })); + advance(CHECK_RETRY).await; + assert_eq!(updater.calls(), ["check", "check"], "offline at start: the next try is within the hour"); +} + +#[tokio::test(start_paused = true)] +async fn the_automatic_check_skips_a_turn_while_the_user_runs_one() { + let updater = FakeUpdater::installed(InstallMethod::Deb); + updater.hold.store(true, Ordering::SeqCst); + let h = harness_with(updater); + h.core.update_check().unwrap(); + settle().await; + h.core.set_settings(automatic(true)).unwrap(); + settle().await; + assert_eq!(h.updater.calls(), ["check"], "the user's check answers the question"); + + h.updater.hold.store(false, Ordering::SeqCst); + h.updater.resume(); + settle().await; + advance(CHECK_INTERVAL).await; + assert_eq!(h.updater.calls(), ["check", "check"], "the automatic one comes back a day later"); +} diff --git a/crates/lockra-core/src/ui.rs b/crates/lockra-core/src/ui.rs index 2be4885d..778ab2b5 100644 --- a/crates/lockra-core/src/ui.rs +++ b/crates/lockra-core/src/ui.rs @@ -78,6 +78,84 @@ pub struct UiState { pub restore: Option, /// When the vault locks itself if nothing happens, Unix milliseconds. pub auto_lock_at_ms: Option, + /// The in-app update. + pub update: UpdateView, +} + +/// How this copy of Lockra was installed, which is how an update installs: the package format the +/// bundler built it into. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InstallMethod { + /// A Debian package (installing asks for an administrator's password). + Deb, + /// An RPM package (installing asks for an administrator's password). + Rpm, + /// An AppImage, replaced in place. + Appimage, + /// The Windows setup program. + Nsis, + /// The Windows installer package. + Msi, + /// The macOS app, replaced in place. + App, +} + +/// The in-app update, as Settings › About shows it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UpdateView { + /// How an update installs here; absent when this copy cannot update itself (it was not + /// installed from a package, or the build has no update key). + pub method: Option, + /// What the updater is doing. + pub status: UpdateStatus, +} + +/// Where the in-app update is. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum UpdateStatus { + /// Nothing asked since start. + Idle, + /// Asking for a newer release. + Checking, + /// This is the newest release. + UpToDate { + /// When it was asked, Unix milliseconds. + checked_at_ms: u64, + }, + /// A newer release is out. + Available { + /// Its version. + version: String, + /// Its release notes (Markdown), when there are any. + notes: Option, + /// When it was published (RFC 3339), when the release says. + date: Option, + /// When it was found, Unix milliseconds. + checked_at_ms: u64, + }, + /// Downloading it. + Downloading { + /// Its version. + version: String, + /// Bytes so far. + received: u64, + /// Its size, when the server says. + total: Option, + }, + /// Installing it; Lockra restarts next. + Installing { + /// Its version. + version: String, + }, + /// The last check or install failed. + Failed { + /// Why. + code: ErrorCode, + /// When, Unix milliseconds. + at_ms: u64, + }, } /// The unlock screen's facts. @@ -377,6 +455,11 @@ pub enum Notice { /// Changing the master password could not keep "remember on this device" (the keychain key /// was unreadable); it is off now. DeviceUnlockTurnedOff, + /// The automatic check found a newer release (once per version). + UpdateAvailable { + /// Its version. + version: String, + }, } /// One event on [`UI_EVENT_NAME`]. diff --git a/crates/lockra-core/src/update.rs b/crates/lockra-core/src/update.rs new file mode 100644 index 00000000..f38f74f1 --- /dev/null +++ b/crates/lockra-core/src/update.rs @@ -0,0 +1,130 @@ +//! The in-app update's bookkeeping: when the automatic check runs, how often download progress is +//! reported, and which code a failed step shows. The steps themselves are `Core::update_check` and +//! `Core::update_install` (session.rs); the network and the installer are the [`Updater`] port. +//! +//! [`Updater`]: crate::ports::Updater + +use std::time::Duration; + +use tokio::time::Instant; + +use crate::error::ErrorCode; +use crate::ports::UpdateFailure; +use crate::ui::UpdateStatus; + +/// How long after start the automatic check runs (the window and the vault settle first). +pub const STARTUP_CHECK_DELAY: Duration = Duration::from_secs(10); +/// Between two automatic checks. +pub const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); +/// After an automatic check that failed (offline at start, say). +pub const CHECK_RETRY: Duration = Duration::from_secs(60 * 60); +/// The fewest bytes between two download progress reports: each one is a whole state for the +/// webview. +pub const PROGRESS_MIN_STEP: u64 = 256 * 1024; + +/// What a run does once it knows about a newer release. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpdateRun { + /// Ask only: the user's "check", or the automatic check. + Check { + /// Started by the timer, not by the user. + automatic: bool, + }, + /// Ask, download, install and restart: the user's "install". + Install, +} + +/// The update part of the core's state. +#[derive(Debug)] +pub(crate) struct UpdateState { + /// What Settings › About shows. + pub status: UpdateStatus, + /// A run is in flight. + pub busy: bool, + /// When the automatic check runs next. + pub next_check: Option, + /// The version the automatic check last announced: each one is announced once. + pub announced: Option, +} + +impl UpdateState { + /// The state at start; `automatic` schedules the first check. + pub fn new(automatic: bool) -> Self { + Self { status: UpdateStatus::Idle, busy: false, next_check: automatic.then(|| Instant::now() + STARTUP_CHECK_DELAY), announced: None } + } +} + +/// The code a failed step shows. +pub(crate) fn failure_code(failure: UpdateFailure) -> ErrorCode { + match failure { + UpdateFailure::Network => ErrorCode::UpdateNetwork, + UpdateFailure::Invalid => ErrorCode::UpdateInvalid, + UpdateFailure::Signature => ErrorCode::UpdateSignature, + UpdateFailure::Install => ErrorCode::UpdateInstallFailed, + UpdateFailure::Cancelled => ErrorCode::UpdateCancelled, + } +} + +/// Which download progress is worth a state: every hundredth of the size, at least +/// [`PROGRESS_MIN_STEP`] apart, and the end. +#[derive(Debug, Default)] +pub(crate) struct ProgressGate { + last: u64, +} + +impl ProgressGate { + /// `true` when `received` deserves a state. + pub fn step(&mut self, received: u64, total: Option) -> bool { + let step = total.map_or(PROGRESS_MIN_STEP, |t| (t / 100).max(PROGRESS_MIN_STEP)); + let due = received.saturating_sub(self.last) >= step || total.is_some_and(|t| received >= t); + if due { + self.last = received; + } + due + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failures_map_to_their_codes() { + let cases = [ + (UpdateFailure::Network, ErrorCode::UpdateNetwork), + (UpdateFailure::Invalid, ErrorCode::UpdateInvalid), + (UpdateFailure::Signature, ErrorCode::UpdateSignature), + (UpdateFailure::Install, ErrorCode::UpdateInstallFailed), + (UpdateFailure::Cancelled, ErrorCode::UpdateCancelled), + ]; + for (failure, code) in cases { + assert_eq!(failure_code(failure), code); + } + } + + #[test] + fn progress_is_reported_every_hundredth_and_at_the_end() { + let total = 100 * PROGRESS_MIN_STEP * 2; + let mut gate = ProgressGate::default(); + assert!(!gate.step(1, Some(total))); + assert!(!gate.step(total / 100 - 1, Some(total))); + assert!(gate.step(total / 100, Some(total))); + assert!(!gate.step(total / 100 + 1, Some(total))); + assert!(gate.step(total, Some(total)), "the end always counts"); + // A small package still steps by PROGRESS_MIN_STEP, and an unknown size by that step alone. + let mut small = ProgressGate::default(); + assert!(!small.step(PROGRESS_MIN_STEP - 1, Some(10 * PROGRESS_MIN_STEP))); + assert!(small.step(PROGRESS_MIN_STEP, Some(10 * PROGRESS_MIN_STEP))); + let mut unknown = ProgressGate::default(); + assert!(!unknown.step(PROGRESS_MIN_STEP - 1, None)); + assert!(unknown.step(PROGRESS_MIN_STEP, None)); + } + + #[tokio::test(start_paused = true)] + async fn the_first_automatic_check_waits_for_the_start() { + let off = UpdateState::new(false); + assert_eq!((off.status, off.busy, off.next_check, off.announced), (UpdateStatus::Idle, false, None, None)); + let on = UpdateState::new(true); + assert_eq!(on.next_check, Some(Instant::now() + STARTUP_CHECK_DELAY)); + } +} diff --git a/deny.toml b/deny.toml index ea06dda0..b0dcc65b 100644 --- a/deny.toml +++ b/deny.toml @@ -50,25 +50,37 @@ multiple-versions = "warn" wildcards = "warn" allow-wildcard-paths = true -# Lockra never goes online (docs/security.md): no HTTP client or TLS stack belongs in a desktop build. +# Lockra goes online for one thing only, the in-app update, and only when the user asks or has +# turned automatic checks on (docs/security.md, "Network"). The HTTP client and the TLS stack may +# come in through tauri-plugin-updater and nothing else: any other crate that pulls one into the +# desktop graph is a design change, not a dependency bump. [[bans.deny]] name = "reqwest" -reason = "Lockra makes no network requests; an HTTP client in the desktop graph is a design change, not a dependency bump." +wrappers = ["tauri-plugin-updater"] +reason = "The update check is Lockra's only network access; another HTTP client in the desktop graph is a design change." [[bans.deny]] name = "hyper" -reason = "Lockra makes no network requests." +wrappers = ["reqwest", "hyper-util", "hyper-rustls"] +reason = "HTTP only for the update check, through reqwest." [[bans.deny]] name = "ureq" -reason = "Lockra makes no network requests." +reason = "The update check is Lockra's only network access." [[bans.deny]] name = "rustls" -reason = "No TLS in an offline application." +wrappers = [ + "tauri-plugin-updater", + "reqwest", + "hyper-rustls", + "tokio-rustls", + "rustls-platform-verifier", +] +reason = "TLS only for the update check." [[bans.deny]] name = "native-tls" -reason = "No TLS in an offline application." +reason = "The update check uses rustls; no second TLS stack." [[bans.deny]] name = "openssl-sys" -reason = "No TLS in an offline application." +reason = "The update check uses rustls; no second TLS stack." [advisories] version = 2 diff --git a/docs/acceptance/updates.md b/docs/acceptance/updates.md new file mode 100644 index 00000000..71bee0a2 --- /dev/null +++ b/docs/acceptance/updates.md @@ -0,0 +1,53 @@ +# In-app updates and the install scripts + +What is proven automatically, what was run by hand here, and what has to be checked on each +system before a release is announced. The design is in `docs/security.md` ("Updates") and +`docs/release.md` ("In-app updates", "The install scripts"). + +## Automated + +| Layer | What | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| core | `crates/lockra-core/src/tests/update.rs` on the fake update source and the paused clock: unavailable copies refuse; check, install with throttled progress, the pending backup written first; one run at a time; failures and their codes; automatic checks (off by default, 10 s after start, daily, retry within the hour, one notice per version, skipped while the user runs one) | +| shell | `apps/desktop/src-tauri/tests/update.rs`: the real tauri-plugin-updater against a manifest server on 127.0.0.1: nothing newer (204), a package whose signature verifies, a valid signature made for another version (refused: `requireSignedVersion`), a package changed after signing (refused), no manifest, no server | +| release | `.github/scripts/test-tauri-release.py`: Lockra's real updater settings, every leg collected with its signatures, `latest.json` with a key per target and per installer (17), each pointing at its own asset and signature | +| installers | `scripts/test-install.sh` (a fake GitHub, fake package managers and macOS tools; apt, dnf, zypper, the AppImage, Apple silicon through Rosetta, Intel, every refusal) and `scripts/test-install.ps1` (PowerShell 7 with the network, installer and registry cmdlets replaced); `.github/workflows/install-scripts.yml` runs both scripts against the real latest release on Linux, macOS and Windows | + +## Run here + +`make smoke-update` (`scripts/smoke-update-linux.sh`), 2026-10-01, Ubuntu 24.04 x64 under Xvfb: +two release AppImages, 9.0.0 and 9.0.1, signed with a throwaway key and pointed at a manifest on +127.0.0.1; 9.0.0 started and driven over WebDriver. + +```text +smoke-update: Lockra 9.0.0, installed as an AppImage +smoke-update: the check found 9.0.1 (2026-10-01T12:00:00Z) +smoke-update: install started; the app replaces its AppImage and restarts +smoke-update: the AppImage on disk is now 9.0.1 +smoke-update: Lockra restarted from the updated AppImage +smoke-update: OK, 9.0.0 updated itself to 9.0.1 +``` + +So on Linux the whole path is the real one: the installer key the bundler wrote into the AppImage, +the manifest lookup, the download, the signature and its version, the replacement of the file and +the restart from it. The `.deb` and `.rpm` paths differ only in the installer the plugin runs +(`pkexec dpkg -i`, `pkexec rpm -U`), which needs an administrator prompt this run cannot answer. + +## By hand, on each system + +Install the release before the one being tested with the install script, open **Settings › +About**, choose **Check for updates**, then **Download and install**, and check: + +| System | Expect | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Ubuntu (`.deb`) | the polkit password prompt; afterwards `dpkg -s lockra` shows the new version and Lockra is running again | +| Fedora (`.rpm`) | the same with `rpm -q lockra` | +| Windows x64 and ARM64 | the installer's progress window, no administrator prompt, Lockra opening again at the new version (**Settings › About**) | +| macOS, Apple silicon and Intel | Lockra restarting at the new version without a Gatekeeper prompt; **Remember on this device** still unlocking, or unlocking with the master password and turning it on again | +| A copy run from the build tree | **Settings › About** saying this copy cannot update itself, and no check button | + +Then turn on **Check for updates automatically**, restart Lockra and check that the notice of a +newer version appears about 10 seconds after the start, and only once. + +Lockra 0.1.x has no updater: the first release this can be checked from is 0.2.0, updating to the +next one. diff --git a/docs/architecture.md b/docs/architecture.md index d3f98649..5bd979e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,6 +35,7 @@ cheap handle; the shell injects the ports: | `Clipboard` | `arboard` on its own thread (on Linux the owner process serves the clipboard) | `FakeClipboard` | | `Clock` | `SystemClock` | `FakeClock` | | `CodeSink` (code frames) | a Tauri `Channel` | `RecordingSink` | +| `Updater` (in-app update) | tauri-plugin-updater (`src-tauri/src/updater.rs`), only in a packaged copy | `FakeUpdater`, `NoUpdater` | State machine: **NoVault → Locked → Unlocked**. Create or restore leads from NoVault to Unlocked; unlock (password or device key) from Locked; lock, auto-lock and closing return to Locked; reset @@ -42,9 +43,20 @@ returns from Locked to NoVault. Only Unlocked holds decrypted entries, the impor export sessions; leaving it drops them. Argon2 and file I/O run on `spawn_blocking`. One **scheduler task** owns every timer — the next -code window, auto-lock, clipboard clearing, the automatic backup debounce, export expiry — sleeps -until the earliest deadline, and is woken through a `Notify` whenever the state changes, so -nothing polls. Core tests run on tokio's paused clock with the fakes. +code window, auto-lock, clipboard clearing, the automatic backup debounce, export expiry, the +automatic update check — sleeps until the earliest deadline, and is woken through a `Notify` +whenever the state changes, so nothing polls. Core tests run on tokio's paused clock with the +fakes. + +The in-app update is a run in the background, one at a time: `update_check` asks the `Updater` +for a newer release (`UiState.update` goes `checking` → `up_to_date` / `available` / `failed`); +`update_install` asks again, downloads with progress (the port verifies the signature), writes a +pending automatic backup, then installs and lets the shell restart. With +`Settings.auto_check_updates` (off by default) the scheduler checks 10 s after start and daily, and +announces each newer version once (`Notice::UpdateAvailable`); it never downloads by itself. The +port reports how this copy installs (`deb`, `rpm`, `appimage`, `nsis`, `msi`, `app`), read from the +bundle type the bundler patched into the executable; a build from the tree has none and cannot +update itself (`docs/security.md`, "Updates"). ## The bridge and the shell @@ -91,12 +103,12 @@ code stream in development (both fixed, both covered by tests). ## Tests -| Layer | How | -| -------- | ------------------------------------------------------------------------------------------------------------------------ | -| crates | unit tests and RFC vectors; core scenarios on fakes and a paused tokio clock; line coverage ≥ 90 % (`make coverage`) | -| contract | Rust fixtures ⇄ zod schemas, secret-leak assertions | -| shell | `tests/ipc.rs` on `tauri::test::MockRuntime`: the registered commands, dispatch, typed errors, no path, the code channel | -| web | vitest + Testing Library on `MockBackend`, line coverage ≥ 85 % per package | -| app | `make smoke-desktop`: the real app under Xvfb driven over WebDriver and X input (`docs/acceptance/visual-qa.md`) | +| Layer | How | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| crates | unit tests and RFC vectors; core scenarios on fakes and a paused tokio clock; line coverage ≥ 90 % (`make coverage`) | +| contract | Rust fixtures ⇄ zod schemas, secret-leak assertions | +| shell | `tests/ipc.rs` on `tauri::test::MockRuntime`: the registered commands, dispatch, typed errors, no path, the code channel; `tests/update.rs`: the real updater plugin against a local manifest (signature, signed version, tampering) | +| web | vitest + Testing Library on `MockBackend`, line coverage ≥ 85 % per package | +| app | `make smoke-desktop`: the real app under Xvfb driven over WebDriver and X input (`docs/acceptance/visual-qa.md`) | `make verify` runs every gate; `make linux-x64` and `make windows-x64` build the packages. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 5ef316ae..e193fe40 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -19,8 +19,8 @@ --- Lockra 是一个 TOTP/HOTP 验证器,支持 Windows、macOS 和 Linux。账号保存在本机的一个加密文件中,Lockra -本身不联网。它可以从 Google 身份验证器和 Microsoft Authenticator 导入账号,也可以导出给它们,并把加密 -备份写入你选择的文件夹。 +只在检查更新时联网。它可以从 Google 身份验证器和 Microsoft Authenticator 导入账号,也可以导出给它们,并把 +加密备份写入你选择的文件夹。 ![Lockra 的验证码页](../acceptance/screens/desktop/codes-1280-light.png) @@ -37,11 +37,26 @@ Lockra 是一个 TOTP/HOTP 验证器,支持 Windows、macOS 和 Linux。账号 Google Drive 或 iCloud 的同步文件夹均可),只保留最近几份。恢复时可以合并,也可以整体替换。 - **安全**:Argon2id 与 XChaCha20-Poly1305,可选用系统钥匙串解锁,自动锁定,显示密钥时阻止截屏(Windows 和 macOS)。 +- **更新**:「设置 › 关于」在你检查时(或开启自动检查后每天一次)查找新版本,只安装带有 Lockra 签名的安装包, + 并通过 Lockra 原来的安装方式(deb、rpm、AppImage、Windows 安装程序或 macOS 应用)完成安装。 - **界面**:四套主题、八种强调色,中英文界面,命令面板(`Ctrl K`),全程可用键盘操作。 ## 安装 -1. **下载安装包**:在[发布页](https://github.com/sunerpy/lockra/releases)选择 Windows 安装程序(x64 与 ARM64)、 +1. **一键安装**:脚本会为当前电脑选择对应的安装包,用该版本的 `SHA256SUMS` 校验后安装 + ([安装指南](https://firlab.app/lockra/zh/guide/install#一键安装))。 + + ```bash + # Linux 和 macOS + curl -fsSL https://raw.githubusercontent.com/sunerpy/lockra/main/scripts/install.sh | sh + ``` + + ```powershell + # Windows(PowerShell) + irm https://raw.githubusercontent.com/sunerpy/lockra/main/scripts/install.ps1 | iex + ``` + +2. **下载安装包**:在[发布页](https://github.com/sunerpy/lockra/releases)选择 Windows 安装程序(x64 与 ARM64)、 适用于 Apple 芯片或 Intel Mac 的 dmg,或适用于 Linux(x64 与 ARM64)的 deb、rpm、AppImage。每个版本都附带 `SHA256SUMS` 和构建证明: @@ -53,7 +68,9 @@ Lockra 是一个 TOTP/HOTP 验证器,支持 Windows、macOS 和 Linux。账号 安装包目前尚未签名,首次启动时 Windows SmartScreen 和 macOS Gatekeeper 会提示确认 ([平台说明](https://firlab.app/lockra/zh/reference/platforms))。 -2. **从源码构建**(见[开发](#开发))。 +3. **从源码构建**(见[开发](#开发))。 + +从 0.2.0 起,Lockra 可以自行更新:「设置 › 关于 › 检查更新」([更新说明](https://firlab.app/lockra/zh/guide/updates))。 ## 快速开始 diff --git a/docs/release.md b/docs/release.md index 377d0951..6657139d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -17,14 +17,13 @@ one workflow run builds, verifies and publishes everything. against the version (`.github/scripts/tauri-release.py check-config --expect-version`) and renders the matrix from `.github/release-targets.json`; - `bundle` builds each target with the lockfile's Tauri CLI: `tauri build --no-bundle` without - any secret, then `tauri bundle` (deb, rpm and AppImage on Linux x64 and arm64; app and dmg for - Apple silicon and Intel; NSIS and MSI on Windows x64, NSIS on Windows arm64); - - `updater` collects every leg, writes `SHA256SUMS`, attests the files (SLSA build provenance) - and attaches them to the draft. Lockra has no in-app updater (`bundle.createUpdaterArtifacts` - is not set), so there is no `latest.json`, and macOS ships its dmg alone: without the updater - Tauri does not archive the `.app`, which the dmg carries. `tauri-release.py collect` checks - that the `.app` was built and publishes nothing for it, and - `.github/scripts/test-tauri-release.py` checks every leg against the manifest; + any secret, then `tauri bundle` with the update signing key (deb, rpm and AppImage on Linux x64 + and arm64; the app archive and the dmg for Apple silicon and Intel; NSIS and MSI on Windows + x64, NSIS on Windows arm64), which signs every package for the in-app update; + - `updater` collects every leg, verifies every signature against the public key in + `tauri.conf.json`, writes `latest.json` (below) and `SHA256SUMS`, attests the files (SLSA + build provenance) and attaches them to the draft. `.github/scripts/test-tauri-release.py` + checks every leg and the manifest's keys against the target manifest; - `publish-release` re-verifies every remote asset against `SHA256SUMS` and flips the draft to public, marking it latest unless it is a prerelease. 4. A failed leg leaves the draft unpublished. Rebuild it after a fix of the workflow or an outage @@ -129,6 +128,7 @@ gh run list --repo $R --branch release-please--branches--main--components--lockr | Secret | Needed for | Notes | | --------------------------------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | none | releases | `GITHUB_TOKEN` creates the tag, the draft and the assets | +| `TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | releases (the in-app update) | the minisign key every package is signed with; `preflight` fails without it (below) | | `FIRLAB_DOCS_TOKEN` | `publish-site.yml` | a fine-grained token for `sunerpy/firlab` only, Contents read and write; see [docs/site/README.md](site/README.md) | | `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_API_*` | optional macOS signing and notarization | without them the app is unsigned and Gatekeeper warns | | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` with `bundle.windows.signCommand` | optional Windows signing | without them SmartScreen warns | @@ -147,6 +147,47 @@ gh secret set FIRLAB_DOCS_TOKEN --repo sunerpy/lockra # paste the value at the The next run that has something to push proves the new token; a run with nothing new to publish stops before it uses the token. +## In-app updates + +From 0.2.0 every release carries what the app's updater reads (`docs/security.md`, "Updates"): + +- `bundle.createUpdaterArtifacts: true` makes `tauri bundle` sign every package with + `TAURI_SIGNING_PRIVATE_KEY`; the signature's trusted comment records the version + (`file:Lockra_0.2.0_amd64.deb version:0.2.0`), which `plugins.updater.requireSignedVersion` + makes the app check. Do not turn that off: it is what refuses an older package offered as new. +- `latest.json` names the version, the release notes (the Release's body) and one entry per + platform key, each a URL pinned to this release's asset and that asset's signature: an + `{os}-{arch}-{installer}` key for every signed package (`linux-x86_64-deb`, `linux-x86_64-rpm`, + `linux-x86_64-appimage`, `windows-x86_64-nsis`, `windows-x86_64-msi`, `darwin-aarch64-app`, …) + and an `{os}-{arch}` key for the updater bundle of each target. The app asks for the key of the + way it was installed first, so a copy installed from the `.deb` updates through a `.deb`. +- The app asks `https://github.com/sunerpy/lockra/releases/latest/download/latest.json` + (`plugins.updater.endpoints`): only the release marked latest reaches installed copies, so a + prerelease never does. + +**The key.** It was generated with `pnpm tauri signer generate` (password protected) on +2026-10-01; its public half is `plugins.updater.pubkey` in `tauri.conf.json`, the private key and +its password are the two secrets above, and the maintainer keeps an offline copy of both. +Installed copies trust only this key: **losing it ends updates** (each copy would need a reinstall of a build with a new +public key), and replacing it takes one release signed with the old key that ships the new public +key. Keep the offline copy outside GitHub. + +Local packages (`make linux-x64`, `make windows-x64`, `make pre-ci`) are built without the key and +therefore unsigned: the scripts pass `--config '{"bundle":{"createUpdaterArtifacts":false}}'` when +`TAURI_SIGNING_PRIVATE_KEY` is not set. A packaged build checks for updates against the real +manifest; `apps/desktop/src-tauri/tests/update.rs` checks the updater against a local manifest +instead, and `docs/acceptance/updates.md` is the update checked by hand on each system. + +## The install scripts + +`scripts/install.sh` (Linux, macOS) and `scripts/install.ps1` (Windows) install the latest release +from its assets, by the names the release workflow gives them, after checking `SHA256SUMS`; the +documentation prints them as `curl … | sh` and `irm … | iex` from `main`. Change them together +with any asset name. `scripts/test-install.sh` and `scripts/test-install.ps1` test them offline +(CI's repository gates, `make check`); `.github/workflows/install-scripts.yml` runs them against +the real latest release on Linux (apt, dnf, AppImage), macOS and Windows (x64 and ARM64) when they +change and every week. + ## Checking a release ```bash @@ -155,6 +196,7 @@ gh release view vX.Y.Z --json isDraft,isLatest,assets --jq '{isDraft, isLatest, gh release download vX.Y.Z --pattern SHA256SUMS && sha256sum -c SHA256SUMS --ignore-missing gh attestation verify Lockra_X.Y.Z_x64-setup.exe --repo sunerpy/lockra \ --signer-workflow sunerpy/lockra/.github/workflows/release.yml +gh release download vX.Y.Z --pattern latest.json --output - | jq '.version, (.platforms | keys)' ``` Every bundle leg must appear by its target name; a green run with a skipped leg is not a release. diff --git a/docs/security.md b/docs/security.md index 2493b48d..b9024351 100644 --- a/docs/security.md +++ b/docs/security.md @@ -7,8 +7,10 @@ What Lockra protects, how, and what it does not. Formats are in `docs/formats.md The TOTP/HOTP secrets, at rest and in the running app, against: someone who copies the vault file or a backup; other processes and web content reaching the app's IPC; the webview (the least trusted part of the app) reading files or secrets it was not explicitly given; secrets lingering -on the clipboard or in screenshots. Lockra never goes online: there is no account, sync, telemetry -or update check, and no network stack in the desktop build (`deny.toml` bans HTTP clients and TLS). +on the clipboard or in screenshots; and an update that is not Lockra's. Lockra goes online for one +thing only, the update check (below), and only when the user asks or has turned automatic checks +on: there is no account, sync or telemetry. The HTTP client and TLS stack reach the desktop build +only through tauri-plugin-updater (`deny.toml` bans them from every other crate). ## At rest @@ -50,11 +52,46 @@ or update check, and no network stack in the desktop build (`deny.toml` bans HTT decrypted entries, the import preview and every export session. - One process at a time writes the vault (single instance: a second start focuses the first). +## Updates + +- **Off the network by default.** A check runs when the user chooses **Check for updates**, or + 10 s after start and once a day when **Check for updates automatically** is on (off by + default). An automatic check only reports a newer version; nothing is downloaded or installed + without the user choosing **Download and install** (`crates/lockra-core/src/tests/update.rs`). +- **From Rust, not the webview.** The webview sends `update_check` / `update_install` through the + same dispatcher as every command and has no permission for the updater plugin; the CSP still + grants no remote host. The requests go to `https://github.com/sunerpy/lockra/releases/latest/download/latest.json` + (a GitHub redirect to its download host) and to the package's release asset; they carry no + account, vault or setting. +- **Signed packages only.** Every package is signed in the release workflow with a minisign key + whose public half is in `tauri.conf.json` (`plugins.updater.pubkey`). tauri-plugin-updater + checks the signature of the downloaded bytes before anything is installed, and with + `requireSignedVersion` it also requires the version in the signature's trusted comment to be + the one the manifest announces, so a genuine older package offered as a newer version (a forced + downgrade) is refused, as is a package changed after signing + (`apps/desktop/src-tauri/tests/update.rs`). The release workflow verifies every signature + against the same public key before it publishes (`docs/release.md`). +- **The right package for the copy.** The bundler writes the install method into each package's + executable; the manifest has a key per method (`linux-x86_64-deb`, …), so a copy installed from + the `.deb` updates through a `.deb` (installed with `pkexec dpkg -i`, which asks for an + administrator), an AppImage replaces itself, the Windows installer runs in its passive mode and + the macOS app is replaced in place. A copy that was not installed from a package (a build from + the tree) reports that it cannot update itself and never replaces its own executable. +- **Before installing**, an automatic backup still inside its debounce is written, because the + process ends with the install. + ## Residual risks - With "remember on this device" on, the vault is as safe as the OS account: anyone who can sign in to the computer can open it. -- An ad-hoc signed macOS build may ask for keychain access again after every update. +- An ad-hoc signed macOS build may ask for keychain access again after every update, or lose + access to the "remember on this device" key; unlocking with the master password and turning the + option on again restores it. +- The updater trusts the release key (a GitHub Actions secret, with an offline copy kept by the + maintainer) and GitHub's TLS and release storage. Whoever holds the key and can publish a release + of `sunerpy/lockra` can ship an update; a stolen key alone cannot, because the manifest's address + is fixed in the app. Losing the key ends updates for installed copies (each would have to be + reinstalled with a build that carries a new public key). - Linux has no screen-capture exclusion (the reveal and export views say so); clipboard hints only work where the clipboard manager honours them (KDE does). - The master password and secrets typed by hand pass through the webview; Lockra drops them from @@ -63,4 +100,8 @@ or update check, and no network stack in the desktop build (`deny.toml` bans HTT - A forgotten master password cannot be recovered; _reset_ keeps the old file but cannot open it. - Importing from Microsoft Authenticator needs a rooted Android phone, and newer versions of that app may encrypt the field Lockra reads. -- The packages are not code-signed (Windows SmartScreen and macOS Gatekeeper warn). +- The packages are not code-signed (Windows SmartScreen and macOS Gatekeeper warn); the update + signature above proves an update comes from Lockra's release key, not who the publisher is to + the operating system. The install scripts check each package against the release's + `SHA256SUMS`, which proves no more than the HTTPS download it came with; `gh attestation verify` + proves the build provenance. diff --git a/docs/site/guide/install.md b/docs/site/guide/install.md index 5f975c9e..82303842 100644 --- a/docs/site/guide/install.md +++ b/docs/site/guide/install.md @@ -3,6 +3,39 @@ This page explains how to download and install Lockra on Windows, macOS and Linux, and how to check that a download is genuine. +## Install with one command + +The install script picks the package for your computer, checks it against the release's +`SHA256SUMS` and installs nothing when the checksum does not match. Run the same command again to +install a newer version. + +Linux and macOS, in a terminal: + +```bash +curl -fsSL https://raw.githubusercontent.com/sunerpy/lockra/main/scripts/install.sh | sh +``` + +Windows, in PowerShell: + +```powershell +irm https://raw.githubusercontent.com/sunerpy/lockra/main/scripts/install.ps1 | iex +``` + +| Your system | What the script installs | +| -------------------------------------------- | ----------------------------------------------------------------------- | +| Linux with apt (Debian, Ubuntu) | the `.deb`, through apt | +| Linux with dnf, zypper or yum (Fedora, SUSE) | the `.rpm` | +| Other Linux | the AppImage, in `~/.local/bin`, with an entry in the applications menu | +| macOS | the app from the dmg, in `/Applications` (or `~/Applications`) | +| Windows | the installer, for your user account only, without administrator rights | + +On Linux the system asks for your password to install the `.deb` or the `.rpm`. On macOS the app +opens without the "unidentified developer" prompt described below: the script clears the flag a +browser download would carry. The script reads three settings: `LOCKRA_VERSION=0.2.0` installs +that release instead of the latest, `LOCKRA_PACKAGE=appimage` (or `deb`, `rpm`) another Linux +package, and `LOCKRA_INSTALL_DIR` another folder for the AppImage or the app. In PowerShell, set +`$env:LOCKRA_VERSION = "0.2.0"` before the command. + ## Download Every release is on the [releases page](https://github.com/sunerpy/lockra/releases), with the diff --git a/docs/site/guide/updates.md b/docs/site/guide/updates.md index c7f19c6f..600d0579 100644 --- a/docs/site/guide/updates.md +++ b/docs/site/guide/updates.md @@ -5,11 +5,42 @@ it completely. ## Updating -Lockra does not check for updates itself, because it never goes online. Watch the -[releases page](https://github.com/sunerpy/lockra/releases) (on GitHub, **Watch › Custom › -Releases**) and install the new package over the old one. Your vault and settings are kept. +From version 0.2.0, Lockra updates itself. Open **Settings › About** and choose **Check for +updates**. When a newer version is out, its notes appear there, and **Download and install** +downloads it, checks its signature and installs it; Lockra then restarts. Your vault and settings +are kept. Before updating, make sure a recent backup exists: **Backup › Save backup**. -Before updating, make sure a recent backup exists: **Backup › Save backup**. +How the update installs depends on how Lockra was installed: + +| Installed from | The update | +| ----------------- | ------------------------------------------------------------------------- | +| `.deb` or `.rpm` | installs the new package; the system asks for an administrator's password | +| AppImage | replaces the AppImage file, then Lockra restarts | +| Windows installer | the installer closes Lockra and opens it again when it is done | +| macOS app | replaces the app, then Lockra restarts | + +Lockra installs a package only when it carries Lockra's signature and was signed for the version +the release announces, so a changed or older package is refused. Lockra 0.1.x has no in-app +update: install 0.2.0 or later over it once, with the [install script](/guide/install#install-with-one-command) +or a package. A copy that was not installed from a package, such as one built from the source, +cannot update itself and says so in **Settings › About**. + +You can always update with the install script instead, or by installing the new package over the +old one. + +### Automatic checks + +**Settings › About › Check for updates automatically** is off until you turn it on. Then Lockra +checks 10 seconds after it starts and once a day after that, and tells you when a newer version is +out. It never downloads or installs one by itself. If a check fails, for example without a +network, the next one follows within an hour. + +### What a check sends + +A check asks GitHub, where Lockra is released, for a short file that names the newest version; +installing downloads the package from the same release. Nothing from your accounts, your vault or +your settings goes with either request. GitHub sees the request and the address it came from, as +for any download. Without a check, Lockra makes no network connections. ## Where the files are @@ -30,7 +61,16 @@ password. ## Uninstalling -Uninstall Lockra the usual way for your system. The vault and the settings stay in the folders -above, so a later install finds them again; delete those folders to remove everything. If you used -**Remember on this device**, turn it off before uninstalling so the key in the system keychain is -deleted too. +Uninstall Lockra the usual way for your system: + +| Installed from | To uninstall | +| -------------- | -------------------------------------------------------------------------------------- | +| `.deb` | `sudo apt remove lockra` | +| `.rpm` | `sudo dnf remove lockra` (or `sudo zypper remove lockra`) | +| AppImage | delete `~/.local/bin/Lockra.AppImage` and `~/.local/share/applications/lockra.desktop` | +| macOS app | move Lockra from Applications to the Trash | +| Windows | **Settings › Apps › Installed apps › Lockra › Uninstall** | + +The vault and the settings stay in the folders above, so a later install finds them again; delete +those folders to remove everything. If you used **Remember on this device**, turn it off before +uninstalling so the key in the system keychain is deleted too. diff --git a/docs/site/index.md b/docs/site/index.md index eca18066..6599dee0 100644 --- a/docs/site/index.md +++ b/docs/site/index.md @@ -7,7 +7,7 @@ description: Lockra keeps your two-factor codes in one encrypted file on your co hero: name: Lockra text: Two-factor codes that stay on your computer. - tagline: An encrypted authenticator for Windows, macOS and Linux. Move your accounts from Google Authenticator or Microsoft Authenticator, keep encrypted backups where you choose, and nothing goes online. + tagline: An encrypted authenticator for Windows, macOS and Linux. Move your accounts from Google Authenticator or Microsoft Authenticator, keep encrypted backups where you choose, and go online only to check for updates. actions: - theme: brand text: Download @@ -24,7 +24,7 @@ home: - term: Runs on text: Windows 10 and 11, macOS 11 or later (Apple silicon and Intel), and Linux, on x64 and ARM64. - term: Your accounts - text: In one encrypted file on this computer. Lockra makes no network connections. + text: In one encrypted file on this computer. Lockra connects to nothing but GitHub, and only to check for updates. visual: home: @@ -106,6 +106,10 @@ home: body: Showing a secret or an export code asks for the master password again and hides it after two minutes. On Windows and macOS, screenshots show the window black meanwhile. status: available link: /security/#secrets-on-screen + - title: Signed updates + body: Settings › About looks for a new version when you ask, or once a day if you turn that on, and installs only a package that carries Lockra's signature. + status: available + link: /guide/updates#updating steps: title: Move your accounts in four steps @@ -196,8 +200,8 @@ home: value: Where you save them detail: Encrypted the same way. Automatic backups go only to the folder you chose; if that folder syncs, the encrypted file syncs with it. - name: The network - value: Not used - detail: There is no account, no sync, no update check and no telemetry. Fonts and images ship with the app. + value: Only to check for updates + detail: Lockra asks GitHub for the newest version when you check, or once a day if you turn that on; nothing from your accounts goes with it. There is no account, no sync and no telemetry, and fonts and images ship with the app. scope: title: Deliberately left out diff --git a/docs/site/privacy.md b/docs/site/privacy.md index b83ec83b..24c40f73 100644 --- a/docs/site/privacy.md +++ b/docs/site/privacy.md @@ -1,6 +1,6 @@ # Privacy -This page lists what Lockra stores, where, and what it sends: nothing. +This page lists what Lockra stores, where, and what it sends: nothing but an update check. ## What Lockra stores @@ -16,9 +16,13 @@ system. ## What Lockra sends -Nothing. Lockra makes no network connections: there is no account, no sync, no update check, no -crash report and no telemetry. Fonts, images and icons ship inside the app. Accounts leave the -computer only when you export them or save a backup to a place that synchronises. +Only an update check. When you choose **Settings › About › Check for updates**, or once a day if +you turned automatic checks on, Lockra asks GitHub for the newest version, and downloads it when +you choose to install it. Nothing from your accounts, your vault or your settings goes with these +requests; GitHub sees the request and the address it came from, as for any download +([Updating](/guide/updates#updating)). There is no account, no sync, no crash report and no +telemetry. Fonts, images and icons ship inside the app. Accounts leave the computer only when you +export them or save a backup to a place that synchronises. ## The clipboard and the screen diff --git a/docs/site/reference/faq.md b/docs/site/reference/faq.md index 27bec22b..faba7524 100644 --- a/docs/site/reference/faq.md +++ b/docs/site/reference/faq.md @@ -45,8 +45,9 @@ next change, or **Run automatic backup now**, writes a new backup. ## Does Lockra work offline? -Always: Lockra never goes online. Codes are calculated on the computer from each account's secret -and the time. +Always: codes are calculated on the computer from each account's secret and the time, with no +network. Lockra goes online only to check for an update, when you ask or after you turn automatic +checks on. ## Where are my accounts stored? diff --git a/docs/site/roadmap.md b/docs/site/roadmap.md index d3148d88..18abf3e1 100644 --- a/docs/site/roadmap.md +++ b/docs/site/roadmap.md @@ -12,7 +12,7 @@ features are scheduled; suggestions are welcome in the ## Deliberately left out -- **Syncing between devices.** Lockra keeps the vault on one computer and never goes online; move +- **Syncing between devices.** Lockra keeps the vault on one computer and syncs nothing; move accounts with an export or a backup. - **Phone apps.** Lockra exports to the authenticator on your phone instead. - **A browser extension or filling in codes.** Copy a code and paste it. diff --git a/docs/site/zh/guide/install.md b/docs/site/zh/guide/install.md index e80e0601..f675cf10 100644 --- a/docs/site/zh/guide/install.md +++ b/docs/site/zh/guide/install.md @@ -2,6 +2,32 @@ 本页介绍如何在 Windows、macOS 和 Linux 上下载并安装 Lockra,以及如何确认下载的文件未被篡改。 +## 一键安装 + +安装脚本会为当前电脑选择对应的安装包,用该版本的 `SHA256SUMS` 校验,校验不一致时不安装任何内容。再次运行同一条命令即可安装更新的版本。 + +在 Linux 和 macOS 的终端中运行: + +```bash +curl -fsSL https://raw.githubusercontent.com/sunerpy/lockra/main/scripts/install.sh | sh +``` + +在 Windows 的 PowerShell 中运行: + +```powershell +irm https://raw.githubusercontent.com/sunerpy/lockra/main/scripts/install.ps1 | iex +``` + +| 系统 | 脚本安装的内容 | +| ------------------------------------------------ | --------------------------------------------------------- | +| 使用 apt 的 Linux(Debian、Ubuntu) | `.deb`,通过 apt 安装 | +| 使用 dnf、zypper 或 yum 的 Linux(Fedora、SUSE) | `.rpm` | +| 其他 Linux | AppImage,放在 `~/.local/bin`,并添加到应用程序菜单 | +| macOS | dmg 中的应用,放在 `/Applications`(或 `~/Applications`) | +| Windows | 安装程序,仅为当前用户安装,无需管理员权限 | + +在 Linux 上安装 `.deb` 或 `.rpm` 时,系统会要求输入密码。在 macOS 上,应用打开时不会出现下文所述的“来自身份不明的开发者”提示:脚本会清除浏览器下载时附带的标记。脚本读取三个设置:`LOCKRA_VERSION=0.2.0` 安装指定版本而不是最新版本,`LOCKRA_PACKAGE=appimage`(或 `deb`、`rpm`)选择其他 Linux 安装包,`LOCKRA_INSTALL_DIR` 指定 AppImage 或应用的其他目录。在 PowerShell 中,请在运行命令前设置 `$env:LOCKRA_VERSION = "0.2.0"`。 + ## 下载 每个版本都发布在[发布页](https://github.com/sunerpy/lockra/releases),包含各平台的安装包: diff --git a/docs/site/zh/guide/updates.md b/docs/site/zh/guide/updates.md index 1e03e69d..aa1d784e 100644 --- a/docs/site/zh/guide/updates.md +++ b/docs/site/zh/guide/updates.md @@ -4,9 +4,28 @@ ## 更新 -Lockra 本身不检查更新,因为它从不联网。请关注[发布页](https://github.com/sunerpy/lockra/releases)(在 GitHub 上选择 Watch › Custom › Releases),然后直接安装新的安装包覆盖旧版本。保险库和设置会保留。 +从 0.2.0 版本起,Lockra 可以自行更新。打开「设置 › 关于」,选择「检查更新」。有新版本时,这里会显示更新内容;「下载并安装」会下载新版本、校验签名并安装,随后 Lockra 自动重启。保险库和设置会保留。更新前,请确认有一份较新的备份:「备份 › 保存备份…」。 -更新前,请确认有一份较新的备份:「备份 › 保存备份…」。 +更新的安装方式取决于 Lockra 的安装方式: + +| 安装方式 | 更新过程 | +| ---------------- | ---------------------------------------- | +| `.deb` 或 `.rpm` | 安装新的安装包;系统会要求输入管理员密码 | +| AppImage | 替换 AppImage 文件,然后 Lockra 重启 | +| Windows 安装程序 | 安装程序关闭 Lockra,安装完成后重新打开 | +| macOS 应用 | 替换应用,然后 Lockra 重启 | + +只有带有 Lockra 签名、且签名对应发布所声明版本的安装包才会被安装,被改动过的或较旧的安装包都会被拒绝。Lockra 0.1.x 没有应用内更新:请用[安装脚本](/zh/guide/install#一键安装)或安装包把 0.2.0 或更高版本覆盖安装一次。不是通过安装包安装的副本(例如从源码构建的副本)无法自行更新,「设置 › 关于」中会说明这一点。 + +也可以随时使用安装脚本更新,或直接安装新的安装包覆盖旧版本。 + +### 自动检查 + +「设置 › 关于 › 自动检查更新」默认关闭。开启后,Lockra 在启动 10 秒后检查一次,之后每天检查一次,发现新版本时会提示。Lockra 不会自行下载或安装新版本。检查失败时(例如没有网络),下一次检查会在一小时内进行。 + +### 检查时发送的内容 + +检查更新时,Lockra 向发布 Lockra 的 GitHub 请求一个标明最新版本的小文件;安装时,从同一个发布下载安装包。这两个请求都不包含账号、保险库或设置中的任何内容。与任何下载一样,GitHub 能看到请求及其来源地址。不检查更新时,Lockra 不建立任何网络连接。 ## 文件位置 @@ -23,4 +42,14 @@ Lockra 本身不检查更新,因为它从不联网。请关注[发布页](http ## 卸载 -按照系统的常规方式卸载 Lockra。保险库和设置仍保留在上述文件夹中,以后重新安装时会再次读取;删除这些文件夹即可彻底清除。如果使用过「在本机记住」,请在卸载前关闭它,以便同时删除系统钥匙串中的密钥。 +按照系统的常规方式卸载 Lockra: + +| 安装方式 | 卸载方法 | +| ---------- | ----------------------------------------------------------------------------------- | +| `.deb` | `sudo apt remove lockra` | +| `.rpm` | `sudo dnf remove lockra`(或 `sudo zypper remove lockra`) | +| AppImage | 删除 `~/.local/bin/Lockra.AppImage` 和 `~/.local/share/applications/lockra.desktop` | +| macOS 应用 | 把 Lockra 从“应用程序”文件夹移到废纸篓 | +| Windows | 「设置 › 应用 › 已安装的应用 › Lockra › 卸载」 | + +保险库和设置仍保留在上述文件夹中,以后重新安装时会再次读取;删除这些文件夹即可彻底清除。如果使用过「在本机记住」,请在卸载前关闭它,以便同时删除系统钥匙串中的密钥。 diff --git a/docs/site/zh/index.md b/docs/site/zh/index.md index eeaac73c..0d7997b6 100644 --- a/docs/site/zh/index.md +++ b/docs/site/zh/index.md @@ -7,7 +7,7 @@ description: Lockra 把两步验证码保存在本机的一个加密文件中。 hero: name: Lockra text: 两步验证码,留在你自己的电脑上。 - tagline: 适用于 Windows、macOS 和 Linux 的加密验证器。从 Google 身份验证器或 Microsoft Authenticator 迁入账号,把加密备份放在你选择的位置,全程不联网。 + tagline: 适用于 Windows、macOS 和 Linux 的加密验证器。从 Google 身份验证器或 Microsoft Authenticator 迁入账号,把加密备份放在你选择的位置,只在检查更新时联网。 actions: - theme: brand text: 下载 @@ -24,7 +24,7 @@ home: - term: 支持平台 text: Windows 10 和 11、macOS 11 及以上(Apple 芯片与 Intel)、Linux,均支持 x64 和 ARM64。 - term: 你的账号 - text: 保存在本机的一个加密文件中。Lockra 不建立任何网络连接。 + text: 保存在本机的一个加密文件中。Lockra 只连接 GitHub,而且只在检查更新时连接。 visual: home: @@ -106,6 +106,10 @@ home: body: 显示密钥或导出二维码前需要再次输入主密码,2 分钟后自动隐藏。在 Windows 和 macOS 上,此时截屏只会得到黑色窗口。 status: available link: /zh/security/#屏幕上的密钥 + - title: 经过签名校验的更新 + body: 「设置 › 关于」在你检查时,或开启自动检查后每天一次,查找新版本,并且只安装带有 Lockra 签名的安装包。 + status: available + link: /zh/guide/updates#更新 steps: title: 四步迁移你的账号 @@ -196,8 +200,8 @@ home: value: 你保存的位置 detail: 以同样的方式加密。自动备份只写入你选择的文件夹;如果这个文件夹会同步,加密文件也会随之同步。 - name: 网络 - value: 不使用 - detail: 没有账户、同步、更新检查或遥测。字体和图片都随应用提供。 + value: 仅用于检查更新 + detail: 你检查更新时,或开启自动检查后每天一次,Lockra 向 GitHub 查询最新版本,不发送账号中的任何内容。没有账户、同步或遥测,字体和图片都随应用提供。 scope: title: 有意不做的功能 diff --git a/docs/site/zh/privacy.md b/docs/site/zh/privacy.md index c68e456c..77b3ffe5 100644 --- a/docs/site/zh/privacy.md +++ b/docs/site/zh/privacy.md @@ -1,6 +1,6 @@ # 隐私 -本页列出 Lockra 保存哪些内容、保存在哪里,以及发送哪些内容:不发送任何内容。 +本页列出 Lockra 保存哪些内容、保存在哪里,以及发送哪些内容:除检查更新外,不发送任何内容。 ## Lockra 保存的内容 @@ -15,7 +15,7 @@ ## Lockra 发送的内容 -不发送任何内容。Lockra 不建立任何网络连接:没有账户、同步、更新检查、崩溃报告或遥测。字体、图片和图标都随应用提供。只有在你导出账号,或把备份保存到会同步的位置时,账号才会离开这台电脑。 +只有检查更新。当你选择「设置 › 关于 › 检查更新」,或开启自动检查后每天一次,Lockra 会向 GitHub 查询最新版本,并在你选择安装时下载安装包。这些请求不包含账号、保险库或设置中的任何内容;与任何下载一样,GitHub 能看到请求及其来源地址([更新](/zh/guide/updates#更新))。没有账户、同步、崩溃报告或遥测。字体、图片和图标都随应用提供。只有在你导出账号,或把备份保存到会同步的位置时,账号才会离开这台电脑。 ## 剪贴板与屏幕 diff --git a/docs/site/zh/reference/faq.md b/docs/site/zh/reference/faq.md index 5663672a..a5fe3708 100644 --- a/docs/site/zh/reference/faq.md +++ b/docs/site/zh/reference/faq.md @@ -32,7 +32,7 @@ Android 版应用禁止截取导出二维码。请用另一部手机或相机拍 ## Lockra 能否离线使用? -始终可以:Lockra 从不联网。验证码根据每个账号的密钥和当前时间在电脑上计算。 +始终可以:验证码根据每个账号的密钥和当前时间在电脑上计算,不需要网络。Lockra 只在检查更新时联网:由你手动检查,或在开启自动检查之后。 ## 账号保存在哪里? diff --git a/docs/site/zh/roadmap.md b/docs/site/zh/roadmap.md index da805c8c..1b8c70ef 100644 --- a/docs/site/zh/roadmap.md +++ b/docs/site/zh/roadmap.md @@ -8,7 +8,7 @@ Lockra 提供电脑上的验证器所需的功能:支持所有标准账号的 ## 有意不做的功能 -- **在设备之间同步。** Lockra 把保险库保存在一台电脑上,从不联网;请使用导出或备份迁移账号。 +- **在设备之间同步。** Lockra 把保险库保存在一台电脑上,不同步任何内容;请使用导出或备份迁移账号。 - **手机应用。** Lockra 改为导出到你手机上的验证器。 - **浏览器扩展或自动填写验证码。** 请复制验证码后粘贴。 - **网站图标。** 账号显示首字母;获取图标需要联网。 diff --git a/packages/shared/src/fixtures/index.ts b/packages/shared/src/fixtures/index.ts index 7b484a50..fe49f81a 100644 --- a/packages/shared/src/fixtures/index.ts +++ b/packages/shared/src/fixtures/index.ts @@ -4,5 +4,6 @@ import commands from "./ipc/commands.json"; import events from "./ipc/events.json"; import responses from "./ipc/responses.json"; import state from "./ipc/state.json"; +import update from "./ipc/update.json"; -export const ipcFixtures = { commands, events, responses, state } as const; +export const ipcFixtures = { commands, events, responses, state, update } as const; diff --git a/packages/shared/src/fixtures/ipc/commands.json b/packages/shared/src/fixtures/ipc/commands.json index ac6f8923..ac245f6b 100644 --- a/packages/shared/src/fixtures/ipc/commands.json +++ b/packages/shared/src/fixtures/ipc/commands.json @@ -144,6 +144,7 @@ "enabled": true, "keep": 7 }, + "auto_check_updates": true, "auto_lock_minutes": 10, "clipboard_clear_seconds": 20, "density": "compact", @@ -158,6 +159,12 @@ }, { "command": "activity" + }, + { + "command": "update_check" + }, + { + "command": "update_install" } ], "shell_commands": [ diff --git a/packages/shared/src/fixtures/ipc/events.json b/packages/shared/src/fixtures/ipc/events.json index bc51ee8a..66f356f0 100644 --- a/packages/shared/src/fixtures/ipc/events.json +++ b/packages/shared/src/fixtures/ipc/events.json @@ -31,7 +31,8 @@ "enabled": false, "dir": null, "keep": 10 - } + }, + "auto_check_updates": false }, "import": null, "backup": { @@ -40,7 +41,16 @@ "last_auto_error": null }, "restore": null, - "auto_lock_at_ms": null + "auto_lock_at_ms": null, + "update": { + "method": "nsis", + "status": { + "state": "downloading", + "version": "0.2.0", + "received": 4194304, + "total": 11508084 + } + } } }, { @@ -128,5 +138,12 @@ "notice": { "type": "device_unlock_turned_off" } + }, + { + "type": "notice", + "notice": { + "type": "update_available", + "version": "0.2.0" + } } ] diff --git a/packages/shared/src/fixtures/ipc/state.json b/packages/shared/src/fixtures/ipc/state.json index 7433d674..ae25492c 100644 --- a/packages/shared/src/fixtures/ipc/state.json +++ b/packages/shared/src/fixtures/ipc/state.json @@ -28,6 +28,7 @@ "enabled": false, "keep": 10 }, + "auto_check_updates": false, "auto_lock_minutes": 5, "clipboard_clear_seconds": 30, "density": "default", @@ -38,6 +39,15 @@ "reduce_motion": false, "sort": "name", "theme": "light" + }, + "update": { + "method": "nsis", + "status": { + "received": 4194304, + "state": "downloading", + "total": 11508084, + "version": "0.2.0" + } } }, "no_vault": { @@ -73,6 +83,7 @@ "enabled": false, "keep": 10 }, + "auto_check_updates": false, "auto_lock_minutes": 5, "clipboard_clear_seconds": 30, "density": "default", @@ -83,6 +94,12 @@ "reduce_motion": false, "sort": "name", "theme": "light" + }, + "update": { + "method": null, + "status": { + "state": "idle" + } } }, "unlocked": { @@ -364,6 +381,7 @@ "enabled": true, "keep": 7 }, + "auto_check_updates": true, "auto_lock_minutes": 10, "clipboard_clear_seconds": 20, "density": "compact", @@ -374,6 +392,16 @@ "reduce_motion": true, "sort": "recent", "theme": "dark" + }, + "update": { + "method": "deb", + "status": { + "checked_at_ms": 1789999970000, + "date": "2026-10-02T08:00:00Z", + "notes": "## [0.2.0](https://github.com/sunerpy/lockra/compare/v0.1.1...v0.2.0) (2026-10-02)\n\n### Features\n\n* **update:** check for updates and install them\n", + "state": "available", + "version": "0.2.0" + } } } } diff --git a/packages/shared/src/fixtures/ipc/update.json b/packages/shared/src/fixtures/ipc/update.json new file mode 100644 index 00000000..e27048de --- /dev/null +++ b/packages/shared/src/fixtures/ipc/update.json @@ -0,0 +1,74 @@ +[ + { + "method": "deb", + "status": { + "state": "idle" + } + }, + { + "method": "rpm", + "status": { + "state": "checking" + } + }, + { + "method": "appimage", + "status": { + "state": "up_to_date", + "checked_at_ms": 1789999970000 + } + }, + { + "method": "nsis", + "status": { + "state": "available", + "version": "0.2.0", + "notes": "## [0.2.0](https://github.com/sunerpy/lockra/compare/v0.1.1...v0.2.0) (2026-10-02)\n\n### Features\n\n* **update:** check for updates and install them\n", + "date": "2026-10-02T08:00:00Z", + "checked_at_ms": 1789999970000 + } + }, + { + "method": "msi", + "status": { + "state": "available", + "version": "0.2.1", + "notes": null, + "date": null, + "checked_at_ms": 1790000000000 + } + }, + { + "method": "app", + "status": { + "state": "downloading", + "version": "0.2.0", + "received": 0, + "total": null + } + }, + { + "method": null, + "status": { + "state": "downloading", + "version": "0.2.0", + "received": 4194304, + "total": 11508084 + } + }, + { + "method": "deb", + "status": { + "state": "installing", + "version": "0.2.0" + } + }, + { + "method": "rpm", + "status": { + "state": "failed", + "code": "update_signature", + "at_ms": 1789999995000 + } + } +] diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 65b2f0da..d3d9ce49 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -430,7 +430,39 @@ export const en: Messages = { fontsValue: "Instrument Sans, JetBrains Mono, Noto Sans SC (SIL OFL 1.1)", credits: "Credits", creditsValue: "Interface design derived from Voltip (Apache-2.0)", - privacy: "Lockra never goes online and collects nothing.", + privacy: "Lockra goes online only to check for updates, and collects nothing.", + update: { + label: "Updates", + check: "Check for updates", + checking: "Checking…", + idle: "Not checked yet", + upToDate: "Lockra is up to date", + checkedAt: "Checked {when}", + available: "Version {version} is available", + publishedOn: "Published {date}", + notes: "What changed", + install: "Download and install", + downloading: "Downloading {version}: {percent}%", + downloadingSize: "Downloading {version}: {received}", + installing: "Installing {version}; Lockra restarts by itself", + failed: "{error}", + retry: "Try again", + unavailable: + "This copy cannot update itself: it was not installed from a package. Update it with the install script or a package.", + method: { + deb: "Installed from the .deb: installing asks for an administrator's password.", + rpm: "Installed from the .rpm: installing asks for an administrator's password.", + appimage: "Replaces this AppImage file, then restarts.", + nsis: "The installer closes Lockra and opens it again when it is done.", + msi: "The installer closes Lockra and opens it again when it is done.", + app: "Replaces the app, then restarts.", + }, + auto: "Check for updates automatically", + autoHint: + "Checks 10 seconds after start, then once a day; it only tells you, and never downloads or installs by itself.", + network: + "Checking connects to GitHub only and sends nothing from your accounts or the vault; a package not signed with Lockra's key is never installed.", + }, }, }, error: { @@ -466,6 +498,13 @@ export const en: Messages = { backup_dir_missing: "Choose a backup folder first", backup_dir_unavailable: "The backup folder cannot be written", io_failed: "Reading or writing a file failed", + update_unavailable: "This copy cannot update itself", + update_busy: "An update check or install is already running", + update_network: "The update server could not be reached", + update_invalid: "The update information is invalid or has no package for this computer", + update_signature: "The package is not signed with Lockra's key and was not installed", + update_install_failed: "Installing the update failed", + update_cancelled: "The administrator prompt was cancelled; nothing was installed", internal: "An internal error occurred", }, reject: { @@ -511,6 +550,7 @@ export const en: Messages = { exportExpired: "The QR codes expired and were hidden", deviceUnlockTurnedOff: "The keychain key was unreadable, so remembering is off; turn it on again", + updateAvailable: "Version {version} is available: install it in Settings › About", }, showcase: { title: "Component showcase (development only)", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index bb310fba..c060828a 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -415,7 +415,39 @@ export const zhCN = { fontsValue: "Instrument Sans、JetBrains Mono、Noto Sans SC(SIL OFL 1.1)", credits: "致谢", creditsValue: "界面设计源自 Voltip(Apache-2.0)", - privacy: "Lockra 不联网、不收集任何数据。", + privacy: "除检查更新外,Lockra 不联网,也不收集任何数据。", + update: { + label: "更新", + check: "检查更新", + checking: "正在检查…", + idle: "尚未检查", + upToDate: "已是最新版本", + checkedAt: "上次检查:{when}", + available: "新版本 {version} 可用", + publishedOn: "发布于 {date}", + notes: "更新内容", + install: "下载并安装", + downloading: "正在下载 {version}({percent}%)", + downloadingSize: "正在下载 {version}({received})", + installing: "正在安装 {version},完成后 Lockra 会自动重启", + failed: "{error}", + retry: "重试", + unavailable: + "此副本无法在应用内更新:它不是通过安装包安装的。请使用一键安装脚本或安装包更新。", + method: { + deb: "通过 .deb 安装:安装时系统会要求输入管理员密码。", + rpm: "通过 .rpm 安装:安装时系统会要求输入管理员密码。", + appimage: "直接替换此 AppImage 文件,然后重启。", + nsis: "安装程序会关闭 Lockra,安装完成后重新打开。", + msi: "安装程序会关闭 Lockra,安装完成后重新打开。", + app: "替换应用后自动重启。", + }, + auto: "自动检查更新", + autoHint: + "开启后,Lockra 在启动 10 秒后检查一次,之后每天检查一次;只显示提示,不会自动下载或安装。", + network: + "检查更新仅连接 GitHub,不发送账号或保险库中的任何内容;未使用 Lockra 密钥签名的安装包不会被安装。", + }, }, }, error: { @@ -450,6 +482,13 @@ export const zhCN = { backup_dir_missing: "请先选择备份文件夹", backup_dir_unavailable: "备份文件夹无法写入", io_failed: "读写文件失败", + update_unavailable: "此副本无法在应用内更新", + update_busy: "正在检查或安装更新", + update_network: "无法连接更新服务器", + update_invalid: "更新信息无效,或其中没有适合这台电脑的安装包", + update_signature: "安装包未使用 Lockra 的密钥签名,已拒绝安装", + update_install_failed: "安装更新失败", + update_cancelled: "已取消管理员授权,未安装更新", internal: "出现内部错误", }, reject: { @@ -494,6 +533,7 @@ export const zhCN = { autoLocked: "长时间没有操作,保险库已自动锁定", exportExpired: "二维码已过期并隐藏", deviceUnlockTurnedOff: "读不到钥匙串里的密钥,本机记住已关闭,请重新开启", + updateAvailable: "新版本 {version} 可用,可在「设置 › 关于」中安装", }, showcase: { title: "组件展示(仅开发版)", diff --git a/packages/shared/src/ipc-contract.test.ts b/packages/shared/src/ipc-contract.test.ts index 0d498cc6..17b20bb8 100644 --- a/packages/shared/src/ipc-contract.test.ts +++ b/packages/shared/src/ipc-contract.test.ts @@ -16,6 +16,7 @@ import { uiCommandSchema, uiEventSchema, uiStateSchema, + updateViewSchema, } from "./schema"; import { TauriBackend, type TauriTransport } from "./tauri-backend"; @@ -50,7 +51,26 @@ describe("IPC fixtures", () => { const parsed = uiEventSchema.parse(event); if (parsed.type === "notice") kinds.add(parsed.notice.type); } - expect(kinds.size).toBe(11); + expect(kinds.size).toBe(12); + }); + + it("every update view parses, each state and each install method once at least", () => { + const views = ipcFixtures.update.map((view) => updateViewSchema.parse(view)); + expect(new Set(views.map((v) => v.status.state))).toEqual( + new Set([ + "idle", + "checking", + "up_to_date", + "available", + "downloading", + "installing", + "failed", + ]), + ); + expect(new Set(views.map((v) => v.method))).toEqual( + new Set(["deb", "rpm", "appimage", "nsis", "msi", "app", null]), + ); + expect(uiStateSchema.parse(ipcFixtures.state.unlocked).update.status.state).toBe("available"); }); it("every answer parses with its schema", () => { diff --git a/packages/shared/src/labels.test.ts b/packages/shared/src/labels.test.ts index fab855e4..67a08de3 100644 --- a/packages/shared/src/labels.test.ts +++ b/packages/shared/src/labels.test.ts @@ -3,6 +3,7 @@ import { createTranslator } from "./i18n"; import { entryLabel, errorText, + formatBytes, groupCode, incompatibleText, noticeIsProblem, @@ -11,6 +12,7 @@ import { parametersText, rejectText, relativeTime, + releaseNotesLines, statusText, themeName, themeSubtitle, @@ -83,4 +85,39 @@ describe("labels", () => { expect(entryLabel("", "octocat")).toBe("octocat"); expect(entryLabel("GitHub", "")).toBe("GitHub"); }); + + it("release notes read as plain lines, without links or references", () => { + const notes = [ + "## [0.2.0](https://github.com/sunerpy/lockra/compare/v0.1.1...v0.2.0) (2026-10-02)", + "", + "", + "### Features", + "", + "* **update:** check for updates and install them ([#9](https://github.com/sunerpy/lockra/issues/9)) ([28d87eb](https://github.com/sunerpy/lockra/commit/28d87eb))", + "* install scripts for every platform", + "", + "### Bug Fixes", + "", + "- keep the [docs](https://firlab.app/lockra/) in step", + "", + ].join("\n"); + expect(releaseNotesLines(notes)).toEqual([ + "Features", + "", + "• update: check for updates and install them", + "• install scripts for every platform", + "", + "Bug Fixes", + "", + "• keep the docs in step", + ]); + expect(releaseNotesLines("")).toEqual([]); + expect(releaseNotesLines("Just a sentence.\r\n")).toEqual(["Just a sentence."]); + }); + + it("sizes read in KB and MB", () => { + expect(formatBytes(0)).toBe("0 KB"); + expect(formatBytes(512 * 1024)).toBe("512 KB"); + expect(formatBytes(4 * 1024 * 1024 + 100_000)).toBe("4.1 MB"); + }); }); diff --git a/packages/shared/src/labels.ts b/packages/shared/src/labels.ts index c0444053..45592512 100644 --- a/packages/shared/src/labels.ts +++ b/packages/shared/src/labels.ts @@ -90,9 +90,40 @@ export function noticeText(t: TFunction, notice: Notice): string { return t("notice.exportExpired"); case "device_unlock_turned_off": return t("notice.deviceUnlockTurnedOff"); + case "update_available": + return t("notice.updateAvailable", { version: notice.version }); } } +/** + * A release's notes as plain lines: the Markdown release-please writes, without the link syntax, + * the emphasis, the pull request and commit references, or the heading that repeats the version. + * The interface renders the lines as text, never as HTML. + */ +export function releaseNotesLines(markdown: string): string[] { + return markdown + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => !/^#+\s*\[?v?\d+\.\d+\.\d+/.test(line)) + .map((line) => + line + .replace(/\s*\(\[[^\]]*\]\([^)]*\)\)/g, "") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/^#+\s*/, "") + .replace(/^[*-]\s+/, "• ") + .trim(), + ) + .filter((line, index, lines) => line !== "" || (index > 0 && lines[index - 1] !== "")) + .filter((line, index, lines) => !(line === "" && (index === 0 || index === lines.length - 1))); +} + +/** `512 KB`, `4.0 MB`: a download's size for people. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024 * 1024) return `${Math.max(0, Math.round(bytes / 1024))} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + /** Whether a notice is bad news (the toast is drawn in the danger tone). */ export function noticeIsProblem(notice: Notice): boolean { return ( diff --git a/packages/shared/src/mock-backend.test.ts b/packages/shared/src/mock-backend.test.ts index cbeccf65..97ff3bac 100644 --- a/packages/shared/src/mock-backend.test.ts +++ b/packages/shared/src/mock-backend.test.ts @@ -310,6 +310,55 @@ describe("MockBackend", () => { ); }); + it("updates the way the core does", async () => { + const none = new MockBackend(); + expect((await none.getState()).update).toEqual({ method: null, status: { state: "idle" } }); + expect(await errorCode(none.dispatch({ command: "update_check" }))).toBe("update_unavailable"); + + const backend = new MockBackend({ updateMethod: "rpm", now: () => 1_790_000_000_000 }); + const seen: string[] = []; + backend.on((event: UiEvent) => { + if (event.type === "state") seen.push(event.state.update.status.state); + }); + await backend.dispatch({ command: "update_install" }); + expect((await backend.getState()).update.status).toEqual({ + state: "up_to_date", + checked_at_ms: 1_790_000_000_000, + }); + backend.setRelease({ version: "0.2.0", notes: "## notes", date: null, size: 100 }); + await backend.dispatch({ command: "update_check" }); + await backend.dispatch({ command: "update_install" }); + expect(seen).toEqual([ + "checking", + "up_to_date", + "checking", + "available", + "checking", + "downloading", + "downloading", + "downloading", + "installing", + ]); + expect(await errorCode(backend.dispatch({ command: "update_check" }))).toBe("update_busy"); + + const failing = new MockBackend({ + updateMethod: "app", + release: { version: "0.2.0", notes: null, date: null, size: 1 }, + updateFailure: { step: "install", code: "update_cancelled" }, + }); + await failing.dispatch({ command: "update_install" }); + expect((await failing.getState()).update.status).toMatchObject({ + state: "failed", + code: "update_cancelled", + }); + const notices: Notice[] = []; + failing.on((event: UiEvent) => { + if (event.type === "notice") notices.push(event.notice); + }); + failing.announceUpdate("0.2.0"); + expect(notices).toEqual([{ type: "update_available", version: "0.2.0" }]); + }); + it("stand-in codes are stable and padded", () => { expect(fakeCode("A", 1, 6)).toBe(fakeCode("A", 1, 6)); expect(fakeCode("A", 1, 8)).toMatch(/^\d{8}$/); diff --git a/packages/shared/src/mock-backend.ts b/packages/shared/src/mock-backend.ts index 55f29289..a52a4500 100644 --- a/packages/shared/src/mock-backend.ts +++ b/packages/shared/src/mock-backend.ts @@ -27,6 +27,7 @@ import { type GoogleBatchView, type ImportOutcome, type Incompatible, + type InstallMethod, type Notice, type OtpKind, type Platform, @@ -55,6 +56,21 @@ export interface MockOptions { /** Text the clipboard import reads. */ clipboard?: string; now?: () => number; + /** How this copy installs an update; `null`: it cannot update itself (the default). */ + updateMethod?: InstallMethod | null; + /** The newer release an update check finds; `null` (the default): nothing newer. */ + release?: MockRelease | null; + /** Make the update fail at this step with `code`. */ + updateFailure?: { step: "check" | "download" | "install"; code: ErrorCode }; +} + +/** A release the mock's update check announces. */ +export interface MockRelease { + version: string; + notes: string | null; + date: string | null; + /** The package size, for the download progress. */ + size: number; } export const MOCK_PASSWORD = "correct horse battery"; @@ -225,6 +241,8 @@ export class MockBackend implements Backend { private restoreEntries: MockEntry[] | null = null; private clipboard: string | undefined; private readonly now: () => number; + private release: MockRelease | null; + private updateFailure: MockOptions["updateFailure"]; /** Every command dispatched, for tests. */ readonly calls: UiCommand[] = []; @@ -232,6 +250,8 @@ export class MockBackend implements Backend { this.now = options.now ?? (() => Date.now()); this.password = options.password ?? MOCK_PASSWORD; this.clipboard = options.clipboard; + this.release = options.release ?? null; + this.updateFailure = options.updateFailure; const entries = options.entries ?? []; for (const entry of entries) this.secrets.set(entry.view.id, entry.secret); const phase = options.phase ?? (entries.length > 0 ? "unlocked" : "no_vault"); @@ -254,6 +274,7 @@ export class MockBackend implements Backend { backup: { last_backup_ms: null, last_auto_file: null, last_auto_error: null }, restore: null, auto_lock_at_ms: null, + update: { method: options.updateMethod ?? null, status: { state: "idle" } }, }; this.lockedEntries = phase === "unlocked" ? [] : entries.map((e) => e.view); this.refreshAutoLock(); @@ -594,7 +615,57 @@ export class MockBackend implements Backend { case "activity": this.refreshAutoLock(); return null; + case "update_check": + return this.runUpdate(false); + case "update_install": + return this.runUpdate(true); + } + } + + /** The core's update run, at once: each status it passes through is published in turn. */ + private runUpdate(install: boolean): null { + if (this.state.update.method === null) throw new LockraError("update_unavailable"); + const busy = ["checking", "downloading", "installing"]; + if (busy.includes(this.state.update.status.state)) throw new LockraError("update_busy"); + const step = (status: UiState["update"]["status"]) => { + this.state.update = { ...this.state.update, status }; + this.publish(); + }; + const failed = (at: "check" | "download" | "install") => { + if (this.updateFailure?.step !== at) return false; + step({ state: "failed", code: this.updateFailure.code, at_ms: this.now() }); + return true; + }; + step({ state: "checking" }); + if (failed("check")) return null; + const checked_at_ms = this.now(); + const release = this.release; + if (release === null) { + step({ state: "up_to_date", checked_at_ms }); + return null; } + const { version, notes, date, size } = release; + if (!install) { + step({ state: "available", version, notes, date, checked_at_ms }); + return null; + } + for (const received of [0, Math.round(size / 2), size]) { + step({ state: "downloading", version, received, total: size }); + } + if (failed("download")) return null; + step({ state: "installing", version }); + failed("install"); + return null; + } + + /** Test hook: what the next update check finds. */ + setRelease(release: MockRelease | null): void { + this.release = release; + } + + /** Test hook: the automatic check announced `version`. */ + announceUpdate(version: string): void { + this.notice({ type: "update_available", version }); } private unlock(password: string): null { diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index 5abb8536..731a3e9c 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -62,6 +62,7 @@ export const settingsSchema = z.object({ hide_codes: z.boolean(), sort: sortOrderSchema, auto_backup: autoBackupSchema, + auto_check_updates: z.boolean(), }); export type Settings = z.infer; @@ -80,6 +81,7 @@ export function defaultSettings(): Settings { hide_codes: false, sort: "name", auto_backup: { enabled: false, dir: null, keep: 10 }, + auto_check_updates: false, }; } @@ -241,6 +243,13 @@ export const ERROR_CODES = [ "backup_dir_missing", "backup_dir_unavailable", "io_failed", + "update_unavailable", + "update_busy", + "update_network", + "update_invalid", + "update_signature", + "update_install_failed", + "update_cancelled", "internal", ] as const; export const errorCodeSchema = z.enum(ERROR_CODES); @@ -261,6 +270,42 @@ export const restoreViewSchema = z.object({ }); export type RestoreView = z.infer; +// ---- the in-app update ----------------------------------------------------------------------- + +/** How this copy was installed, which is how an update installs (lockra-core `InstallMethod`). */ +export const INSTALL_METHODS = ["deb", "rpm", "appimage", "nsis", "msi", "app"] as const; +export const installMethodSchema = z.enum(INSTALL_METHODS); +export type InstallMethod = z.infer; + +export const updateStatusSchema = z.discriminatedUnion("state", [ + z.object({ state: z.literal("idle") }), + z.object({ state: z.literal("checking") }), + z.object({ state: z.literal("up_to_date"), checked_at_ms: msSchema }), + z.object({ + state: z.literal("available"), + version: z.string(), + notes: z.string().nullable(), + date: z.string().nullable(), + checked_at_ms: msSchema, + }), + z.object({ + state: z.literal("downloading"), + version: z.string(), + received: z.number().int().nonnegative(), + total: z.number().int().nonnegative().nullable(), + }), + z.object({ state: z.literal("installing"), version: z.string() }), + z.object({ state: z.literal("failed"), code: errorCodeSchema, at_ms: msSchema }), +]); +export type UpdateStatus = z.infer; + +export const updateViewSchema = z.object({ + /** Absent when this copy cannot update itself. */ + method: installMethodSchema.nullable(), + status: updateStatusSchema, +}); +export type UpdateView = z.infer; + // ---- state and events ------------------------------------------------------------------------ export const lockViewSchema = z.object({ @@ -282,6 +327,7 @@ export const uiStateSchema = z.object({ backup: backupViewSchema, restore: restoreViewSchema.nullable(), auto_lock_at_ms: msSchema.nullable(), + update: updateViewSchema, }); export type UiState = z.infer; @@ -306,6 +352,7 @@ export const noticeSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("auto_locked") }), z.object({ type: z.literal("export_expired"), session: idSchema }), z.object({ type: z.literal("device_unlock_turned_off") }), + z.object({ type: z.literal("update_available"), version: z.string() }), ]); export type Notice = z.infer; @@ -450,6 +497,8 @@ export const uiCommandSchema = z.discriminatedUnion("command", [ z.object({ command: z.literal("restore_cancel") }), z.object({ command: z.literal("settings_set"), settings: settingsSchema }), z.object({ command: z.literal("activity") }), + z.object({ command: z.literal("update_check") }), + z.object({ command: z.literal("update_install") }), ]); export type UiCommand = z.infer; export type CommandName = UiCommand["command"]; diff --git a/scripts/build-linux-x64.sh b/scripts/build-linux-x64.sh index 43c7cfd4..b8efabdd 100755 --- a/scripts/build-linux-x64.sh +++ b/scripts/build-linux-x64.sh @@ -14,7 +14,14 @@ bundles=deb [ -n "${LOCKRA_APPIMAGE:-}" ] && bundles=deb,appimage scripts/check-web-bundle.sh -(cd apps/desktop && pnpm exec tauri build --ci --bundles "$bundles") +# The release signs every package for the in-app update (bundle.createUpdaterArtifacts); a local +# build without the release key (TAURI_SIGNING_PRIVATE_KEY) builds the same packages unsigned. +unsigned=() +if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then + unsigned=(--config '{"bundle":{"createUpdaterArtifacts":false}}') + echo "build-linux-x64: no TAURI_SIGNING_PRIVATE_KEY; the packages are not signed for the updater" +fi +(cd apps/desktop && pnpm exec tauri build --ci "${unsigned[@]}" --bundles "$bundles") version=$(node -p 'require("./package.json").version') deb=target/release/bundle/deb/Lockra_${version}_amd64.deb diff --git a/scripts/build-windows-x64.sh b/scripts/build-windows-x64.sh index 2ab854c4..d6fbde1d 100755 --- a/scripts/build-windows-x64.sh +++ b/scripts/build-windows-x64.sh @@ -15,7 +15,14 @@ rustup target list --installed | grep -q '^x86_64-pc-windows-msvc$' || { echo "b out=${1:-dist/windows-x64} scripts/check-web-bundle.sh -(cd apps/desktop && pnpm exec tauri build --ci --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis) +# The release signs every package for the in-app update (bundle.createUpdaterArtifacts); a local +# build without the release key (TAURI_SIGNING_PRIVATE_KEY) builds the same packages unsigned. +unsigned=() +if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then + unsigned=(--config '{"bundle":{"createUpdaterArtifacts":false}}') + echo "build-windows-x64: no TAURI_SIGNING_PRIVATE_KEY; the packages are not signed for the updater" +fi +(cd apps/desktop && pnpm exec tauri build --ci "${unsigned[@]}" --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis) version=$(node -p 'require("./package.json").version') release=target/x86_64-pc-windows-msvc/release diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 00000000..9b0db513 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,116 @@ +# One-line install of Lockra on Windows (x64 and ARM64) from a GitHub release (Linux and macOS: +# scripts/install.sh): +# +# irm https://raw.githubusercontent.com/sunerpy/lockra/main/scripts/install.ps1 | iex +# +# It downloads the per-user installer for this processor and SHA256SUMS from the same release, +# runs nothing unless the installer's SHA-256 matches its line there, installs silently for the +# current user (no administrator prompt) and starts Lockra. Run it again to update; from Lockra +# 0.2.0 the app also updates itself (Settings > About). +# +# $env:LOCKRA_VERSION = "0.2.0" a given release instead of the latest +# +# Works in Windows PowerShell 5.1 (what `irm | iex` runs in by default) and PowerShell 7. +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$Repo = "sunerpy/lockra" +$ChecksumFile = "SHA256SUMS" + +function Stop-Install($Message) { + Write-Host "lockra-install: $Message" -ForegroundColor Red + throw "lockra-install: $Message" +} + +function Say($Message) { + Write-Host "lockra-install: $Message" +} + +# Windows PowerShell 5.1 on an older Windows 10 may still offer TLS 1.0 only; GitHub needs 1.2. +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + +# The native build for this processor: a 32-bit shell on 64-bit Windows reports its real +# architecture in PROCESSOR_ARCHITEW6432. +$Machine = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } +$Arch = switch ($Machine) { + "AMD64" { "x64" } + "ARM64" { "arm64" } + default { Stop-Install "Lockra ships for 64-bit Windows on x64 and ARM64 only (this is $Machine)" } +} + +if ($env:LOCKRA_VERSION) { + $Version = $env:LOCKRA_VERSION -replace '^v', '' +} else { + # The latest release's SHA256SUMS through GitHub's latest-release redirect, and the version from + # the package names in it: no API call, so no rate limit (an address many computers share runs + # out of anonymous API calls within the hour). + Say "finding the latest release" + try { + $Latest = (Invoke-WebRequest -UseBasicParsing -Uri "https://github.com/$Repo/releases/latest/download/$ChecksumFile").Content + } catch { + Stop-Install "could not find the latest release" + } + # Windows PowerShell 5.1 hands an octet-stream body over as bytes. + if ($Latest -is [byte[]]) { $Latest = [System.Text.Encoding]::UTF8.GetString($Latest) } + $Found = [Regex]::Match([string]$Latest, '(?m)^[0-9a-fA-F]{64}\s+\*?Lockra_([0-9][^_\s]*)_') + if (-not $Found.Success) { Stop-Install "could not find the latest release" } + $Version = $Found.Groups[1].Value +} +# The version goes into URLs and file names: digits and dots, and an optional pre-release tail. +if ($Version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$') { + Stop-Install "not a release version: '$Version'" +} + +$Asset = "Lockra_${Version}_${Arch}-setup.exe" +$BaseUrl = "https://github.com/$Repo/releases/download/v$Version" +$TempDir = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ("lockra-" + [System.Guid]::NewGuid())) + +try { + $Installer = Join-Path $TempDir $Asset + $Checksums = Join-Path $TempDir $ChecksumFile + Say "downloading $Asset (Lockra $Version)" + try { + Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/$ChecksumFile" -OutFile $Checksums + } catch { + Stop-Install "release v$Version has no $ChecksumFile (is $Version a Lockra release?)" + } + $Escaped = [Regex]::Escape($Asset) + $Line = Get-Content $Checksums | Where-Object { $_ -match "^[0-9a-fA-F]{64}\s+\*?$Escaped$" } | Select-Object -First 1 + if (-not $Line) { Stop-Install "release v$Version has no $Asset" } + Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/$Asset" -OutFile $Installer + + $Expected = ($Line -split '\s+')[0].ToLowerInvariant() + $Actual = (Get-FileHash -Algorithm SHA256 -Path $Installer).Hash.ToLowerInvariant() + if ($Actual -ne $Expected) { Stop-Install "checksum mismatch for ${Asset}: nothing was installed" } + Say "SHA-256 matches $ChecksumFile" + + # A running Lockra holds its files; the installer would stop at it. + $Running = Get-Process -Name "lockra-desktop" -ErrorAction SilentlyContinue + if ($Running) { + Say "closing the running Lockra" + $Running | Stop-Process -Force + $Running | Wait-Process -Timeout 15 -ErrorAction SilentlyContinue + } + + Say "installing for the current user" + $Setup = Start-Process -FilePath $Installer -ArgumentList "/S" -Wait -PassThru + if ($Setup.ExitCode -ne 0) { Stop-Install "the installer exited with code $($Setup.ExitCode)" } + + # The per-user installer's directory, as the NSIS template names it; the uninstall entry says + # where it went when that differs. + $Candidates = @((Join-Path $env:LOCALAPPDATA "Lockra\lockra-desktop.exe")) + $Uninstall = Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -eq "Lockra" } | Select-Object -First 1 + if ($Uninstall -and $Uninstall.InstallLocation) { + $Candidates = @((Join-Path ($Uninstall.InstallLocation.Trim('"')) "lockra-desktop.exe")) + $Candidates + } + $Exe = $Candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($Exe) { + Say "installed Lockra $Version; starting it" + Start-Process -FilePath $Exe + } else { + Say "installed Lockra $Version; start it from the Start menu" + } +} finally { + Remove-Item -Recurse -Force $TempDir -ErrorAction SilentlyContinue +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..3c542cf9 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,234 @@ +#!/bin/sh +# One-line install of Lockra on Linux (x64 and ARM64) and macOS (Apple silicon and Intel) from a +# GitHub release (Windows: scripts/install.ps1): +# +# curl -fsSL https://raw.githubusercontent.com/sunerpy/lockra/main/scripts/install.sh | sh +# +# It picks the package for this computer: the .deb where apt is, the .rpm where dnf, zypper or yum +# is, the AppImage elsewhere, and on a Mac the dmg for its processor (also from a Rosetta shell). +# It downloads the package and SHA256SUMS from the same release and installs nothing unless the +# package's SHA-256 matches its line there. Run it again to update; from Lockra 0.2.0 the app also +# updates itself (Settings › About), through the package it was installed from. +# +# LOCKRA_VERSION=0.2.0 a given release instead of the latest +# LOCKRA_PACKAGE=appimage Linux: deb, rpm or appimage instead of the one picked +# LOCKRA_INSTALL_DIR=DIR the AppImage's directory (default ~/.local/bin), or the Mac app's +# (default /Applications, ~/Applications when that is not writable) +set -eu + +REPO="sunerpy/lockra" +CHECKSUM_FILE="SHA256SUMS" + +err() { + printf 'lockra-install: %s\n' "$1" >&2 + exit 1 +} + +info() { + printf 'lockra-install: %s\n' "$1" >&2 +} + +if command -v curl >/dev/null 2>&1; then + download() { curl -fsSL --retry 3 "$1" -o "$2"; } + fetch() { curl -fsSL --retry 3 "$1"; } +elif command -v wget >/dev/null 2>&1; then + download() { wget -qO "$2" "$1"; } + fetch() { wget -qO - "$1"; } +else + err "curl or wget is required" +fi + +# The package for this computer. +case "$(uname -s)" in +Linux) + case "$(uname -m)" in + x86_64 | amd64) deb_arch=amd64 rpm_arch=x86_64 appimage_arch=amd64 ;; + aarch64 | arm64) deb_arch=arm64 rpm_arch=aarch64 appimage_arch=aarch64 ;; + *) err "Lockra ships for x64 and ARM64 Linux only (this is $(uname -m))" ;; + esac + package=${LOCKRA_PACKAGE:-} + if [ -z "$package" ]; then + if command -v apt-get >/dev/null 2>&1; then + package=deb + elif command -v dnf >/dev/null 2>&1 || command -v zypper >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then + package=rpm + else + package=appimage + fi + fi + case "$package" in + deb | rpm | appimage) ;; + *) err "LOCKRA_PACKAGE must be deb, rpm or appimage, not '$package'" ;; + esac + ;; +Darwin) + # A shell under Rosetta reports x86_64 on Apple silicon; the native build is the one to take. + if [ "$(sysctl -n hw.optional.arm64 2>/dev/null || echo 0)" = 1 ]; then + mac_arch=aarch64 + else + case "$(uname -m)" in + x86_64) mac_arch=x64 ;; + *) err "unsupported Mac processor: $(uname -m)" ;; + esac + fi + package=dmg + ;; +*) err "unsupported system: $(uname -s) (on Windows, use scripts/install.ps1)" ;; +esac + +if [ -n "${LOCKRA_VERSION:-}" ]; then + version=$(printf '%s' "$LOCKRA_VERSION" | sed 's/^v//') +else + # The latest release's SHA256SUMS through GitHub's latest-release redirect, and the version + # from the package names in it: no API call, so no rate limit (an address many computers share + # runs out of anonymous API calls within the hour). + info "finding the latest release" + latest=$(fetch "https://github.com/${REPO}/releases/latest/download/${CHECKSUM_FILE}") || + err "could not find the latest release" + version=$(printf '%s\n' "$latest" | + sed -n 's/^[0-9a-fA-F]\{64\}[[:space:]][[:space:]]*\*\{0,1\}Lockra_\([0-9][^_]*\)_.*/\1/p' | head -1) + [ -n "$version" ] || err "could not find the latest release" +fi +# The version goes into URLs and file names: digits and dots, and an optional pre-release tail. +printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$' || + err "not a release version: '$version'" + +case "$package" in +deb) asset="Lockra_${version}_${deb_arch}.deb" ;; +rpm) asset="Lockra-${version}-1.${rpm_arch}.rpm" ;; +appimage) asset="Lockra_${version}_${appimage_arch}.AppImage" ;; +dmg) asset="Lockra_${version}_${mac_arch}.dmg" ;; +esac +base_url="https://github.com/${REPO}/releases/download/v${version}" + +tmp=$(mktemp -d 2>/dev/null || mktemp -d -t lockra) +cleanup() { + if [ -n "${mounted:-}" ]; then hdiutil detach "$mounted" -quiet >/dev/null 2>&1 || true; fi + rm -rf "$tmp" +} +trap cleanup EXIT INT TERM + +info "downloading ${asset} (Lockra ${version})" +download "${base_url}/${CHECKSUM_FILE}" "$tmp/$CHECKSUM_FILE" || + err "release v${version} has no ${CHECKSUM_FILE} (is ${version} a Lockra release?)" +expected=$(awk -v name="$asset" '{ + file = $2 + sub(/^\*/, "", file) + if (file == name) { print $1; exit } +}' "$tmp/$CHECKSUM_FILE") +[ -n "$expected" ] || err "release v${version} has no ${asset}" +download "${base_url}/${asset}" "$tmp/$asset" || err "could not download ${asset}" + +if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$tmp/$asset" | awk '{print $1}') +elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$tmp/$asset" | awk '{print $1}') +else + err "sha256sum or shasum is required" +fi +[ "$actual" = "$expected" ] || err "checksum mismatch for ${asset}: nothing was installed" +info "SHA-256 matches ${CHECKSUM_FILE}" + +as_root() { + if [ "$(id -u)" = 0 ]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo "$@" + else + err "installing the ${package} needs root; run as root, or set LOCKRA_PACKAGE=appimage" + fi +} + +case "$package" in +deb) + # apt reads the file as its own user: let it, instead of warning about the private temp dir. + chmod 755 "$tmp" + chmod 644 "$tmp/$asset" + info "installing with apt (it asks for your password when it needs to)" + # The dependencies come from the distribution's mirror. Package lists older than the mirror + # make apt ask for versions it no longer has (404), so refresh them first; if that fails, the + # install still tries with the lists as they are. + as_root apt-get update -qq || info "apt-get update failed; installing with the package lists as they are" + as_root apt-get install -y "$tmp/$asset" + info "installed Lockra ${version}; start it from the applications menu or run lockra-desktop" + ;; +rpm) + chmod 755 "$tmp" + chmod 644 "$tmp/$asset" + # The package is not GPG-signed: its SHA-256 was checked against the release above. + if command -v dnf >/dev/null 2>&1; then + info "installing with dnf (it asks for your password when it needs to)" + as_root dnf install -y "$tmp/$asset" + elif command -v zypper >/dev/null 2>&1; then + info "installing with zypper (it asks for your password when it needs to)" + as_root zypper --non-interactive install --allow-unsigned-rpm "$tmp/$asset" + elif command -v yum >/dev/null 2>&1; then + info "installing with yum (it asks for your password when it needs to)" + as_root yum install -y "$tmp/$asset" + else + info "installing with rpm (dependencies are not resolved)" + as_root rpm -U --replacepkgs "$tmp/$asset" + fi + info "installed Lockra ${version}; start it from the applications menu or run lockra-desktop" + ;; +appimage) + dir=${LOCKRA_INSTALL_DIR:-$HOME/.local/bin} + mkdir -p "$dir" + install -m 0755 "$tmp/$asset" "$dir/Lockra.AppImage" + data="${XDG_DATA_HOME:-$HOME/.local/share}" + mkdir -p "$data/applications" + # The menu icon, from inside the AppImage (its runtime extracts without FUSE); the entry goes + # without one when that does not work. + icon= + icon_path="usr/share/icons/hicolor/128x128/apps/lockra-desktop.png" + if (cd "$tmp" && "$dir/Lockra.AppImage" --appimage-extract "$icon_path" >/dev/null 2>&1) && + [ -s "$tmp/squashfs-root/$icon_path" ]; then + mkdir -p "$data/icons/hicolor/128x128/apps" + cp "$tmp/squashfs-root/$icon_path" "$data/icons/hicolor/128x128/apps/lockra.png" + icon="Icon=lockra" + fi + cat >"$data/applications/lockra.desktop" </dev/null 2>&1; then + err "Lockra is running: quit it (Lockra › Quit, or Cmd Q) and run this again" + fi + mounted="$tmp/mnt" + mkdir -p "$mounted" + hdiutil attach -nobrowse -readonly -quiet -mountpoint "$mounted" "$tmp/$asset" || + { + mounted= + err "could not open ${asset}" + } + [ -d "$mounted/Lockra.app" ] || err "${asset} holds no Lockra.app" + rm -rf "$dir/Lockra.app" + ditto "$mounted/Lockra.app" "$dir/Lockra.app" + hdiutil detach "$mounted" -quiet >/dev/null 2>&1 || true + mounted= + # Downloaded here rather than by a browser, so the app carries no quarantine flag and opens + # without the Gatekeeper prompt; clear one anyway in case an older copy left it. + xattr -dr com.apple.quarantine "$dir/Lockra.app" 2>/dev/null || true + # Launchpad and Spotlight list the apps LaunchServices knows; a copy made here would only be + # registered at its first start, so register it now. + lsregister=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister + if command -v lsregister >/dev/null 2>&1; then lsregister=$(command -v lsregister); fi + if [ -x "$lsregister" ]; then + "$lsregister" -f "$dir/Lockra.app" || info "LaunchServices did not take the app; it appears in Launchpad after its first start" + fi + info "installed Lockra ${version} to $dir/Lockra.app; open it from Launchpad or with: open -a Lockra" + ;; +esac diff --git a/scripts/smoke-update-linux.sh b/scripts/smoke-update-linux.sh new file mode 100755 index 00000000..e97c217f --- /dev/null +++ b/scripts/smoke-update-linux.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# The in-app update end to end on Linux, with real AppImages: two release builds (9.0.0 and +# 9.0.1) signed with a throwaway key, both trusting that key and asking a manifest on 127.0.0.1; +# the manifest served here; 9.0.0 started under Xvfb and driven over WebDriver to check and to +# install (scripts/smoke/update.py). Then the AppImage on disk must be the 9.0.1 package, and a +# Lockra must be running from it again. The release key and GitHub are never involved. +# Usage: scripts/smoke-update-linux.sh (make smoke-update; docs/acceptance/updates.md) +set -euo pipefail +cd "$(dirname "$0")/.." +for tool in Xvfb xdpyinfo tauri-driver WebKitWebDriver python3 pnpm curl sha256sum pgrep; do + command -v "$tool" >/dev/null || { echo "smoke-update: $tool not installed"; exit 2; } +done +from=9.0.0 +to=9.0.1 + +display="" +for n in $(seq 161 199); do + if [ ! -e "/tmp/.X11-unix/X$n" ] && [ ! -e "/tmp/.X$n-lock" ]; then display=":$n"; break; fi +done +[ -n "$display" ] || { echo "smoke-update: no free X display"; exit 2; } +free_port() { + for p in $(seq "$1" 2 "$2"); do + if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && ! (exec 3<>"/dev/tcp/127.0.0.1/$((p + 1))") 2>/dev/null; then echo "$p"; return; fi + done + echo "smoke-update: no free port" >&2 + exit 2 +} +driver_port=$(free_port 4500 4598) +manifest_port=$(free_port 18600 18698) + +work=$(mktemp -d) +pids=() +cleanup() { + status=$? + if [ "$status" -ne 0 ] && [ -s "$work/driver.log" ]; then + echo "smoke-update: the end of the driver's log:" + tail -n 40 "$work/driver.log" + fi + pkill -f "$work/apps/Lockra.AppImage" 2>/dev/null || true + for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; done + rm -rf "$work" +} +trap cleanup EXIT + +# A throwaway key: an update signed with it installs only into these two builds. +(cd apps/desktop && pnpm exec tauri signer generate --ci -w "$work/key" >/dev/null 2>&1) +pubkey=$(cat "$work/key.pub") + +build() { + local version=$1 config + config=$(python3 -c 'import json, sys; print(json.dumps({"version": sys.argv[1], "bundle": {"createUpdaterArtifacts": True}, "plugins": {"updater": {"pubkey": sys.argv[2], "endpoints": [f"http://127.0.0.1:{sys.argv[3]}/latest.json"], "requireSignedVersion": True, "dangerousInsecureTransportProtocol": True}}}))' "$version" "$pubkey" "$manifest_port") + echo "smoke-update: building the AppImage of $version" + (cd apps/desktop && TAURI_SIGNING_PRIVATE_KEY="$(cat "$work/key")" TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" \ + pnpm exec tauri build --ci --bundles appimage --config "$config" >"$work/build-$version.log" 2>&1) || + { tail -n 30 "$work/build-$version.log"; exit 1; } + cp "target/release/bundle/appimage/Lockra_${version}_amd64.AppImage" "target/release/bundle/appimage/Lockra_${version}_amd64.AppImage.sig" "$work/" +} +build "$to" +build "$from" + +mkdir -p "$work/serve" "$work/apps" +cp "$work/Lockra_${to}_amd64.AppImage" "$work/serve/" +python3 - "$work" "$to" "$manifest_port" <<'EOF' +import json, sys +work, version, port = sys.argv[1], sys.argv[2], sys.argv[3] +name = f"Lockra_{version}_amd64.AppImage" +entry = {"url": f"http://127.0.0.1:{port}/{name}", "signature": open(f"{work}/{name}.sig").read().strip()} +manifest = {"version": version, "notes": f"## {version}\n\n- An update for the smoke test", "pub_date": "2026-10-01T12:00:00Z", + "platforms": {"linux-x86_64-appimage": entry, "linux-x86_64": entry}} +json.dump(manifest, open(f"{work}/serve/latest.json", "w")) +EOF +python3 -m http.server "$manifest_port" --bind 127.0.0.1 --directory "$work/serve" >"$work/serve.log" 2>&1 & +pids+=($!) +timeout 20 sh -c "until curl -sf http://127.0.0.1:$manifest_port/latest.json >/dev/null; do sleep 0.5; done" + +installed="$work/apps/Lockra.AppImage" +install -m 0755 "$work/Lockra_${from}_amd64.AppImage" "$installed" +new_sum=$(sha256sum <"$work/Lockra_${to}_amd64.AppImage" | cut -d' ' -f1) + +Xvfb "$display" -screen 0 1440x900x24 -nolisten tcp >/dev/null 2>&1 & +pids+=($!) +timeout 20 sh -c "until DISPLAY=$display xdpyinfo >/dev/null 2>&1; do sleep 0.5; done" +mkdir -p "$work/data" "$work/config" +# Extract-and-run: no FUSE needed; the runtime still sets APPIMAGE, the file the updater replaces. +export DISPLAY="$display" XDG_DATA_HOME="$work/data" XDG_CONFIG_HOME="$work/config" APPIMAGE_EXTRACT_AND_RUN=1 +tauri-driver --port "$driver_port" --native-port "$((driver_port + 1))" >"$work/driver.log" 2>&1 & +pids+=($!) +timeout 30 sh -c "until curl -sf http://127.0.0.1:$driver_port/status >/dev/null; do sleep 0.5; done" + +python3 scripts/smoke/update.py --driver "http://127.0.0.1:$driver_port" --app "$installed" --from-version "$from" --to-version "$to" + +if ! timeout 120 sh -c "until [ \"\$(sha256sum <'$installed' | cut -d' ' -f1)\" = '$new_sum' ]; do sleep 1; done"; then + echo "smoke-update: the AppImage was not replaced by $to"; exit 1 +fi +echo "smoke-update: the AppImage on disk is now $to" +grep -q "GET /Lockra_${to}_amd64.AppImage" "$work/serve.log" || { echo "smoke-update: the package was not downloaded from the manifest server"; exit 1; } +# The restart: a new process runs from the replaced file (the extract-and-run runtime keeps +# APPIMAGE pointing at it). +if ! timeout 60 sh -c "until pgrep -f '$work/apps/Lockra.AppImage' >/dev/null || pgrep -af lockra-desktop | grep -q '$work'; do sleep 1; done"; then + echo "smoke-update: Lockra did not start again after the update"; pgrep -af 'Lockra|lockra' || true; exit 1 +fi +echo "smoke-update: Lockra restarted from the updated AppImage" +echo "smoke-update: OK, $from updated itself to $to" diff --git a/scripts/smoke/update.py b/scripts/smoke/update.py new file mode 100755 index 00000000..662d3e88 --- /dev/null +++ b/scripts/smoke/update.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Drive a packaged Lockra through an in-app update over WebDriver. scripts/smoke-update-linux.sh +owns the two builds, the manifest server, Xvfb and the driver; this checks that the copy says it +was installed as an AppImage at the old version, that a check finds the newer one, and starts the +install, which replaces the AppImage and restarts Lockra (the session ends with the old process). +Every wait is a condition with a deadline. +""" + +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from desktop import Session, until # noqa: E402 + + +def main(): + parser = argparse.ArgumentParser() + for name in ["driver", "app", "from-version", "to-version"]: + parser.add_argument(f"--{name}", required=True) + args = parser.parse_args() + web = Session(args.driver, args.app) + state = until("the first state", lambda: web.invoke({"command": "app_state"}), timeout=60) + if state["app_version"] != args.from_version: + sys.exit(f"smoke-update: running {state['app_version']}, expected {args.from_version}") + if state["update"] != {"method": "appimage", "status": {"state": "idle"}}: + sys.exit(f"smoke-update: the copy does not see itself as an AppImage: {state['update']}") + print(f"smoke-update: Lockra {args.from_version}, installed as an AppImage") + + web.invoke({"command": "update_check"}) + status = until( + "the check's answer", + lambda: (lambda s: s if s["state"] in ("available", "up_to_date", "failed") else None)(web.invoke({"command": "app_state"})["update"]["status"]), + timeout=60, + ) + if status.get("state") != "available" or status.get("version") != args.to_version: + sys.exit(f"smoke-update: the check found {status}, expected {args.to_version}") + print(f"smoke-update: the check found {args.to_version} ({status.get('date')})") + + web.invoke({"command": "update_install"}) + print("smoke-update: install started; the app replaces its AppImage and restarts") + + +if __name__ == "__main__": + main() diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 new file mode 100644 index 00000000..a75cd8cd --- /dev/null +++ b/scripts/test-install.ps1 @@ -0,0 +1,135 @@ +# Offline test of scripts/install.ps1 under PowerShell 7 (Linux in CI, any OS locally): the +# cmdlets that reach GitHub, run the installer, look for a running Lockra or read the registry are +# replaced by functions of the same name in this scope (a function wins over a cmdlet), so the +# script's own logic runs as it does on Windows. Nothing is installed and nothing goes online. +# Run by scripts/verify-all.sh (when pwsh is installed) and CI. +$ErrorActionPreference = "Stop" +$Script = Join-Path $PSScriptRoot "install.ps1" +$Version = "9.8.7" +$Tmp = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ("lockra-install-test-" + [guid]::NewGuid())) +$Release = New-Item -ItemType Directory -Path (Join-Path $Tmp "v$Version") + +foreach ($Arch in "x64", "arm64") { + Set-Content -Path (Join-Path $Release "Lockra_${Version}_${Arch}-setup.exe") -Value "installer $Arch" -NoNewline +} +function Write-Sums { + $Lines = Get-ChildItem $Release -Filter "*.exe" | Sort-Object Name | ForEach-Object { + "{0} {1}" -f (Get-FileHash -Algorithm SHA256 -Path $_.FullName).Hash.ToLowerInvariant(), $_.Name + } + Set-Content -Path (Join-Path $Release "SHA256SUMS") -Value $Lines + # GitHub's latest-release redirect serves the newest release's assets. + $Latest = New-Item -ItemType Directory -Force -Path (Join-Path $Tmp "latest/download") + Copy-Item (Join-Path $Release "SHA256SUMS") (Join-Path $Latest "SHA256SUMS") +} +Write-Sums + +# The stand-ins below run in install.ps1's scope when it calls them, where `$script:` means that +# script: what they share with this test lives in globals. +$global:LockraTestCalls = [System.Collections.Generic.List[string]]::new() +$global:LockraTestRoot = $Tmp +$global:LockraTestVersion = $Version + +function Invoke-RestMethod { + param($Uri, $Headers, [switch]$UseBasicParsing) + $global:LockraTestCalls.Add("api $Uri") + [pscustomobject]@{ tag_name = "v$($global:LockraTestVersion)" } +} + +function Invoke-WebRequest { + param($Uri, $OutFile, [switch]$UseBasicParsing) + $Path = $Uri -replace '^https://github\.com/sunerpy/lockra/releases/(download/)?', '' + $Source = Join-Path $global:LockraTestRoot $Path + $global:LockraTestCalls.Add("get $Path") + if (-not (Test-Path $Source)) { throw "404 $Uri" } + if ($OutFile) { + Copy-Item $Source $OutFile + } else { + # An asset's body, as Windows PowerShell 5.1 hands an octet-stream over: bytes. + [pscustomobject]@{ Content = [System.IO.File]::ReadAllBytes($Source) } + } +} + +function Get-Process { + param($Name, $ErrorAction) +} + +function Get-ItemProperty { + param($Path, $ErrorAction) +} + +function Start-Process { + param($FilePath, $ArgumentList, [switch]$Wait, [switch]$PassThru) + $global:LockraTestCalls.Add("start $(Split-Path -Leaf $FilePath) $ArgumentList".Trim()) + if ($FilePath -like "*-setup.exe") { + # What the per-user NSIS installer leaves behind. + $Dir = New-Item -ItemType Directory -Force -Path (Join-Path $env:LOCALAPPDATA "Lockra") + Set-Content -Path (Join-Path $Dir "lockra-desktop.exe") -Value "app" + } + [pscustomobject]@{ ExitCode = 0 } +} + +$Failures = 0 +function Check($Label, $Condition) { + if (-not $Condition) { + Write-Host "test-install.ps1: FAIL: $Label" -ForegroundColor Red + Write-Host (" calls: " + ($global:LockraTestCalls -join " | ")) + $script:Failures += 1 + } +} + +function Install-Lockra($Name, $Arch, $Wow = $null, $LockraVersion = $null) { + $global:LockraTestCalls.Clear() + $env:PROCESSOR_ARCHITECTURE = $Arch + if ($Wow) { $env:PROCESSOR_ARCHITEW6432 = $Wow } else { Remove-Item Env:PROCESSOR_ARCHITEW6432 -ErrorAction SilentlyContinue } + if ($LockraVersion) { $env:LOCKRA_VERSION = $LockraVersion } else { Remove-Item Env:LOCKRA_VERSION -ErrorAction SilentlyContinue } + $env:LOCALAPPDATA = Join-Path $Tmp "local-$Name" + try { + & $Script *> $null + return $null + } catch { + return $_.Exception.Message + } +} + +try { + $Error1 = Install-Lockra "x64" "AMD64" + Check "x64 installs" ($null -eq $Error1) + Check "x64 finds the latest release from its checksums" ($global:LockraTestCalls -contains "get latest/download/SHA256SUMS") + Check "x64 asks no rate-limited API" (-not ($global:LockraTestCalls | Where-Object { $_ -like "api *" })) + Check "x64 downloads its installer" ($global:LockraTestCalls -contains "get v$Version/Lockra_${Version}_x64-setup.exe") + Check "x64 runs it silently" ($global:LockraTestCalls -contains "start Lockra_${Version}_x64-setup.exe /S") + Check "x64 starts Lockra" ($global:LockraTestCalls -contains "start lockra-desktop.exe") + + $Error2 = Install-Lockra "arm64" "ARM64" -LockraVersion "v$Version" + Check "ARM64 installs a given version" ($null -eq $Error2) + Check "ARM64 takes the ARM64 installer" ($global:LockraTestCalls -contains "start Lockra_${Version}_arm64-setup.exe /S") + Check "a given version does not look up the latest" (-not ($global:LockraTestCalls | Where-Object { $_ -like "*latest*" })) + + $Error3 = Install-Lockra "wow" "x86" -Wow "ARM64" -LockraVersion $Version + Check "a 32-bit shell on ARM64 takes the ARM64 installer" (($null -eq $Error3) -and ($global:LockraTestCalls -contains "start Lockra_${Version}_arm64-setup.exe /S")) + + Set-Content -Path (Join-Path $Tmp "latest/download/SHA256SUMS") -Value "not a checksum list" + $ErrorLatest = Install-Lockra "no-latest" "AMD64" + Check "a latest release without packages is refused" ($ErrorLatest -like "*could not find the latest release*") + Write-Sums + + $Error4 = Install-Lockra "x86" "x86" + Check "32-bit Windows is refused" ($Error4 -like "*64-bit Windows on x64 and ARM64 only*") + + $Error5 = Install-Lockra "bad-version" "AMD64" -LockraVersion "latest" + Check "a version that is not one is refused" ($Error5 -like "*not a release version*") + + $Error6 = Install-Lockra "no-release" "AMD64" -LockraVersion "9.8.6" + Check "a release that does not exist is refused" ($Error6 -like "*has no SHA256SUMS*") + + Add-Content -Path (Join-Path $Release "Lockra_${Version}_x64-setup.exe") -Value "tampered" + $Error7 = Install-Lockra "tampered" "AMD64" -LockraVersion $Version + Check "a tampered installer is refused" ($Error7 -like "*checksum mismatch*") + Check "a tampered installer never runs" (-not ($global:LockraTestCalls | Where-Object { $_ -like "start *-setup.exe*" })) +} finally { + Remove-Item -Recurse -Force $Tmp -ErrorAction SilentlyContinue + Remove-Variable -Scope Global -Name LockraTestCalls, LockraTestRoot, LockraTestVersion -ErrorAction SilentlyContinue +} + +if ($Failures -gt 0) { exit 1 } +Write-Host "test-install.ps1: install.ps1 passed on x64, ARM64 and every refusal" diff --git a/scripts/test-install.sh b/scripts/test-install.sh new file mode 100755 index 00000000..e885cca6 --- /dev/null +++ b/scripts/test-install.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# Offline test of scripts/install.sh: a fake GitHub (a `curl` on PATH answering from a fixture +# release), fake package managers and macOS tools that record what they are asked to do, and the +# script run as `sh` (dash on Debian and Ubuntu) for every platform it supports. Nothing is +# installed on this machine and nothing goes online. Run by scripts/verify-all.sh and CI. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +script="$root/scripts/install.sh" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +VERSION=9.8.7 + +fail() { + echo "test-install: FAIL: $*" >&2 + if [ -f "$tmp/stderr" ]; then sed 's/^/ | /' "$tmp/stderr" >&2; fi + exit 1 +} + +# ---- the release ---------------------------------------------------------------------------- +release="$tmp/release/v$VERSION" +mkdir -p "$release" +for name in "Lockra_${VERSION}_amd64.deb" "Lockra_${VERSION}_arm64.deb" "Lockra-${VERSION}-1.x86_64.rpm" \ + "Lockra-${VERSION}-1.aarch64.rpm" "Lockra_${VERSION}_aarch64.dmg" "Lockra_${VERSION}_x64.dmg"; do + printf 'package %s\n' "$name" >"$release/$name" +done +# An AppImage answers --appimage-extract by writing the file it is asked for. +for arch in amd64 aarch64; do + cat >"$release/Lockra_${VERSION}_${arch}.AppImage" <"squashfs-root/\$2"; fi +EOF +done +(cd "$release" && sha256sum Lockra* >SHA256SUMS) +# GitHub's latest-release redirect serves the newest release's assets. +mkdir -p "$tmp/release/latest/download" +cp "$release/SHA256SUMS" "$tmp/release/latest/download/SHA256SUMS" + +# ---- the commands --------------------------------------------------------------------------- +fake="$tmp/fake" +mkdir -p "$fake" +log="$tmp/calls.log" +cat >"$fake/curl" <>"$log" +case "\$url" in +https://github.com/sunerpy/lockra/releases/latest/download/*) src="$tmp/release/latest/download/\${url#https://github.com/sunerpy/lockra/releases/latest/download/}" ;; +https://github.com/sunerpy/lockra/releases/download/*) src="$tmp/release/\${url#https://github.com/sunerpy/lockra/releases/download/}" ;; +*) echo "curl: unexpected URL \$url" >&2; exit 2 ;; +esac +[ -f "\$src" ] || exit 22 +if [ -n "\$out" ]; then cp "\$src" "\$out"; else cat "\$src"; fi +EOF +cat >"$fake/uname" <<'EOF' +#!/bin/sh +case "$1" in -s) echo "$FAKE_UNAME_S" ;; -m) echo "$FAKE_UNAME_M" ;; esac +EOF +cat >"$fake/sysctl" <<'EOF' +#!/bin/sh +echo "${FAKE_ARM64:-0}" +EOF +printf '#!/bin/sh\necho 1000\n' >"$fake/id" +cat >"$fake/sudo" <>"$log" +exec "\$@" +EOF +# A package manager records its arguments and the package it was handed. +recorder() { + cat >"$1" <>"$log" +for arg in "\$@"; do + case "\$arg" in *.deb | *.rpm) echo "package: \$(cat "\$arg")" >>"$log" ;; esac +done +EOF + chmod +x "$1" +} +for manager in apt-get dnf zypper; do + mkdir -p "$tmp/pm-$manager" + recorder "$tmp/pm-$manager/$manager" +done +mkdir -p "$tmp/pm-none" +cat >"$fake/hdiutil" <>"$log" +if [ "\$1" = attach ]; then + while [ \$# -gt 0 ]; do [ "\$1" = -mountpoint ] && mount=\$2; shift; done + mkdir -p "\$mount/Lockra.app/Contents/MacOS" && printf 'app\n' >"\$mount/Lockra.app/Contents/MacOS/lockra-desktop" +fi +EOF +cat >"$fake/ditto" <<'EOF' +#!/bin/sh +cp -R "$1" "$2" +EOF +for tool in xattr lsregister; do + cat >"$fake/$tool" <>"$log" +EOF +done +cat >"$fake/pgrep" <<'EOF' +#!/bin/sh +[ -n "${FAKE_RUNNING:-}" ] +EOF +chmod +x "$fake"/* + +# The real tools the script uses, and nothing else from this machine (its own package manager +# would otherwise decide the package). +system="$tmp/system" +mkdir -p "$system" +for tool in sh sed awk grep head mktemp rm mkdir chmod cp install cat sha256sum dirname basename; do + ln -s "$(command -v "$tool")" "$system/$tool" +done + +# run CASE OS ARCH MANAGER [VAR=VALUE...]: the script in a fresh home; its status in $status. +run() { + local name=$1 os=$2 arch=$3 manager=$4 + shift 4 + home="$tmp/home-$name" + mkdir -p "$home" + : >"$log" + set +e + env -i PATH="$fake:$tmp/pm-$manager:$system" HOME="$home" FAKE_UNAME_S="$os" FAKE_UNAME_M="$arch" "$@" \ + sh "$script" >"$tmp/stdout" 2>"$tmp/stderr" + status=$? + set -e +} + +expect_ok() { + [ "$status" = 0 ] || fail "$1: exited $status" +} + +expect_failure() { + [ "$status" != 0 ] || fail "$1: expected a failure" + grep -q -- "$2" "$tmp/stderr" || fail "$1: the error does not say '$2'" + if grep -q -e '-get install' -e 'dnf install' -e 'zypper ' -e 'ditto' "$log"; then fail "$1: something was installed"; fi +} + +logged() { + grep -qF -- "$2" "$log" || { + sed 's/^/ log: /' "$log" >&2 + fail "$1: expected '$2' in the calls" + } +} + +# ---- Linux ---------------------------------------------------------------------------------- +run apt Linux x86_64 apt-get +expect_ok "x64 with apt" +logged "x64 with apt" "curl https://github.com/sunerpy/lockra/releases/latest/download/SHA256SUMS" +logged "x64 with apt" "curl https://github.com/sunerpy/lockra/releases/download/v${VERSION}/Lockra_${VERSION}_amd64.deb" +if grep -q "api.github.com" "$log"; then fail "the latest release is found without the rate-limited API"; fi +logged "x64 with apt" "sudo apt-get update -qq" +logged "x64 with apt" "apt-get install -y" +logged "x64 with apt" "package: package Lockra_${VERSION}_amd64.deb" + +run dnf Linux aarch64 dnf +expect_ok "ARM64 with dnf" +logged "ARM64 with dnf" "sudo dnf install -y" +logged "ARM64 with dnf" "package: package Lockra-${VERSION}-1.aarch64.rpm" + +run zypper Linux x86_64 zypper +expect_ok "x64 with zypper" +logged "x64 with zypper" "zypper --non-interactive install --allow-unsigned-rpm" +logged "x64 with zypper" "package: package Lockra-${VERSION}-1.x86_64.rpm" + +run appimage Linux x86_64 none +expect_ok "x64 without a package manager" +appimage="$home/.local/bin/Lockra.AppImage" +[ -x "$appimage" ] || fail "the AppImage is not at $appimage" +grep -q "AppImage amd64" "$appimage" || fail "the x64 AppImage was not the one installed" +entry="$home/.local/share/applications/lockra.desktop" +grep -qx "Exec=\"$appimage\"" "$entry" || fail "the menu entry does not start the AppImage" +grep -qx "Icon=lockra" "$entry" || fail "the menu entry has no icon" +[ -s "$home/.local/share/icons/hicolor/128x128/apps/lockra.png" ] || fail "the icon was not extracted" + +run forced Linux aarch64 apt-get LOCKRA_PACKAGE=appimage LOCKRA_INSTALL_DIR="$tmp/apps" LOCKRA_VERSION="v$VERSION" +expect_ok "LOCKRA_PACKAGE=appimage on apt" +grep -q "AppImage aarch64" "$tmp/apps/Lockra.AppImage" || fail "the ARM64 AppImage was not installed into LOCKRA_INSTALL_DIR" +if grep -q -e "apt-get" -e "releases/latest" "$log"; then fail "a given version and package: no apt and no lookup of the latest"; fi + +mv "$tmp/release/latest/download/SHA256SUMS" "$tmp/sums.latest" +printf 'not a checksum list\n' >"$tmp/release/latest/download/SHA256SUMS" +run no-latest Linux x86_64 apt-get +expect_failure "a latest release without packages" "could not find the latest release" +mv "$tmp/sums.latest" "$tmp/release/latest/download/SHA256SUMS" + +run bad-package Linux x86_64 apt-get LOCKRA_PACKAGE=snap +expect_failure "an unknown package" "must be deb, rpm or appimage" + +run bad-version Linux x86_64 apt-get LOCKRA_VERSION=latest +expect_failure "a version that is not one" "not a release version" + +run no-release Linux x86_64 apt-get LOCKRA_VERSION=9.8.6 +expect_failure "a release that does not exist" "has no SHA256SUMS" + +cp "$release/Lockra_${VERSION}_amd64.deb" "$tmp/deb.orig" +printf 'tampered\n' >>"$release/Lockra_${VERSION}_amd64.deb" +run tampered Linux x86_64 apt-get +expect_failure "a package that does not match SHA256SUMS" "checksum mismatch" +mv "$tmp/deb.orig" "$release/Lockra_${VERSION}_amd64.deb" + +run riscv Linux riscv64 apt-get +expect_failure "an unsupported processor" "x64 and ARM64 Linux only" + +# ---- macOS ---------------------------------------------------------------------------------- +run rosetta Darwin x86_64 none FAKE_ARM64=1 LOCKRA_INSTALL_DIR="$tmp/Applications" +expect_ok "Apple silicon, from a Rosetta shell" +logged "Apple silicon" "Lockra_${VERSION}_aarch64.dmg" +[ -f "$tmp/Applications/Lockra.app/Contents/MacOS/lockra-desktop" ] || fail "Lockra.app was not copied" +logged "Apple silicon" "xattr -dr com.apple.quarantine" +logged "Apple silicon" "lsregister -f" +logged "Apple silicon" "hdiutil detach" + +run intel Darwin x86_64 none FAKE_ARM64=0 LOCKRA_INSTALL_DIR="$tmp/Applications-intel" +expect_ok "an Intel Mac" +logged "an Intel Mac" "Lockra_${VERSION}_x64.dmg" + +run running Darwin arm64 none FAKE_ARM64=1 FAKE_RUNNING=1 LOCKRA_INSTALL_DIR="$tmp/Applications-running" +expect_failure "Lockra still running" "Lockra is running" + +run freebsd FreeBSD amd64 none +expect_failure "an unsupported system" "unsupported system" + +echo "test-install: install.sh passed on every platform it supports" diff --git a/scripts/verify-all.sh b/scripts/verify-all.sh index e3e7e799..283bd039 100755 --- a/scripts/verify-all.sh +++ b/scripts/verify-all.sh @@ -3,7 +3,8 @@ # oxfmt over TS, JSON, YAML, TOML and Markdown), clippy, the Rust tests (the IPC contract fixtures # are compared byte for byte there), the crates' line coverage floor, the web packages' lint, # types and tests with their own coverage floors, the release configuration, no colour literal in -# a component, a release bundle free of development code, cargo-deny, actionlint and shellcheck. +# a component, a release bundle free of development code, cargo-deny, actionlint, shellcheck and +# the one-line installers (offline). # The desktop smoke test and the packages are separate (make pre-ci): they need a display server # stack and the cross toolchain. Usage: scripts/verify-all.sh (make check) set -euo pipefail @@ -36,4 +37,10 @@ step "release bundle"; scripts/check-web-bundle.sh step "cargo-deny"; cargo deny check licenses bans sources step "actionlint"; actionlint step "shellcheck"; shellcheck scripts/*.sh .github/scripts/*.sh +step "install scripts"; scripts/test-install.sh +if command -v pwsh >/dev/null; then + pwsh -NoProfile -File scripts/test-install.ps1 +else + echo "verify: pwsh is not installed here; scripts/install.ps1 is tested in CI" +fi printf '\nverify: all gates passed\n'