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:
1 parent
3aeca0442b
commit
65ece55bfb
9 files changed
+170
-22
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@edgeever/desktop",
|
||||
"version": "1.6.37",
|
||||
"version": "1.6.38",
|
||||
"desktopName": "EdgeEver",
|
||||
"private": true,
|
||||
"description": "Native EdgeEver desktop client",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user