Skip to content
Closed
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
86 changes: 62 additions & 24 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,30 +31,10 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Check the four version declarations agree
run: |
set -euo pipefail
pkg=$(jq -r .version package.json)
lock=$(jq -r .version package-lock.json)
lockpkg=$(jq -r '.packages."".version' package-lock.json)
cargo=$(sed -n 's/^version = "\(.*\)"/\1/p' src-tauri/Cargo.toml | head -1)
tauri=$(jq -r .version src-tauri/tauri.conf.json)

printf 'package.json : %s\n' "$pkg"
printf 'package-lock.json (top) : %s\n' "$lock"
printf 'package-lock.json (pkgs) : %s\n' "$lockpkg"
printf 'src-tauri/Cargo.toml : %s\n' "$cargo"
printf 'src-tauri/tauri.conf.json: %s\n' "$tauri"

fail=0
for v in "$lock" "$lockpkg" "$cargo" "$tauri"; do
[ "$v" = "$pkg" ] || fail=1
done
if [ "$fail" = "1" ]; then
echo "::error::version declarations disagree - see CLAUDE.md 'Version Bumping'"
exit 1
fi
echo "OK: all version declarations agree at $pkg"
# One script owns the list, so adding a packaging file cannot leave CI
# checking a stale subset of it.
- name: Check every version declaration agrees
run: ./scripts/bump-version.sh --check

# Runs exactly what husky's pre-commit hook runs. That hook only sees STAGED
# files, on machines that ran npm install - a convenience, not a gate.
Expand Down Expand Up @@ -301,6 +281,64 @@ jobs:
working-directory: src-tauri
run: cargo test --test hardware_profiles

# Everything above proves the code COMPILES and LINTS. Not one job has ever
# started the application. A Tauri binary can pass every check in this file and
# still die on launch with a dlopen panic for a library the .deb forgot to
# depend on, or come up showing an empty window because the frontend never
# mounted. The only assertion that catches that is installing the real package
# and looking at the real pixels.
#
# It runs in a container with no ThinkPad hardware, which is the point: it
# proves the app starts, renders its full UI, and degrades cleanly when
# /proc/acpi/ibm/fan is absent -- the environment a bug is most likely to hide
# in. See the header of the script for how it tells those states apart.
gui-launch:
needs: [rust, frontend]
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri

- name: Install Tauri build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \
librsvg2-dev patchelf build-essential file libssl-dev rpm

- run: npm ci

# Built here rather than downloaded, so a PR is tested before anything is
# ever published.
- name: Build the real packages
run: npm run tauri build

- name: List what was built
run: find src-tauri/target/release/bundle -type f \( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) -print

- name: Launch-test the packages in clean containers
run: scripts/test-gui-packages-docker.sh

# A red run should come with the actual picture of the broken window, not
# just whatever reached stdout before the container was discarded.
- name: Upload screenshots, OCR text and app logs
if: always()
uses: actions/upload-artifact@v4
with:
name: gui-launch-evidence
path: build/gui-test-out/
if-no-files-found: warn
retention-days: 14

# A full VitePress production build, not a link check. VitePress compiles every
# page as a Vue SFC, so a literal {{ }} in prose parses as an interpolation and
# fails the build - and only the production build catches it.
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,7 @@ dist-ssr

# VitePress
docs/.vitepress/dist
docs/.vitepress/cache
docs/.vitepress/cache

