diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ca5acd49..08b016e7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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: @@ -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: @@ -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 }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e9bd4ec5..3ee6db98 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index f4d7d5d4..800aec2d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /dist /node_modules /bundles +/squashfs-root /dev/user-data *.nupkg diff --git a/AGENTS.md b/AGENTS.md index ede18c8a..edc4f0c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 @@ -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 `, which also checks that the desktop file it launches is installed; plain `appstreamcli validate` cannot. `utils/version-from-tag.js` stamps its `` 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) @@ -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 `` 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 ` - Development dependencies: `npm install --save-exact -D ` @@ -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) @@ -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% diff --git a/build-config/__tests__/appdata.test.js b/build-config/__tests__/appdata.test.js new file mode 100644 index 00000000..d381dfed --- /dev/null +++ b/build-config/__tests__/appdata.test.js @@ -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))); + }); +}); diff --git a/build-config/__tests__/desktop-entry.test.js b/build-config/__tests__/desktop-entry.test.js new file mode 100644 index 00000000..0b7e3a21 --- /dev/null +++ b/build-config/__tests__/desktop-entry.test.js @@ -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); + }); +}); diff --git a/build-config/com.marcnuri.electronim.appdata.xml b/build-config/com.marcnuri.electronim.appdata.xml new file mode 100644 index 00000000..e0de1e7c --- /dev/null +++ b/build-config/com.marcnuri.electronim.appdata.xml @@ -0,0 +1,47 @@ + + + + com.marcnuri.electronim + CC0-1.0 + Apache-2.0 + ElectronIM + Combine chat services in one window + + Marc Nuri + + Marc Nuri + + +

ElectronIM brings the web versions of your messaging services, such as WhatsApp, Telegram or Slack, together in one window with a tab for each, so you can keep up with every conversation without juggling browser tabs.

+

ElectronIM is free/libre software that you can use, study, share and improve. You don't need an account to use it, and it has no telemetry, analytics or tracking of any kind.

+
    +
  • Any website or web messaging service as a tab
  • +
  • Several accounts of the same service, each in its own isolated tab
  • +
  • Native desktop notifications that you can turn off per service or globally
  • +
  • Spell checking in many languages
  • +
  • Screen sharing with services that support it
  • +
  • Light and dark themes that follow the system by default
  • +
  • Keyboard shortcuts, drag-and-drop tab reordering and find in page
  • +
