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
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ jobs:
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security find-identity -v -p codesigning

- name: Build and release Tauri app
- name: Build, verify, and release Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand All @@ -105,6 +105,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tauriScript: ./scripts/tauri-release-build.sh
tagName: ${{ github.ref_name }}
releaseName: "FanGuard ${{ github.ref_name }}"
releaseBody: "See the assets to download and install this version."
Expand Down
25 changes: 25 additions & 0 deletions design-qa.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Design QA - FanGuard Liquid Glass Dashboard

final result: passed

## Source

- Reference image: `/var/folders/39/sdym6xy92x90bv9qlv4nxg_r0000gn/T/codex-clipboard-2bf006d9-d2ad-4610-b81e-810e1912c498.png`
- Prototype screenshot: `/Users/naufaldi.satriya/WebApps/mac-fan-ctrl/test-results/fanguard-liquid-glass-dashboard.png`
- Viewport: `1536 x 1024`

## Passed Checks

- Single rounded macOS window shell with traffic lights.
- Toolbar title, preset pop-up, search field, and overflow menu align with the reference rhythm.
- No sidebar, no back navigation, and no unrelated toolbar icons.
- Fan Control section uses the reference structure: Mode segmented control, target RPM slider, current average, Apply, Restore Auto, and helper copy.
- Slider now uses a macOS-style white thumb and blue active track.
- Grouped table uses native dense rows, category icons, disclosure arrows, column separators, zebra rows, and the same telemetry groups.
- Table shows all reference rows including both disk drives.
- Bottom status bar matches the reference content and placement.

## Notes

- The implementation keeps the real computed current average RPM. With visible fan rows `2,532`, `2,200`, and `2,000`, the real average is `2,244 RPM`; the reference image labels `2,532 RPM` as the current average.
- Figma live inspection was attempted but unavailable in this run: the Figma app connector timed out and `figma_mcp_go` returned `plugin not connected`. The implementation uses the previously captured macOS 27 variables in `docs/design.md` plus the provided image as the visual source.
14 changes: 12 additions & 2 deletions docs/code-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,19 @@ export APPLE_PASSWORD="app-specific-password"
export APPLE_TEAM_ID="YOUR_TEAM_ID"
```

### Manual Notarization
### Local Notarized Build

After building with `pnpm tauri:build:direct`:
Store the notarization variables in `~/.config/fanguard/notarization.env`, then run:

```bash
./scripts/build-local-notarized.sh
```

The wrapper builds the direct-distribution app, submits the final DMG, staples it,
mounts it, and verifies the app, helper, Gatekeeper assessment, and stapler ticket.
It rejects ambiguous output directories containing more than one DMG.

For manual troubleshooting after `pnpm tauri:build:direct`:

```bash
# Submit for notarization
Expand Down
556 changes: 394 additions & 162 deletions docs/design.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions docs/learning/dev-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,3 +415,17 @@ cargo build --profile=release-with-debug
- [Rust Basics](./rust-basics.md) - Learn Rust fundamentals
- [Tauri Architecture](./tauri-architecture.md) - Understand the stack
- [Testing Strategy](./testing.md) - Deep dive into testing
# Troubleshooting the Tauri development port

FanGuard reserves `http://localhost:5173` for both Vite and Tauri. Vite uses
`strictPort`, so development startup stops instead of silently moving to a port
that Tauri is not configured to load.

If startup reports that port 5173 is already in use, identify the owning process:

```bash
lsof -nP -iTCP:5173 -sTCP:LISTEN
```

Stop that development server, then run `pnpm run tauri dev` again. Do not change
only the Vite port because `src-tauri/tauri.conf.json` must use the same URL.
4 changes: 2 additions & 2 deletions docs/release-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,15 @@ git push origin v0.1.0-beta.1
The `release.yml` workflow triggers on any `v*` tag push. It:
- Builds a universal macOS binary (Intel + Apple Silicon)
- Imports the Developer ID certificate into a temporary CI keychain
- Signs, notarizes, and staples the macOS app bundle
- Signs, notarizes, staples, mounts, and verifies the final DMG through `scripts/tauri-release-build.sh`
- Creates a draft GitHub Release with the `.dmg` attached
- Generates `latest.json` for the auto-updater
- Marks it as a prerelease (for beta tags)

