Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions App/frontend/desktop/src/app/update-coordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export type UpdatePhase =
export interface UpdateFeedback {
key: MessageKey;
values?: MessageValues;
/** A failed install records its reason only in the updates log directory. */
showLogs?: boolean;
}

export interface UpdateCoordinatorValue {
Expand Down Expand Up @@ -255,7 +257,7 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) {
preparedUpdatePath: null,
downloadProgress: null,
dialog: null,
feedback: { key: "settings.about.updateInstallFailed" }
feedback: { key: "settings.about.updateInstallFailed", showLogs: true }
}));
} finally {
if (downloadInFlightRef.current === request) {
Expand Down Expand Up @@ -390,7 +392,7 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) {
phase: "prepared",
dialog: null,
downloadProgress: null,
feedback: { key: "settings.about.updateInstallFailed" }
feedback: { key: "settings.about.updateInstallFailed", showLogs: true }
}));
} finally {
installInFlightRef.current = null;
Expand Down
1 change: 1 addition & 0 deletions App/frontend/desktop/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ declare global {
installCliTools(): Promise<MemmyCliInstallResult>;
restartMemoryService(): Promise<DesktopMemoryServiceRestartResult>;
openLogsDirectory(): Promise<void>;
openUpdateLogs(): Promise<void>;
exportDiagnosticsReport(): Promise<MemmyDiagnosticsReportExportResult>;
getLogLevel(): Promise<"error" | "warn" | "info" | "debug">;
setLogLevel(level: "error" | "warn" | "info" | "debug"): Promise<void>;
Expand Down
2 changes: 2 additions & 0 deletions App/frontend/desktop/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2199,6 +2199,7 @@ export const zhCNMessages = {
"settings.about.updateUnavailable": "当前运行环境不支持检查更新。",
"settings.about.updateCheckFailed": "检查更新失败,请稍后再试。",
"settings.about.updateInstallFailed": "安装更新失败,请稍后再试。",
"settings.about.openUpdateLogs": "查看更新日志",
"settings.about.terms": "服务协议",
"settings.about.community": "加入社区",
"settings.back": "返回设置",
Expand Down Expand Up @@ -4428,6 +4429,7 @@ export const enUSMessages: Record<keyof typeof zhCNMessages, string> = {
"settings.about.updateUnavailable": "Update checking is unavailable in this runtime.",
"settings.about.updateCheckFailed": "Update check failed. Please try again later.",
"settings.about.updateInstallFailed": "Update installation failed. Please try again later.",
"settings.about.openUpdateLogs": "View update logs",
"settings.about.terms": "Terms of Service",
"settings.about.community": "Join Community",
"settings.back": "Back to Settings",
Expand Down
27 changes: 26 additions & 1 deletion App/frontend/desktop/src/pages/settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,22 @@ export function SettingsPageView(props: SettingsPageViewProps) {
}
}

/**
* Opens the update log directory in the system file manager.
*
* Update helper scripts only record their failure reason in the updates directory, so the About
* tab exposes it directly instead of leaving the user with a generic "try again later" message.
*/
function openUpdateLogs() {
const openLogs = typeof window === "undefined" ? undefined : window.memmy?.openUpdateLogs;
if (typeof openLogs !== "function") {
return;
}
void openLogs().catch((error: unknown) => {
console.warn("open update logs failed", error);
});
}

/**
* Opens the desktop log directory in the system file manager.
*/
Expand Down Expand Up @@ -1887,8 +1903,17 @@ export function SettingsPageView(props: SettingsPageViewProps) {
<LinkButton label={t("settings.about.terms")} onClick={() => void openExternalUrl(getLegalLinkUrl("terms", language, bootstrap?.legal))} />
</div>
{update.feedback && (
<div className={`text-xs ${update.phase === "error" ? "text-status-error" : "text-text-ink/45"}`}>
<div className={`text-xs ${update.phase === "error" || update.feedback.showLogs ? "text-status-error" : "text-text-ink/45"}`}>
{t(update.feedback.key, update.feedback.values)}
{update.feedback.showLogs && (
<button
type="button"
onClick={openUpdateLogs}
className="ml-2 underline underline-offset-2 hover:text-action-sky transition-colors cursor-pointer"
>
{t("settings.about.openUpdateLogs")}
</button>
)}
</div>
)}
{update.phase === "downloading" && (
Expand Down
56 changes: 56 additions & 0 deletions App/frontend/desktop/src/pages/tests/settings-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,62 @@ describe("SettingsPageView", () => {
expect(html).toContain("512.0 KB / 1.0 MB");
});

it("更新失败时在关于区提供更新日志入口,而不是只显示稍后再试", () => {
const html = normalizeSsrHtml(renderSettingsPageView(
createReadyState(),
"zh-CN",
createUpdateViewModel({
phase: "error",
feedback: { key: "settings.about.updateInstallFailed", showLogs: true }
})
));

expect(html).toContain("安装更新失败,请稍后再试。");
expect(html).toContain("查看更新日志");
expect(html).toContain('class="ml-2 underline underline-offset-2');

// An install failure keeps the prepared phase, so the entry follows the feedback flag, not the phase.
const preparedFailureHtml = normalizeSsrHtml(renderSettingsPageView(
createReadyState(),
"zh-CN",
createUpdateViewModel({
phase: "prepared",
preparedUpdatePath: "/tmp/Memmy-update.dmg",
feedback: { key: "settings.about.updateInstallFailed", showLogs: true }
})
));
expect(preparedFailureHtml).toContain("查看更新日志");

// Success states keep the plain feedback line without a log entry.
const successHtml = normalizeSsrHtml(renderSettingsPageView(
createReadyState(),
"zh-CN",
createUpdateViewModel({
phase: "latest",
feedback: { key: "settings.about.upToDate", values: { version: "2.1.0" } }
})
));
expect(successHtml).not.toContain("查看更新日志");

const englishHtml = normalizeSsrHtml(renderSettingsPageView(
createReadyState(),
"en-US",
createUpdateViewModel({
phase: "error",
feedback: { key: "settings.about.updateInstallFailed", showLogs: true }
})
));
expect(englishHtml).toContain("View update logs");

const source = readFileSync(settingsPageSourcePath, "utf8");
expect(source).toContain("window.memmy?.openUpdateLogs");
expect(source).toContain("function openUpdateLogs()");
expect(source).toContain("onClick={openUpdateLogs}");

const coordinatorSource = readFileSync(updateCoordinatorSourcePath, "utf8");
expect(coordinatorSource).toContain('feedback: { key: "settings.about.updateInstallFailed", showLogs: true }');
});

it("菜单栏图标开关保存到应用设置并同步桌面 bridge", () => {
const source = readFileSync(settingsPageSourcePath, "utf8");

Expand Down
31 changes: 29 additions & 2 deletions App/shell/desktop/src/main/mac-update-scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,40 @@ export interface MacUpdateValidationOptions {
currentVersion?: string;
}

const MEMMY_BUNDLE_ID = "cn.memtensor.memmy";
/** Apple Developer ID intermediate/leaf extension marking a notarizable Developer ID certificate. */
const APPLE_DEVELOPER_ID_CERTIFICATE_EXTENSION = "1.2.840.113635.100.6.1.13";

/**
* Builds the literal `codesign --requirements` expression used to pin an update to the installed
* bundle identity and signing team.
*
* Two details matter for `codesign` to treat the value as an inline requirement instead of a file:
* - The leading `=` marks a literal requirement; without it `codesign` reports
* "No such file or directory" because it looks for a requirement file.
* - Certificate extension OIDs must carry the `field.` prefix, otherwise the requirement parser
* fails at the first dot of the OID ("unexpected char: '.'").
*
* @param teamIdentifier The TeamIdentifier of the currently installed app.
* @param bundleIdentifier The expected application bundle identifier.
* @returns A literal requirement string starting with `=`.
*/
export function buildMacSigningRequirement(teamIdentifier: string, bundleIdentifier: string = MEMMY_BUNDLE_ID): string {
if (!teamIdentifier) throw new Error("Mac signing requirement requires a team identifier");
if (/["\\]/.test(teamIdentifier) || /["\\]/.test(bundleIdentifier)) {
throw new Error("Mac signing requirement values cannot contain quotes or backslashes");
}
return `=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[field.${APPLE_DEVELOPER_ID_CERTIFICATE_EXTENSION}] exists and certificate leaf[subject.OU] = "${teamIdentifier}"`;
}

function quotedShellValue(value: string): string {
return "'" + value.replaceAll("'", "'\\''") + "'";
}

function macBundleValidationScript(options: MacUpdateValidationOptions): string {
if (!options.teamIdentifier && !options.allowUnsigned) throw new Error("Signed macOS update requires a signing team");
return `EXPECTED_BUNDLE_ID='cn.memtensor.memmy'
const signingRequirement = options.teamIdentifier ? buildMacSigningRequirement(options.teamIdentifier) : "";
return `EXPECTED_BUNDLE_ID=${quotedShellValue(MEMMY_BUNDLE_ID)}
EXPECTED_ARCH=${quotedShellValue(options.architecture === "x64" ? "x86_64" : "arm64")}
EXPECTED_TEAM=${quotedShellValue(options.teamIdentifier ?? "")}
EXPECTED_VERSION=${quotedShellValue(options.expectedVersion ?? "")}
Expand Down Expand Up @@ -83,7 +110,7 @@ validate_app_bundle() {
exit 1
fi
if [[ -n "$EXPECTED_TEAM" ]]; then
local signing_requirement=${quotedShellValue(`anchor apple generic and identifier "cn.memtensor.memmy" and certificate leaf[1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "${options.teamIdentifier ?? ""}"`)}
local signing_requirement=${quotedShellValue(signingRequirement)}
if ! /usr/bin/codesign --verify --deep --strict -R "$signing_requirement" "$app_path"; then
echo "Memmy update does not have the installed Apple Developer ID identity"
exit 1
Expand Down
22 changes: 22 additions & 0 deletions App/shell/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1700,6 +1700,10 @@ function registerIpcHandlers(): void {
await openLogsDirectory();
});

ipcMain.handle("memmy:open-update-logs", async () => {
await openUpdateLogs();
});

ipcMain.handle("memmy:export-diagnostics-report", async (event) => exportDiagnosticsReport(BrowserWindow.fromWebContents(event.sender)));

ipcMain.handle("memmy:get-log-level", () => getCurrentLogLevel());
Expand Down Expand Up @@ -6136,6 +6140,7 @@ async function cleanupBeforeQuit(): Promise<void> {
ipcMain.removeHandler("memmy:install-cli-tools");
ipcMain.removeHandler("memmy:restart-memory-service");
ipcMain.removeHandler("memmy:open-logs-directory");
ipcMain.removeHandler("memmy:open-update-logs");
ipcMain.removeHandler("memmy:export-diagnostics-report");
ipcMain.removeHandler("memmy:get-launch-at-login");
ipcMain.removeHandler("memmy:set-launch-at-login");
Expand Down Expand Up @@ -6667,6 +6672,23 @@ async function openLogsDirectory(): Promise<void> {
}
}

/**
* Opens the update staging/install log directory in the system file manager.
*
* Update helper scripts write `mac-update-install.log` / `win-update-install.log` here, which is
* the only place a failed in-app install records its concrete reason.
*
* @returns Resolves once opened; throws an error when the system refuses to open it.
*/
async function openUpdateLogs(): Promise<void> {
const updatesDirectory = resolveUpdatesDirectory();
await mkdir(updatesDirectory, { recursive: true });
const openError = await shell.openPath(updatesDirectory);
if (openError) {
throw new Error(openError);
}
}

/** Opens a stored Computer History summary in the user's default Markdown application. */
async function openComputerHistoryMarkdown(rawPath: string): Promise<void> {
const filePath = resolveComputerHistoryMarkdownPath(rawPath);
Expand Down
5 changes: 5 additions & 0 deletions App/shell/desktop/src/preload/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ interface MemmyPreloadApi {
installCliTools(): Promise<unknown>;
restartMemoryService(): Promise<DesktopMemoryServiceRestartResult>;
openLogsDirectory(): Promise<void>;
openUpdateLogs(): Promise<void>;
exportDiagnosticsReport(): Promise<DiagnosticsReportExportResult>;
getLogLevel(): Promise<"error" | "warn" | "info" | "debug">;
setLogLevel(level: "error" | "warn" | "info" | "debug"): Promise<void>;
Expand Down Expand Up @@ -375,6 +376,10 @@ const memmyPreloadApi: MemmyPreloadApi = {
return ipcRenderer.invoke("memmy:open-logs-directory");
},

async openUpdateLogs(): Promise<void> {
return ipcRenderer.invoke("memmy:open-update-logs");
},

async exportDiagnosticsReport(): Promise<DiagnosticsReportExportResult> {
return ipcRenderer.invoke("memmy:export-diagnostics-report");
},
Expand Down
Loading
Loading