+
+ electronim.desktop + https://github.com/manusa/electronim + https://github.com/manusa/electronim/issues + https://github.com/manusa/electronim/blob/main/docs/Setup.md + + instant messaging + IM + chat + messenger + WhatsApp + Telegram + Slack + tabs + + + intense + intense + intense + mild + +
diff --git a/build-config/electronim.desktop b/build-config/electronim.desktop index 9c327f21..477ea36b 100644 --- a/build-config/electronim.desktop +++ b/build-config/electronim.desktop @@ -6,4 +6,4 @@ Type=Application Icon=/opt/electronim/assets/icon_1024x1024.png StartupWMClass=ElectronIM Categories=Network;InstantMessaging; -Comment=Free/Libre open source Electron based multi instant messaging (IM) client. +Comment=Combine chat services in one window diff --git a/build-config/electronim.package.desktop b/build-config/electronim.package.desktop new file mode 100644 index 00000000..878ee716 --- /dev/null +++ b/build-config/electronim.package.desktop @@ -0,0 +1,12 @@ +# Desktop entry installed in usr/share/applications of the Linux packages, where AppStream tools +# look for the file the metainfo launches. electron-builder generates its own entry for the root of +# the AppImage, and build-config/electronim.desktop is the one the RPM installs from /opt. +[Desktop Entry] +Name=ElectronIM +Exec=electronim %U +Terminal=false +Type=Application +Icon=electronim +StartupWMClass=ElectronIM +Categories=Network;InstantMessaging; +Comment=Combine chat services in one window diff --git a/build-config/electronim.spec b/build-config/electronim.spec index 29f9a22e..127346c7 100644 --- a/build-config/electronim.spec +++ b/build-config/electronim.spec @@ -6,7 +6,7 @@ Name: electronim Version: 0.0.0 Release: 0%{?dist} -Summary: Electron based multi IM (Instant Messaging) client +Summary: Combine chat services in one window License: Apache-2.0 Url: https://github.com/manusa/electronim # Tag sources @@ -75,14 +75,25 @@ ln -sf %{_optpkgdir}/electronim %{buildroot}%{_bindir}/electronim install -dp %{buildroot}%{_datadir}/applications install -Dp -m0755 build-config/electronim.desktop %{buildroot}%{_datadir}/applications +# install AppStream metainfo, so that software centers list the application. The build:linux script +# also copies it (and a desktop file of its own) into the unpacked application, where nothing reads +# them. Copr builds older tags, which have neither, with this same spec, so %%files takes the +# metainfo from a list only written when it exists. rpm rejects an empty list, hence the binary. +echo "%{_bindir}/electronim" > packaged.files +if [ -f build-config/com.marcnuri.electronim.appdata.xml ]; then + install -Dp -m0644 build-config/com.marcnuri.electronim.appdata.xml \ + %{buildroot}%{_metainfodir}/com.marcnuri.electronim.appdata.xml + echo "%{_metainfodir}/com.marcnuri.electronim.appdata.xml" >> packaged.files +fi +rm -rf %{buildroot}%{_optpkgdir}/usr + #-- FILES ---------------------------------------------------------------------# -%files +%files -f packaged.files %license LICENSE %doc CONTRIBUTING.md README.md %{_optpkgdir}/* %dir %{_datadir}/applications %{_datadir}/applications/%{name}.desktop -%{_bindir}/electronim diff --git a/docs/Setup.md b/docs/Setup.md index a4b92451..4944998d 100644 --- a/docs/Setup.md +++ b/docs/Setup.md @@ -36,7 +36,7 @@ There are 3 options for installing and running ElectronIM in Linux: ``` $ wget https://github.com/manusa/electronim/releases/latest/download/electronim-linux-x64.tar.gz -$ tar xz electronim-linux-x64.tar.gz +$ tar xzf electronim-linux-x64.tar.gz $ cd electronim-linux-x64 $ ./electronim ``` @@ -50,7 +50,7 @@ $ ./electronim ``` $ wget https://github.com/manusa/electronim/releases/latest/download/electronim-linux-x86_64.AppImage $ chmod a+x electronim*.AppImage -$ ./electronim-linux-x86-64.AppImage +$ ./electronim-linux-x86_64.AppImage ``` ##### [Snapcraft](https://snapcraft.io/electronim) package diff --git a/package.json b/package.json index c534bf08..1dc618b9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "electronim", "version": "0.0.0", - "description": "Free/Libre open source Electron based multi instant messaging (IM) client.", + "description": "Combine chat services in one window", "author": { "name": "Marc Nuri", "email": "electronim@marcnuri.com", @@ -17,8 +17,8 @@ "bundles", "docs", "src", - "!src/**/__tests__", - "utils" + "utils", + "!**/__tests__" ], "scripts": { "pretest": "eslint -c eslint.config.mjs . && node webpack.js", @@ -70,7 +70,8 @@ "copyright": "Copyright 2019 Marc Nuri", "files": [ "**/*", - "!**/__tests__" + "!**/__tests__", + "!squashfs-root" ], "directories": { "output": "dist", @@ -80,7 +81,7 @@ "assets/*" ], "snap": { - "summary": "Free/Libre open source Electron based multi instant messaging (IM) client.", + "summary": "Combine chat services in one window", "plugs": [ "alsa", "browser-support", @@ -109,7 +110,7 @@ "artifactName": "electronim-linux-${arch}.${ext}", "executableName": "electronim", "category": "Network;InstantMessaging;", - "synopsis": "Free/Libre open source Electron based multi instant messaging (IM) client.", + "synopsis": "Combine chat services in one window", "desktop": { "entry": { "Terminal": "false", @@ -118,6 +119,16 @@ } }, "icon": "icon_1024x1024.png", + "extraFiles": [ + { + "from": "build-config/com.marcnuri.electronim.appdata.xml", + "to": "usr/share/metainfo/com.marcnuri.electronim.appdata.xml" + }, + { + "from": "build-config/electronim.package.desktop", + "to": "usr/share/applications/electronim.desktop" + } + ], "target": [ "AppImage", "snap", diff --git a/utils/__tests__/check-glibc.test.js b/utils/__tests__/check-glibc.test.js new file mode 100644 index 00000000..8a250d0e --- /dev/null +++ b/utils/__tests__/check-glibc.test.js @@ -0,0 +1,204 @@ +/* + 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 childProcess = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +describe('check-glibc.sh test suite', () => { + const script = path.resolve(__dirname, '..', 'check-glibc.sh'); + let tempDir; + let binaries; + let result; + // Files are native binaries when they start with the ELF magic number, the readelf stub reads the + // sections of each one from the file next to it. A library defines the versions other binaries can + // link against, and needs the versions it links against itself, readelf prints both. + const nativeBinary = (name, {needs = [], defines = []} = {}) => { + fs.writeFileSync(path.join(binaries, name), Buffer.from('7f454c4602010100000000', 'hex')); + fs.writeFileSync(path.join(binaries, `${name}.versions`), [ + `Version definition section '.gnu.version_d' contains ${defines.length} entries:`, + ...defines.map(version => ` 0x0028: Rev: 1 Flags: none Index: 2 Cnt: 1 Name: ${version}`), + `Version needs section '.gnu.version_r' contains ${needs.length} entries:`, + ...needs.map(version => ` 0x0010: Name: ${version} Flags: none Version: 2`) + ].join('\n')); + }; + const checkGlibc = (...paths) => childProcess.spawnSync('bash', [script, ...paths], { + encoding: 'utf8', + env: {...process.env, PATH: `${path.join(tempDir, 'bin')}${path.delimiter}${process.env.PATH}`} + }); + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electronim-test-')); + binaries = path.join(tempDir, 'binaries'); + fs.mkdirSync(binaries); + fs.mkdirSync(path.join(tempDir, 'bin')); + // Prints the sections of the binary it is called with, fails when there are none, just as readelf + // fails for a file it cannot read + fs.writeFileSync(path.join(tempDir, 'bin', 'readelf'), + '#!/bin/sh\nfor file; do :; done\ncat "$file.versions"\n', {mode: 0o755}); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + describe('with native binaries that need the versions Ubuntu 22.04 provides', () => { + beforeEach(() => { + nativeBinary('electronim', {needs: ['GLIBC_2.2.5', 'GLIBC_2.25', 'GLIBC_2.17']}); + nativeBinary('Nodehun.node', { + needs: ['GLIBC_2.14', 'GLIBC_2.35', 'GLIBCXX_3.4.30', 'CXXABI_1.3.13', 'GCC_12.0.0'] + }); + result = checkGlibc(binaries); + }); + test('succeeds', () => { + expect(result.status).toBe(0); + }); + test('reports how many binaries it checked', () => { + expect(result.stdout).toContain('All 2 native binaries'); + }); + }); + describe('with a native binary that needs a newer glibc', () => { + beforeEach(() => { + nativeBinary('electronim', {needs: ['GLIBC_2.25']}); + nativeBinary('Nodehun.node', {needs: ['GLIBC_2.14', 'GLIBC_2.36', 'GLIBCXX_3.4.29']}); + result = checkGlibc(binaries); + }); + test('fails', () => { + expect(result.status).toBe(1); + }); + test('reports the binary that needs it', () => { + expect(result.stderr).toContain('Nodehun.node needs GLIBC_2.36'); + }); + }); + describe('with a native binary that needs a newer libstdc++', () => { + beforeEach(() => { + nativeBinary('Nodehun.node', {needs: ['GLIBC_2.14', 'GLIBCXX_3.4.29', 'GLIBCXX_3.4.31']}); + result = checkGlibc(binaries); + }); + test('fails', () => { + expect(result.status).toBe(1); + }); + }); + describe('with a native binary that needs a newer C++ ABI', () => { + beforeEach(() => { + nativeBinary('Nodehun.node', {needs: ['CXXABI_1.3.15']}); + result = checkGlibc(binaries); + }); + test('fails', () => { + expect(result.status).toBe(1); + }); + }); + describe('with a native binary that needs a newer libgcc', () => { + beforeEach(() => { + nativeBinary('Nodehun.node', {needs: ['GCC_14.0.0']}); + result = checkGlibc(binaries); + }); + test('fails', () => { + expect(result.status).toBe(1); + }); + }); + describe('with a native binary linked with relative relocations', () => { + beforeEach(() => { + // Ubuntu 24.04 links with -Wl,-z,pack-relative-relocs by default, which needs a glibc ABI + // version that Ubuntu 22.04 doesn't have at all + nativeBinary('electronim', {needs: ['GLIBC_2.34', 'GLIBC_ABI_DT_RELR']}); + result = checkGlibc(binaries); + }); + test('fails', () => { + expect(result.status).toBe(1); + }); + test('reports the version it needs', () => { + expect(result.stderr).toContain('GLIBC_ABI_DT_RELR'); + }); + }); + describe('with a bundled library that defines newer versions than it needs', () => { + beforeEach(() => { + // Bundling a newer libstdc++ is how this class of problem gets fixed, the versions a library + // defines say nothing about what it needs to load + nativeBinary('libstdc++.so.6', { + defines: ['GLIBCXX_3.4.30', 'GLIBCXX_3.4.33', 'CXXABI_1.3.15'], + needs: ['GLIBC_2.17', 'GCC_4.2.0'] + }); + result = checkGlibc(binaries); + }); + test('succeeds', () => { + expect(result.status).toBe(0); + }); + }); + describe('with several paths', () => { + let other; + beforeEach(() => { + other = path.join(tempDir, 'other'); + fs.mkdirSync(other); + nativeBinary('electronim', {needs: ['GLIBC_2.25']}); + fs.writeFileSync(path.join(other, 'runtime'), Buffer.from('7f454c4602010100000000', 'hex')); + fs.writeFileSync(path.join(other, 'runtime.versions'), + 'Version needs section \'.gnu.version_r\' contains 1 entries:\n 0x0010: Name: GLIBC_2.38 Flags: none Version: 2'); + result = checkGlibc(binaries, other); + }); + test('fails for a binary outside the first path', () => { + expect(result.status).toBe(1); + }); + test('counts the binaries of every path', () => { + expect(result.stderr).toContain('1 of 2 native binaries'); + }); + }); + describe('with a native binary whose symbol versions cannot be read', () => { + beforeEach(() => { + nativeBinary('Nodehun.node', {needs: ['GLIBC_2.14']}); + fs.rmSync(path.join(binaries, 'Nodehun.node.versions')); + result = checkGlibc(binaries); + }); + test('fails', () => { + expect(result.status).toBe(1); + }); + }); + describe('without native binaries', () => { + beforeEach(() => { + fs.writeFileSync(path.join(binaries, 'README.md'), '# Not a native binary'); + result = checkGlibc(binaries); + }); + test('fails', () => { + expect(result.status).toBe(1); + }); + }); + describe('with a path that cannot be listed', () => { + beforeEach(() => { + result = checkGlibc(path.join(tempDir, 'does-not-exist')); + }); + test('fails', () => { + expect(result.status).not.toBe(0); + }); + }); + describe('baseline', () => { + // The limits only mean something if the packages are built on the release they were measured on + let baseline; + beforeEach(() => { + baseline = fs.readFileSync(script, 'utf8').match(/^UBUNTU=(\S+)$/m)[1]; + }); + test.each(['publish.yml', 'tests.yml'])('is the system %s builds the Linux packages on', workflow => { + const containers = fs.readFileSync(path.resolve(__dirname, '..', '..', '.github', 'workflows', workflow), 'utf8') + .match(/^ +container: \S+$/gm) + .map(line => line.trim()); + expect(containers).toEqual([`container: ubuntu:${baseline}`]); + }); + }); + describe('without a path', () => { + beforeEach(() => { + result = childProcess.spawnSync('bash', [script], {encoding: 'utf8'}); + }); + test('fails', () => { + expect(result.status).not.toBe(0); + }); + }); +}); diff --git a/utils/__tests__/common.test.js b/utils/__tests__/common.test.js new file mode 100644 index 00000000..40c50f4d --- /dev/null +++ b/utils/__tests__/common.test.js @@ -0,0 +1,57 @@ +/* + 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. + */ +describe('Common utils test suite', () => { + let common; + beforeEach(() => { + common = require('../common'); + }); + describe('withRelease', () => { + const metainfo = [ + '', + '', + ' com.marcnuri.electronim', + '', + '' + ].join('\n'); + let stamped; + describe('in metainfo without releases', () => { + beforeEach(() => { + stamped = new DOMParser() + .parseFromString(common.withRelease(metainfo, '1.33.7', '2026-09-21'), 'application/xml'); + }); + test('adds the version', () => { + expect(stamped.querySelector('releases > release').getAttribute('version')).toBe('1.33.7'); + }); + test('adds the date', () => { + expect(stamped.querySelector('releases > release').getAttribute('date')).toBe('2026-09-21'); + }); + test('keeps the rest of the metainfo', () => { + expect(stamped.querySelector('component > id').textContent).toBe('com.marcnuri.electronim'); + }); + }); + describe('in metainfo that was already stamped', () => { + beforeEach(() => { + const first = common.withRelease(metainfo, '1.33.7', '2026-09-21'); + stamped = new DOMParser() + .parseFromString(common.withRelease(first, '1.33.8', '2026-09-22'), 'application/xml'); + }); + test('replaces the release', () => { + expect(Array.from(stamped.querySelectorAll('release'), release => release.getAttribute('version'))) + .toEqual(['1.33.8']); + }); + }); + }); +}); diff --git a/utils/check-glibc.sh b/utils/check-glibc.sh new file mode 100755 index 00000000..9504c8f1 --- /dev/null +++ b/utils/check-glibc.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# 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. + +# Fails when a native binary in the given paths needs a symbol version that the oldest supported +# Ubuntu LTS doesn't provide. Native modules (nodehun) are compiled on the build machine and link +# against its glibc and libstdc++, so a build on a newer system produces packages whose spell +# checker can't load on older distributions. +# +# Usage: ./utils/check-glibc.sh squashfs-root [path...] +set -euo pipefail + +# The oldest supported Ubuntu LTS. The Linux jobs of publish.yml and tests.yml build in its container, +# and a test fails when they drift apart. +UBUNTU=22.04 +# Newest versions it provides (libc6 2.35, libstdc++6 and libgcc-s1 from GCC 12) +MAX_GLIBC=2.35 +MAX_GLIBCXX=3.4.30 +MAX_CXXABI=1.3.13 +MAX_GCC=12.0.0 + +if [ $# -eq 0 ]; then + echo "Usage: $0 ..." >&2 + exit 1 +fi + +if ! command -v readelf >/dev/null; then + echo "readelf is needed to read the symbol versions of the native binaries, install binutils" >&2 + exit 1 +fi + +# Whether version $1 is newer than version $2 +newer_than() { + [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n 1)" = "$1" ] +} + +# The versions of the $1 prefix that the readelf output $2 says the binary needs. Only the version +# needs section counts: the versions a library defines are what it offers to others, and bundling a +# library that defines newer versions is a fix for this problem rather than a symptom of it. +needed_versions() { + { sed -n '/Version needs section/,$p' <<<"$2" | grep -o "${1}_[0-9][0-9.]*" || true; } | + cut -d _ -f 2 | sort -uV +} + +# find fails for a path it cannot list, and a check that silently skips binaries is worthless, so +# pipe it (pipefail turns its failure into the failure of this script) instead of reading from a +# process substitution, whose exit status nothing reports +find "$@" -type f -print0 | { +binaries=0 +failures=0 +while IFS= read -r -d '' file; do + if ! magic=$(head -c 4 "$file" | od -An -c | tr -d ' '); then + echo "$file could not be read" >&2 + failures=$((failures + 1)) + continue + fi + [ "$magic" = '177ELF' ] || continue + binaries=$((binaries + 1)) + # Without no-follow-links, readelf chases the split debug info libvulkan.so.1 links to and floods + # the log with a warning per missing .dwo file. Real failures still make it exit non-zero. + if ! symbols=$(readelf --version-info --wide --debug-dump=no-follow-links "$file"); then + echo "$file symbol versions could not be read" >&2 + failures=$((failures + 1)) + continue + fi + needs="" + for limit in "GLIBC:$MAX_GLIBC" "GLIBCXX:$MAX_GLIBCXX" "CXXABI:$MAX_CXXABI" "GCC:$MAX_GCC"; do + prefix="${limit%%:*}" + max="${limit##*:}" + for version in $(needed_versions "$prefix" "$symbols"); do + if newer_than "$version" "$max"; then + needs="$needs ${prefix}_${version}" + fi + done + done + # Ubuntu 22.04 has no GLIBC_ABI_* version at all. Ubuntu 24.04 links with -z pack-relative-relocs + # by default, which adds a GLIBC_ABI_DT_RELR requirement that the older loader rejects outright. + for version in $({ sed -n '/Version needs section/,$p' <<<"$symbols" | + grep -o 'GLIBC_ABI_[A-Z_]*' || true; } | sort -u); do + needs="$needs $version" + done + if [ -n "$needs" ]; then + echo "$file needs${needs}" >&2 + failures=$((failures + 1)) + fi +done + +if [ "$binaries" -eq 0 ]; then + echo "No native binaries found in $*" >&2 + exit 1 +fi +if [ "$failures" -gt 0 ]; then + echo "$failures of $binaries native binaries need more than Ubuntu $UBUNTU provides" >&2 + exit 1 +fi +echo "All $binaries native binaries load with GLIBC_$MAX_GLIBC, GLIBCXX_$MAX_GLIBCXX," \ + "CXXABI_$MAX_CXXABI and GCC_$MAX_GCC (Ubuntu $UBUNTU)" +} diff --git a/utils/common.js b/utils/common.js index 30947f8f..892c62f0 100644 --- a/utils/common.js +++ b/utils/common.js @@ -24,6 +24,17 @@ const extractVersionFromTag = () => { return null; }; +// Stamps the release of an AppStream metainfo document, which software centers show as the version +// and the date of the application. The version is only known when the tag is pushed. +const withRelease = (metainfo, version, date) => { + const releases = ` \n \n \n`; + if (metainfo.includes('')) { + return metainfo.replace(/ *[\s\S]*?<\/releases>\n/, releases); + } + return metainfo.replace('', `${releases}`); +}; + module.exports = { - extractVersionFromTag + extractVersionFromTag, + withRelease }; diff --git a/utils/version-from-tag.js b/utils/version-from-tag.js index 133ba1da..ea253d31 100755 --- a/utils/version-from-tag.js +++ b/utils/version-from-tag.js @@ -19,7 +19,7 @@ const childProcess = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); const errorHandler = require('./error-handler'); -const {extractVersionFromTag} = require('./common'); +const {extractVersionFromTag, withRelease} = require('./common'); const versionFromTag = () => { const version = extractVersionFromTag(); @@ -37,6 +37,11 @@ const versionFromTag = () => { fs.writeFileSync(electronimSpec, fs.readFileSync(electronimSpec).toString() .replaceAll(/Version.+$/gm, `Version: ${version}`) ); + console.log(`Setting AppStream release to ${version}`); + const metainfo = path.resolve(__dirname, '..', 'build-config', 'com.marcnuri.electronim.appdata.xml'); + fs.writeFileSync(metainfo, withRelease( + fs.readFileSync(metainfo).toString(), version, new Date().toISOString().slice(0, 10) + )); process.exit(0); };