Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/release-notes/0.4.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## Floatick v0.4.2

### 中文

恢复 Tauri 客户端的应用内更新,并修复更新源未签名导致的 Update Error。

- 新增 **设置 → 软件更新**:显示真实应用版本、手动检查更新、每日自动检查开关、最近检查时间和更新说明入口。
- 使用 Sparkle 原生窗口展示新版本与下载进度,验签后由用户确认安装并重启;自动检查不会自动安装。
- 打包完整的 Sparkle 框架、安装器与 XPC 服务。发布流程验证安装包配置,同时签名并校验更新源和 DMG,阻止未签名、密钥不匹配或被篡改的更新发布。
- 保留 v0.4.1 的空正文光标与 checkbox 排版修复。

**升级说明:** 公开发布的 v0.4.0、v0.4.1 没有内置更新组件,需要手动安装一次 v0.4.2。已安装本地更新测试包的用户,可在本次签名更新源发布后使用“检查更新”升级。待办和便签继续保存在原有的 `~/.floatick` 中。

### English

Restore in-app updates in the Tauri client and fix the Update Error caused by the unsigned feed.

- Add **Settings → Software Updates** with the real app version, manual checks, persisted daily checks, the last check time, and a release notes link.
- Use Sparkle's native update, download progress, signature verification, and install/relaunch flow. Automatic checks do not automatically install updates.
- Bundle the complete Sparkle framework and installer services. Validate packaged settings and sign and verify both the feed and DMG before release promotion.
- Retain the empty-body caret and checklist layout fixes from v0.4.1.

**Migration:** Public v0.4.0 and v0.4.1 builds require one manual installation of v0.4.2. Users of the local updater test build can upgrade with Check for Updates once the signed feed is live. Existing todos and notes remain in `~/.floatick`.
4 changes: 4 additions & 0 deletions .github/workflows/candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Prepare verified Sparkle framework
run: bash scripts/setup-sparkle.sh

- name: Verify frontend and backend tests
run: |
pnpm build
Expand All @@ -108,6 +111,7 @@ jobs:
executable="$app_path/Contents/MacOS/$executable_name"
[[ "$installed_version" == "$APP_VERSION" && "$installed_build" == "$APP_BUILD_NUMBER" ]]
lipo "$executable" -verify_arch arm64 x86_64
python3 scripts/verify-update-bundle.py "$app_path"
"$executable" > "$RUNNER_TEMP/floatick-startup.log" 2>&1 &
app_pid=$!
trap 'kill "$app_pid" 2>/dev/null || true' EXIT
Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ jobs:
with:
workspaces: src-tauri -> target

- name: Prepare verified Sparkle framework
run: bash scripts/setup-sparkle.sh

- name: Cargo check
run: cargo check --manifest-path src-tauri/Cargo.toml

Expand Down Expand Up @@ -109,5 +112,7 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build Tauri app (binary verification)
run: pnpm tauri build --no-bundle
- name: Build and verify packaged updater
run: |
pnpm tauri build --bundles app
python3 scripts/verify-update-bundle.py src-tauri/target/release/bundle/macos/Floatick.app
28 changes: 27 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ jobs:
echo "Expected a universal app, found: $architectures" >&2
exit 1
fi
python3 scripts/verify-update-bundle.py "$app_path"
hdiutil detach "$mount_point"
trap - EXIT

Expand All @@ -202,8 +203,16 @@ jobs:
if [[ -f ".github/release-notes/$APP_VERSION.md" ]]; then
cat ".github/release-notes/$APP_VERSION.md"
printf '\n\n'
# Release tags identify the tested candidate before the PR merge.
# GitHub's auto notes can therefore repeat the previous release PR.
previous_tag=$(git describe --tags --match 'v[0-9]*' --abbrev=0 "${RELEASE_COMMIT}^" 2>/dev/null || true)
if [[ -n "$previous_tag" ]]; then
printf '**Full Changelog**: https://github.com/%s/compare/%s...%s\n' \
"$GITHUB_REPOSITORY" "$previous_tag" "$GITHUB_REF_NAME"
fi
else
cat "$generated_notes"
fi
cat "$generated_notes"
} > "$final_notes"

dmg_name="$PRODUCT_NAME-$APP_VERSION-macos-universal.dmg"
Expand Down Expand Up @@ -234,6 +243,9 @@ jobs:
GH_REPO: ${{ github.repository }}

