Skip to content

Latest commit

 

History

History
283 lines (234 loc) · 13.7 KB

CONTRIBUTING.md

File metadata and controls

283 lines (234 loc) · 13.7 KB

Contributing to ThinkWatch Lite

Open PRs against dev

gh pr create --base dev --head your-branch

main is the release line; dev is where routine work lands. GitHub pre-fills a new PR's base with the repo's default branch, which is main, so the default is not the one you want. If you already opened against main, click Edit next to the PR title and change the base — the commits and the discussion carry over. A bot will remind you.

Sign the Contributor License Agreement once

Your first pull request to this repository gets a comment from a bot asking you to sign the Contributor License Agreement (Chinese translation). Read it, then reply on the pull request with exactly:

I have read the CLA Document and I hereby sign the CLA

That is the only time. The signature is recorded in this repository and covers all your later pull requests here. If the check stays red after you sign, comment recheck.

You keep the copyright in your contribution; the agreement grants the project the right to license it under any terms, including commercial ones.

Layout

src/              React 19 + Tailwind 4 interface
src-tauri/        Tauri 2 shell: supervises the local core, connects to a
                  local or remote core, draws the menu bar item and the tray
                  menu, sends system notifications, installs updates
src-tauri/crates/ tw-adopt (pointing clients at the gateway, MCP configuration)
                  and tw-scan (scanning client configuration)
scripts/shots/    the product screenshot pipeline (see CONTRIBUTING.md)

The gateway itself lives in ThinkWatch Core; this repository holds no routing, forwarding, or accounting logic. The app reaches core's control channel over a unix socket on macOS and Linux and over a loopback port on Windows, or over a TCP port when core runs on a server. Every connection begins with an encrypted handshake (Noise NNpsk0) keyed with the control key from core's configuration (listen.control.key); no TLS certificates are involved.

Scope, so you don't build something that gets declined

These decisions are settled and not up for a PR:

  • macOS, Windows and Linux, from one tag. Every release tag produces five files for people to install, each with the gateway inside it and a sha256 beside it: an arm64 disk image for macOS, an x64 and an arm64 installer for Windows, and an x86_64 and an aarch64 AppImage for Linux.
  • macOS: Apple silicon only, and not signed by Apple. The macOS artifact is an arm64 .app in a disk image, signed with the project's own self-signed certificate. That certificate does not satisfy Gatekeeper; it exists so that every release has the same signer, which is what lets Homebrew upgrade the app without warning that the signer changed. A universal binary for Intel and a Developer ID signature are both ongoing costs nobody has taken on — so a PR that adds the notarization step without the account behind it can't be merged, and neither can one that makes the build fall back to whatever architecture the machine happens to be, which ships a file some users can download and cannot open.
  • Windows: not code-signed. The installers carry no Authenticode signature, and no certificate will be bought, so SmartScreen warns when a downloaded installer is first run. Updates are verified against the key compiled into the app, the same as on macOS.
  • Linux: the AppImage only. No deb, rpm, Flatpak or Snap. The sandboxed formats cannot start the bundled twcore or edit client configuration such as ~/.claude, and the AppImage updates itself without a password. Builds target glibc 2.35 (Ubuntu 22.04) and WebKitGTK 4.1.

The gateway itself — routing, forwarding, cost accounting, redaction — lives in ThinkWatch Core. If the behavior you want to change is on the data path, that's the repository for it. This one is the window onto it.

Commit messages

Conventional Commits (fix(scope): subject), in English — this is a public repository and the history is documentation.

Say why in the body, not just what; the diff already shows what changed. A commit explaining the reasoning behind a non-obvious choice saves the next person from re-deriving it or "fixing" it back.

Linux prerequisites

Building on Linux needs the WebKitGTK, AppIndicator and D-Bus development packages. On Ubuntu or Debian, the same set CI installs:

sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev \
  libssl-dev libayatana-appindicator3-dev librsvg2-dev dbus

The notification tests talk to a session bus; without a desktop session, run them under dbus-run-session -- cargo test --manifest-path src-tauri/Cargo.toml.

Before you open the PR

pnpm typecheck
pnpm test
cargo fmt --manifest-path src-tauri/Cargo.toml --all --check
cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings
cargo test --manifest-path src-tauri/Cargo.toml

Warnings are errors. The toolchain is stable, so a newer stable than your local one can surface lints you cannot see — rustup update stable before blaming CI.

Things this UI must not do

