fix: prevent duplicate macOS app entries

Add one-click DMG installation, automatically eject verified EdgeEver installer images, and remove unpacked build products after package verification. Prepare v1.6.38 native release metadata.\n\nRefs #115\nRefs #116
This commit is contained in:
tianma-if committed 2026-07-29 00:40:52 +08:00
1 parent 3aeca0442b
commit 65ece55bfb
9 files changed
+170 -22

No files matched your search

+6 -4
View File
@@ -31,10 +31,12 @@ CSC_IDENTITY_AUTO_DISCOVERY=false bun run --cwd apps/desktop dist -- --publish n
The resulting DMG, NSIS installer, or AppImage is written under
`release/desktop`. The CI workflow accepts optional signing secrets:
On macOS, users must drag EdgeEver to `Applications`, eject the mounted DMG,
and launch the copy from `Applications`. The packaged app refuses to run
directly from the installer disk because macOS registers both copies in
file-association menus, which can make EdgeEver appear twice.
On macOS, users can double-click EdgeEver in the mounted DMG and choose
**Install and Launch**. The app moves itself to `Applications`, relaunches the
installed copy, and ejects any mounted EdgeEver installer images so macOS does
not retain duplicate file-association entries. Dragging the app to
`Applications` remains supported; launching the installed copy also performs
the installer-image cleanup.
- `EDGEEVER_MAC_CERTIFICATE_BASE64` and `EDGEEVER_MAC_CERTIFICATE_PASSWORD`
- `EDGEEVER_APPLE_ID`, `EDGEEVER_APPLE_APP_SPECIFIC_PASSWORD`, and `EDGEEVER_APPLE_TEAM_ID`
+1 -1
View File
@@ -46,7 +46,7 @@ mac:
- arm64
artifactName: EdgeEver-${version}-mac-${arch}.${ext}
dmg:
title: "EdgeEver: drag to Applications, eject this disk, then launch"
title: "EdgeEver: double-click to install"
backgroundColor: '#f5f7f6'
iconSize: 112
window:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@edgeever/desktop",
"version": "1.6.37",
"version": "1.6.38",
"desktopName": "EdgeEver",
"private": true,
"description": "Native EdgeEver desktop client",
+97 -9
View File
@@ -2,7 +2,7 @@ import { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain, session, net, pro
import { existsSync } from "node:fs";
import { appendFile, mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { spawn } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { SidecarRpcClient } from "./rpc.mjs";
import { isSafeResourceId, resourceIdFromRequest } from "./resource-url.mjs";
@@ -11,7 +11,11 @@ import { accountDataDirectory, accountScopeKey } from "./account-scope.mjs";
import { rotateDiagnosticLog } from "./diagnostic-log.mjs";
import { restrictDirectory, restrictFile } from "./file-permissions.mjs";
import { normalizeStagedResourceInput } from "./staged-resource.mjs";
import { isMountedInstallerPath } from "./installation-location.mjs";
import {
isMountedDiskImageVolume,
isMountedInstallerPath,
mountedInstallerCandidates,
} from "./installation-location.mjs";
import { userDataDirectoryFromArguments } from "./user-data-directory.mjs";
import electronUpdater from "electron-updater";
@@ -97,6 +101,67 @@ const writeDiagnostic = async (event, details = {}) => {
}
};
const execFileAsync = (command, argumentsList) => new Promise((resolve, reject) => {
execFile(command, argumentsList, { encoding: "utf8" }, (error, stdout, stderr) => {
if (error) {
error.stderr = stderr;
reject(error);
return;
}
resolve({ stdout, stderr });
});
});
const ejectMountedMacInstallers = async () => {
if (!app.isPackaged || process.platform !== "darwin" || isMountedInstallerPath(app.getAppPath())) return;
let volumeNames;
let diskImageInfo;
try {
const entries = await readdir("/Volumes", { withFileTypes: true });
volumeNames = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
({ stdout: diskImageInfo } = await execFileAsync("/usr/bin/hdiutil", ["info"]));
} catch (error) {
void writeDiagnostic("installation.volume-scan-failed", { message: error.message });
return;
}
for (const candidate of mountedInstallerCandidates(volumeNames)) {
if (!isMountedDiskImageVolume(diskImageInfo, candidate.volumePath)) continue;
const infoPlist = join(candidate.appPath, "Contents", "Info.plist");
if (!existsSync(infoPlist)) continue;
try {
const { stdout } = await execFileAsync("/usr/bin/plutil", [
"-extract",
"CFBundleIdentifier",
"raw",
"-o",
"-",
infoPlist,
]);
if (stdout.trim() !== "org.edgeever.desktop") continue;
let ejectError;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await execFileAsync("/usr/sbin/diskutil", ["eject", candidate.volumePath]);
ejectError = null;
break;
} catch (error) {
ejectError = error;
if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, 500));
}
}
if (ejectError) throw ejectError;
void writeDiagnostic("installation.volume-ejected", { volumePath: candidate.volumePath });
} catch (error) {
void writeDiagnostic("installation.volume-eject-failed", {
volumePath: candidate.volumePath,
message: error.message,
});
}
}
};
process.on("uncaughtException", (error) => { void writeDiagnostic("main.uncaught-exception", { message: error.message, stack: error.stack }); });
process.on("unhandledRejection", (reason) => { void writeDiagnostic("main.unhandled-rejection", { message: reason instanceof Error ? reason.message : String(reason) }); });
@@ -456,20 +521,43 @@ const confirmMacInstallation = async () => {
app.whenReady().then(async () => {
if (app.isPackaged && isMountedInstallerPath(app.getAppPath())) {
const isChinese = app.getLocale().toLowerCase().startsWith("zh");
await dialog.showMessageBox({
const result = await dialog.showMessageBox({
type: "info",
title: isChinese ? "请先安装 EdgeEver" : "Install EdgeEver first",
title: isChinese ? "安装 EdgeEver" : "Install EdgeEver",
message: isChinese
? "请先将 EdgeEver 拖入“应用程序”文件夹。"
: "Please move EdgeEver to the Applications folder first.",
? "将 EdgeEver 安装到“应用程序”文件夹并启动。"
: "Install EdgeEver in the Applications folder and launch it.",
detail: isChinese
? "请关闭此窗口,将 EdgeEver 拖入“应用程序”,然后推出安装盘,再从“应用程序”启动。这样可以避免 macOS 显示两个 EdgeEver。"
: "Close this window, drag EdgeEver to Applications, eject the installer disk, and then launch it from Applications. This prevents macOS from showing duplicate EdgeEver entries.",
buttons: [isChinese ? "知道了" : "Done"],
? "安装完成后,EdgeEver 会自动重启正式副本并推出安装盘,避免 macOS 同时显示两个 EdgeEver 入口。"
: "After installation, EdgeEver relaunches the installed copy and ejects the installer disk so macOS does not keep two EdgeEver entries.",
buttons: [isChinese ? "安装并启动" : "Install and Launch", isChinese ? "取消" : "Cancel"],
defaultId: 0,
cancelId: 1,
});
if (result.response !== 0) {
app.quit();
return;
}
try {
const moved = app.moveToApplicationsFolder({
conflictHandler: (conflictType) => conflictType !== "existsAndRunning",
});
if (moved) return;
} catch (error) {
void writeDiagnostic("installation.move-failed", { message: error.message });
}
await dialog.showMessageBox({
type: "error",
title: isChinese ? "无法完成安装" : "Could Not Install",
message: isChinese
? "请先退出正在运行的 EdgeEver,然后重新打开安装盘并重试。"
: "Quit the running copy of EdgeEver, reopen the installer disk, and try again.",
buttons: [isChinese ? "知道了" : "OK"],
});
app.quit();
return;
}
await ejectMountedMacInstallers();
if (!hasSingleInstanceLock) {
app.quit();
return;
@@ -1,4 +1,26 @@
import { join } from "node:path";
export const isMountedInstallerPath = (appPath, platform = process.platform) => {
if (platform !== "darwin" || typeof appPath !== "string") return false;
return appPath === "/Volumes" || appPath.startsWith("/Volumes/");
};
export const mountedInstallerCandidates = (volumeNames, volumesDirectory = "/Volumes") => {
if (!Array.isArray(volumeNames)) return [];
return volumeNames
.filter((name) => typeof name === "string" && name.length > 0 && !name.includes("/"))
.map((name) => {
const volumePath = join(volumesDirectory, name);
return {
volumePath,
appPath: join(volumePath, "EdgeEver.app"),
};
});
};
export const isMountedDiskImageVolume = (hdiutilOutput, volumePath) => {
if (typeof hdiutilOutput !== "string" || typeof volumePath !== "string") return false;
return hdiutilOutput
.split("\n")
.some((line) => line.slice(line.lastIndexOf("\t") + 1).trim() === volumePath);
};
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test";
import { isMountedInstallerPath } from "./installation-location.mjs";
import {
isMountedDiskImageVolume,
isMountedInstallerPath,
mountedInstallerCandidates,
} from "./installation-location.mjs";
describe("macOS installation location", () => {
test("recognizes an app launched from a mounted volume", () => {
@@ -13,4 +17,30 @@ describe("macOS installation location", () => {
test("does not apply the macOS rule on other platforms", () => {
expect(isMountedInstallerPath("/Volumes/EdgeEver/EdgeEver.app/Contents/Resources/app.asar", "win32")).toBe(false);
});
test("builds safe installer candidates for mounted volumes", () => {
expect(mountedInstallerCandidates(["EdgeEver Installer", "External Disk"])).toEqual([
{
volumePath: "/Volumes/EdgeEver Installer",
appPath: "/Volumes/EdgeEver Installer/EdgeEver.app",
},
{
volumePath: "/Volumes/External Disk",
appPath: "/Volumes/External Disk/EdgeEver.app",
},
]);
});
test("rejects invalid mounted volume names", () => {
expect(mountedInstallerCandidates(["", "../escape", null])).toEqual([]);
});
test("only identifies exact disk image mount points", () => {
const output = [
"/dev/disk7\tGUID_partition_scheme",
"/dev/disk7s1\tApple_HFS\t/Volumes/EdgeEver Installer",
].join("\n");
expect(isMountedDiskImageVolume(output, "/Volumes/EdgeEver Installer")).toBe(true);
expect(isMountedDiskImageVolume(output, "/Volumes/EdgeEver")).toBe(false);
});
});
+2 -2
View File
@@ -3,7 +3,7 @@
"name": "EdgeEver",
"slug": "edgeever",
"scheme": "edgeever",
"version": "1.6.37",
"version": "1.6.38",
"runtimeVersion": {
"policy": "appVersion"
},
@@ -28,7 +28,7 @@
},
"android": {
"package": "org.edgeever.mobile",
"versionCode": 47,
"versionCode": 48,
"adaptiveIcon": {
"backgroundColor": "#d1fae5",
"backgroundImage": "./assets/icon.png",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "edgeever",
"version": "1.6.37",
"version": "1.6.38",
"private": true,
"type": "module",
"workspaces": [
@@ -36,7 +36,7 @@
"verify:web-performance": "bun scripts/verify-web-performance.mjs",
"test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs",
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"verify:desktop-package": "bun scripts/verify-desktop-package.mjs",
"build:cloudflare": "bun run typecheck && bun run build",
"build:site": "bun --cwd apps/site build",
+8 -2
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import { listPackage } from "@electron/asar";
@@ -29,7 +29,9 @@ for (const sidecarPath of files.filter((path) => /[\\/]resources[\\/]sidecar[\\/
if (requestedPlatform === "darwin") {
assert.ok(existsSync(join(outputDirectory, `EdgeEver-${version}-mac-arm64.dmg`)), "macOS package must contain the current arm64 DMG");
const appResources = join(outputDirectory, "mac-arm64", "EdgeEver.app", "Contents", "Resources");
const unpackedApp = join(outputDirectory, "mac-arm64", "EdgeEver.app");
assert.ok(existsSync(unpackedApp), "macOS package must contain the unpacked app bundle");
const appResources = join(unpackedApp, "Contents", "Resources");
const sidecar = join(appResources, "sidecar", "edgeever-sidecar");
assert.ok(existsSync(sidecar), `macOS app bundle is missing the sidecar: ${sidecar}`);
const asarPath = join(appResources, "app.asar");
@@ -37,6 +39,10 @@ if (requestedPlatform === "darwin") {
const asarFiles = new Set(listPackage(asarPath));
assert.ok(asarFiles.has("/src/preload/index.cjs"), "macOS app bundle must contain the sandbox-compatible CommonJS preload");
assert.ok(!asarFiles.has("/src/preload/index.mjs"), "macOS app bundle must not contain the unsupported ESM preload");
// LaunchServices registers unpacked build products as additional document
// handlers. The signed DMG/ZIP are the release artifacts, so discard the
// disposable unpacked bundle once its contents have passed verification.
rmSync(unpackedApp, { recursive: true, force: true });
} else if (requestedPlatform === "win32") {
const installer = matchingPrefix(`EdgeEver-${version}-windows-`).some((path) => path.endsWith(".exe"));
const unpacked = existsSync(join(outputDirectory, "win-arm64-unpacked", "EdgeEver.exe"));