diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d85356a..278dcc9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} @@ -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." diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..50956a9 --- /dev/null +++ b/design-qa.md @@ -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. diff --git a/docs/code-signing.md b/docs/code-signing.md index 35b3917..20a03b1 100644 --- a/docs/code-signing.md +++ b/docs/code-signing.md @@ -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 diff --git a/docs/design.md b/docs/design.md index bc846bc..ad88ded 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,227 +1,459 @@ -# FanGuard — Design System +# FanGuard Design System - macOS 27 Liquid Glass -> Parchment command terminal meets Apple HIG — warm paper surfaces beneath monochrome native controls, with a single ember signal reserved for heat. +FanGuard follows a native macOS 27 utility direction based on the community Figma file: -FanGuard adopts the **ElevenLabs** warm-parchment visual language ([refero.design style](https://styles.refero.design/style/031056ff-7af1-46db-8daa-115f731c5d26)) and delivers it through **Apple Human Interface Guidelines** geometry so the app feels native to macOS rather than like a web marketing page dropped into a utility window. +- File: `macOS 27 (Community)` +- Link: https://www.figma.com/design/57pdOQpJuHYSqIFLpmL8kO/macOS-27--Community-?node-id=684-19522 +- Verified node: `684:19522`, named `List`, size `1512 x 982` +- Relevant pages in the file: `Colors`, `Materials`, `Text Styles`, `Windows`, `Buttons`, `Lists and Tables`, `Sidebars`, `Toolbars`, `Segmented Controls`, `Search Fields`, `Sheets`, `Popovers`, `Toggles` -The core idea: an off-white parchment canvas (`#fdfcfc`) layered with warm-sand surfaces (`#f5f3f1`) separated by hairline `#e5e5e5` dividers. All interactive chrome is monochrome ink (`#000000`) on parchment — color is never used as a call to action. The one sanctioned exception is **Ember Orange `#ff4704`**, used exclusively as a restrained safety signal for hot temperatures (≥85°C). Elevation comes from surface contrast and 1px hairlines, never from soft blurs. +This document is the product-specific translation of that Figma system for FanGuard. Treat it as the design source for future frontend work, not as a generic macOS reference. ---- +## Canonical V1 Screen -## Apple HIG Adaptation +The canonical v1 screen is the generated FanGuard dashboard image selected in the design discussion. The Figma macOS 27 system provides the material language, but the generated image defines the actual FanGuard composition. -The ElevenLabs reference is a web-marketing system (9999px pill buttons, 20–24px rounded cards, Inter/Geist Mono, decorative gradient orbs). FanGuard is a macOS desktop utility, so the parchment *palette* is mapped onto native macOS *geometry*: +Implementation must match this screen before introducing alternate layouts: -| ElevenLabs (web) | FanGuard (macOS HIG) | -| --- | --- | -| 9999px pill buttons | 7px rectangular rounded push buttons (macOS standard) | -| Pill toggle tabs | HIG segmented control (connected 6px segments) | -| Text-link preset menu | HIG pop-up button (rectangular rounded + chevron) | -| 20–24px floating rounded cards | Edge-to-edge panes with 1px hairline dividers | -| Inter + Geist Mono | SF Pro Text (UI) + SF Mono (data) — Apple system fonts | -| Decorative violet/orange gradient orbs | Omitted (not appropriate for a utility) | -| 4px flush editorial inputs | 5px native macOS text fields | - -Native window chrome (traffic-light close/minimize/zoom buttons, title bar) stays OS-provided via Tauri `decorations: true`. Motion is calm and restrained — no cinematic physics, consistent with macOS system utilities. `9999px` capsules are reserved only for HIG capsule contexts (e.g. small status badges); they are not the default button shape. - ---- - -## Tokens — Colors - -| Name | Value | Token | Role | -| --- | --- | --- | --- | -| Parchment White | `#fdfcfc` | `--color-parchment-white` | Window canvas — the dominant background | -| Warm Sand | `#f5f3f1` | `--color-warm-sand` | Secondary surface — table headers, inset panes, hover base | -| Sand Hover | `#efedea` | `--color-sand-hover` | Row/control hover (one step darker than Warm Sand) | -| Ash Border | `#e5e5e5` | `--color-ash-border` | All hairline borders and dividers | -| Ash Strong | `#d4d4d4` | `--color-ash-strong` | Emphasized borders (dialog outlines, pressed states) | -| Midnight Ink | `#000000` | `--color-midnight-ink` | Primary text, headline text, filled control background, icon fills | -| Driftwood | `#777169` | `--color-driftwood` | Secondary text, muted labels, icon strokes | -| Fog | `#a59f97` | `--color-fog` | Tertiary helper text, placeholder text | -| Silver Mist | `#b1b0b0` | `--color-silver-mist` | Unknown/null status, disabled chrome | -| Ember Orange | `#ff4704` | `--color-ember-orange` | **Safety only** — hot temperature (≥85°C) signal | -| Void Violet | `#0447ff` | `--color-void-violet` | **Decorative only** — unused in utility chrome (reserved) | - -### Semantic status mapping - -Temperature status is monochrome by default; ember appears only for hot: - -| Status | Condition | Token | -| --- | --- | --- | -| normal | `< 70°C` | `--color-driftwood` (monochrome dot) | -| warm | `70–84°C` | `--color-driftwood` (monochrome dot — no yellow) | -| hot | `≥ 85°C` | `--color-ember-orange` (the single safety signal) | -| unknown | `null` / N/A | `--color-silver-mist` | +- floating rounded macOS window over a blurred desktop background, +- traffic-light controls in the top-left, +- toolbar with `FanGuard`, centered `Preset: Automatic`, search field, and overflow menu, +- no sidebar and no back/forward navigation, +- one framed `FAN CONTROL` section above the table, +- one global `Auto / Manual` segmented control, +- `Manual` selected in the preview state, +- target slider with `1200`, `3200 RPM`, `6550`, and current average under the track, +- `Apply` primary button before `Restore Auto`, +- info row reading `Applies to all fans.`, +- framed dense table with columns `Name`, `Value`, `Range`, `Mode`, `Status`, +- group rows for `FANS`, `TEMPERATURE SENSORS`, and `DISK DRIVES`, +- preview data with `System Fan 1`, `System Fan 2`, `System Fan 3`, and `Synced` fan modes, +- preview disk rows `APPLE SSD AP0512Q` and `SanDisk Extreme Pro`, +- bottom status bar with `SMC Connected`, model id, `Fan control enabled`, and last update. + +If this document and the generated image disagree, the generated image wins and this document should be corrected. + +## Design Intent ---- +FanGuard should feel like a first-party macOS utility for thermal monitoring and fan control. The interface is quiet, dense, and system-native. It should look closer to Finder, Activity Monitor, Console, or System Settings than to a web dashboard. -## Tokens — Typography +The Liquid Glass direction comes from layering, translucency, subtle material separation, rounded window geometry, and native control styling. It is not decorative glassmorphism. Blur and transparency should support hierarchy, not become the subject of the UI. -Type is set in Apple's system fonts for UI/data, with DM Sans weight 300 reserved for display-size headings (the ElevenLabs "Waldenburg weight 300" personality, via its documented DM Sans 300 substitute). +### Principles -### Font families +- Prefer native macOS structure over custom web layout. +- Use Liquid Glass materials for window, toolbar, fan control, sheets, and popovers. +- Keep the main data area dense, scannable, and table-driven. +- Use system typography: SF Pro Text for UI, SF Mono for metrics. +- Use accent blue for active selection and primary confirmation only. +- Use semantic temperature colors only for thermal state. +- Avoid marketing-page spacing, decorative cards, custom pill controls, and large display typography. -| Role | Family | Token | Weights | -| --- | --- | --- | --- | -| UI text | SF Pro Text (system) | `--font-ui` | 400, 500 | -| Data / metrics | SF Mono (system) | `--font-mono` | 400, tabular-nums | -| Display headings | DM Sans | `--font-display` | 300 (Waldenburg substitute) | +## Source Observations -Font stacks: +The verified Figma `List` frame shows: + +- macOS menu bar above the window. +- rounded app window with large continuous corner radius. +- traffic-light controls in the top-left. +- translucent window and toolbar materials. +- unified toolbar with grouped icon controls and a native search field. +- main content table with dense rows, column headers, separators, group labels, active blue row selection, and a thin scrollbar. +- light and dark variants for many window examples. + +The selected frame inspector exposed these relevant variables: + +| Figma variable | Observed value | FanGuard role | +| --- | --- | --- | +| `Window Background` | `#FFF` at `100%` | Main content surface | +| `Labels/Primary` | `#000` at `85%` | Primary text | +| `Labels/Secondary` | `#000` at `50%` | Secondary text | +| `Labels - Vibrant (Use Plus Lighter | Darker)/Primary` | `#1A1A1A` at `100%` | Text on translucent material | +| `Labels - Vibrant (Use Plus Lighter | Darker)/Secondary` | `#727272` at `100%` | Secondary text on translucent material | +| `Labels - Vibrant (Use Plus Lighter | Darker)/Quaternary` | `#D9D9D9` at `100%` | Faint dividers and inactive affordances | +| `Accents - Vibrant/Blue` | `#0078F0` at `100%` | Accent and selected controls | +| `Miscellaneous/Tables/BG - Selected + Active` | `#0165E2` at `100%` | Active selected table row | +| `Miscellaneous/Tables/Alternating Row Color` | `#000` at `5%` | Alternating table row | +| `Miscellaneous/Tables/Column Separator` | `#000` at `10%` | Table column divider | +| `Miscellaneous/Scrollbar` | `#000` at `50%` | Scrollbar thumb | + +## Tokens + +### Typography + +| Token | Value | Usage | +| --- | --- | --- | +| `--font-ui` | `-apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif` | Labels, buttons, rows, toolbar, dialogs | +| `--font-mono` | `"SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace` | Temperatures, RPM, timestamps, numeric columns | + +Use `font-variant-numeric: tabular-nums` for all thermal values, RPM values, percentages, dates, and aligned metrics. + +| Role | Size | Weight | Line height | Notes | +| --- | --- | --- | --- | --- | +| Table row label | `13px` | `400` | `18px` | Primary scanner text | +| Table value | `12px` | `400` | `18px` | Use mono and tabular nums | +| Table header | `11px` | `500` | `16px` | Muted, compact | +| Section label | `11px` | `600` | `16px` | Upper/lowercase follows Figma page conventions | +| Toolbar title | `13px` | `600` | `18px` | Window or active view title | +| Dialog title | `13px` | `600` | `18px` | Native sheet/dialog title | +| Helper text | `11px` | `400` | `16px` | Muted explanatory text | + +### Color + +Use semantic tokens instead of hard-coded colors in components. + +```css +:root { + color-scheme: light dark; + + --font-ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + --font-mono: "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace; + + --color-window-background: rgb(255 255 255); + --color-label-primary: rgb(0 0 0 / 85%); + --color-label-secondary: rgb(0 0 0 / 50%); + --color-label-vibrant-primary: rgb(26 26 26); + --color-label-vibrant-secondary: rgb(114 114 114); + --color-label-quaternary: rgb(217 217 217); + + --color-accent-blue: rgb(0 120 240); + --color-selection-active: rgb(1 101 226); + --color-table-row-alternating: rgb(0 0 0 / 5%); + --color-table-separator: rgb(0 0 0 / 10%); + --color-scrollbar: rgb(0 0 0 / 50%); + + --color-status-normal: rgb(52 199 89); + --color-status-warm: rgb(255 204 0); + --color-status-hot: rgb(255 69 58); + --color-status-unknown: rgb(142 142 147); +} ``` ---font-ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; ---font-mono: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Monaco, Consolas, monospace; ---font-display: "DM Sans", -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + +Dark mode should mirror the same token names with inverted label colors and darker materials. Do not fork component structure for dark mode; only token values should change. + +### Materials + +Liquid Glass is represented with layered materials: + +| Token | Role | Implementation guidance | +| --- | --- | --- | +| `--material-window` | App window body | Mostly opaque surface with subtle translucency and system blur | +| `--material-toolbar` | Unified top toolbar | Translucent material over the window, with subtle bottom separator | +| `--material-control` | Buttons, search field, segmented controls | Small translucent surface with hairline border and inner highlight | +| `--material-sheet` | Dialogs and sheets | Floating material above window, stronger shadow and backdrop separation | +| `--material-popover` | Menus and compact overlays | Compact floating material with native corner radius and shadow | + +For CSS, use `background-color`, `backdrop-filter: blur(...) saturate(...)`, and subtle inset borders. Avoid heavy shadows. The material should read as native glass, not as a blurred card. + +### Spacing and Density + +Use a 4px base unit. FanGuard is a dense utility, so most layout spacing should stay in the `4px`, `6px`, `8px`, `12px`, and `16px` range. + +| Token | Value | Usage | +| --- | --- | --- | +| `--space-1` | `4px` | Icon gaps, separator offsets | +| `--space-1-5` | `6px` | Compact row internals | +| `--space-2` | `8px` | Toolbar and table cell padding | +| `--space-3` | `12px` | Toolbar padding, control groups | +| `--space-4` | `16px` | Window edge and dialog padding | +| `--space-6` | `24px` | Dialog section separation | + +| Element | Target size | +| --- | --- | +| Table row height | `22px` to `26px` | +| Toolbar height | `52px` to `64px` depending on title layout | +| Compact icon button | `28px` square | +| Search field height | `30px` to `32px` | +| Segmented control height | `28px` to `30px` | + +### Radius + +| Element | Radius | +| --- | --- | +| App window | `22px` to `26px` | +| Sheets/dialogs | `18px` to `22px` | +| Popovers | `14px` to `18px` | +| Toolbar controls | `8px` to `12px` | +| Table selected rows | `6px` | +| Status dots | `50%` | + +## Application Layout + +FanGuard uses the generated single-window dashboard layout. It does not use a persistent sidebar because the core workflow is one screen: set global fan behavior, then scan grouped telemetry. + +```text ++--------------------------------------------------------------+ +| traffic lights FanGuard preset/search/actions | ++--------------------------------------------------------------+ +| FAN CONTROL: Auto / Manual, target RPM slider | ++--------------------------------------------------------------+ +| Name Value Range Mode Status | +| Fans v | +| System Fan 1 2532 RPM 1200-6550 Synced OK | +| System Fan 2 2200 RPM 1200-6550 Synced OK | +| System Fan 3 2000 RPM 1200-6550 Synced OK | +| Temperature Sensors v | +| CPU Core Average 56 C 40-100 - Normal | +| Disk Drives v | +| APPLE SSD AP0512Q 38 C 20-70 - OK | +| SanDisk Extreme Pro 36 C 20-70 - OK | ++--------------------------------------------------------------+ +| SMC Connected Mac model Fan control state Last update | ++--------------------------------------------------------------+ ``` -> **Implementation note:** `--font-display` prefers DM Sans 300 (the documented Waldenburg substitute) when bundled, but falls back to the Apple system font at weight 300 with `-0.02em` tracking. On macOS this renders as SF Pro Display Light — which carries the same light, tightly-tracked headline personality natively and keeps the app fully offline. DM Sans is optional to bundle; the fallback is the default rendering. +### Window -### Type scale (utility-dense) +- Use a single rounded macOS window container inside the app preview. +- Traffic-light controls belong in the upper-left of the generated window shell. +- Content should feel edge-to-edge inside the native window, with only the fan-control and table frames inset. +- Prefer a unified toolbar over a separate web-style header. +- Minimum window sizing should preserve the toolbar, global fan control, full table frame, status bar, and several rows. -| Role | Size | Line height | Letter spacing | Weight | Font | -| --- | --- | --- | --- | --- | --- | -| caption | 10px | 1.4 | 0.1px | 400 | SF Pro Text | -| micro | 11px | 1.45 | 0.11px | 400/500 | SF Pro Text | -| label | 12px | 1.45 | 0.12px | 400/500 | SF Pro Text | -| body | 13px | 1.5 | 0.13px | 400 | SF Pro Text | -| data | 13px | 1.4 | 0 | 400 | SF Mono (tabular-nums) | -| subheading | 15px | 1.45 | 0.15px | 500 | SF Pro Text | -| heading-sm | 20px | 1.3 | -0.2px | 300 | DM Sans | -| heading | 28px | 1.2 | -0.56px | 300 | DM Sans | -| display | 36px | 1.13 | -0.72px | 300 | DM Sans | +### Navigation Model -All numeric metrics (RPM, °C, percentages) use `font-variant-numeric: tabular-nums` via SF Mono so columns align and values don't jitter. +FanGuard does not need a sidebar for the main dashboard. Fans, temperature sensors, and disk drives are visible as disclosure groups inside the main table because they are data categories, not separate screens. ---- +Use a sidebar only if the product later gains real multi-screen navigation such as logs, alerts, history, or advanced rules. Do not add navigation solely to mirror the Figma reference. -## Tokens — Spacing, Radii, Shadows +### Toolbar -### Spacing +The toolbar is unified with the window title area and must follow the generated image: -Base unit `4px`. Common steps: `4, 8, 12, 16, 20, 24, 32`. Pane padding `12–16px`; dialog padding `20–24px`; element gap `8px`. +- traffic lights at far left, +- `FanGuard` title next to them, +- centered preset pop-up labeled `Preset: Automatic`, +- search field near the right edge, +- one compact overflow menu at far right. -### Border radius (HIG) +Do not include fake back/forward navigation or toolbar icons without real behavior. Text labels are acceptable where macOS would normally show them, such as primary confirmation in a sheet. -| Element | Value | Token | -| --- | --- | --- | -| push button | 7px | `--radius-button` | -| segmented control | 6px | `--radius-segmented` | -| input / text field | 5px | `--radius-input` | -| dialog / panel | 10px | `--radius-dialog` | -| list card | 12px | `--radius-card` | -| capsule badge | 9999px | `--radius-capsule` (reserved) | +### Main Content + +The main content area is table-first: + +- fan rows, +- temperature sensor rows, +- disk rows, +- derived or unavailable rows when useful. + +Use native list/table styling: -### Shadows (hairline only) +- compact row heights, +- optional alternating row fill, +- muted column headers, +- subtle column separators, +- active row selection in macOS blue, +- right-aligned numeric values, +- sticky header when the table scrolls, +- thin scrollbar. + +Do not use repeated floating cards for sensors. FanGuard is a monitoring tool; the user should scan rows quickly. + +## Components -| Name | Value | Token | +### Tables and Lists + +Use this table model for all telemetry lists: + +| Column | Width | Notes | | --- | --- | --- | -| hairline ring | `rgba(0,0,0,0.06) 0 0 0 1px, rgba(0,0,0,0.04) 0 1px 2px 0` | `--shadow-hairline` | -| elevated panel | `rgba(0,0,0,0.4) 0 0 1px 0, rgba(0,0,0,0.04) 0 1px 1px 0, rgba(0,0,0,0.04) 0 2px 4px 0` | `--shadow-elevated` | -| inset | `rgba(0,0,0,0.075) 0 0 0 0.5px inset` | `--shadow-inset` | +| Icon | `24px` | SF Symbol-style, muted unless status needs emphasis | +| Name | flexible | Primary sensor/fan label | +| Value | `88px` to `120px` | Right-aligned mono text | +| Detail | `120px` to `180px` | RPM range, source, mode, or last update | +| Status | `72px` to `96px` | Text or compact dot plus label | -Blur never exceeds 4px and opacity never exceeds 0.04. Elevation is primarily signaled by surface contrast (`#fdfcfc` → `#f5f3f1` → `#ffffff`) and 1px `#e5e5e5` dividers. +Rules: ---- +- values use `--font-mono` and tabular numbers, +- unavailable values render as muted `N/A`, +- selected row uses `--color-selection-active` with white text, +- group headers use muted 11px text and should not look like cards, +- column separators use `--color-table-separator`. -## Surfaces +### Fan Controls -| Level | Name | Value | Purpose | -| --- | --- | --- | --- | -| 1 | Canvas | `#fdfcfc` | Window background, pane/row base | -| 2 | Warm Sand | `#f5f3f1` | Table headers, inset panes, secondary tiles | -| 3 | Border | `#e5e5e5` | Hairline borders and dividers | -| 4 | Elevated | `#ffffff` | Dialog panels and floating menus (with `--shadow-elevated`) | +Fan control is global in FanGuard v1. One control applies to every detected fan together. The fan-control section must visually match the generated image: uppercase label, framed glass panel, mode row, target slider row, action buttons to the right, and info copy at the bottom. ---- +Fan control mode must use a native segmented control: -## Components +- `Auto` +- `Manual` + +The selected segment uses accent blue or native selected material depending on context. Manual mode exposes one inline target RPM slider because the dashboard's primary job is fan control. + +Global manual control rules: + +- individual fan rows are telemetry only, +- no per-fan sliders or custom modal, +- the preview state is `Manual` with synced fan rows, +- the shared safe range is `max(fan.min)` to `min(fan.max)`, +- if the detected fans have no shared safe range, disable manual writes and explain why, +- applying manual mode writes the same target RPM to every detected fan, +- restoring auto restores every detected fan to automatic mode. + +RPM controls: + +- validate min and max boundaries before writes, +- show minimum, maximum, requested target, and current average, +- place the requested target above or visually tied to the slider thumb, +- always show numeric RPM beside visual controls. + +### Preset Control + +The active preset belongs in the toolbar. Use a native pop-up or pull-down button style. Preset choices should feel like system menu items, not large tiles. + +Expected options: + +- Automatic +- Quiet +- Balanced +- Performance +- Custom presets when implemented + +### Search Field + +Use a native rounded search field with search icon, compact height, and placeholder text. Search filters visible rows only; it should not change polling or backend state. + +### Buttons -### HIG Push Button -**Role:** Window chrome (Pin on Top, Hide, Preferences, About), dialog actions. +Buttons should match macOS controls: -Parchment `#fdfcfc` background, `#000000` text, 7px radius, `12px 16px` padding, 1px `#e5e5e5` border, `--shadow-hairline`. SF Pro Text 12px weight 400. Hover → `#f5f3f1`. Active/pressed → `--shadow-inset`. Focus → 2px `#000000` ring offset by 2px parchment. +- push buttons for dialog actions, +- toolbar icon buttons for compact commands, +- bordered or material-backed controls for secondary actions, +- destructive actions use system red only in confirmation contexts. -### Filled Ink Primary Button -**Role:** Primary dialog action (OK, Save), affirmative single action. +Avoid custom pill styling. Radius should come from the control size and native material, not from a brand rule. + +### Sheets and Dialogs + +Use sheets for workflows attached to the current window: + +- preferences, +- update confirmation, +- helper access explanation. + +Use dialogs for app-level information: + +- About FanGuard, +- critical safety warnings. + +Sheets should use `--material-sheet`, centered or attached to the parent window depending on implementation constraints in Tauri. Keep text short and controls native. + +### Popovers and Menus + +Use popovers for compact contextual controls: + +- preset menu, +- sensor source details, +- menu bar display mode, +- row actions. + +Popover content should be narrow, dense, and keyboard reachable. + +### Temperature Status + +Temperature status is the main allowed semantic color usage: + +| Status | Meaning | Color | +| --- | --- | --- | +| Normal | below warning threshold | `--color-status-normal` | +| Warm | approaching warning threshold | `--color-status-warm` | +| Hot | above safe threshold | `--color-status-hot` | +| Unknown | unavailable or unsupported | `--color-status-unknown` | -`#000000` background, `#ffffff` text, 7px radius, 1px `#000000` border. SF Pro Text 12px weight 500. Hover → `#1a1a1a`. Disabled → Silver Mist bg + `#a59f97` text. Used sparingly — one primary per dialog. +Use a small dot plus optional label. Do not flood table rows with colored backgrounds. -### HIG Segmented Control -**Role:** Fan Auto/Custom mode toggle, preferences tray-mode toggle. +### Menu Bar -Container: `#f5f3f1` background, 6px radius, 1px `#e5e5e5` border, connected segments divided by 1px `#e5e5e5`. Inactive segment: transparent bg, `#000000` text. Active segment: `#000000` bg, `#ffffff` text, SF Pro Text 11–12px weight 500. The Auto/Custom fan toggle is a 2-segment control; the active mode is filled ink, the other is outlined. +The menu bar icon remains a macOS template asset: -### HIG Pop-up Button -**Role:** Active preset selector. +- black source with alpha, +- automatically adapts to light and dark menu bars, +- supports compact telemetry text when enabled. -Parchment `#fdfcfc` background, `#000000` text, 7px radius, 1px `#e5e5e5` border, `--shadow-hairline`, SF Pro Text 12px weight 400, with a chevron glyph on the right. Opens a floating menu (see List Menu). +Menu bar display modes: -### List Menu (dropdown) -**Role:** Preset list, pop-up menus. +- fan RPM, +- primary temperature, +- icon only. -`#ffffff` elevated panel, 10px radius, `--shadow-elevated`, min-width matches trigger. Menu items: `8px 12px` padding, SF Pro Text 12px, hairline `#e5e5e5` dividers between groups. Hover/focus → `#f5f3f1` (no blue). Selected item → ink `Check` icon (lucide), label weight 500. Destructive (delete) → `X` icon in Driftwood, hover to Ember only if confirming a hot/unsafe preset. Never `bg-blue-500`. +Menu bar popover should reuse the same Liquid Glass material language as app popovers. -### Edge-to-Edge List Pane -**Role:** Fan table (left) and sensor list (right). +## Implementation Notes -Parchment `#fdfcfc` canvas. Column header row: `#f5f3f1` Warm Sand, SF Pro Text 11px weight 500 Driftwood, 1px `#e5e5e5` bottom border. Data rows separated by 1px `#e5e5e5` hairline `divide-y` (no zebra fill). Hover → `#efedea`. Values in SF Mono 12px tabular-nums. Panes divided by a 1px `#e5e5e5` vertical hairline. No outer rounded card — panes run edge-to-edge like Activity Monitor. +### Svelte and Tailwind -### Status Dot -**Role:** Sensor temperature state. +- Keep styling in Tailwind v4 utilities backed by CSS custom properties. +- Use the shared `cn()` helper for dynamic classes. +- Use Svelte 5 runes for state. +- Prefer native-looking controls over hand-built decorative widgets. +- Keep component boundaries aligned with product surfaces: `Window`, `Toolbar`, `FanControl`, `DataTable`, `Sheet`, `Popover`. -6px circle. `< 85°C` → Driftwood `#777169` (monochrome). `≥ 85°C` → Ember `#ff4704` (the single safety signal). `null` → Silver Mist `#b1b0b0`. No green/yellow. +### Tauri Window -### HIG Dialog Panel -**Role:** FanControlModal, Preferences, About, Update. +The Tauri window should support the native design direction: -`#ffffff` Elevated surface, 10px radius, `--shadow-elevated`, `20px` padding, 1px `#e5e5e5` border. Title in SF Pro Text 13px weight 600 ink (or DM Sans 300 for large About headings). Body SF Pro Text 12–13px. Buttons right-aligned: Cancel = HIG Push Button, OK = Filled Ink Primary. Backdrop `rgba(0,0,0,0.18)`. No `shadow-2xl`. +- transparent or translucent window where supported, +- rounded corners, +- hidden or integrated titlebar if the implementation can preserve drag regions and accessibility, +- stable minimum dimensions for dense table scanning. -### Text Field -**Role:** RPM input, preset name, temperature thresholds. +Do not sacrifice accessibility or window behavior just to simulate glass. Native window semantics win when there is a tradeoff. -Parchment `#fdfcfc` (or `#ffffff` inside elevated dialogs) background, 5px radius, 1px `#e5e5e5` border, `4px 8px` padding, SF Pro Text 12px. Numeric inputs use SF Mono tabular-nums, right-aligned. Focus → 2px ink ring. Error → 1px Ember border + Ember helper text below. +### Accessibility -### Temperature Range Bar -**Role:** FanControlModal sensor-based config visualization. +- All controls need keyboard focus states. +- Table rows need readable selected, hover, and inactive states. +- Color cannot be the only indicator for temperature state. +- Search and filter controls need accessible names. +- Dialogs and sheets need focus trapping and escape/close behavior. -Track: `#f5f3f1` Warm Sand, full width, 8px height, 4px radius. Range fill: `#000000` ink (monochrome — no green→yellow→red gradient). Live-current-temp marker: 12px ink circle with 2px parchment border, positioned along the track; turns **Ember `#ff4704`** only when the live reading is `≥ 85°C`. Labels in SF Mono 10px Driftwood. +### Performance -### Safety / Warning Callout -**Role:** Extreme fan-setting warning, privilege/safety errors. +Liquid Glass effects can be expensive in a webview. Apply blur to major surfaces only: -Warm Sand `#f5f3f1` background, 8px radius, 1px **Ember** border (the sanctioned safety use), SF Pro Text 11px ink text with a lucide `AlertTriangle` icon in Ember. No amber fill. This is the only place Ember is used as a border/fill accent. +- window, +- toolbar, +- sheets, +- popovers. ---- +Do not apply backdrop blur to every row or cell. Data tables should stay visually crisp and cheap to render. -## FanGuard Deviations from ElevenLabs Dogma +## Do and Do Not -These deviations are intentional and documented, in service of matching the Apple ecosystem and the safety needs of a fan-control utility: +### Do -1. **Ember `#ff4704` as a safety signal** — ElevenLabs reserves color for decorative orbs only. FanGuard reuses Ember exclusively for hot-temperature (≥85°C) indication (status dots, range-bar marker, warning callout border). It is never used for buttons, links, or hover states. -2. **Decorative gradient orbs omitted** — The violet/orange ambient orbs are a voice-category marketing device; they have no place in a utility. Void Violet is retained as a token but unused in chrome. -3. **HIG rectangular-rounded geometry instead of 9999px pills** — Buttons, pop-ups, and dialogs use macOS-native radii (5–10px). Capsules (9999px) appear only for small HIG badge contexts. -4. **SF Pro Text + SF Mono instead of Inter + Geist Mono** — Apple's system fonts keep the app native to macOS. DM Sans weight 300 is retained for display headings to preserve the ElevenLabs "Waldenburg 300" headline personality. -5. **Edge-to-edge hairline panes instead of floating rounded cards** — Matches Activity Monitor / System Settings information density. -6. **Light-only** — The parchment system is a warm-light theme; dark mode is dropped for design coherence. +- Use SF Pro Text and SF Mono. +- Use Liquid Glass materials for app-level surfaces. +- Keep telemetry rows compact and table-based. +- Use macOS blue for active selection. +- Keep temperature colors small and semantic. +- Preserve native controls, keyboard behavior, and system expectations. ---- +### Do Not -## Do +- Build a marketing dashboard. +- Use large hero typography. +- Put every sensor in a separate decorative card. +- Invent brand-specific button shapes. +- Use decorative gradients as app surfaces. +- Hide important fan or temperature data behind oversized controls. +- Use blur effects inside dense tables. -- Use HIG push-button geometry (7px) for window chrome and dialog actions; reserve Filled Ink for one primary action per dialog. -- Use SF Mono with `tabular-nums` for every numeric metric (RPM, °C) so columns align. -- Separate rows and panes with 1px `#e5e5e5` hairlines; let surface contrast (`#fdfcfc` / `#f5f3f1` / `#ffffff`) carry elevation. -- Use the HIG segmented control for the Auto/Custom fan-mode toggle — active segment filled ink, inactive outlined. -- Reserve Ember `#ff4704` strictly for hot (≥85°C) status dots, the range-bar live marker, and warning-callout borders. -- Use lucide outlined icons at consistent stroke width, monochrome (Driftwood or Ink) — never multicolor. -- Apply DM Sans 300 only to display-size headings (About title, large empty states); everything else is SF Pro Text / SF Mono. +## Verification Checklist -## Don't +Use this checklist when implementing UI from this design document: -- Never use Ember `#ff4704` or Void Violet `#0447ff` for button backgrounds, link colors, hover states, or any interactive affordance. -- Never use green/yellow/red semantic status colors — temperature state is monochrome except the Ember hot signal. -- Never apply soft-blur shadows (blur > 4px or opacity > 0.04) — elevation is hairlines and surface contrast. -- Never use 9999px pill buttons as the default chrome — HIG rectangular-rounded geometry is the standard. -- Never use DM Sans for body text, labels, buttons, or data — SF Pro Text / SF Mono handle all functional text. -- Never add `dark:` variants or hardcoded dark hex values — the system is light-only. -- Never use emojis in chrome — replace `⚠` / `✓` / `✕` with lucide `AlertTriangle` / `Check` / `X`. +- The app reads as a macOS utility at first glance. +- The rendered dashboard visually matches the canonical generated image before alternate layout changes. +- The toolbar, fan control, sheets, and popovers use Liquid Glass materials. +- Main telemetry uses dense list/table structure. +- Numeric columns align with SF Mono tabular numbers. +- Active selection uses macOS blue. +- Temperature status uses small semantic indicators. +- All controls remain keyboard accessible. +- Dark mode changes tokens, not component structure. +- Performance remains smooth during sensor polling. diff --git a/docs/learning/dev-setup.md b/docs/learning/dev-setup.md index 2dd035c..0aab85f 100644 --- a/docs/learning/dev-setup.md +++ b/docs/learning/dev-setup.md @@ -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. diff --git a/docs/release-guide.md b/docs/release-guide.md index cc4bbf1..16d35b2 100644 --- a/docs/release-guide.md +++ b/docs/release-guide.md @@ -66,7 +66,7 @@ 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) @@ -74,7 +74,7 @@ The `release.yml` workflow triggers on any `v*` tag push. It: 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 diff --git a/docs/screenshots/fanguard-native-960-fixed.png b/docs/screenshots/fanguard-native-960-fixed.png new file mode 100644 index 0000000..dc7b7ec Binary files /dev/null and b/docs/screenshots/fanguard-native-960-fixed.png differ diff --git a/e2e/dashboard-layout.spec.ts b/e2e/dashboard-layout.spec.ts new file mode 100644 index 0000000..7ea9094 --- /dev/null +++ b/e2e/dashboard-layout.spec.ts @@ -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(); + }); +}); diff --git a/e2e/fan-control.spec.ts b/e2e/fan-control.spec.ts index 4c40cc9..2b9f95e 100644 --- a/e2e/fan-control.spec.ts +++ b/e2e/fan-control.spec.ts @@ -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/, - ); }); diff --git a/package.json b/package.json index 373d97f..9a9f4e3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index e3643e3..f07c9b5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, diff --git a/scripts/build-local-notarized.sh b/scripts/build-local-notarized.sh index 7f4a26a..3d04d39 100755 --- a/scripts/build-local-notarized.sh +++ b/scripts/build-local-notarized.sh @@ -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" diff --git a/scripts/notarize-dmg.sh b/scripts/notarize-dmg.sh new file mode 100755 index 0000000..c0a88d9 --- /dev/null +++ b/scripts/notarize-dmg.sh @@ -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" diff --git a/scripts/tauri-release-build.sh b/scripts/tauri-release-build.sh new file mode 100755 index 0000000..8122dc7 --- /dev/null +++ b/scripts/tauri-release-build.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$PROJECT_ROOT" +pnpm tauri "$@" + +find_target() { + [[ "$#" -gt 0 ]] || return 0 + [[ "$1" != "--target" ]] || { printf '%s\n' "${2:-}"; return 0; } + shift + find_target "$@" +} + +TARGET="$(find_target "$@")" + +if [[ -n "$TARGET" ]]; then + FANGUARD_DMG_DIR="$PROJECT_ROOT/src-tauri/target/$TARGET/release/bundle/dmg" \ + "$PROJECT_ROOT/scripts/notarize-dmg.sh" +else + "$PROJECT_ROOT/scripts/notarize-dmg.sh" +fi diff --git a/scripts/tests/build-local-notarized.test.sh b/scripts/tests/build-local-notarized.test.sh new file mode 100755 index 0000000..315a280 --- /dev/null +++ b/scripts/tests/build-local-notarized.test.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT_PATH="$PROJECT_ROOT/scripts/build-local-notarized.sh" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_contains() { + local output="$1" + local expected="$2" + + [[ "$output" == *"$expected"* ]] || fail "expected output to contain: $expected" +} + +test_rejects_multiple_dmg_candidates() { + local temp_dir + temp_dir="$(mktemp -d)" + trap 'rm -rf "$temp_dir"' RETURN + mkdir -p "$temp_dir/home/.config/fanguard" "$temp_dir/bundle" "$temp_dir/bin" + printf '%s\n' \ + 'APPLE_ID=test@example.com' \ + 'APPLE_PASSWORD=test-password' \ + 'APPLE_TEAM_ID=TESTTEAM' \ + >"$temp_dir/home/.config/fanguard/notarization.env" + touch "$temp_dir/bundle/first.dmg" "$temp_dir/bundle/second.dmg" + printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" "1) test Developer ID Application"' >"$temp_dir/bin/security" + chmod +x "$temp_dir/bin/security" + + local output + local status + set +e + output="$( + HOME="$temp_dir/home" \ + PATH="$temp_dir/bin:$PATH" \ + FANGUARD_SKIP_BUILD=1 \ + FANGUARD_DMG_DIR="$temp_dir/bundle" \ + bash "$SCRIPT_PATH" 2>&1 + )" + status=$? + set -e + + [[ "$status" -ne 0 ]] || fail "multiple DMGs must return a non-zero exit code" + assert_contains "$output" "Expected exactly one DMG" +} + +test_detaches_verified_dmg() { + local temp_dir + temp_dir="$(mktemp -d)" + trap 'rm -rf "$temp_dir"' RETURN + mkdir -p "$temp_dir/bin" "$temp_dir/bundle" + touch "$temp_dir/bundle/FanGuard.dmg" + + printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" "1) test Developer ID Application"' >"$temp_dir/bin/security" + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' >"$temp_dir/bin/xcrun" + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' >"$temp_dir/bin/codesign" + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' >"$temp_dir/bin/spctl" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [[ "$1" == "attach" ]]; then' \ + ' mount_dir="${@: -1}"' \ + ' mkdir -p "$mount_dir/FanGuard.app/Contents/MacOS"' \ + ' touch "$mount_dir/FanGuard.app/Contents/MacOS/fanguard-helper"' \ + 'fi' \ + '[[ "$1" != "detach" ]] || printf "detached\n" >>"$FANGUARD_TEST_LOG"' \ + >"$temp_dir/bin/hdiutil" + chmod +x "$temp_dir/bin/"* + + APPLE_ID=test@example.com \ + APPLE_PASSWORD=test-password \ + APPLE_TEAM_ID=TESTTEAM \ + FANGUARD_DMG_DIR="$temp_dir/bundle" \ + FANGUARD_TEST_LOG="$temp_dir/hdiutil.log" \ + PATH="$temp_dir/bin:$PATH" \ + bash "$PROJECT_ROOT/scripts/notarize-dmg.sh" >/dev/null + + [[ "$(<"$temp_dir/hdiutil.log")" == "detached" ]] || fail "verified DMG must be detached during cleanup" +} + +test_rejects_multiple_dmg_candidates +test_detaches_verified_dmg +printf 'PASS: build-local-notarized tests\n' diff --git a/scripts/tests/release-workflow.test.sh b/scripts/tests/release-workflow.test.sh new file mode 100755 index 0000000..fed2b2b --- /dev/null +++ b/scripts/tests/release-workflow.test.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WORKFLOW="$PROJECT_ROOT/.github/workflows/release.yml" + +grep -Fq 'tauriScript: ./scripts/tauri-release-build.sh' "$WORKFLOW" || { + printf 'FAIL: release workflow must build through the DMG verification wrapper\n' >&2 + exit 1 +} + +printf 'PASS: release workflow uses the verified build wrapper\n' diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6f98175..d6b7214 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -934,7 +934,7 @@ dependencies = [ [[package]] name = "fanguard" -version = "0.1.0-beta.4" +version = "0.1.0-beta.5" dependencies = [ "core-foundation", "ctrlc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1293b1d..393c2a2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fanguard" -version = "0.1.0-beta.4" +version = "0.1.0-beta.5" description = "FanGuard — macOS fan control utility: monitor temperatures and manage fan speeds via SMC" authors = ["naufaldi"] license = "MIT" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6cb0e72..cbbdb9c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -292,6 +292,43 @@ pub fn set_fan_auto(app_handle: AppHandle, fan_index: u8) -> Result<(), String> apply_set_fan_auto(&app_handle, fan_index) } +#[tauri::command] +pub fn set_all_fans_constant_rpm( + state: State<'_, AppState>, + rpm: f32, +) -> Result<(), String> { + let writer_guard = state.smc_writer.lock().map_err(|error| error.to_string())?; + let writer = writer_guard.as_deref().ok_or_else(|| { + "Fan control unavailable — install or reconnect the privileged helper".to_string() + })?; + let mut service = SensorService::new(); + let sensor_data = service.read_all_sensors().map_err(|error| error.to_string())?; + + state + .fan_control + .lock() + .map_err(|error| error.to_string())? + .set_all_constant_rpm(rpm, &sensor_data.fans, writer) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn restore_all_fans_auto(state: State<'_, AppState>) -> Result<(), String> { + let writer_guard = state.smc_writer.lock().map_err(|error| error.to_string())?; + let writer = writer_guard.as_deref().ok_or_else(|| { + "Fan control unavailable — install or reconnect the privileged helper".to_string() + })?; + let mut service = SensorService::new(); + let sensor_data = service.read_all_sensors().map_err(|error| error.to_string())?; + + state + .fan_control + .lock() + .map_err(|error| error.to_string())? + .restore_detected_fans_auto(&sensor_data.fans, writer) + .map_err(|error| error.to_string()) +} + #[tauri::command] pub fn get_fan_control_configs( state: State<'_, AppState>, diff --git a/src-tauri/src/fan_control.rs b/src-tauri/src/fan_control.rs index cf5b053..c0aed4b 100644 --- a/src-tauri/src/fan_control.rs +++ b/src-tauri/src/fan_control.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use thiserror::Error; use crate::log::warn_log; use crate::smc::{FanData, FanMode, Sensor}; @@ -20,6 +21,29 @@ const EMERGENCY_CLEAR_THRESHOLD: f64 = 90.0; /// the linked fan is forced to max RPM for thermal safety. const SENSOR_MISS_LIMIT: u32 = 3; +#[derive(Debug, Error)] +pub enum GlobalFanControlError { + #[error("No fans were detected")] + NoFans, + #[error("Detected fans do not share a safe RPM range")] + NoSharedRange, + #[error("Target RPM {requested:.0} is outside the shared safe range {min_rpm:.0}-{max_rpm:.0}")] + TargetOutOfRange { + requested: f32, + min_rpm: f32, + max_rpm: f32, + }, + #[error("Failed to apply {requested:.0} RPM to fan {fan_index}: {source}. Recovery: {recovery}")] + ApplyFailed { + requested: f32, + fan_index: u8, + source: SmcWriteError, + recovery: String, + }, + #[error("Failed to restore automatic fan control: {0}")] + RestoreFailed(String), +} + // ── Fan control configuration ──────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -77,6 +101,102 @@ impl FanControlState { Ok(()) } + pub fn set_all_constant_rpm( + &mut self, + target_rpm: f32, + fans: &[FanData], + writer: &dyn SmcWriteApi, + ) -> Result<(), GlobalFanControlError> { + if fans + .iter() + .any(|fan| !fan.min.is_finite() || !fan.max.is_finite() || fan.min > fan.max) + { + return Err(GlobalFanControlError::NoSharedRange); + } + let (min_rpm, max_rpm) = fans + .iter() + .map(|fan| (fan.min, fan.max)) + .reduce(|left, right| (left.0.max(right.0), left.1.min(right.1))) + .ok_or(GlobalFanControlError::NoFans)?; + + if min_rpm > max_rpm { + return Err(GlobalFanControlError::NoSharedRange); + } + if !target_rpm.is_finite() || target_rpm < min_rpm || target_rpm > max_rpm { + return Err(GlobalFanControlError::TargetOutOfRange { + requested: target_rpm, + min_rpm, + max_rpm, + }); + } + + let apply_result = fans.iter().try_fold(Vec::::new(), |touched, fan| { + let next_touched = touched + .iter() + .copied() + .chain(std::iter::once(fan.index)) + .collect::>(); + writer + .set_fan_target_rpm(fan.index, target_rpm, fan.min, fan.max) + .map(|()| next_touched.clone()) + .map_err(|source| (fan.index, source, next_touched)) + }); + + match apply_result { + Ok(touched) => { + self.configs = touched + .into_iter() + .map(|fan_index| { + (fan_index, FanControlConfig::ConstantRpm { target_rpm }) + }) + .collect(); + Ok(()) + } + Err((fan_index, source, touched)) => { + let recovery = restore_fan_indices(&touched, writer); + self.configs = recovery + .retry_indices + .iter() + .copied() + .map(|index| (index, FanControlConfig::ConstantRpm { target_rpm })) + .collect(); + Err(GlobalFanControlError::ApplyFailed { + requested: target_rpm, + fan_index, + source, + recovery: recovery.message, + }) + } + } + } + + pub fn restore_detected_fans_auto( + &mut self, + fans: &[FanData], + writer: &dyn SmcWriteApi, + ) -> Result<(), GlobalFanControlError> { + if fans.is_empty() { + return Err(GlobalFanControlError::NoFans); + } + let fan_indices = fans.iter().map(|fan| fan.index).collect::>(); + let recovery = restore_fan_indices(&fan_indices, writer); + self.configs.retain(|index, _| recovery.retry_indices.contains(index)); + fans.iter() + .filter(|fan| recovery.retry_indices.contains(&fan.index)) + .for_each(|fan| { + self.configs.entry(fan.index).or_insert( + FanControlConfig::ConstantRpm { + target_rpm: fan.target, + }, + ); + }); + if recovery.retry_indices.is_empty() { + Ok(()) + } else { + Err(GlobalFanControlError::RestoreFailed(recovery.message)) + } + } + /// Removes a fan's config (returns it to Auto) and applies. /// Re-locks thermal control if no fans remain in a forced mode. pub fn set_auto( @@ -243,6 +363,44 @@ impl FanControlState { } } +struct RecoveryOutcome { + retry_indices: Vec, + message: String, +} + +fn restore_fan_indices(fan_indices: &[u8], writer: &dyn SmcWriteApi) -> RecoveryOutcome { + let failures = fan_indices + .iter() + .filter_map(|fan_index| { + writer + .set_fan_auto(*fan_index) + .err() + .map(|error| (*fan_index, format!("fan {fan_index}: {error}"))) + }) + .collect::>(); + let lock_failure = writer.lock_fan_control().err(); + let all_failures = failures + .iter() + .map(|(_, message)| message.clone()) + .chain(lock_failure.map(|error| format!("thermal-control lock: {error}"))) + .collect::>(); + let retry_indices = if all_failures.is_empty() { + Vec::new() + } else if failures.is_empty() { + fan_indices.to_vec() + } else { + failures.iter().map(|(fan_index, _)| *fan_index).collect() + }; + RecoveryOutcome { + retry_indices, + message: if all_failures.is_empty() { + "all touched fans restored to Auto".to_string() + } else { + all_failures.join("; ") + }, + } +} + // ── Pure helper functions ──────────────────────────────────────────────────── /// Linearly interpolates fan RPM between min and max based on temperature. @@ -311,6 +469,58 @@ mod tests { use crate::smc::{NullReason, SensorSource}; use crate::smc_writer::mock::{MockSmcCall, MockSmcWriter}; + struct FailOnFanWriter { + calls: std::cell::RefCell>, + failed_fan: u8, + fail_auto_for: Option, + } + + unsafe impl Send for FailOnFanWriter {} + unsafe impl Sync for FailOnFanWriter {} + + impl SmcWriteApi for FailOnFanWriter { + fn set_fan_target_rpm( + &self, + fan_index: u8, + rpm: f32, + _min_rpm: f32, + _max_rpm: f32, + ) -> Result<(), SmcWriteError> { + self.calls.borrow_mut().push(MockSmcCall::SetFanTargetRpm { fan_index, rpm }); + if fan_index == self.failed_fan { + Err(SmcWriteError::TargetVerificationFailed { + fan_index, + requested: rpm, + readback: 0.0, + }) + } else { + Ok(()) + } + } + + fn set_fan_auto(&self, fan_index: u8) -> Result<(), SmcWriteError> { + self.calls.borrow_mut().push(MockSmcCall::SetFanAuto { fan_index }); + if self.fail_auto_for == Some(fan_index) { + Err(SmcWriteError::InsufficientPrivileges) + } else { + Ok(()) + } + } + + fn lock_fan_control(&self) -> Result<(), SmcWriteError> { + self.calls.borrow_mut().push(MockSmcCall::LockFanControl); + Ok(()) + } + + fn unlock_fan_control(&self) -> Result<(), SmcWriteError> { + Ok(()) + } + + fn diagnose_fan_control(&self) -> Vec { + Vec::new() + } + } + fn make_fan(index: u8, min: f32, max: f32) -> FanData { FanData { index, @@ -666,4 +876,72 @@ mod tests { assert_eq!(fans[0].mode, FanMode::Forced); assert_eq!(fans[0].target, 5000.0); } + + #[test] + fn set_all_constant_rpm_uses_shared_range_and_updates_every_fan() { + let writer = MockSmcWriter::new(); + let mut state = FanControlState::new(); + let fans = vec![make_fan(0, 1200.0, 5800.0), make_fan(1, 1600.0, 6200.0)]; + + state.set_all_constant_rpm(3200.0, &fans, &writer).unwrap(); + + assert_eq!(state.configs().len(), 2); + let calls = writer.calls.borrow(); + assert_eq!( + calls.iter().filter(|call| matches!(call, MockSmcCall::SetFanTargetRpm { rpm, .. } if (*rpm - 3200.0).abs() < 1.0)).count(), + 2, + ); + } + + #[test] + fn set_all_constant_rpm_rejects_target_outside_shared_range() { + let writer = MockSmcWriter::new(); + let mut state = FanControlState::new(); + let fans = vec![make_fan(0, 1200.0, 5800.0), make_fan(1, 1600.0, 6200.0)]; + + let result = state.set_all_constant_rpm(1400.0, &fans, &writer); + + assert!(matches!(result, Err(GlobalFanControlError::TargetOutOfRange { min_rpm: 1600.0, max_rpm: 5800.0, .. }))); + assert!(writer.calls.borrow().is_empty()); + } + + #[test] + fn set_all_constant_rpm_restores_touched_fans_when_a_later_write_fails() { + let writer = FailOnFanWriter { + calls: std::cell::RefCell::new(Vec::new()), + failed_fan: 1, + fail_auto_for: None, + }; + let mut state = FanControlState::new(); + let fans = vec![make_fan(0, 1200.0, 5800.0), make_fan(1, 1200.0, 5800.0)]; + + let result = state.set_all_constant_rpm(3200.0, &fans, &writer); + + assert!(matches!(result, Err(GlobalFanControlError::ApplyFailed { fan_index: 1, .. }))); + assert!(state.configs().is_empty()); + let calls = writer.calls.borrow(); + assert!(calls.iter().any(|call| matches!(call, MockSmcCall::SetFanAuto { fan_index: 0 }))); + assert!(calls.iter().any(|call| matches!(call, MockSmcCall::SetFanAuto { fan_index: 1 }))); + assert!(calls.iter().any(|call| matches!(call, MockSmcCall::LockFanControl))); + } + + #[test] + fn set_all_constant_rpm_keeps_failed_recovery_tracked_for_shutdown_retry() { + let writer = FailOnFanWriter { + calls: std::cell::RefCell::new(Vec::new()), + failed_fan: 1, + fail_auto_for: Some(0), + }; + let mut state = FanControlState::new(); + let fans = vec![make_fan(0, 1200.0, 5800.0), make_fan(1, 1200.0, 5800.0)]; + + let result = state.set_all_constant_rpm(3200.0, &fans, &writer); + + assert!(matches!(result, Err(GlobalFanControlError::ApplyFailed { .. }))); + assert!(matches!( + state.configs().get(&0), + Some(FanControlConfig::ConstantRpm { target_rpm: 3200.0 }) + )); + assert!(!state.configs().contains_key(&1)); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4274016..0903d26 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -545,6 +545,7 @@ fn main() { ctrlc::set_handler(move || { warn_log!("[fanguard] Signal received — restoring fans to Auto"); restore_fans(&signal_handle); + std::process::exit(0); }) .unwrap_or_else(|e| { warn_log!("[fanguard] Signal handler registration failed: {e}"); @@ -560,6 +561,8 @@ fn main() { commands::set_fan_constant_rpm, commands::set_fan_sensor_control, commands::set_fan_auto, + commands::set_all_fans_constant_rpm, + commands::restore_all_fans_auto, commands::get_fan_control_configs, commands::get_presets, commands::get_active_preset, diff --git a/src-tauri/src/smc_writer.rs b/src-tauri/src/smc_writer.rs index e5d2c22..ab12353 100644 --- a/src-tauri/src/smc_writer.rs +++ b/src-tauri/src/smc_writer.rs @@ -41,13 +41,13 @@ pub enum SmcWriteError { ModeTransitionTimedOut, #[error("Fan mode verification failed: mode {actual} still blocks target writes")] ModeVerificationFailed { actual: u8 }, - #[error( - "Fan {fan_index} target RPM verification failed: requested {requested}, actual {actual:?}" - )] + #[error("Fan control not available on this Mac: fan {fan_index} has no SMC mode key for safe manual control/restoration")] + UnsupportedFanControl { fan_index: u8 }, + #[error("Fan target verification failed for fan {fan_index}: requested {requested:.0} RPM, read back {readback:.0} RPM")] TargetVerificationFailed { fan_index: u8, requested: f32, - actual: Option, + readback: f32, }, #[error("Privileged helper is not running")] HelperNotRunning, @@ -285,7 +285,7 @@ impl SmcWriter { .to_string(), ); lines.push( - " -> Direct F*Md writes may work (pre-14.4 firmware) or may be blocked" + " -> Safe manual fan control requires F*Md mode keys; absent mode keys are rejected" .to_string(), ); } @@ -509,9 +509,7 @@ impl SmcWriter { self.set_fan_mode(fan_index, true)?; self.verify_mode_allows_target_write(fan_index)?; } else { - debug_log!( - "[smc_writer] F{fan_index}Md key absent — skipping mode transition and writing F{fan_index}Tg directly" - ); + return require_restorable_fan_mode(mode_capability, fan_index); } // Step 3: Write target RPM @@ -530,21 +528,21 @@ impl SmcWriter { const MAX_VERIFY_ATTEMPTS: u8 = 3; const VERIFY_TOLERANCE: f64 = 50.0; - let mut verified = false; + let mut last_readback: Option = None; let mut attempt = 0u8; - let mut last_readback_rpm: Option = None; - while attempt < MAX_VERIFY_ATTEMPTS && !verified { + while attempt < MAX_VERIFY_ATTEMPTS { std::thread::sleep(Duration::from_millis(100)); match self.read_key_bytes(key, key_info.data_size) { Ok(readback) => { let readback_rpm = decode_rpm(&readback, &type_bytes); - last_readback_rpm = Some(readback_rpm); - let diff = (readback_rpm - rpm).abs(); - if diff > VERIFY_TOLERANCE as f32 { + last_readback = Some(readback_rpm); + if let Err(error) = + validate_target_readback(fan_index, rpm, readback_rpm, VERIFY_TOLERANCE) + { debug_log!( - "[smc_writer] F{fan_index}Tg verify attempt {}/{MAX_VERIFY_ATTEMPTS}: wrote={rpm:.0} readback={readback_rpm:.0} (diff={diff:.0})" - , attempt + 1 + "[smc_writer] F{fan_index}Tg verify attempt {}/{MAX_VERIFY_ATTEMPTS}: {error}", + attempt + 1 ); // Retry the write if attempt + 1 < MAX_VERIFY_ATTEMPTS { @@ -552,7 +550,10 @@ impl SmcWriter { } } else { debug_log!("[smc_writer] F{fan_index}Tg verified: {readback_rpm:.0} RPM"); - verified = true; + debug_log!( + "[smc_writer] set_fan_target_rpm: done fan={fan_index} rpm={rpm}" + ); + return Ok(()); } } Err(e) => debug_log!( @@ -563,10 +564,15 @@ impl SmcWriter { attempt += 1; } - validate_target_readback(fan_index, rpm, verified, last_readback_rpm)?; - - debug_log!("[smc_writer] set_fan_target_rpm: done fan={fan_index} rpm={rpm}"); - Ok(()) + let readback = last_readback.unwrap_or(f32::NAN); + warn_log!( + "[smc_writer] F{fan_index}Tg write verification failed after {MAX_VERIFY_ATTEMPTS} attempts — RPM was not applied" + ); + Err(SmcWriteError::TargetVerificationFailed { + fan_index, + requested: rpm, + readback, + }) } /// Returns fan to automatic (system-controlled) mode. @@ -580,8 +586,7 @@ impl SmcWriter { if mode_capability == FanModeCapability::Managed { self.set_fan_mode(fan_index, false) } else { - debug_log!("[smc_writer] F{fan_index}Md key absent — no auto mode to restore"); - Ok(()) + require_restorable_fan_mode(mode_capability, fan_index) } } @@ -881,33 +886,44 @@ fn validate_mode_allows_target_write(actual_mode: u8) -> Result<(), SmcWriteErro } } +fn detect_fan_mode_capability( + mode_key_info: Result, +) -> Result { + match mode_key_info { + Ok(_) => Ok(FanModeCapability::Managed), + Err(SmcWriteError::UnknownKey(_)) => Ok(FanModeCapability::Absent), + Err(error) => Err(error), + } +} + +fn require_restorable_fan_mode( + mode_capability: FanModeCapability, + fan_index: u8, +) -> Result<(), SmcWriteError> { + match mode_capability { + FanModeCapability::Managed => Ok(()), + FanModeCapability::Absent => Err(SmcWriteError::UnsupportedFanControl { fan_index }), + } +} + fn validate_target_readback( fan_index: u8, requested: f32, - verified: bool, - actual: Option, + readback: f32, + tolerance: f64, ) -> Result<(), SmcWriteError> { - if verified { + let diff = (readback - requested).abs(); + if diff <= tolerance as f32 { return Ok(()); } Err(SmcWriteError::TargetVerificationFailed { fan_index, requested, - actual, + readback, }) } -fn detect_fan_mode_capability( - mode_key_info: Result, -) -> Result { - match mode_key_info { - Ok(_) => Ok(FanModeCapability::Managed), - Err(SmcWriteError::UnknownKey(_)) => Ok(FanModeCapability::Absent), - Err(error) => Err(error), - } -} - // ── Tests ──────────────────────────────────────────────────────────────────── // ── Test mock ──────────────────────────────────────────────────────────────── @@ -942,14 +958,6 @@ pub mod mock { should_fail: false, } } - - #[allow(dead_code)] - pub fn failing() -> Self { - Self { - calls: RefCell::new(Vec::new()), - should_fail: true, - } - } } impl SmcWriteApi for MockSmcWriter { @@ -1127,16 +1135,26 @@ mod tests { } #[test] - fn target_verification_failure_returns_error() { - let result = validate_target_readback(0, 5000.0, false, Some(2400.0)); + fn require_restorable_fan_mode_rejects_absent_mode_key() { + let result = require_restorable_fan_mode(FanModeCapability::Absent, 0); + + assert!(matches!( + result, + Err(SmcWriteError::UnsupportedFanControl { fan_index: 0 }) + )); + } + + #[test] + fn validate_target_readback_rejects_mismatch() { + let result = validate_target_readback(0, 5000.0, 4049.0, 50.0); assert!(matches!( result, Err(SmcWriteError::TargetVerificationFailed { fan_index: 0, - requested, - actual: Some(actual), - }) if requested == 5000.0 && actual == 2400.0 + requested: 5000.0, + readback: 4049.0 + }) )); } @@ -1180,22 +1198,11 @@ mod tests { let max_rpm = decode_rpm(&max_bytes, &max_type); let safe_target = current_target.clamp(min_rpm, max_rpm); - writer - .set_fan_target_rpm_impl(fan_index, safe_target, min_rpm, max_rpm) - .expect("target write should succeed without F*Md"); - - let readback = writer - .read_key_bytes(fan_key(fan_index, b"Tg"), target_info.data_size) - .expect("target readback should succeed"); - let readback_rpm = decode_rpm(&readback, &target_type); - - assert!( - (readback_rpm - safe_target).abs() <= 50.0, - "expected target readback near {safe_target}, got {readback_rpm}" - ); + let result = writer.set_fan_target_rpm_impl(fan_index, safe_target, min_rpm, max_rpm); - writer - .set_fan_auto_impl(fan_index) - .expect("auto should be a no-op when F*Md is absent"); + assert!(matches!( + result, + Err(SmcWriteError::UnsupportedFanControl { fan_index: 0 }) + )); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 969e182..3722acb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/schema.json", "productName": "FanGuard", - "version": "0.1.0-beta.4", + "version": "0.1.0-beta.5", "identifier": "io.github.naufaldi.fanguard", "build": { "beforeDevCommand": "pnpm run dev:prepare-helper && pnpm run dev", diff --git a/src/App.svelte b/src/App.svelte index 130b742..ca21429 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,10 +1,13 @@ {#if appStatus.kind === 'loading'} - -
- -
-
-
-
-
-
+
+
+
+
+
+
+
- -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {#each [0, 1] as r} +
+
+
+
+ {/each} +
+
+
+ {#each [0, 1, 2] as r} +
+
+
+
+ {/each}
- -
-
-
+
+
+
+
{:else if appStatus.kind === 'error'} - -
+

Failed to connect to sensor backend

{appStatus.message}

{:else} -
- -
+ {#await dashboardPromise then DashboardComponent} +
+ +
+ {:catch error} +
+
+

Failed to load dashboard

+

{error instanceof Error ? error.message : String(error)}

+
+
+ {/await} {/if} diff --git a/src/app.css b/src/app.css index bdab880..0f459e3 100644 --- a/src/app.css +++ b/src/app.css @@ -7,80 +7,99 @@ select { } @theme { - /* Fonts — Apple system fonts for UI/data, DM Sans (Waldenburg substitute) for display */ - --font-ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; - --font-mono: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Monaco, Consolas, monospace; - --font-display: "DM Sans", -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; - - /* Parchment palette (see docs/design.md) */ - --color-parchment-white: #fdfcfc; - --color-warm-sand: #f5f3f1; - --color-sand-hover: #efedea; - --color-ash-border: #e5e5e5; - --color-ash-strong: #d4d4d4; - --color-midnight-ink: #000000; - --color-driftwood: #777169; - --color-fog: #a59f97; - --color-silver-mist: #b1b0b0; - --color-ember-orange: #ff4704; - --color-void-violet: #0447ff; + --font-ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + --font-mono: "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace; } :root { - color-scheme: light; - - /* Surfaces — parchment canvas + warm sand + elevated */ - --surface-0: var(--color-parchment-white); - --surface-1: var(--color-parchment-white); - --surface-2: var(--color-warm-sand); - --surface-muted: var(--color-warm-sand); - --surface-hover: var(--color-sand-hover); - --surface-elevated: #ffffff; - - /* Borders — hairline ash */ - --border-subtle: var(--color-ash-border); - --border-strong: var(--color-ash-strong); - - /* Text — ink / driftwood / fog */ - --text-primary: var(--color-midnight-ink); - --text-secondary: var(--color-driftwood); - --text-muted: var(--color-fog); - --text-value: var(--color-midnight-ink); - - /* Focus — ink ring, no blue */ - --focus-ring: var(--color-midnight-ink); - --focus-ring-offset: var(--color-parchment-white); - - /* Active control — filled ink (HIG segmented/primary) */ - --control-active-bg: var(--color-midnight-ink); - --control-active-border: var(--color-midnight-ink); - --control-active-text: #ffffff; - - /* Status — monochrome, ember reserved for hot (>=85C) */ - --status-normal: var(--color-driftwood); - --status-warm: var(--color-driftwood); - --status-hot: var(--color-ember-orange); - --status-unknown: var(--color-silver-mist); + color-scheme: light dark; + + --window-background: rgb(255 255 255 / 76%); + --desktop-background: + radial-gradient(circle at 16% 18%, rgb(248 243 238 / 86%), transparent 28%), + radial-gradient(circle at 84% 10%, rgb(181 203 222 / 76%), transparent 34%), + radial-gradient(circle at 70% 92%, rgb(136 151 165 / 62%), transparent 36%), + linear-gradient(135deg, rgb(232 228 223), rgb(191 205 216) 46%, rgb(230 231 230)); + --surface-0: rgb(242 244 247); + --surface-1: rgb(255 255 255 / 58%); + --surface-2: rgb(248 249 251 / 62%); + --surface-muted: rgb(240 242 245 / 78%); + --surface-hover: rgb(0 0 0 / 5%); + --surface-elevated: rgb(255 255 255 / 92%); + + --material-window: rgb(255 255 255 / 50%); + --material-toolbar: rgb(246 248 251 / 58%); + --material-control: rgb(255 255 255 / 62%); + --material-sheet: rgb(255 255 255 / 86%); + --material-popover: rgb(255 255 255 / 82%); + + --border-subtle: rgb(0 0 0 / 12%); + --border-strong: rgb(0 0 0 / 22%); + --table-column-separator: rgb(0 0 0 / 10%); + --table-row-alternating: rgb(0 0 0 / 4%); + --table-section-background: rgb(246 247 249 / 56%); + + --text-primary: rgb(0 0 0 / 88%); + --text-secondary: rgb(0 0 0 / 58%); + --text-muted: rgb(0 0 0 / 36%); + --text-value: rgb(0 0 0 / 78%); + + --accent-blue: rgb(0 120 240); + --accent-blue-pressed: rgb(1 101 226); + --selection-active: rgb(1 101 226); + --focus-ring: rgb(0 120 240 / 55%); + --focus-ring-offset: rgb(255 255 255 / 90%); + + --status-normal: rgb(52 199 89); + --status-warm: rgb(255 204 0); + --status-hot: rgb(255 69 58); + --status-unknown: rgb(142 142 147); + --color-status-normal: var(--status-normal); --color-status-warm: var(--status-warm); --color-status-hot: var(--status-hot); --color-status-unknown: var(--status-unknown); - --color-surface-card: var(--surface-2); + --color-surface-card: var(--surface-1); --color-surface-hover: var(--surface-hover); - /* Radii — Apple HIG geometry */ - --radius-card: 12px; - --radius-button: 7px; - --radius-segmented: 6px; - --radius-input: 5px; - --radius-dialog: 10px; - --radius-capsule: 9999px; - --radius-dot: 9999px; - - /* Shadows — hairline only */ - --shadow-hairline: rgba(0, 0, 0, 0.06) 0px 0px 0px 1px, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px; - --shadow-elevated: rgba(0, 0, 0, 0.4) 0px 0px 1px 0px, rgba(0, 0, 0, 0.04) 0px 1px 1px 0px, rgba(0, 0, 0, 0.04) 0px 2px 4px 0px; - --shadow-inset: rgba(0, 0, 0, 0.075) 0px 0px 0px 0.5px inset; + --radius-card: 18px; + --radius-dot: 50%; +} + +@media (prefers-color-scheme: dark) { + :root { + --window-background: rgb(30 31 34 / 76%); + --desktop-background: + radial-gradient(circle at 16% 18%, rgb(75 70 66 / 86%), transparent 28%), + radial-gradient(circle at 84% 10%, rgb(54 68 82 / 76%), transparent 34%), + radial-gradient(circle at 70% 92%, rgb(33 39 46 / 62%), transparent 36%), + linear-gradient(135deg, rgb(42 42 44), rgb(34 42 50) 46%, rgb(30 31 34)); + --surface-0: rgb(24 25 28); + --surface-1: rgb(36 37 41 / 84%); + --surface-2: rgb(43 44 48 / 76%); + --surface-muted: rgb(50 51 56 / 72%); + --surface-hover: rgb(255 255 255 / 7%); + --surface-elevated: rgb(46 47 52 / 88%); + + --material-window: rgb(34 35 39 / 74%); + --material-toolbar: rgb(42 43 48 / 70%); + --material-control: rgb(255 255 255 / 9%); + --material-sheet: rgb(44 45 50 / 88%); + --material-popover: rgb(50 51 56 / 88%); + + --border-subtle: rgb(255 255 255 / 12%); + --border-strong: rgb(255 255 255 / 24%); + --table-column-separator: rgb(255 255 255 / 11%); + --table-row-alternating: rgb(255 255 255 / 5%); + --table-section-background: rgb(255 255 255 / 6%); + + --text-primary: rgb(255 255 255 / 88%); + --text-secondary: rgb(255 255 255 / 62%); + --text-muted: rgb(255 255 255 / 38%); + --text-value: rgb(255 255 255 / 84%); + + --focus-ring-offset: rgb(31 32 36 / 92%); + } } html, @@ -95,10 +114,64 @@ body, body { font-family: var(--font-ui); - font-size: 13px; - letter-spacing: 0.01em; - background-color: var(--surface-0); + font-size: 12px; + background: + radial-gradient(circle at 20% 0%, rgb(255 255 255 / 60%), transparent 32%), + var(--surface-0); color: var(--text-primary); -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +@supports ((backdrop-filter: blur(20px)) or (-webkit-backdrop-filter: blur(20px))) { + .bg-\(\--material-window\), + .bg-\(\--material-toolbar\), + .bg-\(\--material-control\), + .bg-\(\--material-sheet\), + .bg-\(\--material-popover\) { + -webkit-backdrop-filter: blur(22px) saturate(1.35); + backdrop-filter: blur(22px) saturate(1.35); + } +} + +.liquid-range { + --range-progress: 50%; + appearance: none; + -webkit-appearance: none; + height: 4px; + border-radius: 999px; + background: linear-gradient( + to right, + var(--accent-blue) 0%, + var(--accent-blue) var(--range-progress), + rgb(0 0 0 / 24%) var(--range-progress), + rgb(0 0 0 / 24%) 100% + ); + outline: none; +} + +.liquid-range::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 26px; + height: 26px; + border-radius: 50%; + border: 1px solid rgb(0 0 0 / 12%); + background: rgb(255 255 255); + box-shadow: + 0 3px 8px rgb(0 0 0 / 20%), + inset 0 0 0 0.5px rgb(255 255 255 / 90%); +} + +.liquid-range::-moz-range-thumb { + width: 26px; + height: 26px; + border-radius: 50%; + border: 1px solid rgb(0 0 0 / 12%); + background: rgb(255 255 255); + box-shadow: 0 3px 8px rgb(0 0 0 / 20%); +} + +.liquid-range:disabled { + opacity: 0.45; } diff --git a/src/components/AboutDialog.svelte b/src/components/AboutDialog.svelte index de0fa6c..78db4f1 100644 --- a/src/components/AboutDialog.svelte +++ b/src/components/AboutDialog.svelte @@ -1,7 +1,9 @@
-
+
+ {/if} +
+ {/if} + +
- {bannerMessage} - {#if !isDevMode} +

+ FanGuard +

+ + +
- {/if} -
- {/if} + {#if showMenu} + + {/if} +
+ -
- {#if fanControlAvailable} - Active preset: - - {:else} - Monitoring only - {/if} -
+
+ -
- {#key fanControlRevision} - - {/key} - -
+
+
+
Name
+
Value
+
Range
+
Mode
+
Status
+
-
-
+
+
+ +
- {alwaysOnTop ? "Pinned" : "Pin on Top"} - -
- - - {#if !isAppStoreDistribution()} - - {/if} - -
+ + + SMC Connected + + {modelLabel} + + + {fanAccessLabel} + + Last updated just now + {#if showPreferences} - { showPreferences = false; }} /> + { showPreferences = false; }} + /> {/if} {#if showAbout} @@ -213,4 +333,5 @@ const chromeButtonActiveClass = {#if showUpdate} { showUpdate = false; }} /> {/if} + diff --git a/src/components/DisclosureGroup.svelte b/src/components/DisclosureGroup.svelte new file mode 100644 index 0000000..d760e3e --- /dev/null +++ b/src/components/DisclosureGroup.svelte @@ -0,0 +1,56 @@ + + +
+ + + {#if open} +
+ {@render children()} +
+ {/if} +
diff --git a/src/components/FanControlModal.svelte b/src/components/FanControlModal.svelte index ecbb63b..462f706 100644 --- a/src/components/FanControlModal.svelte +++ b/src/components/FanControlModal.svelte @@ -11,6 +11,13 @@ import { setFanSensorControl, } from "$lib/tauriCommands"; import type { FanControlConfig, FanData, Sensor } from "$lib/types"; +import { + backdropSurface, + buttonPrimary, + buttonSecondary, + inputClass, + sheetSurface, +} from "$lib/ui"; interface Props { fan: FanData; @@ -21,11 +28,8 @@ interface Props { const { fan, sensors, currentConfig, onclose }: Props = $props(); -// Filter to sensors relevant for fan control decisions const tempSensors = $derived(getFanControlSensors(sensors)); -// ── Modal state ────────────────────────────────────────────────────────── - type ControlMode = "constant_rpm" | "sensor_based"; let selectedMode: ControlMode = $state( @@ -61,23 +65,17 @@ let isSubmitting: boolean = $state(false); let errorMessage: string = $state(""); let extremeWarningAcknowledged: boolean = $state(false); -// ── Element refs ───────────────────────────────────────────────────────── - let dialogEl: HTMLDivElement | undefined = $state(undefined); let okButtonEl: HTMLButtonElement | undefined = $state(undefined); -// Auto-focus OK button on mount $effect(() => { okButtonEl?.focus(); }); -// Clamp RPM to valid range const clampedRpm = $derived( Math.max(fan.min, Math.min(fan.max, Math.round(constantRpm))), ); -// ── Temperature range bar state ────────────────────────────────────────── - const selectedSensorValue = $derived( tempSensors.find((s) => s.key === selectedSensorKey)?.value ?? null, ); @@ -94,12 +92,6 @@ const rangeBarAriaLabel = $derived( : `Fan speed range: minimum at ${tempLow}°C, maximum at ${tempHigh}°C.`, ); -const isMarkerHot = $derived( - selectedSensorValue !== null && selectedSensorValue >= 85, -); - -// ── Handlers ───────────────────────────────────────────────────────────── - async function handleSubmit(): Promise { isSubmitting = true; errorMessage = ""; @@ -172,24 +164,6 @@ function handleKeydown(event: KeyboardEvent): void { } } -// ── Shared styles ──────────────────────────────────────────────────────── - -const buttonBase = - "cursor-pointer rounded-(--radius-button) border px-4 py-1.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--focus-ring) focus-visible:ring-offset-1 focus-visible:ring-offset-(--focus-ring-offset)"; -const cancelButton = cn( - buttonBase, - "border-(--border-subtle) bg-(--surface-elevated) text-(--text-primary) shadow-(--shadow-hairline) hover:bg-(--surface-2)", -); -const okButton = cn( - buttonBase, - "border-(--control-active-border) bg-(--control-active-bg) text-(--control-active-text) disabled:opacity-50 disabled:cursor-not-allowed", -); - -const inputBase = - "rounded-(--radius-input) border border-(--border-subtle) bg-(--surface-elevated) px-2 py-1 text-[12px] text-(--text-primary) font-mono [font-variant-numeric:tabular-nums] focus:outline-none focus:ring-2 focus:ring-(--focus-ring)"; - -// ── Extreme settings detection ────────────────────────────────────────── - const rpmRange = $derived(fan.max - fan.min); const isExtremeRpm = $derived( selectedMode === "constant_rpm" && @@ -217,49 +191,41 @@ const extremeWarningMessage = $derived( -
- - - {/if} -
- {#if fanRows.length === 0} -
- No fan telemetry available. +
+ Target RPM +
+
+ + {globalState.range.kind === "ready" ? minRpm : "--"} + +
+ + {globalState.canControl ? `${targetValue} RPM` : "--"} + + +
+ + {globalState.range.kind === "ready" ? maxRpm : "--"} + +
+
+ Current average: {currentRpmLabel} +
- {:else} - {#each fanRows as fan (fan.id)} -
+ +
+
+ +

+ + Applies to all fans. +

+ + {#if globalState.disabledReason} +

+ {globalState.disabledReason} +

+ {/if} + + {#if !hasWriteAccess} +

+ Fan writes need administrator access. +

+ {/if} + + {#if statusMessage} +
+ + {#if statusKind === "error"} + + {:else} + + {/if} + {statusMessage} + + {#if statusKind === "error" && statusMessage === "Fan control requires elevated privileges." && !isDevModeError(statusMessage)} +
-
- {#if fanControlAvailable} -
- - -
- {:else} - Monitoring only - {/if} -
-
- {/each} + Restart with Admin Privileges + + {/if} +
{/if}
- -{#if modalFan} - -{/if} diff --git a/src/components/FanControlPane.test.ts b/src/components/FanControlPane.test.ts index e5f88eb..11077e7 100644 --- a/src/components/FanControlPane.test.ts +++ b/src/components/FanControlPane.test.ts @@ -1,14 +1,18 @@ import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import type { FanData, Sensor, SensorData } from "$lib/types"; +import type { FanData } from "$lib/types"; import FanControlPane from "./FanControlPane.svelte"; +const { setAllFansConstantRpm, restoreAllFansAuto } = vi.hoisted(() => ({ + setAllFansConstantRpm: vi.fn().mockResolvedValue(undefined), + restoreAllFansAuto: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("$lib/tauriCommands", () => ({ - getFanControlConfigs: vi.fn().mockResolvedValue({}), - setFanAuto: vi.fn().mockResolvedValue(undefined), - setFanConstantRpm: vi.fn().mockResolvedValue(undefined), requestPrivilegeRestart: vi.fn(), + restoreAllFansAuto, + setAllFansConstantRpm, })); const autoFan: FanData = { @@ -17,109 +21,52 @@ const autoFan: FanData = { actual: 2000, min: 1200, max: 6550, - target: 1200, + target: 2000, mode: "auto", }; -const forcedFan: FanData = { - ...autoFan, - mode: "forced", - target: 4000, -}; - -const sensors: Sensor[] = [ - { - key: "TC0D", - name: "CPU Die", - value: 55, - unit: "C", - sensor_type: "Cpu", - }, -]; - -const toDesignToken = (fan: FanData) => ({ - id: `fan-${fan.index}`, - fanIndex: fan.index, - label: fan.label, - value: Math.round(fan.actual), - unit: "rpm" as const, - status: "normal" as const, - minRpm: Math.round(fan.min), - maxRpm: Math.round(fan.max), - targetRpm: Math.round(fan.target), - controlMode: fan.mode === "forced" ? ("constant" as const) : ("auto" as const), -}); +const renderPane = (hasWriteAccess = true) => + render(FanControlPane, { + props: { rawFans: [autoFan], hasWriteAccess }, + }); describe("FanControlPane", () => { - it("shows Auto active when fan mode is auto", () => { - render(FanControlPane, { - props: { - fans: [toDesignToken(autoFan)], - rawFans: [autoFan], - sensors, - hasWriteAccess: true, - fanControlAvailable: true, - }, - }); + it("shows the shared numeric RPM range and disables Apply in Auto mode", () => { + renderPane(); - expect(screen.getByRole("button", { name: "Set Fan 0 to auto mode" })).toHaveClass( - "text-(--control-active-text)", - ); - expect(screen.getByRole("button", { name: "Set Fan 0 to custom mode" })).not.toHaveClass( - "text-(--control-active-text)", - ); + expect(screen.getByText("1200")).toBeVisible(); + expect(screen.getByText("6550")).toBeVisible(); + expect(screen.getByText("2000 RPM")).toBeVisible(); + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); }); - it("shows Custom active when fan mode is forced", () => { - render(FanControlPane, { - props: { - fans: [toDesignToken(forcedFan)], - rawFans: [forcedFan], - sensors, - hasWriteAccess: true, - fanControlAvailable: true, - }, - }); + it("applies one manual target through the global backend command", async () => { + const user = userEvent.setup(); + renderPane(); + + await user.click(screen.getByRole("button", { name: "Manual" })); + await user.click(screen.getByRole("button", { name: "Apply" })); - const customButton = screen.getByRole("button", { name: "Set Fan 0 to custom mode" }); - expect(customButton).toHaveClass("text-(--control-active-text)"); - expect(customButton).toHaveTextContent("Constant value of 4000"); - expect(screen.getByRole("button", { name: "Set Fan 0 to auto mode" })).not.toHaveClass( - "text-(--control-active-text)", - ); + expect(setAllFansConstantRpm).toHaveBeenCalledWith(2000); + expect(screen.getByText("Applied 2000 RPM to 1 fan.")).toBeVisible(); }); - it("calls setFanAuto when Auto is clicked", async () => { + it("restores all fans to automatic control", async () => { const user = userEvent.setup(); - const { setFanAuto } = await import("$lib/tauriCommands"); + renderPane(); - render(FanControlPane, { - props: { - fans: [toDesignToken(forcedFan)], - rawFans: [forcedFan], - sensors, - hasWriteAccess: true, - fanControlAvailable: true, - }, - }); + await user.click(screen.getByRole("button", { name: "Restore Auto" })); - await user.click(screen.getByRole("button", { name: "Set Fan 0 to auto mode" })); - expect(setFanAuto).toHaveBeenCalledWith(0); + expect(restoreAllFansAuto).toHaveBeenCalledTimes(1); + expect(screen.getByText("Restored automatic control for 1 fan.")).toBeVisible(); }); - it("hides write controls when fan control is unavailable", () => { - render(FanControlPane, { - props: { - fans: [toDesignToken(autoFan)], - rawFans: [autoFan], - sensors, - hasWriteAccess: false, - fanControlAvailable: false, - }, - }); + it("disables fan writes without administrator access", () => { + renderPane(false); - expect(screen.queryByRole("button", { name: "Set Fan 0 to auto mode" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Set Fan 0 to custom mode" })).not.toBeInTheDocument(); - expect(screen.getByText("Monitoring only")).toBeVisible(); + expect(screen.getByRole("slider", { name: "Target RPM" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Restore Auto" })).toBeDisabled(); + expect(screen.getByText("Fan writes need administrator access.")).toBeVisible(); }); }); diff --git a/src/components/FanTelemetryRows.svelte b/src/components/FanTelemetryRows.svelte new file mode 100644 index 0000000..e3b2555 --- /dev/null +++ b/src/components/FanTelemetryRows.svelte @@ -0,0 +1,59 @@ + + +{#if fanRows.length === 0} +
+ {emptyLabel} +
+{:else} + {#each fanRows as fan, i (fan.id)} +
+
+ + {fan.label} +
+
+ {formatRpm(fan.currentRpm)} +
+
+ {fan.minRpm} - {fan.maxRpm} +
+
+ {modeLabel(fan.controlMode)} +
+
+ + OK +
+
+ {/each} +{/if} diff --git a/src/components/PowerPresetSettings.svelte b/src/components/PowerPresetSettings.svelte index a396dd9..04c6271 100644 --- a/src/components/PowerPresetSettings.svelte +++ b/src/components/PowerPresetSettings.svelte @@ -8,6 +8,7 @@ import { setPowerPresetConfig, } from "$lib/tauriCommands"; import type { PowerPresetConfig, PowerSource, Preset } from "$lib/types"; +import { inputClass } from "$lib/ui"; let config: PowerPresetConfig = $state({ enabled: false, @@ -72,16 +73,18 @@ async function handleBatteryPreset(event: Event): Promise { } } -const powerSourceLabel = $derived( - powerSource === "ac" - ? "AC Power" - : powerSource === "battery" - ? "Battery" - : "Unknown", -); +function formatPowerSource(source: PowerSource): string { + switch (source) { + case "ac": + return "AC Power"; + case "battery": + return "Battery"; + case "unknown": + return "Unknown"; + } +} -const selectClass = - "w-full rounded-(--radius-input) border border-(--border-subtle) bg-(--surface-elevated) px-2 py-1 text-[12px] text-(--text-primary) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--focus-ring)"; +const powerSourceLabel = $derived(formatPowerSource(powerSource));
@@ -92,12 +95,12 @@ const selectClass =
-
+
-
- {#if fanControlAvailable} - - {:else} -

- Fan preset automation is unavailable in TestFlight builds. -

- {/if} -
+ {#if fanControlAvailable} + + {:else} +

Fan preset automation is unavailable in TestFlight builds.

+ {/if} -
+
{#if isOpen} @@ -192,7 +178,8 @@ const primaryButton = cn( role="menu" aria-label="Fan presets" class={cn( - 'absolute top-full left-0 mt-1 z-50 min-w-[200px] rounded-(--radius-dialog) border border-(--border-subtle) bg-(--surface-elevated) shadow-(--shadow-elevated) overflow-hidden' + sheetSurface, + "absolute top-full left-0 mt-1 z-50 min-w-[220px] overflow-hidden py-1" )} > {#each builtinPresets as preset, i (preset.name)} @@ -200,46 +187,46 @@ const primaryButton = cn( type="button" role="menuitem" class={cn( - dropdownItemClass, - preset.name === activePresetName ? 'font-medium' : '', - focusedIndex === i ? 'bg-(--surface-2)' : '' + itemClass, + preset.name === activePresetName && "font-medium", + focusedIndex === i && "bg-(--surface-hover)" )} onclick={() => handleApplyPreset(preset.name)} > {preset.name} {#if preset.name === activePresetName} -