Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 24 additions & 23 deletions .github/workflows/flatpak.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,16 @@ jobs:
name: "Flatpak"
runs-on: ubuntu-latest
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-48
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50
options: --privileged
env:
# The container's HOME (/github/home) sits under a world-writable /github, which the
# SWC native addon refuses to use as a cache root. Keep caches under root's home.
XDG_CACHE_HOME: /root/.cache
SWC_NATIVE_BINDING_CACHE: /root/.cache/swc
steps:
- name: Prepare cache directory
run: mkdir -p /root/.cache/swc && chmod 700 /root/.cache
- uses: actions/checkout@v4

- name: Setup pnpm
Expand All @@ -21,32 +28,26 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '24'

# gtkx deploy builds against the GNOME 50 runtime from the user installation, and codegen
# needs GTK's GIR files, which this container only has inside the SDK.
- name: Install the GNOME 50 SDK
run: |
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y --noninteractive flathub org.gnome.Platform//50 org.gnome.Sdk//50
echo "GTKX_GIR_PATH=$(flatpak info --user --show-location org.gnome.Sdk//50)/files/share/gir-1.0" >> "$GITHUB_ENV"

- name: Install dependencies
run: pnpm install

- name: Build TypeScript
run: pnpm build
- name: Typecheck
run: pnpm typecheck

- name: Bundle JavaScript
run: pnpm bundle

- name: Copy native module
run: |
mkdir -p dist
if [ -f node_modules/@gtkx/native/index.node ]; then
cp node_modules/@gtkx/native/index.node dist/index.node
elif [ -f node_modules/@gtkx/native/dist/index.node ]; then
cp node_modules/@gtkx/native/dist/index.node dist/index.node
else
echo "Error: Could not find @gtkx/native/index.node"
find node_modules/@gtkx/native -name "*.node" || true
exit 1
fi
- name: Build Flatpak
run: pnpm build:flatpak

- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
- uses: actions/upload-artifact@v4
with:
bundle: io.github.tduarte.cafe.flatpak
manifest-path: flatpak/io.github.tduarte.cafe.yaml
cache-key: flatpak-builder-${{ github.sha }}
name: io.github.tduarte.cafe.flatpak
path: build/out/*.flatpak
166 changes: 166 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
name: Release

# Pushing a v* tag builds that tag, signs it and publishes it twice: as a
# Flatpak repository on GitHub Pages, which is what installs update from, and
# as a single-file bundle on the GitHub release. docs/RELEASING.md has the
# one-time setup (signing key, Pages) and the steps for cutting a release.
on:
push:
tags: ['v*']
# Publishes an existing tag again, for example after the Pages setup changed.
# The tag's own source, scripts and config are what get built.
workflow_dispatch:
inputs:
tag:
description: Tag to build and publish, such as v1.0.1
required: true

permissions:
contents: read

# Two releases deploying to the same Pages site must not interleave, and one
# that has started signing should finish.
concurrency:
group: release
cancel-in-progress: false

jobs:
build:
name: Build and sign
runs-on: ubuntu-latest
# pages: read is for configure-pages, which asks the API for the site URL.
permissions:
contents: read
pages: read
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50
options: --privileged
env:
# The container's HOME (/github/home) sits under a world-writable /github, which the
# SWC native addon refuses to use as a cache root. Keep caches under root's home.
XDG_CACHE_HOME: /root/.cache
SWC_NATIVE_BINDING_CACHE: /root/.cache/swc
TAG: ${{ inputs.tag || github.ref_name }}
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Prepare cache directory
run: mkdir -p /root/.cache/swc && chmod 700 /root/.cache

- uses: actions/checkout@v4
with:
ref: refs/tags/${{ env.TAG }}

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'

# gtkx deploy names the release after package.json, so the tag must agree.
- name: Check the tag
id: tag
run: |
if ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::'$TAG' is not a release tag"
exit 1
fi
version=$(node -p "require('./package.json').version")
if [ "v$version" != "$TAG" ]; then
echo "::error::tag is $TAG but its package.json says $version"
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"

- name: Import the signing key
id: key
env:
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
run: |
if [ -z "$FLATPAK_GPG_PRIVATE_KEY" ]; then
echo "::error::the FLATPAK_GPG_PRIVATE_KEY secret is not set, see docs/RELEASING.md"
exit 1
fi
printf '%s\n' "$FLATPAK_GPG_PRIVATE_KEY" | gpg --batch --import
fingerprint=$(gpg --batch --list-secret-keys --with-colons \
| awk -F: '$1=="fpr"{print $10; exit}')
echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"

# gtkx deploy builds against the GNOME 50 runtime from the user installation, and codegen
# needs GTK's GIR files, which this container only has inside the SDK.
- name: Install the GNOME 50 SDK
run: |
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y --noninteractive flathub org.gnome.Platform//50 org.gnome.Sdk//50
echo "GTKX_GIR_PATH=$(flatpak info --user --show-location org.gnome.Sdk//50)/files/share/gir-1.0" >> "$GITHUB_ENV"

- name: Install dependencies
run: pnpm install --frozen-lockfile

# Leaves the OSTree repo in build/targets/flatpak/repo and the bundle in build/out/.
- name: Build
run: pnpm build:flatpak

- name: Find the Pages URL
id: pages
uses: actions/configure-pages@v5

- name: Sign and assemble the site
run: |
build-aux/publish-repo.sh build/targets/flatpak/repo site \
"${{ steps.pages.outputs.base_url }}" \
"${{ steps.key.outputs.fingerprint }}"

- uses: actions/upload-pages-artifact@v3
with:
path: site

- uses: actions/upload-artifact@v4
with:
name: bundle
path: build/out/*.flatpak
if-no-files-found: error

pages:
name: Publish the repository
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v4

bundle:
name: Attach the bundle
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: bundle

# The release may already exist, when the tag was made by drafting the
# release on GitHub.
- name: Upload to the release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ needs.build.outputs.tag }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" ./*.flatpak --clobber
else
gh release create "$TAG" ./*.flatpak \
--title "Cafe ${TAG#v}" --generate-notes
fi
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
node_modules/
dist/
build/
.gtkx/
*.log
.DS_Store
/flatpak-repo
tsconfig*.tsbuildinfo
26 changes: 26 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!-- BEGIN:gtkx-agent-rules -->

# GTKX

This is not the GTK you have seen before. Most GTK code in your training data is C, PyGObject, Vala or GJS, and almost none of it is valid here. Check the rules below against what you are about to write.

- Children are JSX, never `.append()`, `pack_start()`, `set_child()` or `add()`.
- Signals are props: `onClicked`, not `widget.connect("clicked", ...)`.
- Props are camelCase: `marginTop`, not `margin-top` or `margin_top`.
- There is no `Gtk.Template`, no `.ui` XML, and no `GtkBuilder`. The JSX tree is the definition.
- Elements come from `@gtkx/jsx/<namespace>` and classes, enums and functions from `@gtkx/gi/<namespace>`. Both are generated for this project by `gtkx codegen`, not installed from npm, so they match the GIR libraries this project declares.

Read `.gtkx/reference/index.md` before writing widget code. It is generated from this project's own GIR libraries and is the authority on which props, signals and methods exist. Do not infer a prop from another toolkit, from a C function name, or from a similar element.

| Command | What it does |
| --- | --- |
| `gtkx dev` | Run the app with fast refresh |
| `gtkx codegen` | Regenerate bindings and this reference |
| `tsc --noEmit` | Typecheck |
| `vitest run` | Run the tests |

Never call UI work done without looking at the running app. With `gtkx dev` up, the gtkx MCP server exposes the live widget tree, queries, clicks and screenshots; use them to confirm the change landed.

This block is written by `gtkx codegen`. Anything outside the markers is yours and is left alone, and committing the block with your work keeps the tree clean.

<!-- END:gtkx-agent-rules -->
39 changes: 39 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

Cafe is a native GTK4/Libadwaita coffee-to-water ratio calculator for the Linux desktop, written in React + TypeScript on [GTKX](https://gtkx.dev) 1.6. App ID: `io.github.tduarte.cafe` (set in `gtkx.config.ts`). Licensed GPL-3.0; source files carry the GPL header.

**Read `AGENTS.md` before writing widget code.** It is generated by `gtkx codegen` and points at `.gtkx/reference/index.md`, the per-project reference for which props, signals and methods exist. GTKX is not GJS/PyGObject: children are JSX, signals are `on*` props, and most GTK code from other bindings does not apply.

## Commands

Package manager is pnpm; Node 24+. Needs GTK 4.20+ and Libadwaita 1.8+ on the host.

```bash
pnpm install
pnpm dev # gtkx dev: runs the app with fast refresh (entry: src/index.tsx)
pnpm typecheck # gtkx codegen && tsc (the only static check)
pnpm build # gtkx build → dist/bundle.mjs + dist/gtkx.node
pnpm start # run dist/bundle.mjs
pnpm build:flatpak # gtkx deploy --target flatpak → build/
```

There are no tests or linter. `./run-dev.sh` runs `pnpm dev` inside a `coffee-calc-dev` distrobox.

## Architecture

- `@gtkx/gi/<ns>` (classes, enums) and `@gtkx/jsx/<ns>` (JSX elements) are **generated per project** by `gtkx codegen` into `node_modules/.gtkx`, not installed from npm. Run `pnpm codegen` if those imports fail to resolve. Higher-level widgets like `ComboRow` come from `@gtkx/components/adw`.
- `src/app.tsx`: `App` is an `AdwApplication` that registers the `Ctrl+,` accelerator for `win.preferences`. `MainWindow` declares the `win.preferences`/`win.about` actions via the window's `actions` prop (`GSimpleAction`), and a `GMenu` in the header bar triggers them. Dialogs (`AdwPreferencesDialog`, `AdwAboutDialog`) are rendered conditionally as children of the window from a `dialog` state, and reset it in `onClosed`.
- State is a single `Brew` (`grams`, `method`, `units`). Coffee weight in grams is the source of truth; the displayed coffee, water and both rows' bounds are all derived from it by `displayAmounts`, so unit switches are lossless. The spin rows echo programmatic value changes back through `onNotifyValue`, so the handlers read `brewRef` (not the render closure) and ignore values within half a display step of what is shown. The rows are keyed on units+method so that changed bounds are never applied after the value, which would clamp it and echo a bogus edit.
- `src/utils/calculations.ts`: pure logic. `BREWING_RATIOS` (coffee:water as a fraction), `MAX_WATER_ML` per method (home-use limits; the coffee limit is derived through the ratio by `maxCoffeeGrams`), metric/imperial conversions, `calculateWater`/`calculateCoffee`. All math is done in metric internally. To add a brew method, add a key here (the `BrewingMethod` type derives from it) plus `MAX_WATER_ML`, and an entry in `BREWING_METHODS` in `app.tsx`.

## Packaging

`gtkx deploy` generates the Flatpak manifest (GNOME 50 runtime), desktop entry and metainfo from the `deploy` block in `gtkx.config.ts`, and it bundles Node. There is no hand-written manifest. `vite.config.ts` (merged by `gtkx dev`) excludes `build/` from the file watcher, because the Flatpak build tree has symlink loops that made the dev server run out of memory. Keep that exclusion. Icons live in `data/icons/` (hicolor layout, named after the app ID). Releases: pushing a `v*` tag matching `package.json`'s version runs `.github/workflows/release.yml`. It builds with `gtkx deploy`, then `build-aux/publish-repo.sh` GPG-signs the OSTree repo in `build/targets/flatpak/repo` and builds a static site: the repo, a `.flatpakref`/`.flatpakrepo`, and `build-aux/pages/index.html` with `@URL@` substituted. The site is deployed to GitHub Pages and the bundle is attached to the GitHub release. The app branch must stay `stable` (it is pinned in `gtkx.config.ts`). Setup and the local test procedure are in `docs/RELEASING.md`. CI (`.github/workflows/flatpak.yml`) runs typecheck and then `pnpm build:flatpak` in the `gnome-50` flatpak-github-actions container.

## Verifying UI changes

With `pnpm dev` running, the GTKX MCP server (`npx gtkx mcp`, which needs the `@gtkx/testing` dev dependency) can list the widget tree, click widgets and take window screenshots. Two quirks: open `AdwDialog`s do not show up in the widget tree even when visible, so check dialogs with a screenshot. Also give dialog animations a moment before capturing. Fast Refresh preserves component state, so changes to initial `useState` values need a dev-server restart.
Loading
Loading