Local direct-distribution builds that need the helper should use:

```bash
pnpm tauri:build:direct
./scripts/build-local-notarized.sh
```

### 4. Publish the draft release
Expand Down
Binary file added docs/screenshots/fanguard-native-960-fixed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
85 changes: 85 additions & 0 deletions e2e/dashboard-layout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { expect, test } from "@playwright/test";

test.describe("FanGuard dashboard Liquid Glass layout", () => {
test("fits the configured 960 by 540 Tauri viewport without clipping controls", async ({
page,
}) => {
// Tauri's configured 960x540 window leaves roughly 486 CSS pixels for
// the webview after the native macOS title bar.
await page.setViewportSize({ width: 960, height: 486 });
await page.goto("/");

const restoreButton = page.getByRole("button", { name: "Restore Auto" });
const applyButton = page.getByRole("button", { name: "Apply" });
const targetSlider = page.getByRole("slider", { name: "Target RPM" });
const telemetryHeader = page.getByText("Name", { exact: true });
const restoreBounds = await restoreButton.boundingBox();
const applyBounds = await applyButton.boundingBox();
const sliderBounds = await targetSlider.boundingBox();

expect(restoreBounds).not.toBeNull();
expect((restoreBounds?.x ?? 960) + (restoreBounds?.width ?? 1)).toBeLessThanOrEqual(960);
await expect(restoreButton).toBeInViewport();
await expect(telemetryHeader).toBeInViewport();
await expect(page.getByText("System Fan 1", { exact: true })).toBeInViewport();
expect((sliderBounds?.x ?? 0) + (sliderBounds?.width ?? 960)).toBeLessThanOrEqual(
applyBounds?.x ?? 0,
);
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBeLessThanOrEqual(960);
});

test("renders a single-window dashboard without sidebar or fake navigation", async ({
page,
}) => {
await page.goto("/");

await expect(page.getByRole("heading", { name: "FanGuard" })).toBeVisible();
await expect(page.getByText("Fan Control", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "More actions" })).toBeVisible();
await expect(page.getByRole("searchbox", { name: "Search sensors" })).toBeVisible();

await expect(page.getByRole("button", { name: /^Fans\b/ })).toBeVisible();
await expect(
page.getByRole("button", { name: /^Temperature Sensors/ }),
).toBeVisible();
await expect(page.getByRole("button", { name: /^Disk Drives/ })).toBeVisible();

await expect(page.getByRole("button", { name: /Back/i })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Forward/i })).toHaveCount(0);
await expect(page.getByRole("button", { name: /to custom mode$/i })).toHaveCount(0);
await expect(page.getByRole("dialog")).toHaveCount(0);

await expect(page.getByText("SMC Connected")).toBeVisible();
await expect(page.getByText("MacBookPro18,3")).toBeVisible();
});

test("collapses and expands a disclosure group", async ({ page }) => {
await page.goto("/");

const temperatureGroup = page.getByRole("button", {
name: /^Temperature Sensors/,
});
await expect(temperatureGroup).toHaveAttribute("aria-expanded", "true");
await expect(page.getByText("Battery", { exact: true })).toBeVisible();

await temperatureGroup.click();
await expect(temperatureGroup).toHaveAttribute("aria-expanded", "false");
await expect(
page.getByText("Battery", { exact: true }),
).not.toBeVisible();
});

test("filters visible telemetry rows without changing global control", async ({
page,
}) => {
await page.goto("/");

await page.getByRole("searchbox", { name: "Search sensors" }).fill("gpu");

await expect(page.getByText("Fan Control", { exact: true })).toBeVisible();
await expect(page.getByText("GPU Cluster (AGX)")).toBeVisible();
await expect(page.getByText("Battery", { exact: true })).not.toBeVisible();
});
});
72 changes: 36 additions & 36 deletions e2e/fan-control.spec.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,42 @@
import { expect, test } from "@playwright/test";

