Merge remote-tracking branch 'gitlab/claude/electron-userdata-dirs' into dev-merge-batch1
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { test, expect, _electron as electron } from '@playwright/test';
|
||||
import type { ElectronApplication, Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
// Live-sandbox verification run (not part of the regular regression suite).
|
||||
//
|
||||
// Unlike e2e/electron-smoke.spec.ts (which deliberately uses a fake
|
||||
// JMAP_SERVER_URL just to skip the /setup wizard, and never expects a real
|
||||
// server on the other end), this spec launches the exact same packaged
|
||||
// artifact against the REAL sandbox JMAP backend at
|
||||
// https://stalwart.sandbox.vnc.de and proves:
|
||||
// 1. the login screen renders with no TLS/network errors reaching that host
|
||||
// 2. submitting an obviously-fake, nonexistent test credential produces a
|
||||
// structured "invalid credentials" style response from the real server
|
||||
// (not a network failure) - proving the renderer -> Next API route ->
|
||||
// real JMAP server round trip works end-to-end, without ever using or
|
||||
// guessing a real account's credentials.
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const SANDBOX_URL = 'https://stalwart.sandbox.vnc.de';
|
||||
|
||||
test.describe('Electron desktop shell - live sandbox connectivity', () => {
|
||||
let electronApp: ElectronApplication;
|
||||
let appWindow: Page;
|
||||
const pageErrors: Error[] = [];
|
||||
const networkFailures: string[] = [];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
electronApp = await electron.launch({
|
||||
args: [projectRoot],
|
||||
env: {
|
||||
...process.env,
|
||||
JMAP_SERVER_URL: SANDBOX_URL,
|
||||
SESSION_SECRET: process.env.SESSION_SECRET || 'live-sandbox-verification-run',
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
});
|
||||
|
||||
appWindow = await electronApp.firstWindow();
|
||||
appWindow.on('pageerror', (error) => {
|
||||
pageErrors.push(error);
|
||||
});
|
||||
appWindow.on('requestfailed', (request) => {
|
||||
networkFailures.push(`${request.method()} ${request.url()} - ${request.failure()?.errorText}`);
|
||||
});
|
||||
await appWindow.waitForLoadState('domcontentloaded');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await electronApp?.close();
|
||||
});
|
||||
|
||||
test('renders the real login screen (not SETUP REQUIRED) with no network/TLS errors', async () => {
|
||||
const bodyText = await appWindow.locator('body').innerText();
|
||||
expect(bodyText).not.toContain('SETUP REQUIRED');
|
||||
expect(bodyText).not.toContain('Setup Required');
|
||||
|
||||
const emailInput = appWindow.locator('input[type="text"]').first();
|
||||
const passwordInput = appWindow.locator('input[type="password"]').first();
|
||||
await expect(emailInput).toBeVisible({ timeout: 20000 });
|
||||
await expect(passwordInput).toBeVisible();
|
||||
|
||||
await appWindow.screenshot({
|
||||
path: path.join(projectRoot, 'live-sandbox-login-screen.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
expect(pageErrors.map((e) => e.message).join('\n')).not.toMatch(/ERR_CERT|ERR_CONNECTION|ERR_NAME_NOT_RESOLVED|ECONNREFUSED/i);
|
||||
expect(networkFailures.join('\n')).not.toMatch(/ERR_CERT|ERR_CONNECTION|ERR_NAME_NOT_RESOLVED|ECONNREFUSED/i);
|
||||
});
|
||||
|
||||
test('submitting a nonexistent test credential reaches the real JMAP server and returns a structured auth error (no real account used/guessed)', async () => {
|
||||
const emailInput = appWindow.locator('input[type="text"]').first();
|
||||
const passwordInput = appWindow.locator('input[type="password"]').first();
|
||||
|
||||
// Deliberately fake, nonexistent address - not a real account, not a
|
||||
// guess against one. This only proves the pipe to the real server works.
|
||||
await emailInput.fill('electron-live-sandbox-verify-8f2c@invalid-test.example');
|
||||
await passwordInput.fill('not-a-real-password-8f2c');
|
||||
|
||||
const allResponses: { url: string; status: number }[] = [];
|
||||
appWindow.on('response', (res) => {
|
||||
allResponses.push({ url: res.url(), status: res.status() });
|
||||
});
|
||||
|
||||
await appWindow.locator('button[type="submit"]').first().click();
|
||||
|
||||
// The important assertion: the app renders a structured "invalid
|
||||
// credentials" style error sourced from the real JMAP server's rejection
|
||||
// (visible in whatever locale the app negotiated), not a network/TLS
|
||||
// failure. A real connectivity break to stalwart.sandbox.vnc.de would
|
||||
// instead surface as a generic network-error message or a stuck spinner.
|
||||
const errorBanner = appWindow.getByText(/invalid|ungültig|incorrect|falsch|unauthorized/i).first();
|
||||
await expect(errorBanner).toBeVisible({ timeout: 15000 });
|
||||
const errorText = await errorBanner.innerText();
|
||||
console.log('[live-sandbox] login error banner text:', errorText);
|
||||
expect(errorText.length).toBeGreaterThan(0);
|
||||
expect(errorText).not.toMatch(/network error|failed to fetch|ERR_CERT|ERR_CONNECTION|ECONNREFUSED/i);
|
||||
|
||||
await appWindow.screenshot({
|
||||
path: path.join(projectRoot, 'live-sandbox-after-failed-login-attempt.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
console.log('[live-sandbox] ALL responses observed after click:', JSON.stringify(allResponses, null, 2));
|
||||
const authResponses = allResponses.filter((r) => r.url.includes('/api/auth/'));
|
||||
if (authResponses.length > 0) {
|
||||
for (const r of authResponses) {
|
||||
expect(r.status).toBeGreaterThanOrEqual(400);
|
||||
expect(r.status).toBeLessThan(500);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,24 @@ module.exports = {
|
||||
directories: {
|
||||
output: "dist-electron-builds",
|
||||
},
|
||||
// The packaged app (`files` below) is plain esbuild-bundled JS - no native
|
||||
// node modules of its own. The one native dependency anywhere in the repo,
|
||||
// @signalapp/sqlcipher (used by lib/mail-index/**), ships its own prebuilt
|
||||
// .node binaries for every platform/arch and is copied in wholesale by
|
||||
// scripts/assemble-standalone.mjs as part of the extraResources standalone
|
||||
// bundle below - it is never rebuilt by electron-builder.
|
||||
//
|
||||
// Without this, electron-builder's default @electron/rebuild pass scans
|
||||
// the ENTIRE node_modules tree (not just what's actually packaged) for
|
||||
// anything with a native binding and tries to recompile it from source
|
||||
// against Electron's ABI via node-gyp. That caught @parcel/watcher - a
|
||||
// transitive devDependency of some dev tool, never shipped in this app -
|
||||
// and hard-failed the whole packaging step on any machine without a full
|
||||
// Xcode Command Line Tools install (`gyp: No Xcode or CLT version
|
||||
// detected!`), even though nothing that rebuild step touches is part of
|
||||
// the artifact. Verified by execution: builds failed with npmRebuild at
|
||||
// its default (true) and succeeded once set to false.
|
||||
npmRebuild: false,
|
||||
files: ["dist-electron/**/*", "package.json"],
|
||||
extraResources: [
|
||||
{
|
||||
@@ -65,6 +83,7 @@ module.exports = {
|
||||
// but left explicit so it's obvious what step 9 needs to flip on.
|
||||
hardenedRuntime: false,
|
||||
},
|
||||
afterSign: "scripts/after-sign.cjs",
|
||||
win: {
|
||||
target: [{ target: "nsis", arch: ["x64"] }],
|
||||
},
|
||||
|
||||
@@ -33,6 +33,43 @@ function getIndexStoreDir(): string {
|
||||
return path.join(app.getPath("userData"), "offline");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every writable data dir the standalone server uses, redirected under
|
||||
* `userData`.
|
||||
*
|
||||
* WITHOUT this, all four default to `<cwd>/data/*` (see lib/admin/paths.ts,
|
||||
* lib/settings-sync.ts, lib/telemetry/state.ts, lib/version-check/state.ts),
|
||||
* and in a packaged build cwd is `.../VNCmail+.app/Contents/Resources/standalone`
|
||||
* - i.e. the app writes its own runtime state INSIDE its own bundle. Three
|
||||
* separate failure modes, all observed rather than theorised:
|
||||
*
|
||||
* 1. It INVALIDATES THE CODE SIGNATURE. A signed .app seals its Resources;
|
||||
* writing there breaks the seal, so `codesign --verify` starts failing
|
||||
* ("code has no resources but signature indicates they must be present")
|
||||
* and macOS reports the app as *damaged* on a later launch. Verified on
|
||||
* an installed copy in /Applications: signature valid at install time,
|
||||
* exit 1 after the app had run once and written data/admin + data/telemetry.
|
||||
* Deep-signing the bundle at build time (scripts/after-sign.cjs) is
|
||||
* necessary but NOT sufficient on its own - the app immediately breaks
|
||||
* its own signature at runtime unless the writes go elsewhere.
|
||||
* 2. An app update replaces the bundle, silently destroying the user's admin
|
||||
* config, settings and setup state.
|
||||
* 3. It fails outright wherever the bundle isn't user-writable.
|
||||
*
|
||||
* `userData` is the correct home for per-user mutable state on every platform
|
||||
* and is where the search index already lives, so this keeps one convention.
|
||||
*/
|
||||
function getServerDataDirs(): Record<string, string> {
|
||||
const root = app.getPath("userData");
|
||||
return {
|
||||
ADMIN_CONFIG_DIR: path.join(root, "admin"),
|
||||
ADMIN_STATE_DIR: path.join(root, "admin-state"),
|
||||
SETTINGS_DATA_DIR: path.join(root, "settings"),
|
||||
TELEMETRY_DATA_DIR: path.join(root, "telemetry"),
|
||||
VERSION_CHECK_DATA_DIR: path.join(root, "version-check"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates the standalone server's entrypoint. Packaged builds ship it as an
|
||||
* extraResource (see electron-builder.config.js) because .next/standalone
|
||||
@@ -124,6 +161,12 @@ async function startStandaloneServer(): Promise<string> {
|
||||
PORT: String(port),
|
||||
HOSTNAME: "127.0.0.1",
|
||||
NODE_ENV: process.env.NODE_ENV || "production",
|
||||
// Keep all mutable state out of the .app bundle - see
|
||||
// getServerDataDirs() for why that matters. Placed after
|
||||
// ...process.env so the desktop shell's paths win over any inherited
|
||||
// value; the same standalone server run outside Electron (the Docker
|
||||
// image) never executes this and keeps its documented env behaviour.
|
||||
...getServerDataDirs(),
|
||||
...(encryption.ok
|
||||
? { VNCMAIL_DESKTOP_STORE_DIR: storeDir, VNCMAIL_DESKTOP_KEY_FD: "3" }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// electron-builder afterSign hook (mac only - see electron-builder.config.js).
|
||||
//
|
||||
// Without a real Apple Developer ID, electron-builder's mac target ships
|
||||
// with only the auto ad-hoc signature the linker applies to the main
|
||||
// executable - the rest of the bundle (Resources, Helper.app children,
|
||||
// frameworks) is left unsigned. That inconsistency is what makes macOS
|
||||
// report a flat "VNCmail+ is damaged and can't be opened" once the .dmg
|
||||
// picks up a quarantine attribute (from a browser download, AirDrop, or
|
||||
// any other trust-boundary crossing) - not the more recoverable
|
||||
// "unidentified developer, right-click to open anyway" prompt a properly
|
||||
// (even if only ad-hoc) signed bundle gets. `codesign --deep` here
|
||||
// produces one consistent signature covering everything, verified against
|
||||
// the exact failure mode (`codesign --verify --deep --strict` on the
|
||||
// unsigned-except-linker bundle failed before this was added).
|
||||
//
|
||||
// Still not a real Developer ID signature - Gatekeeper will still warn on
|
||||
// first launch (`spctl` rejects any non-notarized app outright), but as
|
||||
// the recoverable kind, not the "move to Trash" kind.
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
module.exports = async function afterSign(context) {
|
||||
if (context.electronPlatformName !== "darwin") return;
|
||||
|
||||
const appPath = `${context.appOutDir}/${context.packager.appInfo.productFilename}.app`;
|
||||
execFileSync("codesign", ["--force", "--deep", "--sign", "-", appPath], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user