fix: AppImage required --no-sandbox
Queue Release Build / prepare (push) Successful in 28s
Deploy Web Apps / deploy (push) Failing after 10m14s
Queue Release Build / build-windows (push) Failing after 15m12s
Queue Release Build / build-linux (push) Successful in 43m41s
Queue Release Build / finalize (push) Skipped
Queue Release Build / build-android (push) Successful in 17m25s
Queue Release Build / prepare (push) Successful in 28s
Deploy Web Apps / deploy (push) Failing after 10m14s
Queue Release Build / build-windows (push) Failing after 15m12s
Queue Release Build / build-linux (push) Successful in 43m41s
Queue Release Build / finalize (push) Skipped
Queue Release Build / build-android (push) Successful in 17m25s
Not tested enough but works on my machine
This commit is contained in:
@@ -78,4 +78,5 @@ Tags help grepping: `rg '\\[attachments\\]' agents-docs/LESSONS-INDEX.md`
|
|||||||
- Record whether the user is in a call before calling zero RTP a failure — `[testing] [voice] [verification]`
|
- Record whether the user is in a call before calling zero RTP a failure — `[testing] [voice] [verification]`
|
||||||
- Assert continuity when the state you broke repairs itself — `[testing] [voice] [verification]`
|
- Assert continuity when the state you broke repairs itself — `[testing] [voice] [verification]`
|
||||||
- Missing gossip about a peer is not evidence it left voice — `[voice] [webrtc] [realtime]`
|
- Missing gossip about a peer is not evidence it left voice — `[voice] [webrtc] [realtime]`
|
||||||
|
- `app.commandLine.appendSwitch` cannot disable the Chromium sandbox — `[electron] [packaging] [linux]`
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project.
|
|||||||
|
|
||||||
## Lessons
|
## Lessons
|
||||||
|
|
||||||
|
### `app.commandLine.appendSwitch` cannot disable the Chromium sandbox [electron] [packaging] [linux]
|
||||||
|
|
||||||
|
- **Trigger:** a packaged Linux build shows only the window background and spams `Unable to access(W_OK|X_OK) /tmp` / `Creating shared memory in /tmp/... failed`, while the same build works when the user types `--no-sandbox`.
|
||||||
|
- **Rule:** sandbox and Ozone switches only count when they are on the real command line at process start. Never pair a runtime `appendSwitch('no-sandbox')` with `appendSwitch('disable-dev-shm-usage')` — the first is a no-op because the zygote has already forked, the second takes effect and redirects shared memory into `/tmp`, which the still-active sandbox denies forever.
|
||||||
|
- **Why:** electron-builder's AppImage `AppRun` is a bash script that execs `$APPDIR/<executableName> "$@"` and ignores the bundled desktop entry, so `linux.executableArgs` never reaches a double-click or terminal launch. Only an installed `.desktop` file passes those arguments.
|
||||||
|
- **Example:** `electron/app/linux-launcher.rules.ts` generates the launcher that `tools/after-pack.js` installs in place of the real binary (renamed `<name>-bin`); it enables `--no-sandbox` only where unprivileged user namespaces are denied (Ubuntu 24.04+ AppArmor, hardened kernels), since an AppImage payload is mounted `nosuid` and cannot fall back to the SUID helper.
|
||||||
|
|
||||||
### A hold-on-unknown rule needs every attach site behind it [voice] [webrtc] [realtime]
|
### A hold-on-unknown rule needs every attach site behind it [voice] [webrtc] [realtime]
|
||||||
|
|
||||||
- **Trigger:** replacing a strict media gate with "hold an established path when nothing confirms the peer", while some fast path still attaches the track without asking the rule.
|
- **Trigger:** replacing a strict media gate with "hold an established path when nothing confirms the peer", while some fast path still attaches the track without asking the rule.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Owns the desktop runtime: the Electron main process, the preload bridge that exp
|
|||||||
| **Local API server** | An in-process HTTP server (`electron/api/local-api-server.ts`) that serves the prebuilt Docusaurus docs and OpenAPI views to the renderer over `http://localhost:<port>/`. | "internal API" |
|
| **Local API server** | An in-process HTTP server (`electron/api/local-api-server.ts`) that serves the prebuilt Docusaurus docs and OpenAPI views to the renderer over `http://localhost:<port>/`. | "internal API" |
|
||||||
| **Plugin library** | The plugin loader (`electron/plugin-library.ts`) — resolves manifests, validates entry points, and prepares the sandbox the renderer mounts plugins into. | "plugin manager" |
|
| **Plugin library** | The plugin loader (`electron/plugin-library.ts`) — resolves manifests, validates entry points, and prepares the sandbox the renderer mounts plugins into. | "plugin manager" |
|
||||||
| **Data archive** | The export/import format implemented in `electron/data-archive.ts` for moving a user's local database between installs. | "backup" |
|
| **Data archive** | The export/import format implemented in `electron/data-archive.ts` for moving a user's local database between installs. | "backup" |
|
||||||
|
| **Linux launcher** | The shell script installed as the packaged Linux executable by `tools/after-pack.js`; built by `electron/app/linux-launcher.rules.ts`, it picks the sandbox switches and hands over to the renamed real binary `<executableName>-bin`. | "wrapper", "AppRun" |
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ Owns the desktop runtime: the Electron main process, the preload bridge that exp
|
|||||||
- Every schema change is accompanied by a **TypeORM migration**; the database is never mutated outside the migration system.
|
- Every schema change is accompanied by a **TypeORM migration**; the database is never mutated outside the migration system.
|
||||||
- IPC handler errors are translated to typed error envelopes before crossing back into the renderer — the renderer never sees a raw `Error` from main.
|
- IPC handler errors are translated to typed error envelopes before crossing back into the renderer — the renderer never sees a raw `Error` from main.
|
||||||
- The **Preload bridge** exposes a frozen, allow-listed set of methods; adding a method requires touching both `preload.ts` and the matching handler.
|
- The **Preload bridge** exposes a frozen, allow-listed set of methods; adding a method requires touching both `preload.ts` and the matching handler.
|
||||||
|
- Chromium sandbox and Ozone switches are only ever set on the real command line — the **Linux launcher** for packaged builds, the launch scripts in development. `app.commandLine.appendSwitch` runs too late for them and must not be used to fake it.
|
||||||
|
|
||||||
## Flagged ambiguities
|
## Flagged ambiguities
|
||||||
|
|
||||||
|
|||||||
+4
-15
@@ -4,7 +4,6 @@ import { readDesktopSettings } from '../desktop-settings';
|
|||||||
|
|
||||||
export function configureAppFlags(): void {
|
export function configureAppFlags(): void {
|
||||||
configureDesktopBranding();
|
configureDesktopBranding();
|
||||||
linuxSpecificFlags();
|
|
||||||
networkFlags();
|
networkFlags();
|
||||||
setupGpuEncodingFlags();
|
setupGpuEncodingFlags();
|
||||||
chromiumFlags();
|
chromiumFlags();
|
||||||
@@ -21,6 +20,10 @@ function chromiumFlags(): void {
|
|||||||
const enabledFeatures: string[] = [];
|
const enabledFeatures: string[] = [];
|
||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
|
// Sandbox and Ozone platform selection happen before this file runs. The
|
||||||
|
// packaged launcher script and the dev launch scripts pass those switches
|
||||||
|
// on the real command line instead.
|
||||||
|
|
||||||
// PipeWire-based audio pipeline for screen share audio capture
|
// PipeWire-based audio pipeline for screen share audio capture
|
||||||
enabledFeatures.push('AudioServiceOutOfProcess');
|
enabledFeatures.push('AudioServiceOutOfProcess');
|
||||||
// PipeWire-based screen capture so the xdg-desktop-portal system picker works
|
// PipeWire-based screen capture so the xdg-desktop-portal system picker works
|
||||||
@@ -38,20 +41,6 @@ function chromiumFlags(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function linuxSpecificFlags(): void {
|
|
||||||
if (process.platform !== 'linux') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
|
||||||
app.commandLine.appendSwitch('no-sandbox');
|
|
||||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
|
||||||
|
|
||||||
// Chromium chooses the Linux Ozone platform before Electron runs this file.
|
|
||||||
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland
|
|
||||||
// sessions so the browser process selects the correct backend early enough.
|
|
||||||
}
|
|
||||||
|
|
||||||
function networkFlags(): void {
|
function networkFlags(): void {
|
||||||
// Accept self-signed certificates in development (for --ssl dev server)
|
// Accept self-signed certificates in development (for --ssl dev server)
|
||||||
if (process.env['SSL'] === 'true') {
|
if (process.env['SSL'] === 'true') {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
mkdtempSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { buildLinuxLauncherScript, resolveLinuxLauncherNames } from './linux-launcher.rules';
|
||||||
|
|
||||||
|
interface KernelFlags {
|
||||||
|
apparmorRestriction: string;
|
||||||
|
unprivilegedUsernsClone: string;
|
||||||
|
maxUserNamespaces: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERMISSIVE_KERNEL: KernelFlags = {
|
||||||
|
apparmorRestriction: '0',
|
||||||
|
unprivilegedUsernsClone: '1',
|
||||||
|
maxUserNamespaces: '15000'
|
||||||
|
};
|
||||||
|
|
||||||
|
let workspace = '';
|
||||||
|
|
||||||
|
function writeKernelFlags(flags: KernelFlags): Record<keyof KernelFlags, string> {
|
||||||
|
const paths = {
|
||||||
|
apparmorRestriction: join(workspace, 'apparmor_restrict_unprivileged_userns'),
|
||||||
|
unprivilegedUsernsClone: join(workspace, 'unprivileged_userns_clone'),
|
||||||
|
maxUserNamespaces: join(workspace, 'max_user_namespaces')
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of Object.keys(paths) as (keyof KernelFlags)[]) {
|
||||||
|
writeFileSync(paths[key], `${flags[key]}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runLauncher(flags: KernelFlags, args: string[] = []): string {
|
||||||
|
const paths = writeKernelFlags(flags);
|
||||||
|
const { launcherFileName, binaryFileName } = resolveLinuxLauncherNames('toju');
|
||||||
|
const launcherPath = join(workspace, launcherFileName);
|
||||||
|
const binaryPath = join(workspace, binaryFileName);
|
||||||
|
|
||||||
|
writeFileSync(binaryPath, '#!/bin/sh\nprintf \'%s\\n\' "$@"\n', { encoding: 'utf8', mode: 0o755 });
|
||||||
|
writeFileSync(
|
||||||
|
launcherPath,
|
||||||
|
buildLinuxLauncherScript({
|
||||||
|
binaryFileName,
|
||||||
|
apparmorRestrictionPath: paths.apparmorRestriction,
|
||||||
|
unprivilegedUsernsClonePath: paths.unprivilegedUsernsClone,
|
||||||
|
maxUserNamespacesPath: paths.maxUserNamespaces
|
||||||
|
}),
|
||||||
|
{ encoding: 'utf8', mode: 0o755 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return execFileSync(launcherPath, args, { encoding: 'utf8' }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildLinuxLauncherScript', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
workspace = mkdtempSync(join(tmpdir(), 'toju-launcher-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(workspace, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the sandbox on when the kernel allows unprivileged user namespaces', () => {
|
||||||
|
expect(runLauncher(PERMISSIVE_KERNEL)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the sandbox when AppArmor confines unprivileged user namespaces', () => {
|
||||||
|
const output = runLauncher({ ...PERMISSIVE_KERNEL, apparmorRestriction: '1' });
|
||||||
|
|
||||||
|
expect(output).toBe('--no-sandbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the sandbox when the kernel forbids unprivileged namespace cloning', () => {
|
||||||
|
const output = runLauncher({ ...PERMISSIVE_KERNEL, unprivilegedUsernsClone: '0' });
|
||||||
|
|
||||||
|
expect(output).toBe('--no-sandbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the sandbox when no user namespaces are available at all', () => {
|
||||||
|
const output = runLauncher({ ...PERMISSIVE_KERNEL, maxUserNamespaces: '0' });
|
||||||
|
|
||||||
|
expect(output).toBe('--no-sandbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards launch arguments to the real binary', () => {
|
||||||
|
const output = runLauncher(PERMISSIVE_KERNEL, ['toju://invite/abc', '--ozone-platform=wayland']);
|
||||||
|
|
||||||
|
expect(output.split('\n')).toEqual(['toju://invite/abc', '--ozone-platform=wayland']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never repeats a sandbox switch the caller already supplied', () => {
|
||||||
|
const output = runLauncher(
|
||||||
|
{ ...PERMISSIVE_KERNEL, apparmorRestriction: '1' },
|
||||||
|
['--no-sandbox', '%U']
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(output.split('\n')).toEqual(['--no-sandbox', '%U']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assumes a blocked sandbox is fine when the kernel switches are unreadable', () => {
|
||||||
|
const { launcherFileName, binaryFileName } = resolveLinuxLauncherNames('toju');
|
||||||
|
const launcherPath = join(workspace, launcherFileName);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(workspace, binaryFileName),
|
||||||
|
'#!/bin/sh\nprintf \'%s\\n\' "$@"\n',
|
||||||
|
{ encoding: 'utf8', mode: 0o755 }
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
launcherPath,
|
||||||
|
buildLinuxLauncherScript({
|
||||||
|
binaryFileName,
|
||||||
|
apparmorRestrictionPath: join(workspace, 'missing-apparmor'),
|
||||||
|
unprivilegedUsernsClonePath: join(workspace, 'missing-clone'),
|
||||||
|
maxUserNamespacesPath: join(workspace, 'missing-max')
|
||||||
|
}),
|
||||||
|
{ encoding: 'utf8', mode: 0o755 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(execFileSync(launcherPath, { encoding: 'utf8' }).trim()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveLinuxLauncherNames', () => {
|
||||||
|
it('keeps the published executable name for the launcher and renames the binary', () => {
|
||||||
|
expect(resolveLinuxLauncherNames('toju')).toEqual({
|
||||||
|
launcherFileName: 'toju',
|
||||||
|
binaryFileName: 'toju-bin'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
export const LINUX_LAUNCHER_BINARY_SUFFIX = '-bin';
|
||||||
|
|
||||||
|
export const APPARMOR_USERNS_RESTRICTION_PATH = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns';
|
||||||
|
export const UNPRIVILEGED_USERNS_CLONE_PATH = '/proc/sys/kernel/unprivileged_userns_clone';
|
||||||
|
export const MAX_USER_NAMESPACES_PATH = '/proc/sys/user/max_user_namespaces';
|
||||||
|
|
||||||
|
export interface LinuxLauncherNames {
|
||||||
|
launcherFileName: string;
|
||||||
|
binaryFileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinuxLauncherScriptOptions {
|
||||||
|
binaryFileName: string;
|
||||||
|
apparmorRestrictionPath?: string;
|
||||||
|
unprivilegedUsernsClonePath?: string;
|
||||||
|
maxUserNamespacesPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLinuxLauncherNames(executableName: string): LinuxLauncherNames {
|
||||||
|
return {
|
||||||
|
launcherFileName: executableName,
|
||||||
|
binaryFileName: `${executableName}${LINUX_LAUNCHER_BINARY_SUFFIX}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chromium reads `--no-sandbox` while the browser process boots, long before
|
||||||
|
* the main script runs, so `app.commandLine.appendSwitch` cannot influence it.
|
||||||
|
* The packaged executable is therefore this script, which decides before
|
||||||
|
* handing over to the real binary.
|
||||||
|
*
|
||||||
|
* The sandbox stays on wherever the kernel can host it. It is dropped only
|
||||||
|
* where unprivileged user namespaces are denied - Ubuntu 24.04+ confines
|
||||||
|
* unconfined binaries through AppArmor, and hardened kernels disable the
|
||||||
|
* namespaces outright. An AppImage cannot fall back to the SUID helper because
|
||||||
|
* its payload is mounted `nosuid`, so without this the app aborts at startup.
|
||||||
|
*/
|
||||||
|
export function buildLinuxLauncherScript(options: LinuxLauncherScriptOptions): string {
|
||||||
|
const apparmorRestrictionPath = options.apparmorRestrictionPath ?? APPARMOR_USERNS_RESTRICTION_PATH;
|
||||||
|
const unprivilegedUsernsClonePath = options.unprivilegedUsernsClonePath ?? UNPRIVILEGED_USERNS_CLONE_PATH;
|
||||||
|
const maxUserNamespacesPath = options.maxUserNamespacesPath ?? MAX_USER_NAMESPACES_PATH;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'#!/bin/sh',
|
||||||
|
'# Generated during packaging. Chromium only honours --no-sandbox when it is',
|
||||||
|
'# present on the real command line, so the decision happens here.',
|
||||||
|
'set -eu',
|
||||||
|
'',
|
||||||
|
'launcher_path="$0"',
|
||||||
|
'',
|
||||||
|
'case "$launcher_path" in',
|
||||||
|
' */*) ;;',
|
||||||
|
' *) launcher_path="$(command -v -- "$launcher_path" 2>/dev/null || printf \'%s\' "$launcher_path")" ;;',
|
||||||
|
'esac',
|
||||||
|
'',
|
||||||
|
'launcher_path="$(readlink -f -- "$launcher_path" 2>/dev/null || printf \'%s\' "$launcher_path")"',
|
||||||
|
`binary_path="$(dirname -- "$launcher_path")/${options.binaryFileName}"`,
|
||||||
|
'',
|
||||||
|
'read_kernel_flag() {',
|
||||||
|
' if [ ! -r "$1" ]; then',
|
||||||
|
' printf \'%s\' "$2"',
|
||||||
|
' return 0',
|
||||||
|
' fi',
|
||||||
|
'',
|
||||||
|
' cat -- "$1" 2>/dev/null || printf \'%s\' "$2"',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
'sandbox_is_blocked() {',
|
||||||
|
` if [ "$(read_kernel_flag ${apparmorRestrictionPath} 0)" = "1" ]; then`,
|
||||||
|
' return 0',
|
||||||
|
' fi',
|
||||||
|
'',
|
||||||
|
` if [ "$(read_kernel_flag ${unprivilegedUsernsClonePath} 1)" = "0" ]; then`,
|
||||||
|
' return 0',
|
||||||
|
' fi',
|
||||||
|
'',
|
||||||
|
` if [ "$(read_kernel_flag ${maxUserNamespacesPath} 1)" = "0" ]; then`,
|
||||||
|
' return 0',
|
||||||
|
' fi',
|
||||||
|
'',
|
||||||
|
' return 1',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
'for launcher_arg in "$@"; do',
|
||||||
|
' case "$launcher_arg" in',
|
||||||
|
' --no-sandbox) exec "$binary_path" "$@" ;;',
|
||||||
|
' esac',
|
||||||
|
'done',
|
||||||
|
'',
|
||||||
|
'if sandbox_is_blocked; then',
|
||||||
|
' exec "$binary_path" --no-sandbox "$@"',
|
||||||
|
'fi',
|
||||||
|
'',
|
||||||
|
'exec "$binary_path" "$@"',
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -181,6 +181,7 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron"
|
"output": "dist-electron"
|
||||||
},
|
},
|
||||||
|
"afterPack": "tools/after-pack.js",
|
||||||
"files": [
|
"files": [
|
||||||
"!node_modules",
|
"!node_modules",
|
||||||
"dist/client/**/*",
|
"dist/client/**/*",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildLinuxLauncherScript,
|
||||||
|
resolveLinuxLauncherNames
|
||||||
|
} = require('../dist/electron/app/linux-launcher.rules.js');
|
||||||
|
|
||||||
|
const LAUNCHER_MODE = 0o755;
|
||||||
|
|
||||||
|
function installLinuxLauncher(appOutDir, executableName) {
|
||||||
|
const { launcherFileName, binaryFileName } = resolveLinuxLauncherNames(executableName);
|
||||||
|
const launcherPath = path.join(appOutDir, launcherFileName);
|
||||||
|
const binaryPath = path.join(appOutDir, binaryFileName);
|
||||||
|
|
||||||
|
if (!fs.existsSync(binaryPath)) {
|
||||||
|
fs.renameSync(launcherPath, binaryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(launcherPath, buildLinuxLauncherScript({ binaryFileName }), 'utf8');
|
||||||
|
fs.chmodSync(launcherPath, LAUNCHER_MODE);
|
||||||
|
|
||||||
|
return launcherFileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.default = async function afterPack(context) {
|
||||||
|
if (context.electronPlatformName !== 'linux') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const launcherFileName = installLinuxLauncher(context.appOutDir, context.packager.executableName);
|
||||||
|
|
||||||
|
console.log(` • linux launcher installed file=${launcherFileName}`);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user