test("fan control auto to custom rpm to auto flow", async ({ page }) => {
await page.goto("/");

await expect(page.getByRole("button", { name: "Set Fan 0 to auto mode" })).toBeVisible({
timeout: 15_000,
test.describe("FanGuard global fan control", () => {
test("applies one manual RPM target to all detected fans", async ({ page }) => {
await page.goto("/");

await expect(page.getByText("1200", { exact: true })).toBeVisible();
await expect(page.getByText("6550", { exact: true })).toBeVisible();
await expect(page.getByText(/Current average: [\d,]+ RPM/)).toBeVisible();
await page.getByRole("button", { name: "Manual" }).click();
await page.getByLabel("Target RPM").fill("5000");
await expect(page.getByText("5000 RPM", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Apply" }).click();

await expect(page.getByRole("alert")).toHaveCount(0);
await expect(page.getByText("Applied 5000 RPM to 3 fans.")).toBeVisible();
await expect(page.getByText("System Fan 1")).toBeVisible();
await expect(page.getByText("System Fan 2")).toBeVisible();
await expect(page.getByText("System Fan 3")).toBeVisible();
await expect(page.getByText("Synced")).toHaveCount(3);
});

const autoButton = page.getByRole("button", { name: "Set Fan 0 to auto mode" });
const customButton = page.getByRole("button", { name: "Set Fan 0 to custom mode" });

await expect(autoButton).toHaveClass(/control-active-text/);
await expect(customButton).not.toHaveClass(/control-active-text/);

await customButton.click();
await expect(page.getByRole("dialog")).toBeVisible();

const rpmInput = page.getByRole("spinbutton", {
name: /Target RPM for Fan 0/i,
test("restores automatic control for all detected fans", async ({ page }) => {
await page.goto("/");

await page.getByRole("button", { name: "Manual" }).click();
await page.getByLabel("Target RPM").fill("5000");
await page.getByRole("button", { name: "Apply" }).click();
await page.getByRole("button", { name: "Restore Auto" }).click();

await expect(page.getByText("Restored automatic control for 3 fans.")).toBeVisible();
await expect(
page.locator('[role="row"]').filter({ hasText: "System Fan 1" }).getByText("Auto", { exact: true }),
).toBeVisible();
await expect(
page.locator('[role="row"]').filter({ hasText: "System Fan 2" }).getByText("Auto", { exact: true }),
).toBeVisible();
await expect(
page.locator('[role="row"]').filter({ hasText: "System Fan 3" }).getByText("Auto", { exact: true }),
).toBeVisible();
});
await rpmInput.fill("5000");
await page.getByRole("button", { name: "OK" }).click();

await expect(page.getByRole("button", { name: "Set Fan 0 to custom mode" })).toHaveClass(/control-active-text/, {
timeout: 10_000,
});
await expect(page.getByRole("button", { name: "Set Fan 0 to custom mode" })).toContainText(
"Constant value of 5000",
);
await expect(page.getByRole("button", { name: "Set Fan 0 to auto mode" })).not.toHaveClass(/control-active-text/);

await page.getByRole("button", { name: "Set Fan 0 to auto mode" }).click();
await expect(page.getByRole("button", { name: "Set Fan 0 to custom mode" })).toHaveText(
"Custom...",
{ timeout: 10_000 },
);
await expect(page.getByRole("button", { name: "Set Fan 0 to auto mode" })).toHaveClass(/control-active-text/);
await expect(page.getByRole("button", { name: "Set Fan 0 to custom mode" })).not.toHaveClass(
/control-active-text/,
);
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fanguard",
"version": "0.1.0-beta.4",
"version": "0.1.0-beta.5",
"private": true,
"description": "FanGuard — macOS fan control application built with Tauri v2 and Svelte 5",
"author": "naufaldi",
Expand Down
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default defineConfig({
trace: "off",
},
webServer: {
command: "VITE_E2E_MOCK=true pnpm dev",
command: "VITE_E2E_MOCK=true ./node_modules/.bin/vite --port 1420 --strictPort",
url: "http://localhost:1420",
timeout: 120_000,
reuseExistingServer: !process.env.CI,
Expand Down
21 changes: 19 additions & 2 deletions scripts/build-local-notarized.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail

source "$HOME/.config/fanguard/notarization.env"
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CREDENTIALS_FILE="${FANGUARD_NOTARIZATION_ENV:-$HOME/.config/fanguard/notarization.env}"
DMG_DIR="${FANGUARD_DMG_DIR:-$PROJECT_ROOT/src-tauri/target/release/bundle/dmg}"

pnpm tauri build
if [[ ! -f "$CREDENTIALS_FILE" ]]; then
printf 'Missing notarization credentials: %s\n' "$CREDENTIALS_FILE" >&2
exit 1
fi

# shellcheck source=/dev/null
set -a
source "$CREDENTIALS_FILE"
set +a

if [[ "${FANGUARD_SKIP_BUILD:-0}" != "1" ]]; then
cd "$PROJECT_ROOT"
pnpm tauri build
fi

FANGUARD_DMG_DIR="$DMG_DIR" "$PROJECT_ROOT/scripts/notarize-dmg.sh"
73 changes: 73 additions & 0 deletions scripts/notarize-dmg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail

PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DMG_DIR="${FANGUARD_DMG_DIR:-$PROJECT_ROOT/src-tauri/target/release/bundle/dmg}"
MOUNT_DIR=""

require_env() {
local name="$1"
[[ -n "${!name:-}" ]] || { printf 'Missing required environment variable: %s\n' "$name" >&2; exit 1; }
}

find_only() {
local label="$1"
local directory="$2"
local paths=()
shopt -s nullglob
case "$label" in
DMG) paths=("$directory"/*.dmg) ;;
app) paths=("$directory"/*.app) ;;
*) printf 'Unsupported artifact label: %s\n' "$label" >&2; exit 1 ;;
esac
shopt -u nullglob
[[ "${#paths[@]}" -eq 1 ]] || { printf 'Expected exactly one %s in %s, found %s\n' "$label" "$directory" "${#paths[@]}" >&2; exit 1; }
printf '%s\n' "${paths[0]}"
}

cleanup() {
[[ -z "$MOUNT_DIR" ]] || hdiutil detach "$MOUNT_DIR" >/dev/null 2>&1 || true
[[ -z "$MOUNT_DIR" ]] || rmdir "$MOUNT_DIR" 2>/dev/null || true
}

verify_app() {
local app_path="$1"
local helper_path="$app_path/Contents/MacOS/fanguard-helper"
[[ -f "$helper_path" ]] || { printf 'Missing bundled helper: %s\n' "$helper_path" >&2; return 1; }
codesign --verify --deep --strict --verbose=4 "$app_path"
codesign --verify --strict --verbose=4 "$helper_path"
spctl --assess --type execute --verbose=4 "$app_path"
xcrun stapler validate -v "$app_path"
}

verify_dmg() {
local dmg_path="$1"
codesign --verify --strict --verbose=4 "$dmg_path"
spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg_path"
xcrun stapler validate -v "$dmg_path"
}

require_env APPLE_ID
require_env APPLE_PASSWORD
require_env APPLE_TEAM_ID
security find-identity -v -p codesigning | grep -F "Developer ID Application" >/dev/null || {
printf 'No valid Developer ID Application identity found in the current keychain search list\n' >&2
exit 1
}

DMG_PATH="$(find_only DMG "$DMG_DIR")"
printf 'Submitting final DMG for notarization: %s\n' "$DMG_PATH"
xcrun notarytool submit "$DMG_PATH" \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple -v "$DMG_PATH"
verify_dmg "$DMG_PATH"

MOUNT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/fanguard-dmg.XXXXXX")"
trap cleanup EXIT
hdiutil attach "$DMG_PATH" -nobrowse -readonly -mountpoint "$MOUNT_DIR" >/dev/null
APP_PATH="$(find_only app "$MOUNT_DIR")"
verify_app "$APP_PATH"
printf 'Verified notarized FanGuard distribution: %s\n' "$DMG_PATH"
Loading
Loading