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
48 changes: 44 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,29 @@ on:
tags:
- '*'

# Uploading the release assets is all the token is for
permissions:
contents: write

jobs:
linux:
name: Linux Artifact
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Native modules (nodehun) link against the glibc and libstdc++ of the build system, build them on
# the oldest supported Ubuntu LTS so that the AppImage and tar.gz load them there too
container: ubuntu:22.04
# The token is only given to the upload steps, npm install runs the install scripts of hundreds of
# packages and none of them needs it
steps:
- name: Build requirements
run: |
apt-get update
apt-get install -y --no-install-recommends appstream binutils ca-certificates g++ git make python3
- name: Checkout
uses: actions/checkout@v6
with:
# Nothing here pushes to the repository, the uploads use the token from the environment
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v6
with:
Expand All @@ -38,18 +52,36 @@ jobs:
- name: Build Linux
# The snap is built and published by publish-snap.yml
run: npm run build:linux -- AppImage tar.gz
- name: Check the spell checker is in the application
# The whole point of the glibc check is the native module of the spell checker, it is only
# scanned while electron-builder keeps unpacking it from the asar
run: test -f dist/linux-unpacked/resources/app.asar.unpacked/node_modules/nodehun/build/Release/Nodehun.node
- name: Check glibc requirements
# dist covers the application directory and the runtime of the AppImage, which is the first
# binary that has to run. This job holds a token that can publish releases, so it doesn't run
# the artifact it is about to upload: the Linux Build job of tests.yml extracts and checks the
# rest of the AppImage on every pull request
run: ./utils/check-glibc.sh dist
- name: Validate AppStream metainfo
run: appstreamcli validate-tree --no-net dist/linux-unpacked
- name: Upload tar.gz
run: ./utils/upload-artifact.js electronim-linux-x64.tar.gz application/tar+gzip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload AppImage
run: ./utils/upload-artifact.js electronim-linux-x86_64.AppImage application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
macOS:
name: macOS Artifact
runs-on: macos-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The token is only given to the upload steps, see the linux job
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Nothing here pushes to the repository, the uploads use the token from the environment
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v6
with:
Expand All @@ -67,9 +99,17 @@ jobs:
run: npm run build:mac
- name: Upload arm64-dmg
run: ./utils/upload-artifact.js electronim-mac-arm64.dmg application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload arm64-tar.gz
run: ./utils/upload-artifact.js electronim-mac-arm64.tar.gz application/tar+gzip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload x64-dmg
run: ./utils/upload-artifact.js electronim-mac-x64.dmg application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload x64-tar.gz
run: ./utils/upload-artifact.js electronim-mac-x64.tar.gz application/tar+gzip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49 changes: 43 additions & 6 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,48 @@ jobs:
linux-build:
name: Linux Build
runs-on: ubuntu-latest
# Same build system as the Linux Artifact job of publish.yml, see why there
container: ubuntu:22.04
steps:
- name: Build requirements
run: |
apt-get update
apt-get install -y --no-install-recommends appstream binutils ca-certificates g++ git make python3
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '22.x'
- name: Install
run: npm install
- name: Build
# The snap is built by the snap-build job, as in the publish workflows
run: npm run build:linux -- AppImage tar.gz
- name: Extract AppImage
# Check what the AppImage ships, it adds the runtime libraries of the electron-builder toolset to
# the application directory
run: ./dist/electronim-linux-x86_64.AppImage --appimage-extract
- name: Check the spell checker is in the AppImage
# The whole point of the glibc check is the native module of the spell checker, it is only
# scanned while electron-builder keeps unpacking it from the asar
run: test -f squashfs-root/resources/app.asar.unpacked/node_modules/nodehun/build/Release/Nodehun.node
- name: Check glibc requirements
# squashfs-root is the application directory and the libraries the AppImage bundles, dist adds
# the runtime of the AppImage itself, which is the first binary that has to run
run: ./utils/check-glibc.sh squashfs-root dist
- name: Validate AppStream metainfo
# The tree validation also checks that the desktop file the metainfo launches is installed, as the
# appdir-lint.sh check of the AppImage catalog does
run: appstreamcli validate-tree --no-net squashfs-root
- name: CI Artifact for AppImage
uses: actions/upload-artifact@v5
with:
name: electronim-linux-x86_64.AppImage
path: dist/electronim-linux-x86_64.AppImage
snap-build:
name: Snap Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
Expand All @@ -77,12 +119,7 @@ jobs:
- name: Install
run: npm install
- name: Build
run: npm run build:linux
- name: CI Artifact for AppImage
uses: actions/upload-artifact@v5
with:
name: electronim-linux-x86_64.AppImage
path: dist/electronim-linux-x86_64.AppImage
run: npm run build:linux -- snap
macos-build:
name: macOS Build
runs-on: macos-latest
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/dist
/node_modules
/bundles
/squashfs-root
/dev/user-data

