Skip to content
Open
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
53 changes: 53 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,59 @@ jobs:
BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }}
run: ./gradlew :client:composeApp:${{ matrix.task }}

# The Flathub manifest (packaging/linux/com.plusmobileapps.chefmate.yml) consumes this tarball
# rather than the .deb: it is the jpackage app image — bundled jlink runtime included, so the
# flatpak needs no JDK extension — plus the hicolor icon sizes and the license file that the
# manifest installs into the sandbox prefix.
- name: Package Linux app image tarball (Flathub)
if: runner.os == 'Linux'
# Same reason the Windows MSIX step re-declares these: BuildKonfig reads them via
# System.getenv at Gradle configuration time, and GitHub Actions env vars are per-step.
# Without them this step reconfigures with placeholder Supabase keys and rebuilds the app
# image with broken auth.
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }}
run: |
set -euo pipefail
./gradlew :client:composeApp:createReleaseDistributable

if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION=$(sed -n 's/.*versionName = "\(.*\)".*/\1/p' client/composeApp/build.gradle.kts | head -n1)
fi

# jpackage names the app image directory after the platform packageName, so glob for it
# instead of hardcoding a name the Gradle config could change out from under us.
APP_DIR=$(find client/composeApp/build/compose/binaries/main-release/app \
-mindepth 1 -maxdepth 1 -type d | head -n1)
if [ -z "$APP_DIR" ]; then echo "No app image produced" >&2; exit 1; fi

STAGE="${RUNNER_TEMP}/chef-mate-${VERSION}"
mkdir -p "$STAGE/icons"
cp -a "$APP_DIR" "$STAGE/app"
cp LICENSE "$STAGE/LICENSE"

# Flathub wants icons at the standard hicolor sizes; the source is 584x584, which is not
# one of them, so resize rather than shipping a mis-binned icon.
IM=$(command -v magick || command -v convert)
for size in 64 128 256 512; do
"$IM" client/composeApp/src/jvmMain/resources/app-icon.png \
-resize "${size}x${size}" "$STAGE/icons/${size}.png"
done

tar -czf "${RUNNER_TEMP}/chef-mate-${VERSION}-linux-x86_64.tar.gz" \
-C "${RUNNER_TEMP}" "chef-mate-${VERSION}"

- name: Upload Flathub tarball (Linux)
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: desktop-linux-flatpak-tarball
path: ${{ runner.temp }}/chef-mate-*-linux-x86_64.tar.gz

- name: Build app image for MSIX (Windows)
if: runner.os == 'Windows'
# BuildKonfig reads these at Gradle configuration time via System.getenv, and GitHub
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.plusmobileapps.chefmate.deeplink

import co.touchlab.kermit.Logger
import com.plusmobileapps.chefmate.ChefMateUrls
import com.plusmobileapps.chefmate.platform.isRunningInFlatpak
import java.io.File

