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
Binary file modified public/lite/clients-en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/lite/clients.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/lite/dry-run-en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/lite/dry-run.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/lite/findings-en.webp
Binary file not shown.
Binary file removed public/lite/findings.webp
Binary file not shown.
Binary file added public/lite/keys-en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/lite/keys.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/lite/mcp-en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/lite/mcp.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/lite/menubar-cost.png
Binary file not shown.
Binary file added public/lite/menubar-menu-en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/lite/menubar-menu.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/lite/menubar-quota.png
Binary file not shown.
Binary file added public/lite/menubar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/lite/overview-en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/lite/overview.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/lite/remote-add-en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/lite/remote-add.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/lite/remote-clients-en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/lite/remote-clients.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/lite/remote-switcher-en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/lite/remote-switcher.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/lite/requests-en.webp
Binary file not shown.
Binary file removed public/lite/requests.webp
Binary file not shown.
Binary file added public/lite/routing-en.webp
Binary file added public/lite/routing.webp
Binary file added public/lite/security-en.webp
Binary file added public/lite/security.webp
Binary file added public/lite/settings-en.webp
Binary file added public/lite/settings.webp
Binary file added public/lite/traffic-en.webp
Binary file added public/lite/traffic.webp
Binary file modified public/lite/upstreams-en.webp
Binary file modified public/lite/upstreams.webp
4 changes: 2 additions & 2 deletions src/components/LiteDownload.astro
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const look =
<!--
Which file. Windows: ARM64 when the browser says arm with 64-bit, x64 for every
other answer, a failure or a browser without Client Hints. macOS: the one
Apple Silicon build, unless Client Hints say x86, which means an Intel Mac,
Apple silicon build, unless Client Hints say x86, which means an Intel Mac,
for which there is no build. An iPad also reports "Macintosh"; touch points
tell it apart. Linux: the aarch64 AppImage when Client Hints say arm with
64-bit (or, without them, the User-Agent says aarch64), x86_64 otherwise;
Expand Down Expand Up @@ -97,7 +97,7 @@ const look =
} else if (linux) {
set(el, el.dataset[arch], el.dataset.labelLinux, arch);
} else if (arch !== "x86") {
set(el, el.dataset.mac, el.dataset.labelMac, "Apple Silicon");
set(el, el.dataset.mac, el.dataset.labelMac, "Apple silicon");
}
}
};
Expand Down
93 changes: 79 additions & 14 deletions src/components/pages/LitePage.astro
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
---
// The /lite product page: ThinkWatch Lite, the desktop app for individual
// developers on macOS, Windows and Linux. Status copy must stay in line with the Lite
// README.
// README; how the copy is checked is described in src/i18n/pages/lite.ts.
//
// Each page shows the interface in its own language: the app ships in both,
// and a screenshot of the other one says little to the reader.
// and a screenshot of the other one says little to the reader. The images in
// public/lite come from the Lite repository's docs/screenshots/web: window
// shots are 2360×1540, the menu 664×1120, the menu bar item 178×64, all at
// twice the size they are shown at.
import LiteDownload from "~/components/LiteDownload.astro";
import Base from "~/layouts/Base.astro";
import SiteHeader from "~/components/SiteHeader.astro";
Expand Down Expand Up @@ -52,6 +55,9 @@ const dmg = { url: assetUrl("-arm64.dmg") ?? latestPage, sha: assetUrl("-arm64.d
const liteVersion = liteRelease?.tag.replace(/^v/, "") ?? null;
const installDocsHref = localePath(lang, "/docs/lite/install");
const sourceDocsHref = localePath(lang, "/docs/lite/run-from-source");
const remoteDocsHref = localePath(lang, "/docs/lite/remote-core");
/** Installing and running twcore on a Linux server, in the Core docs */
const serverDocsHref = localePath(lang, "/docs/core/server-deployment");
const jsonLd = await liteLd(lang);

const delay = (i: number) => `animation-delay: ${i * 90}ms;`;
Expand Down Expand Up @@ -79,14 +85,15 @@ const archLabel = { x64: "x64", arm64: "ARM64" } as const;
</p>
<h1
class:list={[
"reveal font-display m-0 text-[var(--color-text)]",
"reveal font-display m-0 text-[var(--color-text)] [text-wrap:balance]",
zh
? "text-[30px] leading-[1.35] sm:text-[40px] lg:text-[48px]"
: "text-[36px] leading-[1.1] sm:text-[46px] lg:text-[56px] lg:leading-[1.05]",
]}
style={delay(1)}
>
{c.hero.titleA}<span class="text-shimmer">{c.hero.titleHighlight}</span>
{/* Chinese breaks between any two characters: keep the highlighted noun on one line. */}
{c.hero.titleA}<span class:list={["text-shimmer", zh && "whitespace-nowrap"]}>{c.hero.titleHighlight}</span>
</h1>
<p class="reveal m-0 max-w-[36rem] text-[17px] leading-relaxed text-[#cbd5e1] sm:text-xl sm:leading-normal" style={delay(2)}>
{c.hero.sub}
Expand Down Expand Up @@ -152,29 +159,80 @@ const archLabel = { x64: "x64", arm64: "ARM64" } as const;
</div>
</section>

<section class="border-t border-[var(--color-border-strong)] py-16 sm:py-20 lg:py-24" aria-labelledby="lite-remote">
<div class="container-page flex flex-col gap-12 sm:gap-14">
<div class="grid items-center gap-8 lg:grid-cols-2 lg:gap-14">
<div class="flex min-w-0 flex-col gap-4">
<p class:list={[eyebrow, "m-0"]}>{c.remote.eyebrow}</p>
<h2 id="lite-remote" class={h2}>{c.remote.title}</h2>
{c.remote.body.map((p) => (
<p class="m-0 text-base leading-relaxed text-[var(--color-muted)] sm:text-[17px]">{p}</p>
))}
<div class="flex flex-wrap items-center gap-x-6 gap-y-2">
<a href={serverDocsHref} class={textLink}>{c.remote.serverDocs}</a>
<a href={remoteDocsHref} class={textLink}>{c.remote.docs}</a>
</div>
</div>
<img
src={shot("remote-add")}
width="2360"
height="1540"
loading="lazy"
decoding="async"
class={shotClass}
alt={c.remote.addAlt}
/>
</div>
<div class="grid gap-10 md:grid-cols-2 lg:gap-14">
{c.remote.figures.map((figure) => (
<figure class="m-0 flex min-w-0 flex-col gap-3">
<img
src={shot(figure.id)}
width="2360"
height="1540"
loading="lazy"
decoding="async"
class={shotClass}
alt={figure.alt}
/>
<figcaption class="text-[15px] leading-relaxed text-[var(--color-muted)] sm:text-base">{figure.caption}</figcaption>
</figure>
))}
</div>
</div>
</section>

<section class="pb-16 sm:pb-20 lg:pb-24" aria-labelledby="lite-menubar">
<div class="container-page grid items-center gap-8 rounded-2xl border border-[var(--color-border-strong)] p-6 sm:p-10 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-14">
<div class="flex flex-col gap-3">
<div class="flex min-w-0 flex-col gap-3">
<p class:list={[eyebrow, "m-0"]}>{c.menubar.eyebrow}</p>
<h2 id="lite-menubar" class={h2}>{c.menubar.title}</h2>
{/* The menu bar item at its real size: the bitmap is drawn at 2×, 89 × 32 points. */}
<img src="/lite/menubar.png" width="178" height="64" loading="lazy" class="my-2 h-auto w-[89px]" alt={c.menubar.chipAlt} />
<p class="m-0 text-base leading-relaxed text-[var(--color-muted)] sm:text-[17px]">{c.menubar.body}</p>
<p class="m-0 text-base leading-relaxed text-[var(--color-muted)] sm:text-[17px]">{c.menubar.notices}</p>
</div>
<div class="flex flex-wrap items-center gap-4">
<img src="/lite/menubar-cost.png" width="420" height="64" loading="lazy" class="h-auto w-[420px] max-w-full" alt={c.menubar.costAlt} />
<img src="/lite/menubar-quota.png" width="420" height="64" loading="lazy" class="h-auto w-[420px] max-w-full" alt={c.menubar.quotaAlt} />
</div>
<img
src={shot("menubar-menu")}
width="664"
height="1120"
loading="lazy"
decoding="async"
class="mx-auto h-auto w-[332px] max-w-full"
alt={c.menubar.menuAlt}
/>
</div>
</section>

<section class="bg-[var(--color-surface)] py-16 sm:py-20 lg:py-[88px]" aria-labelledby="lite-built">
<div class="container-page">
<h2 id="lite-built" class:list={[eyebrow, "m-0 font-normal"]}>{c.built.eyebrow}</h2>
<div class="mt-8 grid items-stretch lg:mt-10 lg:grid-cols-[minmax(0,1fr)_120px_minmax(0,1fr)_120px_minmax(0,1fr)] lg:items-center">
<div class="mt-8 grid items-stretch lg:mt-10 lg:grid-cols-[minmax(0,1fr)_216px_minmax(0,1fr)_96px_minmax(0,1fr)] lg:items-center">
{nodes.map((node, i) => (
<Fragment>
{i > 0 && (
<div
class="flow-link relative flex items-center justify-center py-2 lg:py-0"
class="flow-link relative flex flex-col items-center justify-center gap-3 py-3 lg:py-0"
data-active={i === 1 ? "true" : undefined}
aria-hidden={i === 1 ? undefined : "true"}
>
Expand All @@ -186,10 +244,17 @@ const archLabel = { x64: "x64", arm64: "ARM64" } as const;
>
{i === 1 && <span class="flow-dot" aria-hidden="true"></span>}
</div>
{/* The channel between the app and core: under the line when the nodes sit
side by side, below the vertical line when they are stacked. */}
{i === 1 && (
<span class="absolute left-[calc(50%+14px)] font-mono text-xs text-[var(--color-muted)] lg:left-auto lg:top-[calc(50%+8px)]">
{c.built.link}
</span>
<div class="text-center font-mono text-xs leading-relaxed text-[var(--color-muted)] lg:absolute lg:inset-x-2 lg:top-[calc(50%+12px)]">
<p class="m-0 text-[var(--color-text)]">{c.built.link.title}</p>
<ul class="m-0 list-none p-0">
{c.built.link.lines.map((line) => (
<li>{line}</li>
))}
</ul>
</div>
)}
</div>
)}
Expand Down
22 changes: 19 additions & 3 deletions src/content/docs-lite/en/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,34 @@ ThinkWatch Lite is an interface to the gateway, not the gateway itself.

```
src/ React 19 + Tailwind 4 frontend
src-tauri/ Tauri 2 shell: supervises core, renders the menu bar
src-tauri/ Tauri 2 shell: supervises core, renders the menu bar and the tray
src-tauri/crates/ client setup (tw-adopt) and the configuration scan (tw-scan)
```

- **`src-tauri/`** is the Tauri 2 shell. It supervises ThinkWatch Core and renders the menu bar on macOS, the notification-area icon and menu on Windows, and the tray icon and menu on Linux. On macOS, the supervisor integrates with launchd.
- **`src-tauri/`** is the Tauri 2 shell. It starts ThinkWatch Core and supervises it: a core that exits is restarted, and after repeated failed starts it runs in a safe mode in which only its control plane is up, so that configuration, history and rollback stay available. The shell renders the menu bar on macOS, the notification-area icon and menu on Windows, and the tray icon and menu on Linux, and it delivers system notifications. Launch at login starts the app, which then starts core; core is not registered on its own.
- **`src-tauri/crates/`** holds `tw-adopt`, which points clients at the gateway and restores them, and `tw-scan`, which scans client configuration for the MCP page. Both act on the computer the app runs on, also while the app is connected to a core on a server.
- **`src/`** is the frontend, written in React 19 and Tailwind 4.

## Lite and Core

The gateway is implemented in [ThinkWatch Core](/docs/core). This repository contains no routing, forwarding, or accounting logic. Lite communicates with Core over a unix socket on macOS and Linux, and over a loopback port on Windows; both carry a per-launch credential.
The gateway is implemented in [ThinkWatch Core](/docs/core). This repository contains no routing, forwarding, or accounting logic.

Routing, forwarding, cost accounting, and redaction are the responsibility of Core. Changes to behaviour on the data path belong in the [ThinkWatch Core repository](https://github.com/ThinkWatchProject/ThinkWatch-Core).

## Control channel

Lite reads and changes everything through Core's control API. How it reaches Core depends on where Core runs:

| Where Core runs | Channel |
|---|---|
| The same computer, on macOS or Linux | A unix socket in the data directory |
| The same computer, on Windows | A loopback TCP port |
| A server | The server's remote control port, over TCP |

Every channel carries the same HTTP API inside a Noise handshake, `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`, whose pre-shared key is `listen.control.key` in Core's `config.yaml`. The handshake encrypts and authenticates the connection in both directions; a program that can reach the socket or the port but does not hold the key cannot control Core. There is no TLS and no certificate. Versions are exchanged in the handshake, and a connection between an app and a core whose versions do not match is refused.

For the core it starts, the app reads the key from the local `config.yaml`. For a core on a server, the key is entered once when the connection is added and kept in a file in the app's data directory that only the current user can read. See [Connecting to a remote core](/docs/lite/remote-core).

## What the UI must not do

The following rules are mandatory. A change that violates any of them will be returned for revision:
Expand Down
2 changes: 1 addition & 1 deletion src/content/docs-lite/en/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ gh pr create --base dev --head your-branch
The following decisions are settled and are not open to change through a pull request:

- **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 pull request that adds the notarization step without the account behind it cannot be merged, and neither can one that makes the build fall back to whatever architecture the machine happens to be — that ships a file some users can download and cannot open.
- **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 pull request that adds the notarization step without the account behind it cannot be merged, and neither can one that makes the build fall back to whatever architecture the machine happens to be — that 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.

Expand Down
9 changes: 5 additions & 4 deletions src/content/docs-lite/en/install.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Install and update

ThinkWatch Lite runs on macOS 12 or later on Apple Silicon, on Windows 10 21H2 or later on x64 or ARM64, and on Linux on x86_64 or aarch64. The gateway, ThinkWatch Core, ships inside the app; nothing else needs to be installed.
ThinkWatch Lite runs on macOS 12 or later on Apple silicon, on Windows 10 21H2 or later on x64 or ARM64, and on Linux on x86_64 or aarch64. The gateway, ThinkWatch Core, ships inside the app; nothing else needs to be installed.

## macOS: Homebrew

Expand Down Expand Up @@ -63,17 +63,17 @@ The tray icon relies on AppIndicator. Ubuntu ships the GNOME extension for it; F

## Updates

The app looks for a new version shortly after it starts and once a day after that, reading a small manifest and nothing else. It can be turned off in Settings.
The app looks for a new version two minutes after it starts and once a day after that, reading a small manifest and nothing else. The automatic check can be turned off in Settings › About.

When there is one, a small window says so, and what happens next depends on how the app was installed.
When the automatic check finds a new version, the app posts a system notification, unless **Notices** in Settings › General is set to **In app only** or **Off**. The notification, the **Install Version** item in the menu bar or tray menu, and **Update to** in Settings › About open the update window; **Check for updates**, in the same menu or in Settings › About, opens it at once when there is a new version. What happens next depends on how the app was installed.

**Downloaded from the releases page on macOS:** one press on the install button does the rest. The app downloads the update, verifies it against a key compiled into itself, waits for the requests the gateway is serving to finish — up to three minutes — then replaces itself and restarts. A task in the middle of a response is not cut off to make room for the update.

**On Windows:** the same single press. The app downloads the new installer, verifies it against the key compiled into itself, waits for the requests in flight to finish in the same way, then runs the installer, and the new version starts once it is done. The app is installed for all users, so Windows asks for administrator permission at every update; declining leaves the current version running.

**On Linux:** the same single press, and no password is asked for. The app downloads the new AppImage, verifies it against the key compiled into itself, waits for the requests in flight to finish, then replaces its own file and restarts. The AppImage has to be in a folder the user can write to.

**Installed with Homebrew:** the window gives the command to copy, and the app never replaces itself. Homebrew records which version it put in `/Applications`; an app that overwrote it would be written back over by the next `brew upgrade`. The window only appears once the tap carries the new version, so the command always has something to install:
**Installed with Homebrew:** the window gives the command to copy, and the app never replaces itself. Homebrew records which version it put in `/Applications`; an app that overwrote it would be written back over by the next `brew upgrade`. A Homebrew installation is offered a new version only once the tap carries it, so the command always has something to install:

```bash
brew update && brew upgrade --cask thinkwatch-lite
Expand All @@ -85,4 +85,5 @@ brew update && brew upgrade --cask thinkwatch-lite

- [Overview](/docs/lite): what the app shows.
- [Build from source](/docs/lite/run-from-source): run a development build.
- [Connecting to a remote core](/docs/lite/remote-core): use a gateway that runs on a server.
- [Architecture](/docs/lite/architecture): the structure of the app and how it communicates with Core.
Loading
Loading