These are load-bearing and a PR that breaks one will be asked to change:

  • Show a secret only where the user manages it. The config file editor and the forms that edit a secret (an upstream's API key and headers, a proxy's credentials, a remote connection's key) show the real value — that is what is being edited — with key fields hidden behind an eye toggle until the user asks. Everywhere else it is masked before it leaves the process: change diffs, logs, error messages, notifications and diagnostic bundles. Gateway keys are listed masked; copying one goes straight to the clipboard, never through the webview.
  • Never present an estimate as exact. Measured, estimated, and unpriced stay three separate figures. An invented precise number is more harmful than an honest "don't know".
  • Never delete a user's file or config without showing the diff first. Every destructive action goes through our own confirmation UI, never a browser confirm.

Building a release bundle

pnpm tauri build produces a self-contained bundle. The twcore inside it is downloaded from a ThinkWatch-Core release and checksum-verified — not copied out of a sibling checkout, because then "which build did we hand out" would be a question about somebody's afternoon.

Which release is decided by the tag that Cargo.lock resolved for tw-api, so the protocol mirror compiled into the app and the binary shipped beside it always come from one core commit. To move to a newer core: change the tag in src-tauri/Cargo.toml, cargo update -p tw-api, rebuild.

Every build needs that file, including pnpm tauri dev, cargo clippy and cargo test, because Tauri checks at compile time that the resources tauri.conf.json declares exist. In a fresh checkout, run bash src-tauri/scripts/fetch-core.sh once before the first build. pnpm tauri dev then runs the copy the build places beside the debug binary, and the control-plane tests start it and complete a handshake with it, so it has to be the version Cargo.lock pins.

On macOS the command produces the .app, which the release workflow puts in a disk image with dmgbuild (layout in src-tauri/dmg/); on Windows it produces an NSIS installer, which is not code-signed; on Linux it produces an AppImage. The macOS bundle is Apple silicon only, and neither signed nor notarized. On macOS 15 and later a downloaded copy has to be cleared once:

xattr -dr com.apple.quarantine "/Applications/ThinkWatch Lite.app"

That flag is set by whatever downloaded the file. An update fetched by the app itself never carries it, so this is a one-time step rather than one per release.

Releases

A release is an annotated tag v<version> on main, such as v2026.9.16. The version is written in package.json, src-tauri/tauri.conf.json and src-tauri/Cargo.toml (and in src-tauri/Cargo.lock); bash scripts/version.sh checks that the three agree, and release.yml refuses a tag that differs from them.

Pushing the tag runs release.yml. It builds the five installers, looks inside each one, signs them for the updater and publishes them in one step, together with their .sha256 files, latest.json and install.sh; if one platform fails, nothing is published. To try a change to release.yml, push the branch as rehearse/<name>: every job runs and the installers are kept as the run's artifacts, but nothing is published.

A release has two texts:

  • The tag message. Its first line is ThinkWatch Lite <version>; the rest becomes the notes of latest.json, the manifest for in-app updates. It is not shown on the release page.

  • The release page, titled ThinkWatch Lite <version> and written in English by scripts/release_notes.py, in this order:

    1. release-notes/<version>.md, when that file exists: a summary of the release in paragraphs or lists, without a top-level heading. Add it in the pull request that bumps the version, because the tag fixes what the tree contains.
    2. A table of the file for each platform, and the Homebrew and Linux install commands.
    3. How to verify a download against its .sha256.
    4. GitHub's list of the pull requests merged since the previous release.

    The text is written when the release is created. A release that already exists keeps its text, so a correction after publishing is made on the release page itself. CI runs python3 scripts/release_notes_test.py, which checks the script against release.yml and renders every file in release-notes/; a rehearsal shows the complete text in the summary of its run.

Product screenshots

The images in docs/screenshots/ — used by the READMEs and, through docs/screenshots/web/, by the website — are generated by a script rather than captured by hand, so they can be regenerated whenever the interface changes:

pnpm shots                          # every scene, both languages, light and dark
pnpm shots --only overview,keys     # just these scenes
pnpm shots --langs en --themes dark

This runs on macOS only, because the images show the macOS app: the pages are rendered by the system WKWebView, as in the app, and framed as a macOS window. It needs the Xcode command line tools (swiftc) and src-tauri/resources/twcore, which bash src-tauri/scripts/fetch-core.sh downloads, since the menu bar images are drawn by the app's own code.

How it works, in the order scripts/shots/shots.sh runs it:

  • The real interface on mocked IPC. scripts/shots/ is a separate Vite entry that mounts src/main.tsx with every Tauri command and control-plane endpoint answered from fixed data. It is never part of the app build, and src/index.css keeps scripts/ out of Tailwind's source scan, so the app bundle is byte-for-byte the same with or without it.
  • Deterministic. The clock is fixed at Friday 2026-09-25 16:42:07 local time and the traffic is generated from a fixed seed, so two runs produce the same pages. The menu bar images use the same clock: the page hands its fixed time to menubar_shots as --now.
  • Core's own answers. The configuration views (upstreams, keys, routes, security rules, prices, dry runs) are what the pinned core returns for scripts/shots/core/config.{en,zh}.yaml, stored as JSON in scripts/shots/core/{en,zh}/. After changing those files or the core tag in src-tauri/Cargo.toml, regenerate them with bash scripts/shots/core/oracle.sh <path to a ThinkWatch-Core checkout>. Sentences that core or the Rust side produce carry their real message codes and English text, so the Chinese interface translates them through src/i18n/core.zh.json exactly as it does in the app.
  • The mock is type-checked with the app. pnpm typecheck, and so CI, also checks scripts/shots/. Every Tauri command and control-plane endpoint handler returns the type the interface reads (from src/generated/ or the page's own API module), and the stored core answers are checked against the core types, so a protocol change fails the check rather than producing pages with missing data. When a newer core tag changes those types, run oracle.sh again. At capture time, a command the mock does not answer, or one called without the arguments it expects, stops the run.
  • Scenes are listed in scripts/shots/scenes.ts: which page, local or remote core, and what to open before the picture is taken. scripts/shots/capture.swift loads each one in an off-screen WKWebView, waits until the page reports it is ready with no console errors and two snapshots in a row are identical, and frames it. A scene with a table that does not fit the window stops the run instead of being captured with its last columns cut off; such a scene can collapse the sidebar, as the MCP scene does. The sidebar is shown in its solid color: the translucent material the app uses on macOS is drawn by the window server and cannot be captured off-screen. src-tauri/examples/menubar_shots.rs draws the menu bar. scripts/shots/finish.mjs writes the results.

What it writes:

Files What they are
docs/screenshots/{zh,en}/<scene>-{light,dark}.png The window, 1100×690 points at 2×, in a macOS frame: 2360×1540 pixels, 256 colors
docs/screenshots/menubar-{light,dark}.png The menu bar item: the mark and today's tokens and cost
docs/screenshots/{zh,en}/menubar-menu-{light,dark}.png The menu it opens (standard items are drawn approximately)
docs/screenshots/web/ What the website uses: the dark variant as WebP at quality 85, -en for the English interface