steps:
- name: Check out tagged release tooling
uses: actions/checkout@v7

- name: Download verified release assets
uses: actions/download-artifact@v8
with:
Expand All @@ -243,6 +255,7 @@ jobs:
- name: Generate signed Sparkle appcast
env:
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
APP_VERSION: ${{ needs.verify.outputs.app_version }}
shell: bash
run: |
sparkle_archive="$RUNNER_TEMP/Sparkle-$SPARKLE_VERSION.tar.xz"
Expand All @@ -262,6 +275,12 @@ jobs:
mkdir -p "$sparkle_directory"
tar -xJf "$sparkle_archive" -C "$sparkle_directory"

python3 scripts/test-update-feed.py "$sparkle_directory/bin"

# A promotion retry also downloads the website alias. Publish only
# the immutable versioned archive in the update feed.
rm -f "$RUNNER_TEMP/artifacts/$PRODUCT_NAME-macos-universal.dmg"

printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \
| "$sparkle_directory/bin/generate_appcast" \
--ed-key-file - \
Expand All @@ -274,6 +293,13 @@ jobs:
"$RUNNER_TEMP/artifacts"

test -s "$RUNNER_TEMP/artifacts/appcast.xml"
# SURequireSignedFeed protects the feed as well as the archive.
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \
| "$sparkle_directory/bin/sign_update" --ed-key-file - \
"$RUNNER_TEMP/artifacts/appcast.xml"
python3 scripts/verify-update-feed.py \
"$RUNNER_TEMP/artifacts/appcast.xml" \
"$sparkle_directory/bin/sign_update" "$download_prefix" "$APP_VERSION"
mkdir -p "$RUNNER_TEMP/pages"
cp "$RUNNER_TEMP/artifacts/appcast.xml" \
"$RUNNER_TEMP/pages/appcast.xml"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ build/
coverage/
dist/
node_modules/
__pycache__/
src-tauri/target/
*.dmg
*.xcarchive
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ Download the latest DMG installer from [GitHub Releases](https://github.com/luca
> **First-Launch Note for macOS**:
> If macOS displays an "unidentified developer" warning, open **System Settings → Privacy & Security**, scroll down to **Security**, and click **Open Anyway** (only required on first launch).

## App Updates

Open **Settings → Software Updates** (`⌘,`) to check for a new version, read release notes, or enable daily automatic checks. Sparkle displays download progress and verifies Ed25519 signatures before installing and relaunching, with your confirmation. Automatic checks do not automatically install updates.

Versions 0.4.0 and 0.4.1 do not contain the updater; install v0.4.2 manually once. Updates require an internet connection; your todos and notes remain local. Update preferences are stored in macOS user defaults.

## Shortcuts & Controls

| Action | Shortcut / Gesture | Description |
Expand Down Expand Up @@ -75,7 +81,7 @@ Floatick automatically creates human-readable JSON files in your home directory
| `~/.floatick/tags.json` | Reusable color tags shared between todos and notes |
| `~/.floatick/settings.json` | Theme (system/light/dark), language, autostart, and stay-on-top |

Your data never leaves your computer. Floatick operates completely offline and collects zero telemetry.
Your todos and notes never leave your computer and work entirely offline. Optional update checks contact the release feed; Floatick collects zero telemetry.

## Development

Expand All @@ -95,6 +101,8 @@ pnpm install
pnpm tauri:dev
```

The Tauri commands automatically download the pinned, checksum-verified Sparkle framework. Before running Cargo directly, run `pnpm setup:sparkle` once. The updater is available in a packaged `.app`; browser previews and unbundled `tauri dev` show an unavailable state. Build and launch the `.app` to test native update windows.

### Production Build

```bash
Expand Down
8 changes: 8 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ Floatick 是一款专为 macOS 设计的原生轻量效率工具。安静常驻
> **macOS 首次打开提示**:
> 如打开时提示“来自未受信任的开发者”,请前往 **系统设置 → 隐私与安全**,在“安全性”中点击 **仍要打开** 即可(仅需首次确认一次)。

## 应用更新

打开 **设置 → 软件更新**(`⌘,`),可手动检查新版本、查看更新说明,或开启每天自动检查。Sparkle 原生窗口负责展示下载进度、验证 Ed25519 签名,并在你确认后安装、重启;自动检查不会自动安装。

v0.4.0 和 v0.4.1 尚未内置更新组件,需要先手动安装一次 v0.4.2。检查更新需要联网,待办和便签数据仍保存在本机。更新偏好保存在 macOS 用户偏好中。

## 快捷键与常用交互

| 操作 | 快捷键 / 交互 | 说明 |
Expand Down Expand Up @@ -95,6 +101,8 @@ pnpm install
pnpm tauri:dev
```

Tauri 命令会自动下载固定版本且经过 SHA-256 校验的 Sparkle 框架。直接运行 Cargo 前,请先执行一次 `pnpm setup:sparkle`。更新功能仅在打包后的 `.app` 中启用,浏览器预览及未打包的 `tauri dev` 会显示不可用提示;测试原生更新窗口请构建并启动 `.app`。

### 生产打包

```bash
Expand Down
51 changes: 51 additions & 0 deletions docs/testing/app-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# App update validation

The Tauri client uses Sparkle 2.9.2 with an Ed25519-signed feed and archives. The
private production key remains in the protected GitHub production environment.
Unbundled development sessions deliberately report that native updates are unavailable.

## Local checks

```sh
pnpm setup:sparkle
pnpm build
cargo test --manifest-path src-tauri/Cargo.toml --locked
pnpm tauri build --bundles app
python3 scripts/verify-update-bundle.py src-tauri/target/release/bundle/macos/Floatick.app
# With the checksum-verified Sparkle tools extracted locally:
python3 scripts/test-update-feed.py /path/to/sparkle/bin
```

The feed regression suite generates temporary signing keys. It checks a valid feed,
a modified feed, a same-size modified archive, and an unsigned archive enclosure.
The stable release workflow runs these tests and verifies the real feed and DMG
signatures before promoting the candidate. Bundle checks also run in CI and on the
candidate and stable release assets.

## Native end-to-end checks performed

Tested an isolated app copy with a separate bundle identifier, a temporary signing
key, and a localhost feed. No installed user application was replaced. The fixture
kept the production application's executable and changed only bundle metadata and
signatures. Its version change was for testing, not a published release.

- Open Settings, check for updates, and display the native available-version window.
- Download a signed DMG and reach the ready-to-install window.
- Install and relaunch: the old process exits, a new process starts, and the fixture's
bundle build changes from 13 to 14.
- Check again and confirm the native “You're up to date” result.
- Modify the signed feed and confirm a native signature error prevents the update.
- Toggle automatic checks and confirm the native preference is persisted.

Chromium and WebKit checks cover the settings unavailable state, actual version
snapshot, manual checks, duplicate-action prevention, preference failures, check
retries, and recovery after a failed settings read. Existing editor regression
checks still cover the empty caret, checklist alignment, nesting, wrapping, and saving.

## First release with this feature

Versions 0.4.0 and 0.4.1 do not contain a client updater. Those users must manually
install the first release containing this implementation. The current 0.4.1 feed is
unsigned and is correctly rejected by the new client. Publishing the new release
through the updated workflow will replace it with a signed feed and archive. Do not
turn off signature verification to accept the old feed.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"name": "floatick",
"version": "0.4.1",
"version": "0.4.2",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"setup:sparkle": "bash scripts/setup-sparkle.sh",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
Expand Down
26 changes: 26 additions & 0 deletions scripts/setup-sparkle.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash
set -euo pipefail

# Match the pinned release tooling; never execute an unverified remote script.
SPARKLE_VERSION=2.9.2
SPARKLE_SHA256=1cb340cbbef04c6c0d162078610c25e2221031d794a3449d89f2f56f4df77c95
project_dir="$(cd "$(dirname "$0")/.." && pwd)"
framework_dir="$project_dir/src-tauri"
if [[ "$(uname -s)" != Darwin ]]; then
exit 0
fi
if [[ -s "$framework_dir/Sparkle.framework/Sparkle" && -f "$framework_dir/.sparkle-archive-sha256" ]] &&
[[ "$(cat "$framework_dir/.sparkle-archive-sha256")" == "$SPARKLE_SHA256" ]]; then
exit 0
fi
scratch_dir="$(mktemp -d)"
trap 'rm -rf "$scratch_dir"' EXIT
curl --fail --location --silent --show-error --retry 3 \
"https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz" \
--output "$scratch_dir/Sparkle.tar.xz"
printf '%s %s\n' "$SPARKLE_SHA256" "$scratch_dir/Sparkle.tar.xz" | shasum -a 256 --check
tar -xJf "$scratch_dir/Sparkle.tar.xz" -C "$scratch_dir"
# Preserve framework symlinks and its embedded installer/XPC services.
ditto "$scratch_dir/Sparkle.framework" "$framework_dir/Sparkle.framework"
printf '%s\n' "$SPARKLE_SHA256" > "$framework_dir/.sparkle-archive-sha256"
printf 'Sparkle %s is ready.\n' "$SPARKLE_VERSION"
61 changes: 61 additions & 0 deletions scripts/test-update-feed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Regression checks with ephemeral keys: python3 scripts/test-update-feed.py SPARKLE_BIN."""
import base64
import importlib.util
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch

spec = importlib.util.spec_from_file_location("feed", Path(__file__).with_name("verify-update-feed.py"))
feed = importlib.util.module_from_spec(spec)
spec.loader.exec_module(feed)
signer = Path(sys.argv.pop(1)) / "sign_update"


class SignedFeedTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.key = base64.b64encode(os.urandom(32)).decode()
self.archive = self.root / "Floatick-0.4.2-macos-universal.dmg"
self.archive.write_bytes(b"signed update fixture")
self.xml = self.root / "appcast.xml"
sig = self.sign(self.archive, "-p").strip()
self.xml.write_text(f'''<?xml version="1.0"?><rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><item><sparkle:shortVersionString>0.4.2</sparkle:shortVersionString><enclosure url="https://example.test/{self.archive.name}" length="{self.archive.stat().st_size}" sparkle:edSignature="{sig}"/></item></channel></rss>''')
self.sign(self.xml)

def sign(self, path, *args):
return subprocess.check_output([str(signer), "--ed-key-file", "-", *args, str(path)],
input=self.key, text=True)

def verify(self):
with patch.dict(os.environ, {"SPARKLE_ED_PRIVATE_KEY": self.key}):
feed.verify(self.xml, signer, "https://example.test/", "0.4.2")

def test_signed_feed_and_archive(self):
self.verify()

def test_tampered_feed_is_rejected(self):
self.xml.write_text(self.xml.read_text().replace("0.4.2", "0.4.9"))
with self.assertRaises(subprocess.CalledProcessError):
self.verify()

def test_same_length_tampered_archive_is_rejected(self):
self.archive.write_bytes(b"tamper" + self.archive.read_bytes()[6:])
with self.assertRaises(subprocess.CalledProcessError):
self.verify()

def test_unsigned_enclosure_is_rejected(self):
import re
self.xml.write_text(re.sub(r' sparkle:edSignature="[^"]+"', '', self.xml.read_text()))
self.sign(self.xml)
with self.assertRaisesRegex(ValueError, "unsigned"):
self.verify()


if __name__ == "__main__":
unittest.main()
31 changes: 31 additions & 0 deletions scripts/verify-update-bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Fail a release if the packaged updater differs from its trusted configuration."""
import plistlib
import subprocess
import sys
from pathlib import Path


def verify(app: Path):
root = Path(__file__).resolve().parents[1]
expected = plistlib.loads((root / "src-tauri/Info.plist").read_bytes())
actual = plistlib.loads((app / "Contents/Info.plist").read_bytes())
for key, value in expected.items():
if actual.get(key) != value:
raise ValueError(f"Packaged updater setting {key} differs from Info.plist")
framework = app / "Contents/Frameworks/Sparkle.framework"
for path in ["Sparkle", "Resources/Info.plist", "Versions/B/Autoupdate",
"Versions/B/Updater.app/Contents/MacOS/Updater",
"Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader",
"Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer"]:
if not (framework / path).is_file():
raise ValueError(f"Missing updater component: {path}")
subprocess.run(["codesign", "--verify", "--deep", "--strict", str(app)], check=True)
executable = app / "Contents/MacOS" / actual["CFBundleExecutable"]
linked = subprocess.check_output(["otool", "-L", str(executable)], text=True)
if "@rpath/Sparkle.framework/" not in linked:
raise ValueError("Application does not link the embedded Sparkle framework")
print("Packaged update configuration, framework, and code signatures verified.")


if __name__ == "__main__":
verify(Path(sys.argv[1]))
Loading
Loading