From 65ece55bfb8a0750e93965deecc82342c8d413cb Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 29 Jul 2026 00:40:52 +0800 Subject: [PATCH] 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 --- apps/desktop/README.md | 10 +- apps/desktop/electron-builder.yml | 2 +- apps/desktop/package.json | 2 +- apps/desktop/src/main/index.mjs | 106 ++++++++++++++++-- .../src/main/installation-location.mjs | 22 ++++ .../src/main/installation-location.test.mjs | 32 +++++- apps/mobile/app.json | 4 +- package.json | 4 +- scripts/verify-desktop-package.mjs | 10 +- 9 files changed, 170 insertions(+), 22 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ec8e43ae..96e10ac3 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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` diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index fb6a999b..9db61d1a 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -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: diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6e4c9232..b6e3357b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@edgeever/desktop", - "version": "1.6.37", + "version": "1.6.38", "desktopName": "EdgeEver", "private": true, "description": "Native EdgeEver desktop client", diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 232b5380..76aeb6cf 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -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; diff --git a/apps/desktop/src/main/installation-location.mjs b/apps/desktop/src/main/installation-location.mjs index b89237be..0a01130e 100644 --- a/apps/desktop/src/main/installation-location.mjs +++ b/apps/desktop/src/main/installation-location.mjs @@ -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); +}; diff --git a/apps/desktop/src/main/installation-location.test.mjs b/apps/desktop/src/main/installation-location.test.mjs index 69a54a32..bd86bc24 100644 --- a/apps/desktop/src/main/installation-location.test.mjs +++ b/apps/desktop/src/main/installation-location.test.mjs @@ -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); + }); }); diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 50462c1f..503bd259 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -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", diff --git a/package.json b/package.json index eeae1a72..d1096d35 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-desktop-package.mjs b/scripts/verify-desktop-package.mjs index ff1c076d..c65183ea 100644 --- a/scripts/verify-desktop-package.mjs +++ b/scripts/verify-desktop-package.mjs @@ -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"));