/**
Expand All @@ -25,6 +26,11 @@ object SchemeRegistrar {
* `--args` instead).
*/
fun registerIfPackaged() {
// The Flathub build declares x-scheme-handler/chefmate in its exported .desktop file, so
// the sandbox already routes the scheme. Self-registering there would only write a handler
// inside the sandbox pointing at the sandbox-internal launcher path, which the host session
// can neither see nor run.
if (isRunningInFlatpak) return
val launcher = launcherPath() ?: return
runCatching {
when (desktopOs()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ private const val KEY_WINDOW_PLACEMENT = "window.placement"

@OptIn(ExperimentalTime::class, FlowPreview::class)
fun main(args: Array<String>) {
// X11 derives a window's WM_CLASS from the main class name unless this is set, which would make
// it `com-plusmobileapps-chefmate-MainKt`. Desktop shells match a running window to its
// launcher by comparing WM_CLASS against the .desktop file's StartupWMClass, so pin it to the
// app id the Linux .desktop files declare — otherwise the window shows up as a second,
// unnamed entry in the dock instead of the Chef Mate icon. Must run before AWT initializes.
System.setProperty("sun.awt.X11.XToolkit.awtAppClassName", "com.plusmobileapps.chefmate")

// Windows/Linux spawn a fresh process for every `chefmate://…` open. If another instance is
// already running, forward this launch's link to it and exit without opening a second window.
if (!SingleInstance.acquireOrForward(args)) return
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.plusmobileapps.chefmate.platform

/**
* Whether this process is running inside the Flatpak sandbox (the Flathub build of the Linux app).
*
* Flatpak exports `FLATPAK_ID` into every sandboxed process, and also creates `/.flatpak-info`
* inside the sandbox; either is a reliable signal, and both are checked because `FLATPAK_ID` can be
* dropped by a wrapper that scrubs the environment.
*
* Behavior that the sandbox owns rather than the app must be skipped when this is true — the app
* cannot replace its own installation (Flathub publishes updates), and it cannot register OS-level
* handlers itself (the exported `.desktop` file does that declaratively).
*/
val isRunningInFlatpak: Boolean by lazy {
!System.getenv("FLATPAK_ID").isNullOrBlank() || java.io.File("/.flatpak-info").exists()
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.plusmobileapps.chefmate.update

import co.touchlab.kermit.Logger
import com.plusmobileapps.chefmate.buildconfig.BuildConfig
import com.plusmobileapps.chefmate.platform.isRunningInFlatpak
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get
Expand Down Expand Up @@ -50,8 +51,12 @@ class DesktopUpdater(

private var available: UpdateState.Available? = null

/** In-app updates run on Linux only; macOS and Windows defer to their respective app stores. */
private val isSupported: Boolean = platformKey() == "linux"
/**
* In-app updates run on Linux only; macOS and Windows defer to their respective app stores. The
* Flathub build is excluded for the same reason as the stores: Flatpak owns the install and the
* sandbox is read-only, so the app can neither replace itself nor run a downloaded .deb.
*/
private val isSupported: Boolean = platformKey() == "linux" && !isRunningInFlatpak

fun checkForUpdates() {
if (!isSupported) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,16 @@ actual class DriverFactory {
return when {
os.contains("mac") -> File(userHome, "Library/Application Support/Chef Mate")
os.contains("win") -> File(System.getenv("APPDATA") ?: userHome, "Chef Mate")
else -> File(userHome, ".local/share/chef-mate") // Linux
// Linux: honor XDG_DATA_HOME, falling back to its spec default of ~/.local/share.
// Outside a sandbox the two are the same path, but the Flatpak build has no access to
// the real ~/.local/share — Flatpak points XDG_DATA_HOME at the app's private
// ~/.var/app/<id>/data instead, and writing the hardcoded path there fails.
else -> {
val dataHome =
System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() }
?: File(userHome, ".local/share").path
File(dataHome, "chef-mate")
}
}
}
}
19 changes: 19 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ GitHub always serves `releases/latest/download/<asset>` from the newest non-prer

This is a *signed-installer* model (download + run the new signed package), **not** in-place jar patching.

It is also a no-op in the Flathub build (`isRunningInFlatpak`), where Flatpak owns updates and the sandbox is read-only. See [Linux: Flathub](#linux-flathub).

**`latest.json` shape:**

```json
Expand All @@ -219,6 +221,23 @@ This is a *signed-installer* model (download + run the new signed package), **no

---

## Linux: Flathub

Linux ships two ways: the `.deb` on the GitHub Release (self-updating via the feed above), and a
Flatpak on Flathub.

The Flatpak manifest and its AppStream/desktop metadata live in [`packaging/linux/`](../packaging/linux/README.md),
which is also the runbook for building, linting, submitting, and maintaining it. In short:

- App id `com.plusmobileapps.chefmate`, runtime `org.gnome.Platform//50` (GTK 3 is needed by JavaFX).
- The manifest consumes `chef-mate-<version>-linux-x86_64.tar.gz` — the jpackage app image, bundled
jlink runtime included — which the `Desktop Release` workflow builds and attaches to the release.
It is **not** built from source on Flathub infrastructure; the reasons are in the packaging README.
- Once submitted, Flathub's external-data-checker opens the version-bump PR when a new release
publishes a matching tarball. Nothing in this repo pushes to Flathub.

---

## Windows: Microsoft Store (MSIX)

Windows ships through the **Microsoft Store** as an MSIX package. This is the recommended Windows path because:
Expand Down
122 changes: 122 additions & 0 deletions packaging/linux/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Flathub packaging

Flatpak/Flathub packaging for the Linux desktop client. The files here are the ones that get
committed to the app's Flathub repository; they live in this repo so they are reviewed and
version-bumped alongside the code they package.

| File | Purpose |
| ---------------------------------------- | ------------------------------------------------ |
| `com.plusmobileapps.chefmate.yml` | Flatpak manifest (the submission entry point) |
| `com.plusmobileapps.chefmate.metainfo.xml`| AppStream metadata — store listing, OARS, releases|
| `com.plusmobileapps.chefmate.desktop` | Desktop entry exported by the sandbox |
| `chef-mate.sh` | Launcher shim placed at `/app/bin/chef-mate` |

The app id is `com.plusmobileapps.chefmate`, which Flathub allows because `plusmobileapps.com` is
under our control and serves the app's site at `https://chefmate.plusmobileapps.com`.

## Why this is not built from source

Flathub prefers manifests that compile the app inside the build sandbox, with no network access.
That is not achievable for this app today:

- Every `client/*` module applies the Android Gradle plugin, so the whole Gradle graph — including
the JVM target — requires the Android SDK to configure. The SDK is not redistributable and cannot
be a Flatpak source, so even a JVM-only entry point cannot resolve.
- Flathub builds are offline. A Gradle build of this size would need every Maven artifact, the
Gradle distribution, and the Kotlin compiler pinned as manifest sources (via something like
`flatpak-gradle-generator`), regenerated on each dependency change.

So the manifest consumes the jpackage app image tarball published by the `Desktop Release` workflow,
the same approach Flathub accepts for other large JVM apps (for example IntelliJ IDEA Community,
which is Apache-2.0 and still ships an upstream-built tarball). **Expect reviewers to ask about
this** — the answer is the two bullets above. If they insist on a source build, the prerequisite is
splitting a JVM-only variant of the module graph that never applies the Android plugin.

The tarball already contains a jlink'd JRE produced by jpackage, so the manifest needs no OpenJDK
SDK extension.

## Runtime choices

- **`org.gnome.Platform`, not `org.freedesktop.Platform`** — JavaFX (the in-app recipe browser's
WebView) links against GTK 3, which the Freedesktop runtime does not ship.
- **`--socket=x11`, not `--socket=fallback-x11`** — Compose Desktop renders through AWT/Skiko and
JavaFX through GTK; neither has a Wayland backend, so both need XWayland even in a Wayland
session. `chef-mate.sh` pins `GDK_BACKEND=x11` so GDK does not pick Wayland out from under JavaFX.
- **`--filesystem=xdg-pictures`, `xdg-documents`, `xdg-download`** — the pickers are
`java.awt.FileDialog`, which browses the sandbox filesystem directly instead of going through the
document portal. Without these, photo import and recipe archive import/export cannot see anything.

## Code paths that are Flatpak-aware

- `platform/Flatpak.kt` — `isRunningInFlatpak`, detected from `FLATPAK_ID` / `/.flatpak-info`.
- `update/DesktopUpdater.kt` — the in-app updater is disabled under Flatpak. Flathub owns updates,
and the sandbox is read-only, so downloading and running a `.deb` cannot work.
- `deeplink/SchemeRegistrar.kt` — self-registration of `chefmate://` is skipped; the exported
`.desktop` file declares `x-scheme-handler/chefmate` instead.
- `database/DriverFactory.jvm.kt` — the Linux data directory honors `XDG_DATA_HOME`, which Flatpak
points at `~/.var/app/com.plusmobileapps.chefmate/data`. The hardcoded `~/.local/share` is not
writable inside the sandbox.
- `main.kt` — sets `sun.awt.X11.XToolkit.awtAppClassName` so `WM_CLASS` matches the `.desktop`
file's `StartupWMClass` and the window binds to the right dock icon.

## Build and test locally (needs a Linux machine)

```bash
flatpak install -y flathub org.flatpak.Builder org.gnome.Platform//50 org.gnome.Sdk//50
```

```bash
flatpak run --command=flathub-build org.flatpak.Builder --install packaging/linux/com.plusmobileapps.chefmate.yml
```

```bash
flatpak run com.plusmobileapps.chefmate
```

```bash
flatpak run --command=flatpak-builder-lint org.flatpak.Builder manifest packaging/linux/com.plusmobileapps.chefmate.yml
```

```bash
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream packaging/linux/com.plusmobileapps.chefmate.metainfo.xml
```

Both linters must pass clean before submitting.

## Before the first submission

1. **Screenshots.** `com.plusmobileapps.chefmate.metainfo.xml` points at
`https://chefmate.plusmobileapps.com/flathub/*.png`, which do not exist yet. Capture them from
the Linux build and host them at those URLs (or edit the URLs to wherever they land). At least
one is required, and the first is the hero image on the store page.
2. **Tarball on the release.** Cut a release so
`chef-mate-<version>-linux-x86_64.tar.gz` exists as a GitHub Release asset, then put its real
`sha256sum` in the manifest — the checked-in value is a placeholder.
3. **Verify the runtime branch.** The manifest pins GNOME 50; confirm it is still current with
`flatpak remote-ls flathub --runtime | grep org.gnome.Platform`.
4. **Read the Flathub requirements**, in particular the generative AI policy at
<https://docs.flathub.org/docs/for-app-authors/requirements> — it applies to the app, the
manifest, the PR description, and the review replies.

## Submitting

Per <https://docs.flathub.org/docs/for-app-authors/submission>:

1. Fork <https://github.com/flathub/flathub> with "Copy the master branch only" **unchecked**.
2. Clone the `new-pr` branch and create a branch named `com.plusmobileapps.chefmate`.
3. Add the four files from this directory at the repository root.
4. Open a PR against the `new-pr` base branch — **not** `master`.
5. Do not close the PR while addressing feedback, and do not merge `master` into the branch.

After approval Flathub creates `flathub/com.plusmobileapps.chefmate`. Accept the repo invite within
a week and enable 2FA on the GitHub account.

## Maintaining

The manifest carries `x-checker-data`, so Flathub's external-data-checker opens a version-bump PR
against the app repo when a new GitHub Release publishes a matching tarball. Merging that PR is what
ships the update — nothing in this repository publishes to Flathub directly.

To claim the app as verified, follow
<https://docs.flathub.org/docs/for-app-authors/verification> (a token under
`https://plusmobileapps.com/.well-known/org.flathub.VerifiedApps.txt`).
11 changes: 11 additions & 0 deletions packaging/linux/chef-mate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/bin/sh
# Flatpak launcher shim. The jpackage app image lives at /app/chef-mate; its bin/chef-mate launcher
# resolves the bundled jlink runtime relative to itself, so it must be exec'd in place rather than
# symlinked into /app/bin.
set -e

# JavaFX (the in-app recipe browser's WebView) has no Wayland backend and aborts if GDK picks one,
# so pin GDK to X11 — the manifest grants --socket=x11, which gives XWayland in Wayland sessions.
export GDK_BACKEND=x11

exec /app/chef-mate/bin/chef-mate "$@"
13 changes: 13 additions & 0 deletions packaging/linux/com.plusmobileapps.chefmate.desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[Desktop Entry]
Type=Application
Name=Chef Mate
GenericName=Recipe Manager
Comment=Save, organize, and cook your recipes with an AI kitchen assistant
Exec=chef-mate %u
Icon=com.plusmobileapps.chefmate
Terminal=false
Categories=Utility;
Keywords=recipe;cooking;kitchen;food;meal;grocery;
StartupNotify=true
StartupWMClass=com.plusmobileapps.chefmate
MimeType=x-scheme-handler/chefmate;
Loading
Loading