*.nupkg
23 changes: 18 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ npm install # Install dependencies - takes ~55 seconds
- `npm run build:win` - Builds and bundles the application for Windows systems

### Testing
- `npm test` - Run full test suite - takes ~10 seconds, runs 1136 tests (59 test suites). NEVER CANCEL - Set timeout to 30+ minutes.
- `npm test` - Run full test suite - takes ~10 seconds, runs 1221 tests (64 test suites). NEVER CANCEL - Set timeout to 30+ minutes.
- `npm run test:e2e` - Run end-to-end tests to verify application startup - takes ~10-15 seconds
- The project uses Jest with ECMAScript modules requiring the experimental VM modules flag for Node.js

Expand All @@ -54,6 +54,8 @@ npm install # Install dependencies - takes ~55 seconds
- `npm run build:win` - Build Windows packages (zip, portable exe)
- `npm run build:linux -- dir` - Build only the given electron-builder targets (e.g. `dir`, `snap`, `AppImage tar.gz`). The platform flag is the last argument of each `build:*` script so extra arguments become its targets; the `prebuild:*` hook still bundles with webpack first. Never call `electron-builder` directly, it skips that hook.
- Bundling happens only in `pretest`, `pretest:e2e`, `prestart`, `prepack` and the `prebuild:*` hooks, not on `npm install`.
- The Linux release artifacts (AppImage, tar.gz) are built in an `ubuntu:22.04` container, both in `publish.yml` and in the `Linux Build` job of `tests.yml`. nodehun is compiled on the build system and links against its glibc and libstdc++, so a build on a newer system ships a spell checker that doesn't load on older distributions. `./utils/check-glibc.sh` fails the build when a native binary needs more than Ubuntu 22.04 provides (it reads the symbol versions with `readelf`, from binutils). `tests.yml` extracts the AppImage and checks all of it, `publish.yml` checks `dist` only, because that job holds a token that can publish releases and shouldn't run the artifact it is about to upload. That token only reaches the upload steps, never `npm install`. `UBUNTU` in `utils/check-glibc.sh` names the release both jobs build on, and a test fails when the workflows' `container:` drifts from it.
- `build.linux.extraFiles` applies to every Linux target. The snap template brings its own `usr/`, so in the snap these files end up in an unused `usr_1/`, and the Copr RPM gets a copy under `/opt/electronim/usr`; both are harmless. The tar.gz is the one artifact whose `usr/share` tree a user can install, which is why the entry it ships is `build-config/electronim.package.desktop` (relative `Exec`/`Icon`) and not the RPM's `/opt/electronim` one.
- **IMPORTANT**: Build commands fail in environments with network restrictions due to Electron header downloads (node-gyp attempting to download from https://www.electronjs.org/headers). Document this limitation if builds fail with "network connectivity" errors.

## Validation
Expand Down Expand Up @@ -149,7 +151,9 @@ they catch things the unit suite cannot: **always run them when changing anythin
- `build-config/` - Platform-specific build configurations
- `chocolateyInstall.ps1` - [PowerShell](https://blog.marcnuri.com/tag/powershell) installation script for [Chocolatey](https://chocolatey.org/) (Windows)
- `chocolateyUninstall.ps1` - [PowerShell](https://blog.marcnuri.com/tag/powershell) installation script for [Chocolatey](https://chocolatey.org/) (Windows)
- `electronim.desktop` - Desktop entry configuration (Linux)
- `com.marcnuri.electronim.appdata.xml` - [AppStream](https://www.freedesktop.org/software/appstream/docs/) metainfo, installed in `usr/share/metainfo` of the Linux packages. The [AppImage catalog](https://appimage.github.io/) reads it, so update it whenever the README.md features change. Validate it with `appstreamcli validate-tree <application directory>`, which also checks that the desktop file it launches is installed; plain `appstreamcli validate` cannot. `utils/version-from-tag.js` stamps its `<releases>` at release time, since the version is only known then, and the RPM installs it in `%{_metainfodir}` (Linux)
- `electronim.desktop` - Desktop entry configuration of the [Fedora COPR package](https://copr.fedorainfracloud.org/coprs/manusa/electronim), which installs to `/opt/electronim` (Linux)
- `electronim.package.desktop` - Desktop entry installed in `usr/share/applications` of the AppImage, tar.gz and snap, because the AppStream metainfo launches it. Keep it in sync with `build.linux` of package.json, there are tests for that (Linux)
- `electronim.nuspec` - [Chocolatey](https://chocolatey.org/) Nuspec information file (should be updated whenever the README.md is updated) (Windows)
- `electronim.spec` - Spec file to build the [Fedora COPR package](https://copr.fedorainfracloud.org/coprs/manusa/electronim) (Linux)
- `entitlements.mac.plist` Contains the MacOS entitlements for the application (Mac)
Expand All @@ -170,6 +174,15 @@ they catch things the unit suite cannot: **always run them when changing anythin

## Common Tasks

### The one-liner that describes the application

The same sentence is the `<summary>` of the AppStream metainfo, the `description` of package.json
(which electron-builder turns into the `Comment` of the desktop entry it generates), the
`build.snap.summary`, the `build.linux.synopsis`, the `Summary:` of `build-config/electronim.spec`
and the `Comment` of both desktop entries in `build-config`. Change them together, or stores show a
different sentence for each package. Keep it short: software centers cut it off, and Flathub asks
for 35 characters or fewer.

### Adding Dependencies
- Production dependencies: `npm install --save-exact <package>`
- Development dependencies: `npm install --save-exact -D <package>`
Expand Down Expand Up @@ -278,7 +291,7 @@ The project provides several utilities in `src/__tests__/` to facilitate testing

- **npm install**: ~55 seconds
- **Linting and bundling** (`npm run pretest`): ~2 seconds
- **Test suite** (`npm test`): ~10 seconds (1136 tests, 59 test suites)
- **Test suite** (`npm test`): ~10 seconds (1221 tests, 64 test suites)
- **Application startup**: ~3-5 seconds
- **Platform builds**: 10-20 minutes (network dependent)

Expand Down Expand Up @@ -328,8 +341,8 @@ added 825 packages, and audited 826 packages in 55s

### Sample Test Output
```
Test Suites: 59 passed, 59 total
Tests: 1136 passed, 1136 total
Test Suites: 64 passed, 64 total
Tests: 1221 passed, 1221 total
Snapshots: 0 total
Time: 12.653 s
Coverage: Lines: ~91% | Functions: ~74% | Branches: ~46% | Statements: ~79%
Expand Down
69 changes: 69 additions & 0 deletions build-config/__tests__/appdata.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
Copyright 2026 Marc Nuri San Felix

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const fs = require('node:fs');
const path = require('node:path');

describe('AppStream metainfo test suite', () => {
const projectRoot = path.resolve(__dirname, '..', '..');
let packageJson;
let metainfo;
beforeEach(() => {
packageJson = require('../../package.json');
const xml = fs.readFileSync(path.resolve(__dirname, '..', `${packageJson.build.appId}.appdata.xml`), 'utf8');
metainfo = new DOMParser().parseFromString(xml, 'application/xml');
});
test('is well-formed XML', () => {
expect(metainfo.querySelector('parsererror')).toBeNull();
});
test('describes a desktop application', () => {
expect(metainfo.documentElement.getAttribute('type')).toBe('desktop-application');
});
test('has the electron-builder appId as its id', () => {
expect(metainfo.querySelector('component > id').textContent).toBe(packageJson.build.appId);
});
test('has the product name as its name', () => {
expect(metainfo.querySelector('component > name').textContent).toBe(packageJson.build.productName);
});
test('has the package license as its project license', () => {
expect(metainfo.querySelector('project_license').textContent).toBe(packageJson.license);
});
test('launches the desktop file electron-builder generates', () => {
// electron-builder names the desktop file it adds to the AppImage after the executable, as long as
// the project sets no desktopName to sync it with
expect(metainfo.querySelector('launchable[type="desktop-id"]').textContent)
.toBe(`${packageJson.build.linux.executableName}.desktop`);
});
test('is installed by electron-builder where AppImage tools look for it', () => {
// The appimage.github.io catalog only reads the listing data from *.appdata.xml files
expect(packageJson.build.linux.extraFiles).toContainEqual({
from: `build-config/${packageJson.build.appId}.appdata.xml`,
to: `usr/share/metainfo/${packageJson.build.appId}.appdata.xml`
});
});
test('launches a desktop file electron-builder installs where AppStream tools look for it', () => {
// electron-builder adds its desktop file to the root of the AppImage only, appstreamcli validate-tree
// (run by the catalog's appdir-lint.sh) looks for the launchable in usr/share/applications
expect(packageJson.build.linux.extraFiles).toContainEqual({
from: 'build-config/electronim.package.desktop',
to: `usr/share/applications/${metainfo.querySelector('launchable[type="desktop-id"]').textContent}`
});
});
test('is installed along files that exist', () => {
// electron-builder only logs that a file source doesn't exist and carries on
expect(packageJson.build.linux.extraFiles)
.toSatisfyAll(({from}) => fs.existsSync(path.resolve(projectRoot, from)));
});
});
54 changes: 54 additions & 0 deletions build-config/__tests__/desktop-entry.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
Copyright 2026 Marc Nuri San Felix

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const fs = require('node:fs');
const path = require('node:path');

describe('Package desktop entry test suite', () => {
let packageJson;
let entry;
beforeEach(() => {
packageJson = require('../../package.json');
entry = Object.fromEntries(fs.readFileSync(path.resolve(__dirname, '..', 'electronim.package.desktop'), 'utf8')
.split('\n')
.filter(line => line.includes('=') && !line.startsWith('#'))
.map(line => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)]));
});
test('has the product name as its name', () => {
expect(entry.Name).toBe(packageJson.build.productName);
});
test('runs the executable electron-builder generates', () => {
// Unlike the RPM entry, the packages this one ships in have no fixed installation directory
expect(entry.Exec).toBe(`${packageJson.build.linux.executableName} %U`);
});
test('uses the icon name electron-builder installs', () => {
expect(entry.Icon).toBe(packageJson.build.linux.executableName);
});
test('has the categories of the generated entry', () => {
expect(entry.Categories).toBe(packageJson.build.linux.category);
});
test('has the package description as its comment', () => {
expect(entry.Comment).toBe(packageJson.description);
});
test('associates the window with the entry', () => {
expect(entry.StartupWMClass).toBe(packageJson.build.productName);
});
test('has the type of the generated entry', () => {
expect(entry.Type).toBe(packageJson.build.linux.desktop.entry.Type);
});
test('has the terminal flag of the generated entry', () => {
expect(entry.Terminal).toBe(packageJson.build.linux.desktop.entry.Terminal);
});
});
Loading
Loading