feat(electron): suffix version string per distribution target

Append a short channel marker to the (display-only) version string so bug
reports and the config footer reveal which build a user runs, e.g.:

  win nsis W   portable P   MS Store MS
  mac dmg  D   App Store MAS
  linux AppImage AI   snap SN   flatpak FP   deb/rpm L
  android Play A   F-Droid AF   ios I   web WB

Desktop channels are detected in the Electron process (process.platform,
process.mas/windowsStore, APPIMAGE/SNAP/FLATPAK_ID) via a shared helper
reused by the tray-GUID logic and exposed sync over the preload bridge
(no IPC, getAppVersionStr stays synchronous). Mobile/web are detected in
the frontend. deb vs rpm is not distinguishable at runtime, so both
report L. No caller semver-compares this string.
This commit is contained in:
Johannes Millan 2026-05-19 15:28:06 +02:00
parent e58dc04179
commit 79133f9046
6 changed files with 184 additions and 7 deletions

View file

@ -19,6 +19,7 @@ import {
LocalRestApiRequestPayload,
LocalRestApiResponsePayload,
} from './shared-with-frontend/local-rest-api.model';
import { ElectronDistChannel } from './shared-with-frontend/get-dist-channel';
export interface ElectronAPI {
on(
@ -26,6 +27,10 @@ export interface ElectronAPI {
listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
): void;
// SYNC
// ----
getDistChannel(): ElectronDistChannel | null;
// INVOKE
// ------
getUserDataPath(): Promise<string>;

View file

@ -9,6 +9,7 @@ import {
} from 'electron';
import { log } from 'electron-log/main';
import { IPC } from './shared-with-frontend/ipc-events.const';
import { getDistChannel } from './shared-with-frontend/get-dist-channel';
import { getIsTrayShowCurrentTask, getIsTrayShowCurrentCountdown } from './shared-state';
import { TaskCopy } from '../src/app/features/tasks/task.model';
import { release } from 'os';
@ -76,10 +77,11 @@ const WINDOWS_TRAY_GUIDS = {
} as const;
const getWindowsTrayGuid = (): string => {
if (process.env.PORTABLE_EXECUTABLE_DIR) {
const channel = getDistChannel();
if (channel === 'win-portable') {
return WINDOWS_TRAY_GUIDS.portable;
}
if ((process as NodeJS.Process & { windowsStore?: boolean }).windowsStore) {
if (channel === 'win-store') {
return WINDOWS_TRAY_GUIDS.store;
}
return WINDOWS_TRAY_GUIDS.nsis;

View file

@ -7,6 +7,10 @@ import {
} from 'electron';
import { ElectronAPI } from './electronAPI.d';
import { IPC, IPCEventValue } from './shared-with-frontend/ipc-events.const';
import {
getDistChannel,
ElectronDistChannel,
} from './shared-with-frontend/get-dist-channel';
import { LocalBackupMeta } from '../src/app/imex/local-backup/local-backup.model';
import {
PluginManifest,
@ -52,6 +56,10 @@ const ea: ElectronAPI = {
// NOTE: there is no proper way to unsubscribe apart from unsubscribing all
ipcRenderer.on(channel, listener);
},
// SYNC
// ----
getDistChannel: (): ElectronDistChannel | null => getDistChannel(),
// INVOKE
// ------
getUserDataPath: () => _invoke('GET_PATH', 'userData') as Promise<string>,

View file

@ -0,0 +1,51 @@
/**
* Distribution channels detectable from the Electron process.
* Mobile (android/ios) and plain web are determined in the frontend instead.
*/
export type ElectronDistChannel =
| 'win-nsis'
| 'win-portable'
| 'win-store'
| 'mac-dmg'
| 'mac-store'
| 'linux-appimage'
| 'linux-snap'
| 'linux-flatpak'
| 'linux-native';
/**
* Detects which distribution channel the running Electron app belongs to.
* Returns `null` on unknown platforms.
*
* Shared between the Electron main process (tray GUID selection) and the
* preload bridge (channel suffix appended to the diagnostic version string).
* Safe to call from preload: only reads `process` / `process.env`.
*
* Note: deb vs rpm cannot be told apart at runtime (electron-builder sets no
* env var for native packages), so both report `linux-native`.
*/
export const getDistChannel = (): ElectronDistChannel | null => {
const p = process as NodeJS.Process & { windowsStore?: boolean; mas?: boolean };
switch (process.platform) {
case 'win32':
if (process.env.PORTABLE_EXECUTABLE_DIR) {
return 'win-portable';
}
return p.windowsStore ? 'win-store' : 'win-nsis';
case 'darwin':
return p.mas ? 'mac-store' : 'mac-dmg';
case 'linux':
if (process.env.APPIMAGE) {
return 'linux-appimage';
}
if (process.env.SNAP) {
return 'linux-snap';
}
if (process.env.FLATPAK_ID) {
return 'linux-flatpak';
}
return 'linux-native';
default:
return null;
}
};

View file

@ -0,0 +1,39 @@
import { DistChannel, distChannelSuffix, getAppVersionStr } from './get-app-version-str';
import { environment } from '../../environments/environment';
describe('distChannelSuffix', () => {
const cases: [DistChannel, string][] = [
['win-nsis', 'W'],
['win-portable', 'P'],
['win-store', 'MS'],
['mac-dmg', 'D'],
['mac-store', 'MAS'],
['linux-appimage', 'AI'],
['linux-snap', 'SN'],
['linux-flatpak', 'FP'],
['linux-native', 'L'],
['android-play', 'A'],
['android-fdroid', 'AF'],
['ios', 'I'],
['web', 'WB'],
];
cases.forEach(([channel, suffix]) => {
it(`maps ${channel} -> "${suffix}"`, () => {
expect(distChannelSuffix(channel)).toBe(suffix);
});
});
it('maps null/undefined -> "" (no suffix)', () => {
expect(distChannelSuffix(null)).toBe('');
expect(distChannelSuffix(undefined)).toBe('');
});
});
describe('getAppVersionStr', () => {
// In the Karma/browser env IS_ELECTRON, IS_IOS and IS_ANDROID_WEB_VIEW are
// all false, so the channel resolves to web -> "WB".
it('appends the web suffix in a browser context', () => {
expect(getAppVersionStr()).toBe(`${environment.version}WB`);
});
});

View file

@ -1,9 +1,81 @@
import { IS_ANDROID_WEB_VIEW } from './is-android-web-view';
import { IS_ANDROID_WEB_VIEW, IS_F_DROID_APP } from './is-android-web-view';
import { IS_IOS } from './is-ios';
import { IS_ELECTRON } from '../app.constants';
import { androidInterface } from '../features/android/android-interface';
import { environment } from '../../environments/environment';
export const getAppVersionStr = (): string => {
const b =
(IS_ANDROID_WEB_VIEW && androidInterface?.getVersion?.()) || environment.version;
return IS_ANDROID_WEB_VIEW ? `${b}A` : b;
/**
* Every distribution target the app ships to. Mobile/web are detected in the
* frontend; desktop ones come from the Electron `getDistChannel()` bridge.
*/
export type DistChannel =
| 'win-nsis'
| 'win-portable'
| 'win-store'
| 'mac-dmg'
| 'mac-store'
| 'linux-appimage'
| 'linux-snap'
| 'linux-flatpak'
| 'linux-native'
| 'android-play'
| 'android-fdroid'
| 'ios'
| 'web';
/**
* Channel marker appended to the (display-only) version string so bug reports
* and the config footer reveal which build a user runs, e.g. `18.6.0AI` for
* the Linux AppImage. Display-only: no caller does a semver compare on this.
*/
export const distChannelSuffix = (channel: DistChannel | null | undefined): string => {
switch (channel) {
case 'win-nsis':
return 'W';
case 'win-portable':
return 'P';
case 'win-store':
return 'MS';
case 'mac-dmg':
return 'D';
case 'mac-store':
return 'MAS';
case 'linux-appimage':
return 'AI';
case 'linux-snap':
return 'SN';
case 'linux-flatpak':
return 'FP';
case 'linux-native':
return 'L';
case 'android-play':
return 'A';
case 'android-fdroid':
return 'AF';
case 'ios':
return 'I';
case 'web':
return 'WB';
default:
return '';
}
};
const detectChannel = (): DistChannel => {
if (IS_IOS) {
return 'ios';
}
if (IS_ANDROID_WEB_VIEW) {
return IS_F_DROID_APP ? 'android-fdroid' : 'android-play';
}
if (IS_ELECTRON && typeof window !== 'undefined') {
return window.ea?.getDistChannel?.() ?? 'web';
}
return 'web';
};
export const getAppVersionStr = (): string => {
const base =
(IS_ANDROID_WEB_VIEW && androidInterface?.getVersion?.()) || environment.version;
return `${base}${distChannelSuffix(detectChannel())}`;
};