# GUI launch-test output (screenshots, OCR text, app logs)
build/
5 changes: 1 addition & 4 deletions docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ export default withMermaid(
nav: [
{ text: 'Guide', link: '/guide/getting-started' },
{ text: 'Development', link: '/development/architecture' },
{
text: 'Download',
link: 'https://github.com/vietanhdev/ThinkUtils/releases',
},
{ text: 'Download', link: '/download' },
],
sidebar: {
'/guide/': [
Expand Down
65 changes: 65 additions & 0 deletions docs/.vitepress/theme/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,68 @@
max-width: 100%;
height: auto;
}

/* --- Download page --------------------------------------------------------
Cards for the package downloads. Kept in the theme rather than inline in
download.md so the markdown stays readable and the styling can be reused. */

.dl-version {
margin: 0 0 1.5rem;
color: var(--vp-c-text-2);
font-size: 0.95rem;
}

.dl-grid {
display: grid;

/* auto-fit rather than a fixed column count, so three cards sit in a row on a
desktop and stack cleanly on a phone without a media query. */
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 0.85rem;
margin: 1.5rem 0;
}

.dl-card {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 1.1rem 1.25rem;
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
background: var(--vp-c-bg-soft);
text-decoration: none;
transition:
border-color 0.2s ease,
transform 0.2s ease;
}

.dl-card:hover {
border-color: var(--vp-c-brand-1);
transform: translateY(-2px);
}

/* Keyboard users get the same affordance as the hover state. */
.dl-card:focus-visible {
outline: 2px solid var(--vp-c-brand-1);
outline-offset: 2px;
}

.dl-card-title {
font-weight: 600;
color: var(--vp-c-text-1);
}

.dl-card-sub {
font-size: 0.85rem;
color: var(--vp-c-text-2);
}

@media (prefers-reduced-motion: reduce) {
.dl-card {
transition: none;
}

.dl-card:hover {
transform: none;
}
}
175 changes: 175 additions & 0 deletions docs/download.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
---
title: Download
description: Download ThinkUtils for Linux — .deb, .rpm and AppImage for ThinkPad laptops.
---

<script setup>
import { ref, onMounted, computed } from "vue";

const REPO = "vietanhdev/ThinkUtils";
const RELEASES_URL = `https://github.com/${REPO}/releases`;

const release = ref(null);
const failed = ref(false);

// Fetched at view time rather than baked in at build time: the docs site and the
// release pipeline deploy independently, so a hard-coded version here would go
// stale the moment a release ships without a docs rebuild. If GitHub is
// unreachable or rate-limits the request (60/hour per IP unauthenticated),
// `failed` flips and every button falls back to the releases page, which always
// works.
onMounted(async () => {
try {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`);
if (!res.ok) throw new Error(String(res.status));
release.value = await res.json();
} catch {
failed.value = true;
}
});

const version = computed(() => release.value?.tag_name ?? null);

// Matched by predicate rather than exact filename, so the page survives a version
// bump — the version is embedded in every asset name — without an edit here.
function find(pred) {
return release.value?.assets?.find((a) => pred(a.name)) ?? null;
}

// Each predicate pins the architecture suffix. ThinkUtils ships x86_64 only
// today, but `find` returns the FIRST match, so a loose predicate would silently
// hand out the wrong package the moment a second architecture is added. The
// failure would be user-side and quiet: the page looks right, the download works,
// and the package refuses to install.
const is = {
deb: (n) => n.endsWith("_amd64.deb"),
rpm: (n) => n.endsWith(".x86_64.rpm"),
appimage: (n) => n.endsWith("_amd64.AppImage"),
};

// Always yields a working link: the direct asset once a release exists, the
// releases page otherwise (no release cut yet, or the API call failed).
function url(pred) {
return find(pred)?.browser_download_url ?? RELEASES_URL;
}
function size(pred) {
const a = find(pred);
return a ? `${(a.size / 1024 / 1024).toFixed(1)} MB` : "";
}
</script>

# Download ThinkUtils

<p class="dl-version">
<template v-if="version">
Latest release: <strong>{{ version }}</strong> · <a :href="RELEASES_URL">all releases</a>
</template>
<template v-else-if="failed">
<a :href="RELEASES_URL">View all releases on GitHub →</a>
</template>
<template v-else>Looking up the latest release…</template>
</p>

For **Lenovo ThinkPad** laptops running Linux on **x86_64**. Built on Ubuntu 22.04
(glibc 2.35), so it runs on **Ubuntu 22.04+, Debian 12+ and Fedora 36+**.

Every release is installed into a clean container and launched under a virtual
display before it ships — on Ubuntu 22.04 and 24.04, Debian 12, and Fedora 41 —
with a screenshot checked by OCR to confirm the interface actually rendered.

<div class="dl-grid">
<a class="dl-card" :href="url(is.deb)">
<span class="dl-card-title">Debian / Ubuntu</span>
<span class="dl-card-sub">.deb<template v-if="size(is.deb)"> · {{ size(is.deb) }}</template></span>
</a>
<a class="dl-card" :href="url(is.rpm)">
<span class="dl-card-title">Fedora / RHEL</span>
<span class="dl-card-sub">.rpm<template v-if="size(is.rpm)"> · {{ size(is.rpm) }}</template></span>
</a>
<a class="dl-card" :href="url(is.appimage)">
<span class="dl-card-title">Any distro</span>
<span class="dl-card-sub">AppImage<template v-if="size(is.appimage)"> · {{ size(is.appimage) }}</template></span>
</a>
</div>

```bash
# Debian / Ubuntu
sudo apt install ./thinkutils_*_amd64.deb

# Fedora / RHEL
sudo dnf install ./thinkutils-*.x86_64.rpm

# AppImage — portable, nothing to install
chmod +x thinkutils_*_amd64.AppImage
./thinkutils_*_amd64.AppImage
```

::: tip Use `apt install ./file.deb`, not `dpkg -i`
`apt` pulls in the WebKit and GTK libraries ThinkUtils needs. `dpkg -i` does not,
and leaves you resolving them by hand.
:::

## Ubuntu APT repository

For automatic updates through `apt`:

```bash
echo "deb [trusted=yes] https://gh.vietanh.dev/ThinkUtils/apt ./" \
| sudo tee /etc/apt/sources.list.d/thinkutils.list
sudo apt update
sudo apt install thinkutils
```

## Before fan control works

One step is not optional, and it is the most common reason people think the app
is broken. The `thinkpad_acpi` kernel module **refuses every fan change** unless
it was loaded with `fan_control=1`:

```bash
echo 'options thinkpad_acpi fan_control=1' \
| sudo tee /etc/modprobe.d/thinkpad_acpi.conf
sudo modprobe -r thinkpad_acpi && sudo modprobe thinkpad_acpi
```

The app detects this and offers to do it for you on the Fan Control page. It is
worth knowing why granting permissions alone cannot fix it: the setting is a
kernel module parameter, fixed at load time, so no amount of privilege changes it
while the module is running.

Reboot if the reload fails — the module is often held open by something else.

::: warning Ubuntu 22.04 will still ask for your password
Ubuntu 22.04 ships polkit 0.105, which Debian and Ubuntu patched to ignore
JavaScript rule files. That is the mechanism ThinkUtils uses to grant passwordless
fan control, so on 22.04 every fan change prompts for a password. Everything
works; it is just not silent. Upgrading the distribution is the only fix.
:::

## Which ThinkPads are supported

Fan control needs the `thinkpad_acpi` kernel module, which covers most ThinkPads
from the X, T, P and L series. To check before installing:

```bash
ls /proc/acpi/ibm/fan && echo "supported"
```

Dual-fan machines — P1, P15, X1 Extreme and similar — are supported, and both
fans are reported. The firmware drives them together, so they cannot be set to
different speeds; that is a hardware limitation, not an app one.

Battery thresholds, CPU governor and system monitoring work on any Linux laptop.
Only fan control is ThinkPad-specific.

## Building from source

```bash
git clone https://github.com/vietanhdev/ThinkUtils.git
cd ThinkUtils
npm install
npm run tauri build
```

Packages land in `src-tauri/target/release/bundle/`. See the
[development guide](/development/architecture) for the toolchain you will need.
Loading
Loading