diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 41be82a2a..9c2d23110 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,9 +66,11 @@ on: required: false type: string +# Release metadata is a read-modify-write upload to the same latest*.yml asset. +# Queue same target/phase runs across arch to avoid release asset clobbering. concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.phase || 'submit' }}-${{ inputs.channel || 'dev' }}-${{ inputs.target || 'macos' }}-${{ inputs.arch || 'arm64' }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ inputs.source_ref || github.ref_name }}-${{ inputs.phase || 'submit' }}-${{ inputs.channel || 'dev' }}-${{ inputs.target || 'macos' }} + cancel-in-progress: false permissions: actions: read @@ -297,12 +299,21 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Read desktop package version + id: package_version + shell: bash + run: | + set -euo pipefail + version=$(node -p "require('./packages/desktop-electron/package.json').version") + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Build Electron app if: ${{ inputs.phase != 'finalize' }} run: bun run build working-directory: packages/desktop-electron env: OPENCODE_CHANNEL: ${{ inputs.channel || 'dev' }} + PAWWORK_FEEDBACK_FORM_URL: ${{ vars.PAWWORK_FEEDBACK_FORM_URL || '' }} - name: Setup Apple API Key if: runner.os == 'macOS' @@ -310,6 +321,41 @@ jobs: env: APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} + - name: Download existing updater metadata + # macOS finalize can run after notarization as a separate phase; Windows finalizes during full packaging. + if: ${{ (runner.os == 'macOS' && (inputs.phase == 'finalize' || inputs.phase == 'full') && inputs.arch == matrix.arch_label) || (runner.os == 'Windows' && inputs.phase == 'full') }} + shell: bash + run: | + set -euo pipefail + + existing_dir="$RUNNER_TEMP/existing-latest-yml" + mkdir -p "$existing_dir" + tag="v${{ steps.package_version.outputs.version }}" + + download_or_warn() { + local pattern="$1" + local err="$RUNNER_TEMP/${pattern}.err" + if gh release download "$tag" --pattern "$pattern" --dir "$existing_dir" --repo "$GITHUB_REPOSITORY" 2>"$err"; then + return 0 + fi + if grep -qiE 'no assets to download|no matches found|no assets match|could not find any assets' "$err"; then + echo "No existing $pattern found; expected for first release." + return 0 + fi + cat "$err" >&2 + return 1 + } + + if [[ "$RUNNER_OS" == "macOS" ]]; then + download_or_warn latest-mac.yml + fi + if [[ "$RUNNER_OS" == "Windows" ]]; then + download_or_warn latest.yml + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + - name: Package signed app if: ${{ runner.os == 'macOS' && (inputs.phase == 'submit' || inputs.phase == 'full') }} run: npx electron-builder --mac dir --${{ matrix.arch_label }} --publish never --config electron-builder.config.ts @@ -625,6 +671,7 @@ jobs: timeout-minutes: 30 env: OPENCODE_CHANNEL: ${{ inputs.channel || 'dev' }} + PAWWORK_FEEDBACK_FORM_URL: ${{ vars.PAWWORK_FEEDBACK_FORM_URL || '' }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Verify notarized artifacts @@ -731,6 +778,7 @@ jobs: timeout-minutes: 60 env: OPENCODE_CHANNEL: ${{ inputs.channel || 'dev' }} + PAWWORK_FEEDBACK_FORM_URL: ${{ vars.PAWWORK_FEEDBACK_FORM_URL || '' }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload packaged app artifact @@ -744,6 +792,46 @@ jobs: packages/desktop-electron/dist/*.exe.blockmap packages/desktop-electron/dist/latest*.yml + - name: Collect updater metadata + if: ${{ (runner.os == 'macOS' && (inputs.phase == 'finalize' || inputs.phase == 'full') && inputs.arch == matrix.arch_label) || (runner.os == 'Windows' && inputs.phase == 'full') }} + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}-${{ matrix.arch_label }}" in + macos-arm64) + metadata_dir="$RUNNER_TEMP/latest-yml/latest-yml-aarch64-apple-darwin" + metadata_file="latest-mac.yml" + ;; + macos-x64) + metadata_dir="$RUNNER_TEMP/latest-yml/latest-yml-x86_64-apple-darwin" + metadata_file="latest-mac.yml" + ;; + windows-x64) + metadata_dir="$RUNNER_TEMP/latest-yml/latest-yml-x86_64-pc-windows-msvc" + metadata_file="latest.yml" + ;; + *) + echo "Unsupported updater metadata target: ${{ matrix.target }}-${{ matrix.arch_label }}" + exit 1 + ;; + esac + + mkdir -p "$metadata_dir" + cp "dist/$metadata_file" "$metadata_dir/$metadata_file" + working-directory: packages/desktop-electron + + - name: Finalize updater metadata + if: ${{ (runner.os == 'macOS' && (inputs.phase == 'finalize' || inputs.phase == 'full') && inputs.arch == matrix.arch_label) || (runner.os == 'Windows' && inputs.phase == 'full') }} + run: bun ./scripts/finalize-latest-yml.ts + working-directory: packages/desktop-electron + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + EXISTING_LATEST_YML_DIR: ${{ runner.temp }}/existing-latest-yml + LATEST_YML_DIR: ${{ runner.temp }}/latest-yml + OPENCODE_VERSION: ${{ steps.package_version.outputs.version }} + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a if: ${{ runner.os == 'macOS' && (inputs.phase == 'finalize' || inputs.phase == 'full') && inputs.arch == matrix.arch_label }} with: diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 377cfec9b..c09d4dd42 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -8,11 +8,12 @@ import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" -import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" +import { type BaseRouterProps, Navigate, Route, Router, useLocation } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { type Duration, Effect } from "effect" import { type Component, + createEffect, createMemo, createResource, createSignal, @@ -44,6 +45,7 @@ import { TerminalProvider } from "@/context/terminal" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" +import { buildDesktopContext, type DesktopContext } from "./utils/desktop-context" import { useCheckServerHealth } from "./utils/server-health" const HomeRoute = lazy(() => import("@/pages/home")) @@ -77,6 +79,7 @@ declare global { } api?: { setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise + setDesktopContext?: (context: DesktopContext) => Promise } } } @@ -122,6 +125,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { return ( }> + {props.appChildren} {props.children} @@ -129,6 +133,82 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { ) } +function DesktopContextRouteBridge() { + const language = useLanguage() + const location = useLocation() + const desktopContextMaxRetries = 5 + let lastDesktopContext = "" + let pendingDesktopContext = "" + let desktopContextRetryTimer: number | undefined + let desktopContextRetryCount = 0 + let disposed = false + + const clearDesktopContextRetry = () => { + if (desktopContextRetryTimer !== undefined) window.clearTimeout(desktopContextRetryTimer) + desktopContextRetryTimer = undefined + } + + const syncDesktopContext = (context: DesktopContext, serialized: string) => { + if (disposed || isSessionRoute(location.pathname)) return + const setDesktopContext = window.api?.setDesktopContext + if (!setDesktopContext) return + void setDesktopContext(context) + .then(() => { + if (disposed || pendingDesktopContext !== serialized) return + lastDesktopContext = serialized + pendingDesktopContext = "" + desktopContextRetryCount = 0 + clearDesktopContextRetry() + }) + .catch(() => { + if (disposed || pendingDesktopContext !== serialized || lastDesktopContext === serialized) return + if (desktopContextRetryCount >= desktopContextMaxRetries) { + pendingDesktopContext = "" + desktopContextRetryCount = 0 + return + } + clearDesktopContextRetry() + desktopContextRetryCount += 1 + const retryDelay = Math.min(4000, 250 * 2 ** (desktopContextRetryCount - 1)) + desktopContextRetryTimer = window.setTimeout(() => { + desktopContextRetryTimer = undefined + if (disposed || pendingDesktopContext !== serialized || lastDesktopContext === serialized) return + syncDesktopContext(context, serialized) + }, retryDelay) + }) + } + + createEffect(() => { + if (!window.api?.setDesktopContext) return + if (isSessionRoute(location.pathname)) { + pendingDesktopContext = "" + desktopContextRetryCount = 0 + clearDesktopContextRetry() + return + } + const context = buildDesktopContext({ + route: `${location.pathname}${location.search}${location.hash}`, + locale: language.locale(), + }) + const serialized = JSON.stringify(context) + if (serialized === lastDesktopContext || serialized === pendingDesktopContext) return + pendingDesktopContext = serialized + desktopContextRetryCount = 0 + syncDesktopContext(context, serialized) + }) + + onCleanup(() => { + disposed = true + clearDesktopContextRetry() + }) + + return null +} + +function isSessionRoute(pathname: string) { + return /^\/[^/]+\/session(?:\/[^/]+)?\/?$/.test(pathname) +} + export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return ( diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index f5fcaad29..6122b4d92 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -114,6 +114,30 @@ export const SettingsGeneral: Component = () => { void platform .checkUpdate() .then((result) => { + if (result.status === "busy") { + showToast({ + title: language.t("settings.updates.toast.busy.title"), + description: language.t("settings.updates.toast.busy.description"), + }) + return + } + + if (result.status === "disabled") { + showToast({ + title: language.t("settings.updates.toast.disabled.title"), + description: language.t("settings.updates.toast.disabled.description"), + }) + return + } + + if (result.status === "failed") { + showToast({ + title: language.t("common.requestFailed"), + description: result.message || language.t("settings.updates.toast.failed.description"), + }) + return + } + if (!result.updateAvailable) { showToast({ variant: "success", diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index eae25536c..b2fcf927b 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -7,7 +7,11 @@ type PickerPaths = string | string[] | null type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] } type SaveFilePickerOptions = { title?: string; defaultPath?: string } -type UpdateInfo = { updateAvailable: boolean; version?: string } +type UpdateFailureReason = "check" | "download" | "metadata" +export type UpdateInfo = + | { updateAvailable: false; status: "disabled" | "none" | "busy"; version?: undefined } + | { updateAvailable: true; status: "ready"; version: string } + | { updateAvailable: false; status: "failed"; reason: UpdateFailureReason; message: string; version?: undefined } export type Platform = { /** Platform discriminator */ diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 91c6ad99b..c8695f978 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -481,6 +481,10 @@ export const dict = { "error.page.action.checking": "Checking...", "error.page.action.checkUpdates": "Check for updates", "error.page.action.updateTo": "Update to {{version}}", + "error.page.action.upToDate": "PawWork is up to date.", + "error.page.action.busy": "PawWork is already checking for updates.", + "error.page.action.checkFailed": "Failed to check for updates.", + "error.page.action.disabled": "Updates are not available in this build.", "error.page.circular": "[Circular]", "error.page.report.prefix": "Please report this error to the PawWork team", "error.page.report.github": "on GitHub", @@ -805,6 +809,11 @@ export const dict = { "settings.updates.row.check.description": "Manually check for updates and install if available", "settings.updates.action.checkNow": "Check now", "settings.updates.action.checking": "Checking...", + "settings.updates.toast.busy.title": "Update check in progress", + "settings.updates.toast.busy.description": "PawWork is already checking for updates.", + "settings.updates.toast.disabled.title": "Updates unavailable", + "settings.updates.toast.disabled.description": "Updates are not available in this build.", + "settings.updates.toast.failed.description": "Failed to check for updates.", "settings.updates.toast.latest.title": "You're up to date", "settings.updates.toast.latest.description": "You're running the latest version of PawWork.", "sound.option.none": "None", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 29208b476..d486e39ba 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -469,6 +469,10 @@ export const dict = { "error.page.action.checking": "检查中...", "error.page.action.checkUpdates": "检查更新", "error.page.action.updateTo": "更新到 {{version}}", + "error.page.action.upToDate": "PawWork 已是最新版本。", + "error.page.action.busy": "PawWork 正在检查更新。", + "error.page.action.checkFailed": "检查更新失败。", + "error.page.action.disabled": "此构建不支持更新。", "error.page.report.prefix": "请将此错误报告给 PawWork 团队", "error.page.report.github": "在 GitHub 上", "error.page.version": "版本:{{version}}", @@ -699,6 +703,11 @@ export const dict = { "settings.updates.row.check.description": "手动检查更新并在有更新时安装", "settings.updates.action.checkNow": "立即检查", "settings.updates.action.checking": "正在检查...", + "settings.updates.toast.busy.title": "正在检查更新", + "settings.updates.toast.busy.description": "PawWork 已经在检查更新。", + "settings.updates.toast.disabled.title": "更新不可用", + "settings.updates.toast.disabled.description": "此构建不支持更新。", + "settings.updates.toast.failed.description": "检查更新失败。", "settings.updates.toast.latest.title": "已是最新版本", "settings.updates.toast.latest.description": "你正在使用最新版本的 PawWork。", diff --git a/packages/app/src/pages/error-update.test.ts b/packages/app/src/pages/error-update.test.ts new file mode 100644 index 000000000..20563a779 --- /dev/null +++ b/packages/app/src/pages/error-update.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" +import { updateErrorPageState } from "./error-update" + +const t = (key: string, vars?: Record) => { + if (key === "error.page.action.upToDate") return "PawWork is up to date." + if (key === "error.page.action.busy") return "PawWork is already checking for updates." + if (key === "error.page.action.checkFailed") return "Failed to check for updates." + if (key === "error.page.action.disabled") return "Updates are not available in this build." + if (key === "error.page.action.updateTo") return `Update to ${vars?.version ?? ""}` + return key +} + +describe("error page update state", () => { + test("shows available update version", () => { + expect(updateErrorPageState({ updateAvailable: true, version: "0.2.5", status: "ready" }, t)).toEqual({ + version: "0.2.5", + actionError: undefined, + actionMessage: undefined, + }) + }) + + test("shows no-update feedback", () => { + expect(updateErrorPageState({ updateAvailable: false, status: "none" }, t)).toEqual({ + version: undefined, + actionError: undefined, + actionMessage: "PawWork is up to date.", + }) + }) + + test("shows busy feedback", () => { + expect(updateErrorPageState({ updateAvailable: false, status: "busy" }, t)).toEqual({ + version: undefined, + actionError: undefined, + actionMessage: "PawWork is already checking for updates.", + }) + }) + + test("shows failed feedback", () => { + expect( + updateErrorPageState({ updateAvailable: false, status: "failed", reason: "check", message: "network down" }, t), + ).toEqual({ + version: undefined, + actionError: "network down", + actionMessage: undefined, + }) + }) + + test("falls back to generic failed feedback when message is empty", () => { + expect( + updateErrorPageState({ updateAvailable: false, status: "failed", reason: "check", message: "" }, t), + ).toEqual({ + version: undefined, + actionError: "Failed to check for updates.", + actionMessage: undefined, + }) + }) + + test("shows disabled feedback", () => { + expect(updateErrorPageState({ updateAvailable: false, status: "disabled" }, t)).toEqual({ + version: undefined, + actionError: undefined, + actionMessage: "Updates are not available in this build.", + }) + }) +}) diff --git a/packages/app/src/pages/error-update.ts b/packages/app/src/pages/error-update.ts new file mode 100644 index 000000000..9722b86bc --- /dev/null +++ b/packages/app/src/pages/error-update.ts @@ -0,0 +1,42 @@ +import type { UpdateInfo } from "@/context/platform" + +type Translator = (key: string, vars?: Record) => string + +export function updateErrorPageState(result: UpdateInfo, t: Translator) { + switch (result.status) { + case "ready": + return { + version: result.version, + actionError: undefined, + actionMessage: undefined, + } + case "busy": + return { + version: undefined, + actionError: undefined, + actionMessage: t("error.page.action.busy"), + } + case "disabled": + return { + version: undefined, + actionError: undefined, + actionMessage: t("error.page.action.disabled"), + } + case "failed": + return { + version: undefined, + actionError: result.message || t("error.page.action.checkFailed"), + actionMessage: undefined, + } + case "none": + return { + version: undefined, + actionError: undefined, + actionMessage: t("error.page.action.upToDate"), + } + default: { + const exhaustive: never = result + return exhaustive + } + } +} diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 01951dbdb..803b881f1 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -7,6 +7,7 @@ import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { Icon } from "@opencode-ai/ui/icon" import type { E2EWindow } from "@/testing/terminal" +import { updateErrorPageState } from "./error-update" export type InitError = { name: string @@ -218,13 +219,21 @@ interface ErrorPageProps { error: unknown } +type ErrorPageStore = { + checking: boolean + version: string | undefined + actionError: string | undefined + actionMessage: string | undefined +} + export const ErrorPage: Component = (props) => { const platform = usePlatform() const language = useLanguage() - const [store, setStore] = createStore({ + const [store, setStore] = createStore({ checking: false, - version: undefined as string | undefined, - actionError: undefined as string | undefined, + version: undefined, + actionError: undefined, + actionMessage: undefined, }) onMount(() => { @@ -240,11 +249,14 @@ export const ErrorPage: Component = (props) => { await platform .checkUpdate() .then((result) => { - setStore("actionError", undefined) - if (result.updateAvailable && result.version) setStore("version", result.version) + setStore(updateErrorPageState(result, language.t)) }) .catch((err) => { - setStore("actionError", formatError(err, language.t)) + setStore({ + version: undefined, + actionError: formatError(err, language.t), + actionMessage: undefined, + }) }) .finally(() => { setStore("checking", false) @@ -256,9 +268,9 @@ export const ErrorPage: Component = (props) => { await platform .update() .then(() => platform.restart!()) - .then(() => setStore("actionError", undefined)) + .then(() => setStore({ actionError: undefined, actionMessage: undefined })) .catch((err) => { - setStore("actionError", formatError(err, language.t)) + setStore({ actionError: formatError(err, language.t), actionMessage: undefined }) }) } @@ -303,6 +315,9 @@ export const ErrorPage: Component = (props) => { {(message) =>

{message()}

}
+ + {(message) =>

{message()}

} +
{language.t("error.page.report.prefix")} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5bd1b1d84..7e813f77a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@opencode-ai/ui/toast" import { checksum } from "@opencode-ai/util/encode" -import { useSearchParams } from "@solidjs/router" +import { useLocation, useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" @@ -41,6 +41,7 @@ import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" +import { buildDesktopContext, type DesktopContext } from "@/utils/desktop-context" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { @@ -333,8 +334,67 @@ export default function Page() { const prompt = usePrompt() const comments = useComments() const terminal = useTerminal() + const location = useLocation() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const { params, sessionKey, tabs, view } = useSessionLayout() + // Per Page instance: cleanup below cancels pending retries on unmount. + // Five bounded retries cover transient IPC teardown/order races without spinning forever. + const desktopContextMaxRetries = 5 + let lastDesktopContext = "" + let pendingDesktopContext = "" + let desktopContextRetryTimer: number | undefined + let desktopContextRetryCount = 0 + let disposed = false + + const syncDesktopContext = (context: DesktopContext, serialized: string) => { + if (disposed) return + const setDesktopContext = window.api?.setDesktopContext + if (!setDesktopContext) return + void setDesktopContext(context) + .then(() => { + if (disposed) return + if (pendingDesktopContext !== serialized) return + lastDesktopContext = serialized + pendingDesktopContext = "" + desktopContextRetryCount = 0 + if (desktopContextRetryTimer !== undefined) { + window.clearTimeout(desktopContextRetryTimer) + desktopContextRetryTimer = undefined + } + }) + .catch(() => { + if (disposed) return + if (pendingDesktopContext !== serialized || lastDesktopContext === serialized) return + if (desktopContextRetryCount >= desktopContextMaxRetries) { + pendingDesktopContext = "" + desktopContextRetryCount = 0 + return + } + if (desktopContextRetryTimer !== undefined) window.clearTimeout(desktopContextRetryTimer) + desktopContextRetryCount += 1 + const retryDelay = Math.min(4000, 250 * 2 ** (desktopContextRetryCount - 1)) + desktopContextRetryTimer = window.setTimeout(() => { + desktopContextRetryTimer = undefined + if (disposed || pendingDesktopContext !== serialized || lastDesktopContext === serialized) return + syncDesktopContext(context, serialized) + }, retryDelay) + }) + } + + createEffect(() => { + if (!window.api?.setDesktopContext) return + const context = buildDesktopContext({ + directory: sdk.directory, + sessionID: params.id ?? null, + route: `${location.pathname}${location.search}${location.hash}`, + locale: language.locale(), + }) + const serialized = JSON.stringify(context) + if (serialized === lastDesktopContext || serialized === pendingDesktopContext) return + pendingDesktopContext = serialized + desktopContextRetryCount = 0 + syncDesktopContext(context, serialized) + }) createEffect(() => { if (!prompt.ready()) return @@ -1885,6 +1945,8 @@ export default function Page() { }) onCleanup(() => { + disposed = true + if (desktopContextRetryTimer !== undefined) window.clearTimeout(desktopContextRetryTimer) if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) diff --git a/packages/app/src/utils/desktop-context.test.ts b/packages/app/src/utils/desktop-context.test.ts new file mode 100644 index 000000000..3ac9d9a8a --- /dev/null +++ b/packages/app/src/utils/desktop-context.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { buildDesktopContext } from "./desktop-context" + +describe("desktop context", () => { + test("builds route-only context without stale session", () => { + expect( + buildDesktopContext({ + route: "/abc", + locale: "zh", + }), + ).toEqual({ + directory: null, + sessionID: null, + route: "/abc", + locale: "zh", + }) + }) + + test("builds active session context", () => { + expect( + buildDesktopContext({ + directory: "/tmp/project", + sessionID: "ses_123", + route: "/abc/session/ses_123", + locale: "en", + }), + ).toEqual({ + directory: "/tmp/project", + sessionID: "ses_123", + route: "/abc/session/ses_123", + locale: "en", + }) + }) +}) diff --git a/packages/app/src/utils/desktop-context.ts b/packages/app/src/utils/desktop-context.ts new file mode 100644 index 000000000..826c03903 --- /dev/null +++ b/packages/app/src/utils/desktop-context.ts @@ -0,0 +1,22 @@ +import type { Locale } from "@/context/language" + +export type DesktopContext = { + directory: string | null + sessionID: string | null + route: string + locale: Locale +} + +export function buildDesktopContext(input: { + directory?: string | null + sessionID?: string | null + route: string + locale: Locale +}): DesktopContext { + return { + directory: input.directory ?? null, + sessionID: input.sessionID ?? null, + route: input.route, + locale: input.locale, + } +} diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index 4b167563c..f30f47097 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -15,6 +15,7 @@ const channel = (() => { if (raw === "dev" || raw === "beta" || raw === "prod") return raw return "dev" })() +const feedbackFormUrl = process.env.PAWWORK_FEEDBACK_FORM_URL ?? "" const OPENCODE_ROOT = path.resolve(process.cwd(), "../opencode") const { runtimeDir: OPENCODE_SERVER_DIST, runtimeEntry: OPENCODE_SERVER_ENTRY } = embeddedServerArtifacts(OPENCODE_ROOT) @@ -31,6 +32,7 @@ export default defineConfig({ main: { define: { "import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel), + "import.meta.env.PAWWORK_FEEDBACK_FORM_URL": JSON.stringify(feedbackFormUrl), }, build: { rollupOptions: { diff --git a/packages/desktop-electron/scripts/finalize-latest-yml.ts b/packages/desktop-electron/scripts/finalize-latest-yml.ts index aa2ae5c96..06dd77db2 100644 --- a/packages/desktop-electron/scripts/finalize-latest-yml.ts +++ b/packages/desktop-electron/scripts/finalize-latest-yml.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { $ } from "bun" +import { mkdir } from "node:fs/promises" import path from "path" const dir = process.env.LATEST_YML_DIR! @@ -76,18 +77,91 @@ async function read(subdir: string, filename: string): Promise { + const file = Bun.file(filepath) + if (!(await file.exists())) return undefined + return parse(await file.text()) +} + +function mergeLatest(...items: Array): LatestYml | undefined { + const present = items.filter((item): item is LatestYml => Boolean(item)) + if (present.length === 0) return undefined + + const files = new Map() + // On URL collision, later items overwrite earlier entries so live/current data wins. + for (const item of present) { + for (const file of item.files) files.set(file.url, file) + } + + // Use the last item as the metadata base so fresh live releaseDate and version fields win over cached snapshots. + const base = present.at(-1)! + return { + version: base.version, + files: [...files.values()], + releaseDate: base.releaseDate, + } +} + +function shellErrorText(error: unknown) { + const parts: string[] = [] + if (error instanceof Error) parts.push(error.message) + else parts.push(String(error)) + const maybe = error as { stdout?: unknown; stderr?: unknown } + if (maybe.stderr) parts.push(String(maybe.stderr)) + if (maybe.stdout) parts.push(String(maybe.stdout)) + return parts.join("\n") +} + +function isMissingAssetError(message: string) { + // `gh release download` does not expose an asset-missing exit code, so keep this + // narrow and let generic 404/release/repo/auth failures propagate. + return /no assets to download|no matches found|could not find any assets/i.test(message) +} + +function assertSameVersion(source: string, filename: string, data: LatestYml | undefined) { + if (data && data.version !== version) { + throw new Error(`Existing ${filename} from ${source} has version ${data.version}, expected ${version}`) + } + return data +} + +async function downloadExisting(tag: string, filename: string) { + const configured = process.env.EXISTING_LATEST_YML_DIR + const cached = assertSameVersion( + "EXISTING_LATEST_YML_DIR", + filename, + configured ? await readFile(path.join(configured, filename)) : undefined, + ) + + const existingDir = path.join(tmp, "existing-latest-yml") + await mkdir(existingDir, { recursive: true }) + try { + await $`gh release download ${tag} --pattern ${filename} --dir ${existingDir} --repo ${repo}`.quiet() + } catch (error) { + const message = shellErrorText(error) + if (isMissingAssetError(message)) return cached + throw new Error(`Failed to download existing ${filename}: ${message}`) + } + const live = assertSameVersion("GitHub release", filename, await readFile(path.join(existingDir, filename))) + return mergeLatest(cached, live) +} + const output: Record = {} +const tag = `v${version}` +const tmp = process.env.RUNNER_TEMP ?? "/tmp" // Windows: merge arm64 + x64 into single file const winX64 = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml") const winArm64 = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml") if (winX64 || winArm64) { const base = winArm64 ?? winX64! - output["latest.yml"] = serialize({ - version: base.version, - files: [...(winArm64?.files ?? []), ...(winX64?.files ?? [])], - releaseDate: base.releaseDate, - }) + output["latest.yml"] = serialize( + mergeLatest(await downloadExisting(tag, "latest.yml"), { + version: base.version, + files: [...(winArm64?.files ?? []), ...(winX64?.files ?? [])], + releaseDate: base.releaseDate, + })!, + ) } // Linux x64: pass through @@ -103,17 +177,16 @@ const macX64 = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml") const macArm64 = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml") if (macX64 || macArm64) { const base = macArm64 ?? macX64! - output["latest-mac.yml"] = serialize({ - version: base.version, - files: [...(macArm64?.files ?? []), ...(macX64?.files ?? [])], - releaseDate: base.releaseDate, - }) + output["latest-mac.yml"] = serialize( + mergeLatest(await downloadExisting(tag, "latest-mac.yml"), { + version: base.version, + files: [...(macArm64?.files ?? []), ...(macX64?.files ?? [])], + releaseDate: base.releaseDate, + })!, + ) } // Upload to release -const tag = `v${version}` -const tmp = process.env.RUNNER_TEMP ?? "/tmp" - for (const [filename, content] of Object.entries(output)) { const filepath = path.join(tmp, filename) await Bun.write(filepath, content) diff --git a/packages/desktop-electron/scripts/release-metadata-contract.test.ts b/packages/desktop-electron/scripts/release-metadata-contract.test.ts new file mode 100644 index 000000000..c3f793bcd --- /dev/null +++ b/packages/desktop-electron/scripts/release-metadata-contract.test.ts @@ -0,0 +1,328 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { delimiter, dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const roots: string[] = [] +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const packageDir = dirname(scriptDir) + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function spawnFinalizer(binDir: string, latestDir: string, runnerTemp: string, env: Record = {}) { + return Bun.spawn({ + cmd: ["bun", "./scripts/finalize-latest-yml.ts"], + cwd: packageDir, + env: { + ...process.env, + GH_REPO: "Astro-Han/pawwork", + LATEST_YML_DIR: latestDir, + OPENCODE_VERSION: "0.2.4", + PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}`, + RUNNER_TEMP: runnerTemp, + ...env, + }, + stderr: "pipe", + stdout: "pipe", + }) +} + +describe("release metadata finalizer", () => { + test("merges macOS and Windows updater metadata", async () => { + const root = mkdtempSync(join(tmpdir(), "pawwork-release-metadata-")) + roots.push(root) + const latestDir = join(root, "latest-yml") + const runnerTemp = join(root, "runner") + const binDir = join(root, "bin") + mkdirSync(runnerTemp, { recursive: true }) + mkdirSync(binDir, { recursive: true }) + writeFakeGh(binDir) + + writeLatest(join(latestDir, "latest-yml-aarch64-apple-darwin"), "latest-mac.yml", "PawWork-arm64.zip") + writeLatest(join(latestDir, "latest-yml-x86_64-apple-darwin"), "latest-mac.yml", "PawWork-x64.zip") + writeLatest(join(latestDir, "latest-yml-x86_64-pc-windows-msvc"), "latest.yml", "PawWork Setup.exe") + + const proc = spawnFinalizer(binDir, latestDir, runnerTemp) + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + + expect(exitCode).toBe(0) + expect(`${stdout}${stderr}`).toContain("finalized latest yml files") + expect(readFileSync(join(runnerTemp, "latest-mac.yml"), "utf8")).toContain("PawWork-arm64.zip") + expect(readFileSync(join(runnerTemp, "latest-mac.yml"), "utf8")).toContain("PawWork-x64.zip") + expect(readFileSync(join(runnerTemp, "latest.yml"), "utf8")).toContain("PawWork Setup.exe") + const uploads = readFileSync(join(root, "gh-uploads.log"), "utf8") + expect(uploads).toContain("release upload v0.2.4") + expect(uploads).toContain("latest-mac.yml") + expect(uploads).toContain("latest.yml") + }) + + test("preserves existing macOS metadata when finalizing one architecture", async () => { + const root = mkdtempSync(join(tmpdir(), "pawwork-release-metadata-")) + roots.push(root) + const latestDir = join(root, "latest-yml") + const runnerTemp = join(root, "runner") + const binDir = join(root, "bin") + mkdirSync(runnerTemp, { recursive: true }) + mkdirSync(binDir, { recursive: true }) + writeFakeGh(binDir, { + "latest-mac.yml": "PawWork-existing-x64.zip", + }) + + writeLatest(join(latestDir, "latest-yml-aarch64-apple-darwin"), "latest-mac.yml", "PawWork-new-arm64.zip") + + const proc = spawnFinalizer(binDir, latestDir, runnerTemp) + + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + const latestMac = readFileSync(join(runnerTemp, "latest-mac.yml"), "utf8") + expect(latestMac).toContain("PawWork-new-arm64.zip") + expect(latestMac).toContain("PawWork-existing-x64.zip") + }) + + test("merges live metadata into the predownloaded snapshot baseline", async () => { + const root = mkdtempSync(join(tmpdir(), "pawwork-release-metadata-")) + roots.push(root) + const latestDir = join(root, "latest-yml") + const runnerTemp = join(root, "runner") + const binDir = join(root, "bin") + const snapshotDir = join(root, "snapshot") + mkdirSync(runnerTemp, { recursive: true }) + mkdirSync(binDir, { recursive: true }) + mkdirSync(snapshotDir, { recursive: true }) + writeFakeGh(binDir, { + "latest-mac.yml": "PawWork-live-x64.zip", + }) + writeLatest(snapshotDir, "latest-mac.yml", "PawWork-stale-x64.zip") + + writeLatest(join(latestDir, "latest-yml-aarch64-apple-darwin"), "latest-mac.yml", "PawWork-new-arm64.zip") + + const proc = spawnFinalizer(binDir, latestDir, runnerTemp, { EXISTING_LATEST_YML_DIR: snapshotDir }) + + expect(await proc.exited).toBe(0) + const latestMac = readFileSync(join(runnerTemp, "latest-mac.yml"), "utf8") + expect(latestMac).toContain("PawWork-new-arm64.zip") + expect(latestMac).toContain("PawWork-live-x64.zip") + expect(latestMac).toContain("PawWork-stale-x64.zip") + }) + + test("uses live integrity metadata when live and snapshot urls collide", async () => { + const root = mkdtempSync(join(tmpdir(), "pawwork-release-metadata-")) + roots.push(root) + const latestDir = join(root, "latest-yml") + const runnerTemp = join(root, "runner") + const binDir = join(root, "bin") + const snapshotDir = join(root, "snapshot") + mkdirSync(runnerTemp, { recursive: true }) + mkdirSync(binDir, { recursive: true }) + mkdirSync(snapshotDir, { recursive: true }) + writeFakeGh(binDir, { + "latest-mac.yml": { url: "PawWork-x64.zip", sha512: "live-sha", size: 222 }, + }) + writeLatest(snapshotDir, "latest-mac.yml", "PawWork-x64.zip", { sha512: "stale-sha", size: 111 }) + + writeLatest(join(latestDir, "latest-yml-aarch64-apple-darwin"), "latest-mac.yml", "PawWork-new-arm64.zip") + + const proc = spawnFinalizer(binDir, latestDir, runnerTemp, { EXISTING_LATEST_YML_DIR: snapshotDir }) + + expect(await proc.exited).toBe(0) + const latestMac = readFileSync(join(runnerTemp, "latest-mac.yml"), "utf8") + expect(latestMac).toContain("PawWork-x64.zip") + expect(latestMac).toContain("live-sha") + expect(latestMac).toContain("size: 222") + expect(latestMac).not.toContain("stale-sha") + expect(latestMac).not.toContain("size: 111") + }) + + test("uses predownloaded metadata when live metadata is missing", async () => { + const root = mkdtempSync(join(tmpdir(), "pawwork-release-metadata-")) + roots.push(root) + const latestDir = join(root, "latest-yml") + const runnerTemp = join(root, "runner") + const binDir = join(root, "bin") + const snapshotDir = join(root, "snapshot") + mkdirSync(runnerTemp, { recursive: true }) + mkdirSync(binDir, { recursive: true }) + mkdirSync(snapshotDir, { recursive: true }) + writeFakeGh(binDir, {}, "no matches found") + writeLatest(snapshotDir, "latest-mac.yml", "PawWork-snapshot-x64.zip") + + writeLatest(join(latestDir, "latest-yml-aarch64-apple-darwin"), "latest-mac.yml", "PawWork-new-arm64.zip") + + const proc = spawnFinalizer(binDir, latestDir, runnerTemp, { EXISTING_LATEST_YML_DIR: snapshotDir }) + + expect(await proc.exited).toBe(0) + const latestMac = readFileSync(join(runnerTemp, "latest-mac.yml"), "utf8") + expect(latestMac).toContain("PawWork-new-arm64.zip") + expect(latestMac).toContain("PawWork-snapshot-x64.zip") + }) + + test("fails when existing metadata download has a non-missing error", async () => { + const root = mkdtempSync(join(tmpdir(), "pawwork-release-metadata-")) + roots.push(root) + const latestDir = join(root, "latest-yml") + const runnerTemp = join(root, "runner") + const binDir = join(root, "bin") + mkdirSync(runnerTemp, { recursive: true }) + mkdirSync(binDir, { recursive: true }) + writeFakeGh(binDir, {}, "rate limit exceeded") + + writeLatest(join(latestDir, "latest-yml-aarch64-apple-darwin"), "latest-mac.yml", "PawWork-new-arm64.zip") + + const proc = spawnFinalizer(binDir, latestDir, runnerTemp) + + const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]) + + expect(exitCode).not.toBe(0) + expect(stderr).toContain("Failed to download existing latest-mac.yml") + expect(stderr).toContain("rate limit exceeded") + }) + + test("fails when existing metadata download returns a generic not-found error", async () => { + const root = mkdtempSync(join(tmpdir(), "pawwork-release-metadata-")) + roots.push(root) + const latestDir = join(root, "latest-yml") + const runnerTemp = join(root, "runner") + const binDir = join(root, "bin") + const snapshotDir = join(root, "snapshot") + mkdirSync(runnerTemp, { recursive: true }) + mkdirSync(binDir, { recursive: true }) + mkdirSync(snapshotDir, { recursive: true }) + writeFakeGh(binDir, {}, "HTTP 404: Not Found") + writeLatest(snapshotDir, "latest-mac.yml", "PawWork-snapshot-x64.zip") + writeLatest(join(latestDir, "latest-yml-aarch64-apple-darwin"), "latest-mac.yml", "PawWork-new-arm64.zip") + + const proc = spawnFinalizer(binDir, latestDir, runnerTemp, { EXISTING_LATEST_YML_DIR: snapshotDir }) + + const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]) + expect(exitCode).not.toBe(0) + expect(stderr).toContain("Failed to download existing latest-mac.yml") + expect(stderr).toContain("HTTP 404: Not Found") + }) + + test("fails when existing metadata version does not match the release", async () => { + const root = mkdtempSync(join(tmpdir(), "pawwork-release-metadata-")) + roots.push(root) + const latestDir = join(root, "latest-yml") + const runnerTemp = join(root, "runner") + const binDir = join(root, "bin") + const snapshotDir = join(root, "snapshot") + mkdirSync(runnerTemp, { recursive: true }) + mkdirSync(binDir, { recursive: true }) + mkdirSync(snapshotDir, { recursive: true }) + writeFakeGh(binDir, {}, "no matches found") + writeLatest(snapshotDir, "latest-mac.yml", "PawWork-old-x64.zip", { version: "0.2.3" }) + writeLatest(join(latestDir, "latest-yml-aarch64-apple-darwin"), "latest-mac.yml", "PawWork-new-arm64.zip") + + const proc = spawnFinalizer(binDir, latestDir, runnerTemp, { EXISTING_LATEST_YML_DIR: snapshotDir }) + + const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]) + expect(exitCode).not.toBe(0) + expect(stderr).toContain("Existing latest-mac.yml from EXISTING_LATEST_YML_DIR has version 0.2.3") + }) +}) + +type FixtureFile = { + url: string + sha512?: string + size?: number +} + +function fixtureFile(value: string | FixtureFile): Required { + if (typeof value === "string") return { url: value, sha512: "abc123", size: 123 } + return { url: value.url, sha512: value.sha512 ?? "abc123", size: value.size ?? 123 } +} + +function writeLatest( + dir: string, + filename: string, + url: string, + options: { sha512?: string; size?: number; version?: string } = {}, +) { + const file = fixtureFile({ url, ...options }) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, filename), + [ + `version: ${options.version ?? "0.2.4"}`, + "files:", + ` - url: ${file.url}`, + ` sha512: ${file.sha512}`, + ` size: ${file.size}`, + "releaseDate: '2026-04-21T00:00:00.000Z'", + "", + ].join("\n"), + "utf8", + ) +} + +function writeFakeGh(binDir: string, downloads: Record = {}, downloadFailure?: string) { + const helper = join(binDir, "fake-gh.js") + writeFileSync( + helper, + [ + "const { appendFileSync, mkdirSync, writeFileSync } = require('node:fs')", + "const { join } = require('node:path')", + `const downloads = ${JSON.stringify(downloads)}`, + `const downloadFailure = ${JSON.stringify(downloadFailure ?? null)}`, + `const uploadLog = ${JSON.stringify(join(binDir, "..", "gh-uploads.log"))}`, + "const args = process.argv.slice(2)", + "if (args[0] === 'release' && args[1] === 'download') {", + " if (downloadFailure) {", + " console.error(downloadFailure)", + " process.exit(1)", + " }", + " let dir = ''", + " let pattern = ''", + " for (let index = 0; index < args.length; index++) {", + " if (args[index] === '--dir') dir = args[++index] ?? ''", + " else if (args[index] === '--pattern') pattern = args[++index] ?? ''", + " }", + " mkdirSync(dir, { recursive: true })", + " if (!downloads[pattern]) {", + " console.error(`no matches found for ${pattern}`)", + " process.exit(1)", + " }", + " writeFileSync(join(dir, pattern), latestYml(downloads[pattern]), 'utf8')", + " process.exit(0)", + "}", + "appendFileSync(uploadLog, `${args.join(' ')}\\n`, 'utf8')", + "function latestYml(value) {", + " const file = typeof value === 'string' ? { url: value, sha512: 'abc123', size: 123 } : { sha512: 'abc123', size: 123, ...value }", + " return [", + " 'version: 0.2.4',", + " 'files:',", + " ` - url: ${file.url}`,", + " ` sha512: ${file.sha512}`,", + " ` size: ${file.size}`,", + " \"releaseDate: '2026-04-21T00:00:00.000Z'\",", + " '',", + " ].join('\\n')", + "}", + "", + ].join("\n"), + "utf8", + ) + + if (process.platform === "win32") { + const script = join(binDir, "gh.cmd") + writeFileSync(script, `@echo off\r\n"${process.execPath}" "${helper}" %*\r\n`, "utf8") + return + } + + const script = join(binDir, "gh") + writeFileSync(script, `#!/usr/bin/env bash\nexec ${shellQuote(process.execPath)} ${shellQuote(helper)} "$@"\n`, "utf8") + chmodSync(script, 0o755) +} + +function shellQuote(value: string) { + return `'${value.replaceAll("'", "'\\''")}'` +} diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts index 811ce23cd..58dcd9a00 100644 --- a/packages/desktop-electron/src/main/constants.ts +++ b/packages/desktop-electron/src/main/constants.ts @@ -1,5 +1,6 @@ import { app } from "electron" import { PAWWORK_RUNTIME } from "./runtime-namespace" +export { FEEDBACK_FORM_URL } from "./support-links" type Channel = "dev" | "beta" | "prod" const raw = import.meta.env.OPENCODE_CHANNEL diff --git a/packages/desktop-electron/src/main/desktop-context-store.test.ts b/packages/desktop-electron/src/main/desktop-context-store.test.ts new file mode 100644 index 000000000..585a991c8 --- /dev/null +++ b/packages/desktop-electron/src/main/desktop-context-store.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test" +import { createDesktopContextStore } from "./desktop-context-store" +import type { DesktopContext } from "../preload/types" + +const base: DesktopContext = { + directory: null, + sessionID: null, + route: "/", + locale: "en", +} + +describe("desktop context store", () => { + test("returns the context for the requested window", () => { + const store = createDesktopContextStore(() => base) + const activeSession = { directory: "/active", sessionID: "active", route: "/session/active", locale: "en" } as const + const backgroundSession = { + directory: "/background", + sessionID: "background", + route: "/session/background", + locale: "zh", + } as const + + store.set(1, activeSession) + store.set(2, backgroundSession) + + expect(store.current(1)).toEqual(activeSession) + expect(store.current(2)).toEqual(backgroundSession) + }) + + test("falls back to the most recent context when no window is focused", () => { + const store = createDesktopContextStore(() => base) + const context = { directory: "/last", sessionID: "last", route: "/session/last", locale: "zh" } as const + + store.set(5, context) + + expect(store.current()).toEqual(context) + }) + + test("forgets closed windows", () => { + const store = createDesktopContextStore(() => base) + const context = { directory: "/closed", sessionID: "closed", route: "/session/closed", locale: "en" } as const + + store.set(9, context) + store.delete(9) + + expect(store.current(9)).toEqual(base) + }) + + test("falls back to the most recently updated remaining window", () => { + const store = createDesktopContextStore(() => base) + const first = { directory: "/first", sessionID: "first", route: "/session/first", locale: "en" } as const + const second = { directory: "/second", sessionID: "second", route: "/session/second", locale: "zh" } as const + const updatedFirst = { + directory: "/first", + sessionID: "first-new", + route: "/session/first-new", + locale: "zh", + } as const + + store.set(1, first) + store.set(2, second) + store.set(1, updatedFirst) + store.set(3, { directory: "/third", sessionID: "third", route: "/session/third", locale: "en" }) + store.delete(3) + + expect(store.current()).toEqual(updatedFirst) + }) + + test("uses the dynamic fallback when no window context is available", () => { + let fallback = base + const store = createDesktopContextStore(() => fallback) + fallback = { ...base, locale: "zh" } + + expect(store.current(10)).toEqual(fallback) + expect(store.current()).toEqual(fallback) + }) +}) diff --git a/packages/desktop-electron/src/main/desktop-context-store.ts b/packages/desktop-electron/src/main/desktop-context-store.ts new file mode 100644 index 000000000..c461f963d --- /dev/null +++ b/packages/desktop-electron/src/main/desktop-context-store.ts @@ -0,0 +1,25 @@ +import type { DesktopContext } from "../preload/types" + +export function createDesktopContextStore(fallback: () => DesktopContext) { + const contexts = new Map() + let mostRecent: DesktopContext | undefined + + return { + set(windowID: number, context: DesktopContext) { + // Refresh Map insertion order so the fallback uses the most recently updated window. + contexts.delete(windowID) + contexts.set(windowID, context) + mostRecent = context + }, + delete(windowID: number) { + const removed = contexts.get(windowID) + contexts.delete(windowID) + // Reference identity is intentional: removed is the exact object stored for this window. + if (removed === mostRecent) mostRecent = [...contexts.values()].at(-1) + }, + current(windowID?: number | null) { + if (windowID !== undefined && windowID !== null) return contexts.get(windowID) ?? fallback() + return mostRecent ?? fallback() + }, + } +} diff --git a/packages/desktop-electron/src/main/error.ts b/packages/desktop-electron/src/main/error.ts new file mode 100644 index 000000000..3911546c8 --- /dev/null +++ b/packages/desktop-electron/src/main/error.ts @@ -0,0 +1,3 @@ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/desktop-electron/src/main/feedback.test.ts b/packages/desktop-electron/src/main/feedback.test.ts new file mode 100644 index 000000000..9284dd8e5 --- /dev/null +++ b/packages/desktop-electron/src/main/feedback.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test" +import { createFeedbackHandler, feedbackDialogLabels } from "./feedback" + +const diagnostics = { + appVersion: "0.2.4", + channel: "prod", + packaged: true, + updaterEnabled: true, + platform: "darwin" as NodeJS.Platform, + osVersion: "Darwin 25.0.0", + arch: "arm64", + electronVersion: "40.8.0", + locale: "en", + route: "/session/ses_1", + directory: "/tmp/project", + sessionID: "ses_1", + logPath: "/tmp/main.log", +} + +function setup(overrides: Partial[0]> = {}) { + const calls = { + copied: "", + opened: "", + errors: [] as unknown[], + } + return { + calls, + handler: createFeedbackHandler({ + feedbackUrl: "https://example.com/form", + confirm: async () => true, + copy: async (value) => { + calls.copied = value + }, + openExternal: async (url) => { + calls.opened = url + }, + diagnostics: () => diagnostics, + logTail: () => "log tail", + sessionExport: async () => ({ status: "none" }), + onError: (error) => { + calls.errors.push(error) + }, + ...overrides, + }), + } +} + +describe("feedback handler", () => { + test("has localized confirmation labels for Simplified Chinese", () => { + expect(feedbackDialogLabels("zh").title).toBe("复制问题报告?") + expect(feedbackDialogLabels("zh").confirm).toBe("复制报告并打开表单") + }) + + test("has English confirmation labels", () => { + expect(feedbackDialogLabels("en").title).toBe("Copy problem report?") + expect(feedbackDialogLabels("en").confirm).toBe("Copy report and open form") + expect(feedbackDialogLabels("en").failedTitle).toBe("Problem Report Failed") + }) + + test("falls back to English confirmation labels", () => { + expect(feedbackDialogLabels("fr" as never).title).toBe("Copy problem report?") + expect(feedbackDialogLabels("fr" as never).confirm).toBe("Copy report and open form") + }) + + test("cancel does not copy or open", async () => { + const subject = setup({ confirm: async () => false }) + await subject.handler() + expect(subject.calls.copied).toBe("") + expect(subject.calls.opened).toBe("") + }) + + test("confirm copies report and opens form", async () => { + const subject = setup() + await subject.handler() + expect(subject.calls.copied).toContain("PawWork Problem Report") + expect(subject.calls.opened).toBe("https://example.com/form") + }) + + test("uses the context captured before confirmation", async () => { + let current = "active" + let exportedContext: unknown + let diagnosticsContext: unknown + const subject = setup({ + context: () => current, + confirm: async () => { + current = "background" + return true + }, + diagnostics: (context) => { + diagnosticsContext = context + return diagnostics + }, + sessionExport: async (context) => { + exportedContext = context + return { status: "none" } + }, + }) + + await subject.handler() + + expect(exportedContext).toBe("active") + expect(diagnosticsContext).toBe("active") + }) + + test("session export failure downgrades report", async () => { + const subject = setup({ + sessionExport: async () => { + throw new Error("session unavailable") + }, + }) + await subject.handler() + expect(subject.calls.copied).toContain('"status": "failed"') + expect(subject.calls.opened).toBe("https://example.com/form") + }) + + test("missing feedback URL does not copy or open", async () => { + const subject = setup({ feedbackUrl: "" }) + await subject.handler() + expect(subject.calls.copied).toBe("") + expect(subject.calls.opened).toBe("") + }) + + test("non-session failures are reported without rejecting", async () => { + const subject = setup({ + copy: async () => { + throw new Error("clipboard unavailable") + }, + }) + + await expect(subject.handler()).resolves.toBeUndefined() + expect(subject.calls.errors).toHaveLength(1) + expect(subject.calls.opened).toBe("") + }) +}) diff --git a/packages/desktop-electron/src/main/feedback.ts b/packages/desktop-electron/src/main/feedback.ts new file mode 100644 index 000000000..244233300 --- /dev/null +++ b/packages/desktop-electron/src/main/feedback.ts @@ -0,0 +1,74 @@ +import { buildProblemReport, type ProblemReportDiagnostics, type SessionExport } from "./problem-report" +import type { MenuLocale } from "./menu-labels" +import { errorMessage } from "./error" + +type FeedbackDeps = { + feedbackUrl: string + context?: () => unknown + confirm: (context?: unknown) => Promise + copy: (value: string) => Promise | void + openExternal: (url: string) => Promise | void + diagnostics: (context?: unknown) => ProblemReportDiagnostics + logTail: () => string + sessionExport: (context?: unknown) => Promise + onError?: (error: unknown) => Promise | void +} + +export function feedbackDialogLabels(locale: MenuLocale) { + const labels = { + en: { + title: "Copy problem report?", + message: + "PawWork will copy a problem report to your clipboard and open the feedback form.\n\nThe report may include app diagnostics, recent app logs, current session messages, tool output, file names, paths that include your system username, and file snippets. Review it before submitting.", + confirm: "Copy report and open form", + cancel: "Cancel", + failedTitle: "Problem Report Failed", + failedMessage: "Could not copy the report or open the feedback form.", + }, + zh: { + title: "复制问题报告?", + message: + "PawWork 会复制一份问题报告到剪贴板,并打开反馈表单。\n\n报告可能包含应用诊断信息、最近应用日志、当前会话消息、工具输出、文件名、包含系统用户名的路径和文件片段。提交前请先检查。", + confirm: "复制报告并打开表单", + cancel: "取消", + failedTitle: "问题报告失败", + failedMessage: "无法复制报告或打开反馈表单。", + }, + } satisfies Record< + MenuLocale, + { title: string; message: string; confirm: string; cancel: string; failedTitle: string; failedMessage: string } + > + + // Runtime fallback for unexpected locale values crossing process boundaries, + // such as malformed IPC payloads or manually edited config. + return labels[locale] ?? labels.en +} + +export function createFeedbackHandler(deps: FeedbackDeps) { + return async function reportProblem() { + try { + if (!deps.feedbackUrl) return + const context = deps.context?.() + const confirmed = await deps.confirm(context) + if (!confirmed) return + + let sessionExport: SessionExport + try { + sessionExport = await deps.sessionExport(context) + } catch (error) { + sessionExport = { status: "failed", error: errorMessage(error) } + } + + const report = buildProblemReport({ + diagnostics: deps.diagnostics(context), + logTail: deps.logTail(), + sessionExport, + }) + + await deps.copy(report.markdown) + await deps.openExternal(deps.feedbackUrl) + } catch (error) { + await deps.onError?.(error) + } + } +} diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 342c7a89e..925227e16 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -2,10 +2,10 @@ import { randomUUID } from "node:crypto" import { EventEmitter } from "node:events" import { mkdirSync, writeFileSync } from "node:fs" import { createServer } from "node:net" -import { homedir } from "node:os" +import os, { homedir } from "node:os" import { dirname, join } from "node:path" import type { Event } from "electron" -import { app, BrowserWindow, dialog } from "electron" +import { app, BrowserWindow, clipboard, dialog, shell } from "electron" import pkg from "electron-updater" import contextMenu from "electron-context-menu" @@ -42,15 +42,22 @@ app.setPath("userData", join(userDataRoot, app.isPackaged ? APP_IDS[CHANNEL] : " const CI_SMOKE_READY_FILE = join(app.getPath("userData"), "ci-smoke-ready.json") const { autoUpdater } = pkg -import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" +import type { DesktopContext, InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" -import { CHANNEL, UPDATER_ENABLED } from "./constants" +import { CHANNEL, FEEDBACK_FORM_URL, UPDATER_ENABLED } from "./constants" +import { createDesktopContextStore } from "./desktop-context-store" +import { errorMessage } from "./error" +import { createFeedbackHandler, feedbackDialogLabels } from "./feedback" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" -import { initLogging } from "./logging" +import { filePath, initLogging, tail } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" +import { type MenuLocale } from "./menu-labels" +import { readStoredMenuLocale, writeStoredMenuLocale } from "./menu-i18n" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { PAWWORK_RUNTIME } from "./runtime-namespace" +import { createUpdaterController } from "./updater" +import { updaterDialogLabels } from "./updater-dialog-labels" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" import { registerWindowLifecycle, @@ -69,11 +76,133 @@ let mainWindow: BrowserWindow | null = null let server: Server.Listener | null = null const loadingComplete = defer() const deepLinkReadyWindows = new WeakSet() +let menuLocale: MenuLocale = readStoredMenuLocale(app.getLocale()) +const defaultDesktopContext = (): DesktopContext => ({ + directory: null, + sessionID: null, + route: "/", + locale: menuLocale, +}) +const desktopContexts = createDesktopContextStore(defaultDesktopContext) +const contextWindowCleanup = new Set() const pendingDeepLinks: string[] = [] const serverReady = defer() const logger = initLogging() +const updater = createUpdaterController({ + enabled: UPDATER_ENABLED, + currentVersion: () => app.getVersion(), + checkForUpdates: () => autoUpdater.checkForUpdates(), + downloadUpdate: () => autoUpdater.downloadUpdate(), + quitAndInstall: () => { + killSidecar() + autoUpdater.quitAndInstall() + }, + log: (message, data) => logger.log(message, data), + error: (message, error) => logger.error(message, error), +}) + +function diagnostics(context = currentDesktopContext()) { + return { + appVersion: app.getVersion(), + channel: CHANNEL, + packaged: app.isPackaged, + updaterEnabled: UPDATER_ENABLED, + platform: process.platform, + osVersion: `${os.type()} ${os.release()}`, + arch: process.arch, + electronVersion: process.versions.electron, + locale: context.locale, + route: context.route, + directory: context.directory, + sessionID: context.sessionID, + logPath: filePath(), + } +} + +async function sessionExport(context = currentDesktopContext()) { + if (!context.sessionID) return { status: "none" as const } + const ready = await serverReady.promise + const sessionID = encodeURIComponent(context.sessionID) + const url = new URL(`/session/${sessionID}/message`, ready.url) + const headers: Record = {} + if (ready.username || ready.password) { + headers.authorization = `Basic ${Buffer.from(`${ready.username ?? "opencode"}:${ready.password ?? ""}`).toString("base64")}` + } + const controller = new AbortController() + let timeout: ReturnType | undefined + let res: Response + try { + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + controller.abort() + reject(new Error("session export timed out")) + }, 10_000) + }) + res = await Promise.race([fetch(url, { headers, signal: controller.signal }), timeoutPromise]) + } finally { + if (timeout !== undefined) clearTimeout(timeout) + } + if (!res.ok) throw new Error(`session export failed: ${res.status}`) + return { + status: "ok" as const, + info: context, + messages: (await res.json()) as unknown[], + } +} + +function currentDesktopContext() { + return desktopContexts.current(BrowserWindow.getFocusedWindow()?.id) +} + +function normalizeDesktopContext(context: unknown): DesktopContext { + const value = context && typeof context === "object" ? (context as Record) : {} + return { + directory: typeof value.directory === "string" ? value.directory : null, + sessionID: typeof value.sessionID === "string" ? value.sessionID : null, + route: typeof value.route === "string" && value.route.length > 0 ? value.route : "/", + locale: value.locale === "zh" ? "zh" : "en", + } +} + +function feedbackContext(context: unknown): DesktopContext { + return context === undefined ? currentDesktopContext() : normalizeDesktopContext(context) +} + +const reportProblem = createFeedbackHandler({ + feedbackUrl: FEEDBACK_FORM_URL, + context: currentDesktopContext, + confirm: async (context) => { + const labels = feedbackDialogLabels(context === undefined ? menuLocale : feedbackContext(context).locale) + const response = await dialog.showMessageBox({ + type: "warning", + title: labels.title, + message: labels.message, + buttons: [labels.confirm, labels.cancel], + defaultId: 0, + cancelId: 1, + }) + return response.response === 0 + }, + copy: (value) => clipboard.writeText(value), + openExternal: (url) => { + return shell.openExternal(url).then(() => undefined) + }, + diagnostics: (context) => diagnostics(feedbackContext(context)), + logTail: tail, + sessionExport: (context) => sessionExport(feedbackContext(context)), + onError: async (error) => { + logger.error("problem report failed", error) + const labels = feedbackDialogLabels(currentDesktopContext().locale) + await dialog.showMessageBox({ + type: "error", + title: labels.failedTitle, + message: labels.failedMessage, + detail: errorMessage(error), + }) + }, +}) logger.log("app starting", { version: app.getVersion(), @@ -184,10 +313,12 @@ function mainWindowGlobals() { function openMainWindow() { const win = createMainWindow(mainWindowGlobals()) mainWindow = win + win.on("focus", () => syncMenuLocaleForWindow(win)) win.on("closed", () => { if (mainWindow !== win) return mainWindow = selectNextMainWindow(win, BrowserWindow.getAllWindows()) flushPendingDeepLinksForReadyWindow(mainWindow) + syncMenuLocaleForWindow(mainWindow) }) wireMenu() return win @@ -263,6 +394,21 @@ async function initialize() { overlay?.close() } +function focusedMenuLocale() { + const focused = BrowserWindow.getFocusedWindow() + if (!focused) return menuLocale + return desktopContexts.current(focused.id).locale +} + +function syncMenuLocaleForWindow(win: BrowserWindow | null) { + if (!win) return + const next = desktopContexts.current(win.id).locale + if (next === menuLocale) return + menuLocale = next + writeStoredMenuLocale(next) + wireMenu() +} + function wireMenu() { if (!mainWindow) return const commandWindow = () => selectCommandWindow(BrowserWindow.getFocusedWindow(), mainWindow) @@ -281,7 +427,10 @@ function wireMenu() { app.exit(0) }, newWindow: () => openMainWindow(), - }) + reportProblem: () => { + void reportProblem() + }, + }, focusedMenuLocale()) } registerIpcHandlers({ @@ -316,6 +465,18 @@ registerIpcHandlers({ setBackgroundColor: (color) => setBackgroundColor(color), reportDeepLinkReady: (win) => reportDeepLinkReady(win), reportCiSmokeReady: () => reportCiSmokeReady(), + setDesktopContext: (context, win) => { + const next = normalizeDesktopContext(context) + desktopContexts.set(win.id, next) + if (!contextWindowCleanup.has(win.id)) { + contextWindowCleanup.add(win.id) + win.once("closed", () => { + desktopContexts.delete(win.id) + contextWindowCleanup.delete(win.id) + }) + } + if (BrowserWindow.getFocusedWindow()?.id === win.id) syncMenuLocaleForWindow(win) + }, }) function killSidecar() { @@ -389,81 +550,70 @@ function setupAutoUpdater() { }) } -let updateReady = false - async function checkUpdate() { - if (!UPDATER_ENABLED) return { updateAvailable: false } - updateReady = false - logger.log("checking for updates", { - currentVersion: app.getVersion(), - channel: autoUpdater.channel, - allowPrerelease: autoUpdater.allowPrerelease, - allowDowngrade: autoUpdater.allowDowngrade, - }) - try { - const result = await autoUpdater.checkForUpdates() - const updateInfo = result?.updateInfo - logger.log("update metadata fetched", { - releaseVersion: updateInfo?.version ?? null, - releaseDate: updateInfo?.releaseDate ?? null, - releaseName: updateInfo?.releaseName ?? null, - files: updateInfo?.files?.map((file) => file.url) ?? [], - }) - const version = result?.updateInfo?.version - if (result?.isUpdateAvailable === false || !version) { - logger.log("no update available", { - reason: "provider returned no newer version", - }) - return { updateAvailable: false } - } - logger.log("update available", { version }) - await autoUpdater.downloadUpdate() - logger.log("update download completed", { version }) - updateReady = true - return { updateAvailable: true, version } - } catch (error) { - logger.error("update check failed", error) - return { updateAvailable: false, failed: true } + const result = await updater.check() + if (result.status === "ready") return { updateAvailable: true as const, status: result.status, version: result.version } + if (result.status === "failed") { + return { updateAvailable: false as const, status: result.status, reason: result.reason, message: result.message } } + return { updateAvailable: false as const, status: result.status } } async function installUpdate() { - if (!updateReady) return - killSidecar() - autoUpdater.quitAndInstall() + const started = updater.install() + if (!started) logger.log("install update skipped", { reason: "no ready update" }) + return started } async function checkForUpdates(alertOnFail: boolean) { - if (!UPDATER_ENABLED) return + const labels = updaterDialogLabels(currentDesktopContext().locale) logger.log("checkForUpdates invoked", { alertOnFail }) const result = await checkUpdate() + if (result.status === "busy") { + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "info", + title: labels.busy.title, + message: labels.busy.message, + }) + return + } + if (result.status === "disabled") { + logger.log("no update decision", { reason: "updates disabled" }) + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "info", + title: labels.disabled.title, + message: labels.disabled.message, + }) + return + } + if (result.status === "failed") { + logger.log("no update decision", { reason: result.reason ?? "update check failed" }) + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "error", + message: result.message ?? labels.failed.fallbackMessage, + title: labels.failed.title, + }) + return + } if (!result.updateAvailable) { - if (result.failed) { - logger.log("no update decision", { reason: "update check failed" }) - if (!alertOnFail) return - await dialog.showMessageBox({ - type: "error", - message: "Update check failed.", - title: "Update Error", - }) - return - } - logger.log("no update decision", { reason: "already up to date" }) if (!alertOnFail) return await dialog.showMessageBox({ type: "info", - message: "You're up to date.", - title: "No Updates", + message: labels.none.message, + title: labels.none.title, }) return } const response = await dialog.showMessageBox({ type: "info", - message: `Update ${result.version ?? ""} downloaded. Restart now?`, - title: "Update Ready", - buttons: ["Restart", "Later"], + message: labels.ready.message(result.version), + title: labels.ready.title, + buttons: labels.ready.buttons, defaultId: 0, cancelId: 1, }) @@ -472,7 +622,25 @@ async function checkForUpdates(alertOnFail: boolean) { restartNow: response.response === 0, }) if (response.response === 0) { - await installUpdate() + try { + const started = await installUpdate() + if (!started) { + await dialog.showMessageBox({ + type: "info", + title: labels.none.title, + message: labels.none.message, + }) + } + } catch (error) { + logger.error("install update failed", error) + await dialog.showMessageBox({ + type: "error", + title: labels.failed.title, + message: error instanceof Error ? error.message : labels.failed.fallbackMessage, + }) + } + } else { + updater.dismissReady() } } diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index e176c11c3..84cb6366b 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -5,7 +5,15 @@ import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } f import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" import { IMAGE_EXTS } from "@opencode-ai/util/file-extensions" -import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types" +import type { + DesktopContext, + InitStep, + ServerReadyData, + SqliteMigrationProgress, + TitlebarTheme, + UpdateInfo, + WslConfig, +} from "../preload/types" import { getStore } from "./store" import { setTitlebar } from "./windows" @@ -47,11 +55,12 @@ type Deps = { resolveAppPath: (appName: string) => Promise loadingWindowComplete: () => void runUpdater: (alertOnFail: boolean) => Promise | void - checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> - installUpdate: () => Promise | void + checkUpdate: () => Promise + installUpdate: () => Promise | boolean setBackgroundColor: (color: string) => void reportDeepLinkReady: (win: BrowserWindow | null) => void reportCiSmokeReady: () => Promise | void + setDesktopContext: (context: DesktopContext, win: BrowserWindow) => Promise | void } export function registerIpcHandlers(deps: Deps) { @@ -130,6 +139,11 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("report-deep-link-ready", (event: IpcMainInvokeEvent) => deps.reportDeepLinkReady(BrowserWindow.fromWebContents(event.sender)), ) + ipcMain.handle("set-desktop-context", (event: IpcMainInvokeEvent, context: DesktopContext) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return + return deps.setDesktopContext(context, win) + }) ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { const store = getStore(name) const value = store.get(key) diff --git a/packages/desktop-electron/src/main/logging.ts b/packages/desktop-electron/src/main/logging.ts index d315b2d34..c517d0f18 100644 --- a/packages/desktop-electron/src/main/logging.ts +++ b/packages/desktop-electron/src/main/logging.ts @@ -13,8 +13,7 @@ export function initLogging() { export function tail(): string { try { - const path = log.transports.file.getFile().path - const contents = readFileSync(path, "utf8") + const contents = readFileSync(filePath(), "utf8") const lines = contents.split("\n") return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n") } catch { @@ -22,8 +21,12 @@ export function tail(): string { } } +export function filePath() { + return log.transports.file.getFile().path +} + function cleanup() { - const path = log.transports.file.getFile().path + const path = filePath() const dir = dirname(path) const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000 diff --git a/packages/desktop-electron/src/main/menu-i18n.ts b/packages/desktop-electron/src/main/menu-i18n.ts new file mode 100644 index 000000000..0a30908ec --- /dev/null +++ b/packages/desktop-electron/src/main/menu-i18n.ts @@ -0,0 +1,16 @@ +import { getStore } from "./store" +import { detectSystemMenuLocale, parseStoredMenuLocale, type MenuLocale } from "./menu-labels" + +export function readStoredMenuLocale(systemLocale: string | null | undefined): MenuLocale { + const raw = getStore("opencode.global.dat").get("language") + const stored = parseStoredMenuLocale(raw) + // Preserve an explicit user preference, including English; otherwise auto-detect from the OS locale. + if (stored) return stored + return detectSystemMenuLocale(systemLocale) +} + +export function writeStoredMenuLocale(locale: MenuLocale) { + // Legacy values used raw locale strings; current values wrap the locale in JSON. + // parseStoredMenuLocale reads both formats for backward compatibility. + getStore("opencode.global.dat").set("language", JSON.stringify({ locale })) +} diff --git a/packages/desktop-electron/src/main/menu-labels.test.ts b/packages/desktop-electron/src/main/menu-labels.test.ts new file mode 100644 index 000000000..df0ff27c8 --- /dev/null +++ b/packages/desktop-electron/src/main/menu-labels.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import { detectSystemMenuLocale, menuLabel, parseMenuLocale, parseStoredMenuLocale } from "./menu-labels" + +describe("menu labels", () => { + test("parses supported locale values", () => { + expect(parseMenuLocale("zh")).toBe("zh") + expect(parseMenuLocale("zh_Hant_TW")).toBe("zh") + expect(parseMenuLocale({ locale: "zht" })).toBe("zh") + expect(parseMenuLocale(JSON.stringify({ locale: "zh-Hant" }))).toBe("zh") + expect(parseMenuLocale(JSON.stringify({ locale: "en" }))).toBe("en") + expect(parseMenuLocale("en-US")).toBe("en") + }) + + test("falls back to English for unknown locale values", () => { + expect(parseMenuLocale("fr")).toBe("en") + expect(parseMenuLocale({ locale: "fr" })).toBe("en") + expect(parseMenuLocale(null)).toBe("en") + }) + + test("only treats valid stored locale values as explicit preferences", () => { + expect(parseStoredMenuLocale(JSON.stringify({ locale: "zh" }))).toBe("zh") + expect(parseStoredMenuLocale(JSON.stringify({ locale: "en" }))).toBe("en") + expect(parseStoredMenuLocale({ locale: "en-US" })).toBe("en") + expect(parseStoredMenuLocale("zh")).toBe("zh") + expect(parseStoredMenuLocale("en")).toBe("en") + expect(parseStoredMenuLocale({ locale: "fr" })).toBeUndefined() + expect(parseStoredMenuLocale("fr")).toBeUndefined() + expect(parseStoredMenuLocale("{")).toBeUndefined() + }) + + test("detects supported system locale prefixes", () => { + expect(detectSystemMenuLocale("zh-CN")).toBe("zh") + expect(detectSystemMenuLocale("zh-TW")).toBe("zh") + expect(detectSystemMenuLocale("zh-Hant-TW")).toBe("zh") + expect(detectSystemMenuLocale("en-US")).toBe("en") + expect(detectSystemMenuLocale("fr-FR")).toBe("en") + expect(detectSystemMenuLocale(null)).toBe("en") + expect(detectSystemMenuLocale(undefined)).toBe("en") + }) + + test("returns custom labels for simplified Chinese", () => { + expect(menuLabel("zh", "file")).toBe("文件") + expect(menuLabel("zh", "reloadWindow")).toBe("重新加载窗口") + expect(menuLabel("zh", "reportProblem")).toBe("报告问题") + expect(menuLabel("fr" as never, "file")).toBe("File") + }) +}) diff --git a/packages/desktop-electron/src/main/menu-labels.ts b/packages/desktop-electron/src/main/menu-labels.ts new file mode 100644 index 000000000..17b307ea3 --- /dev/null +++ b/packages/desktop-electron/src/main/menu-labels.ts @@ -0,0 +1,129 @@ +export type MenuLocale = "en" | "zh" + +export type MenuLabelKey = + | "file" + | "edit" + | "view" + | "go" + | "help" + | "checkForUpdates" + | "reloadWindow" + | "restart" + | "newSession" + | "openProject" + | "newWindow" + | "toggleSidebar" + | "toggleTerminal" + | "toggleFileTree" + | "back" + | "forward" + | "previousSession" + | "nextSession" + | "previousProject" + | "nextProject" + | "pawworkOnGithub" + | "reportProblem" + | "openGithubIssue" + +const labels: Record> = { + en: { + file: "File", + edit: "Edit", + view: "View", + go: "Go", + help: "Help", + checkForUpdates: "Check for Updates...", + reloadWindow: "Reload Window", + restart: "Restart", + newSession: "New Session", + openProject: "Open Project...", + newWindow: "New Window", + toggleSidebar: "Toggle Sidebar", + toggleTerminal: "Toggle Terminal", + toggleFileTree: "Toggle File Tree", + back: "Back", + forward: "Forward", + previousSession: "Previous Session", + nextSession: "Next Session", + previousProject: "Previous Project", + nextProject: "Next Project", + pawworkOnGithub: "PawWork on GitHub", + reportProblem: "Report a Problem", + openGithubIssue: "Open GitHub Issue", + }, + zh: { + file: "文件", + edit: "编辑", + view: "视图", + go: "前往", + help: "帮助", + checkForUpdates: "检查更新...", + reloadWindow: "重新加载窗口", + restart: "重启", + newSession: "新建会话", + openProject: "打开项目...", + newWindow: "新建窗口", + toggleSidebar: "切换侧边栏", + toggleTerminal: "切换终端", + toggleFileTree: "切换文件树", + back: "后退", + forward: "前进", + previousSession: "上一个会话", + nextSession: "下一个会话", + previousProject: "上一个项目", + nextProject: "下一个项目", + pawworkOnGithub: "PawWork 在 GitHub", + reportProblem: "报告问题", + openGithubIssue: "打开 GitHub Issue", + }, +} + +function parseStoredOrRaw(value: unknown): unknown { + if (typeof value !== "string") return value + try { + return JSON.parse(value) as unknown + } catch { + return value + } +} + +export function parseMenuLocale(value: unknown): MenuLocale { + const parsed = parseStoredOrRaw(value) + if (typeof parsed === "string") { + const normalized = parsed.toLowerCase().replaceAll("_", "-") + if (normalized.startsWith("zh")) return "zh" + if (normalized.startsWith("en")) return "en" + } + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const locale = (parsed as Record).locale + return parseMenuLocale(locale) + } + return "en" +} + +export function parseStoredMenuLocale(value: unknown): MenuLocale | undefined { + if (!value) return undefined + const parsed = parseStoredOrRaw(value) + if (typeof parsed === "string") { + const normalized = parsed.toLowerCase().replaceAll("_", "-") + if (normalized.startsWith("zh")) return "zh" + if (normalized.startsWith("en")) return "en" + return undefined + } + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parseStoredMenuLocale((parsed as Record).locale) + } + return undefined +} + +export function detectSystemMenuLocale(locale: string | null | undefined): MenuLocale { + if (locale?.toLowerCase().startsWith("zh")) return "zh" + return "en" +} + +export function menuLabel(locale: MenuLocale, key: MenuLabelKey) { + const value = labels[locale]?.[key] ?? labels.en[key] + if (value !== undefined) return value + if (import.meta.env.DEV) console.warn("[menu] missing desktop label", { locale, key }) + return key +} diff --git a/packages/desktop-electron/src/main/menu-template.ts b/packages/desktop-electron/src/main/menu-template.ts new file mode 100644 index 000000000..b6c6abe38 --- /dev/null +++ b/packages/desktop-electron/src/main/menu-template.ts @@ -0,0 +1,129 @@ +import { menuLabel, type MenuLocale } from "./menu-labels" +import { PAWWORK_GITHUB_ISSUE_URL, PAWWORK_GITHUB_URL } from "./support-links" + +export type MenuItemTemplate = { + label?: string + role?: string + type?: "separator" + accelerator?: string + enabled?: boolean + submenu?: MenuItemTemplate[] + click?: () => void +} + +export type MenuTemplateDeps = { + trigger: (id: string) => void + checkForUpdates: () => void + reload: () => void + relaunch: () => void + reportProblem: () => void + openExternal: (url: string) => void + newWindow: () => void +} + +type BuildMenuOptions = { + deps: MenuTemplateDeps + appName: string + locale: MenuLocale + feedbackEnabled: boolean +} + +export function buildMenuTemplate(options: BuildMenuOptions): MenuItemTemplate[] { + const { deps, appName, locale, feedbackEnabled } = options + const t = (key: Parameters[1]) => menuLabel(locale, key) + + const helpSubmenu: MenuItemTemplate[] = [ + { label: t("pawworkOnGithub"), click: () => deps.openExternal(PAWWORK_GITHUB_URL) }, + { type: "separator" }, + ] + + if (feedbackEnabled) { + helpSubmenu.push({ label: t("reportProblem"), click: () => deps.reportProblem() }) + } + + helpSubmenu.push({ label: t("openGithubIssue"), click: () => deps.openExternal(PAWWORK_GITHUB_ISSUE_URL) }) + + return [ + { + label: appName, + submenu: [ + { role: "about" }, + { + label: t("checkForUpdates"), + click: () => deps.checkForUpdates(), + }, + { + label: t("reloadWindow"), + click: () => deps.reload(), + }, + { + label: t("restart"), + click: () => deps.relaunch(), + }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }, + { + label: t("file"), + submenu: [ + { label: t("newSession"), accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") }, + { label: t("openProject"), accelerator: "Cmd+O", click: () => deps.trigger("project.open") }, + { label: t("newWindow"), accelerator: "Cmd+Shift+N", click: () => deps.newWindow() }, + { type: "separator" }, + { role: "close" }, + ], + }, + { + label: t("edit"), + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + { + label: t("view"), + submenu: [ + { label: t("toggleSidebar"), accelerator: "Cmd+B", click: () => deps.trigger("sidebar.toggle") }, + { label: t("toggleTerminal"), accelerator: "Ctrl+`", click: () => deps.trigger("terminal.toggle") }, + { label: t("toggleFileTree"), click: () => deps.trigger("fileTree.toggle") }, + { type: "separator" }, + { role: "reload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { + label: t("go"), + submenu: [ + { label: t("back"), accelerator: "Cmd+[", click: () => deps.trigger("common.goBack") }, + { label: t("forward"), accelerator: "Cmd+]", click: () => deps.trigger("common.goForward") }, + { type: "separator" }, + { label: t("previousSession"), accelerator: "Option+Up", click: () => deps.trigger("session.previous") }, + { label: t("nextSession"), accelerator: "Option+Down", click: () => deps.trigger("session.next") }, + { type: "separator" }, + { label: t("previousProject"), accelerator: "Cmd+Option+Up", click: () => deps.trigger("project.previous") }, + { label: t("nextProject"), accelerator: "Cmd+Option+Down", click: () => deps.trigger("project.next") }, + ], + }, + { role: "windowMenu" }, + { + label: t("help"), + submenu: helpSubmenu, + }, + ] +} diff --git a/packages/desktop-electron/src/main/menu.test.ts b/packages/desktop-electron/src/main/menu.test.ts new file mode 100644 index 000000000..dd89574c5 --- /dev/null +++ b/packages/desktop-electron/src/main/menu.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, mock, test } from "bun:test" +import { buildMenuTemplate, type MenuItemTemplate } from "./menu-template" + +function deps() { + return { + trigger: mock(() => undefined), + checkForUpdates: mock(() => undefined), + reload: mock(() => undefined), + relaunch: mock(() => undefined), + reportProblem: mock(() => undefined), + openExternal: mock(() => undefined), + newWindow: mock(() => undefined), + } +} + +function labels(template: MenuItemTemplate[]) { + return template.map((item) => item.label ?? item.role ?? "") +} + +function submenu(template: MenuItemTemplate[], label: string) { + return template.find((item) => item.label === label)?.submenu ?? [] +} + +describe("desktop menu template", () => { + test("localizes PawWork-controlled labels", () => { + const template = buildMenuTemplate({ + deps: deps(), + appName: "PawWork", + locale: "zh", + feedbackEnabled: true, + }) + + expect(labels(template)).toContain("文件") + expect(labels(template)).toContain("视图") + expect(labels(template)).toContain("前往") + expect(labels(template)).toContain("帮助") + }) + + test("renames stale webview label", () => { + const template = buildMenuTemplate({ + deps: deps(), + appName: "PawWork Dev", + locale: "en", + feedbackEnabled: true, + }) + const appMenu = submenu(template, "PawWork Dev") + + expect(appMenu.some((item) => item.label === "Reload Window")).toBe(true) + expect(appMenu.some((item) => item.label === "Reload Webview")).toBe(false) + }) + + test("keeps check for updates clickable", () => { + const menuDeps = deps() + const template = buildMenuTemplate({ + deps: menuDeps, + appName: "PawWork", + locale: "en", + feedbackEnabled: true, + }) + const appMenu = submenu(template, "PawWork") + const checkForUpdates = appMenu.find((item) => item.label === "Check for Updates...") + + expect(checkForUpdates?.enabled).not.toBe(false) + expect(checkForUpdates?.click).toBeDefined() + checkForUpdates?.click?.() + expect(menuDeps.checkForUpdates).toHaveBeenCalled() + }) + + test("shows report problem only when configured and always keeps github issue", () => { + const menuDeps = deps() + const template = buildMenuTemplate({ + deps: menuDeps, + appName: "PawWork", + locale: "en", + feedbackEnabled: false, + }) + const help = submenu(template, "Help") + + expect(help.some((item) => item.label === "Report a Problem")).toBe(false) + expect(help.some((item) => item.label === "Open GitHub Issue")).toBe(true) + help.find((item) => item.label === "Open GitHub Issue")?.click?.() + expect(menuDeps.openExternal).toHaveBeenCalled() + }) + + test("shows report problem when feedback is configured", () => { + const menuDeps = deps() + const template = buildMenuTemplate({ + deps: menuDeps, + appName: "PawWork", + locale: "en", + feedbackEnabled: true, + }) + const help = submenu(template, "Help") + + expect(help.some((item) => item.label === "Report a Problem")).toBe(true) + expect(help.some((item) => item.label === "Open GitHub Issue")).toBe(true) + help.find((item) => item.label === "Report a Problem")?.click?.() + help.find((item) => item.label === "Open GitHub Issue")?.click?.() + expect(menuDeps.reportProblem).toHaveBeenCalled() + expect(menuDeps.openExternal).toHaveBeenCalled() + }) +}) diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts index 118080e2e..0dbf80ebe 100644 --- a/packages/desktop-electron/src/main/menu.ts +++ b/packages/desktop-electron/src/main/menu.ts @@ -1,134 +1,29 @@ -import { Menu, shell } from "electron" +import { app, Menu, shell } from "electron" +// electron-log exposes this ESM entrypoint with the `.js` suffix. +import log from "electron-log/main.js" -import { UPDATER_ENABLED } from "./constants" +import { FEEDBACK_FORM_URL } from "./constants" +import { readStoredMenuLocale } from "./menu-i18n" +import { buildMenuTemplate, type MenuTemplateDeps } from "./menu-template" -type Deps = { - trigger: (id: string) => void - checkForUpdates: () => void - reload: () => void - relaunch: () => void - newWindow: () => void -} +type Deps = Omit -export function createMenu(deps: Deps) { +export function createMenu(deps: Deps, locale = readStoredMenuLocale(app.getLocale())) { if (process.platform !== "darwin") return - const template: Electron.MenuItemConstructorOptions[] = [ - { - label: "PawWork", - submenu: [ - { role: "about" }, - { - label: "Check for Updates...", - enabled: UPDATER_ENABLED, - click: () => deps.checkForUpdates(), - }, - { - label: "Reload Webview", - click: () => deps.reload(), - }, - { - label: "Restart", - click: () => deps.relaunch(), - }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }, - { - label: "File", - submenu: [ - { label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") }, - { label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") }, - { - label: "New Window", - accelerator: "Cmd+Shift+N", - click: () => deps.newWindow(), - }, - { type: "separator" }, - { role: "close" }, - ], - }, - { - label: "Edit", - submenu: [ - { role: "undo" }, - { role: "redo" }, - { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { role: "selectAll" }, - ], - }, - { - label: "View", - submenu: [ - { label: "Toggle Sidebar", accelerator: "Cmd+B", click: () => deps.trigger("sidebar.toggle") }, - { label: "Toggle Terminal", accelerator: "Ctrl+`", click: () => deps.trigger("terminal.toggle") }, - { label: "Toggle File Tree", click: () => deps.trigger("fileTree.toggle") }, - { type: "separator" }, - { role: "reload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], - }, - { - label: "Go", - submenu: [ - { label: "Back", accelerator: "Cmd+[", click: () => deps.trigger("common.goBack") }, - { label: "Forward", accelerator: "Cmd+]", click: () => deps.trigger("common.goForward") }, - { type: "separator" }, - { - label: "Previous Session", - accelerator: "Option+Up", - click: () => deps.trigger("session.previous"), - }, - { - label: "Next Session", - accelerator: "Option+Down", - click: () => deps.trigger("session.next"), - }, - { type: "separator" }, - { - label: "Previous Project", - accelerator: "Cmd+Option+Up", - click: () => deps.trigger("project.previous"), - }, - { - label: "Next Project", - accelerator: "Cmd+Option+Down", - click: () => deps.trigger("project.next"), - }, - ], - }, - { role: "windowMenu" }, - { - label: "Help", - submenu: [ - { label: "PawWork on GitHub", click: () => shell.openExternal("https://github.com/Astro-Han/pawwork") }, - { type: "separator" }, - { - label: "Share Feedback", - click: () => - shell.openExternal("https://github.com/Astro-Han/pawwork/issues/new"), - }, - { - label: "Report a Bug", - click: () => shell.openExternal("https://github.com/Astro-Han/pawwork/issues/new"), - }, - ], + const template = buildMenuTemplate({ + deps: { + ...deps, + openExternal: (url) => { + void shell.openExternal(url).catch((error) => { + log.warn("[menu] failed to open external url", { url, error }) + }) + }, }, - ] + appName: app.getName(), + locale, + feedbackEnabled: Boolean(FEEDBACK_FORM_URL), + }) as Electron.MenuItemConstructorOptions[] Menu.setApplicationMenu(Menu.buildFromTemplate(template)) } diff --git a/packages/desktop-electron/src/main/problem-report.test.ts b/packages/desktop-electron/src/main/problem-report.test.ts new file mode 100644 index 000000000..1c520e152 --- /dev/null +++ b/packages/desktop-electron/src/main/problem-report.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from "bun:test" +import { buildProblemReport, parseProblemReportPayload } from "./problem-report" + +const base = { + diagnostics: { + appVersion: "0.2.4", + channel: "prod", + packaged: true, + updaterEnabled: true, + platform: "darwin", + osVersion: "Darwin 25.0.0", + arch: "arm64", + electronVersion: "40.8.0", + locale: "zh", + route: "/session/ses_1", + directory: "/Users/test/project", + sessionID: "ses_1", + logPath: "/Users/test/Library/Logs/PawWork/main.log", + }, + logTail: "line one\nline two", + sessionExport: { + status: "ok" as const, + info: { id: "ses_1", title: "Bug", directory: "/Users/test/project" }, + messages: [ + { + info: { id: "msg_1", sessionID: "ses_1", role: "user", time: { created: 1 } }, + parts: [{ id: "part_1", sessionID: "ses_1", messageID: "msg_1", type: "text", text: "hello" }], + }, + ], + }, +} + +describe("problem report", () => { + test("creates markdown with valid fenced JSON", () => { + const report = buildProblemReport(base) + expect(report.markdown).toContain("# PawWork Problem Report") + const payload = parseProblemReportPayload(report.markdown) + expect(payload.reportVersion).toBe(1) + expect(payload.diagnostics.sessionID).toBe("ses_1") + expect(payload.sessionExport.status).toBe("ok") + }) + + test("keeps no-session reports useful", () => { + const report = buildProblemReport({ + ...base, + diagnostics: { ...base.diagnostics, sessionID: null }, + sessionExport: { status: "none" }, + }) + const payload = parseProblemReportPayload(report.markdown) + expect(payload.sessionExport).toEqual({ status: "none" }) + expect(payload.logTail).toContain("line two") + }) + + test("keeps failed export status", () => { + const report = buildProblemReport({ + ...base, + sessionExport: { status: "failed", error: "session export failed: 500" }, + }) + const payload = parseProblemReportPayload(report.markdown) + expect(payload.sessionExport).toEqual({ status: "failed", error: "session export failed: 500" }) + }) + + test("truncates oversized failed export errors", () => { + const report = buildProblemReport( + { + ...base, + logTail: "", + sessionExport: { status: "failed", error: "session export failed\n".repeat(20_000) }, + }, + { maxBytes: 8_000 }, + ) + + expect(Buffer.byteLength(report.markdown, "utf8")).toBeLessThanOrEqual(8_000) + const payload = parseProblemReportPayload(report.markdown) + expect(payload.truncation.omittedFailedExportErrorBytes).toBeGreaterThan(0) + expect(payload.sessionExport.status).toBe("failed") + if (payload.sessionExport.status === "failed") expect(payload.sessionExport.error.length).toBeLessThan(100_000) + }) + + test("sanitizes non-json session export values", () => { + const circular: Record = { id: "root" } + circular.self = circular + const report = buildProblemReport({ + ...base, + sessionExport: { + status: "ok", + info: { size: 123n, circular }, + messages: [{ body: 456n, circular }], + }, + }) + + const payload = parseProblemReportPayload(report.markdown) + expect(payload.sessionExport.status).toBe("ok") + if (payload.sessionExport.status === "ok") { + expect(payload.sessionExport.info).toEqual({ size: "123", circular: { id: "root", self: "[Circular]" } }) + expect(payload.sessionExport.messages[0]).toEqual({ + body: "456", + circular: { id: "root", self: "[Circular]" }, + }) + } + }) + + test("enforces max bytes while preserving parseable JSON", () => { + const report = buildProblemReport( + { + ...base, + logTail: "x".repeat(20_000), + sessionExport: { + status: "ok", + info: base.sessionExport.info, + messages: Array.from({ length: 200 }, (_, index) => ({ + info: { id: `msg_${index}`, sessionID: "ses_1", role: "assistant" }, + parts: [{ type: "text", text: "y".repeat(1000) }], + })), + }, + }, + { maxBytes: 10_000 }, + ) + + expect(Buffer.byteLength(report.markdown, "utf8")).toBeLessThanOrEqual(10_000) + const payload = parseProblemReportPayload(report.markdown) + expect(payload.truncation.omittedMessages).toBeGreaterThan(0) + }) + + test("omits oversized session info to honor max bytes", () => { + const report = buildProblemReport( + { + ...base, + logTail: "", + sessionExport: { + status: "ok", + info: { snapshot: "z".repeat(20_000) }, + messages: [], + }, + }, + { maxBytes: 5_000 }, + ) + + expect(Buffer.byteLength(report.markdown, "utf8")).toBeLessThanOrEqual(5_000) + const payload = parseProblemReportPayload(report.markdown) + expect(payload.truncation.omittedSessionInfoBytes).toBeGreaterThan(0) + expect(payload.sessionExport.status).toBe("ok") + if (payload.sessionExport.status === "ok") expect(payload.sessionExport.info).toBeNull() + }) + + test("rejects invalid max byte limits", () => { + expect(() => buildProblemReport(base, { maxBytes: Number.NaN })).toThrow("maxBytes must be a positive finite number") + expect(() => buildProblemReport(base, { maxBytes: 0 })).toThrow("maxBytes must be a positive finite number") + }) + + test("parses only the first JSON fence", () => { + const report = [ + "```json", + JSON.stringify({ + reportVersion: 1, + generatedAt: new Date().toISOString(), + diagnostics: base.diagnostics, + logTail: "", + sessionExport: { status: "none" }, + truncation: { + omittedMessages: 0, + omittedLogBytes: 0, + omittedSessionInfoBytes: 0, + omittedFailedExportErrorBytes: 0, + omittedDiagnosticsBytes: 0, + }, + }), + "```", + "", + "```", + "extra", + "```", + ].join("\n") + + expect(parseProblemReportPayload(report).sessionExport.status).toBe("none") + }) + + test("parses CRLF fenced JSON", () => { + const report = [ + "```json", + JSON.stringify({ + reportVersion: 1, + generatedAt: new Date().toISOString(), + diagnostics: base.diagnostics, + logTail: "", + sessionExport: { status: "none" }, + truncation: { + omittedMessages: 0, + omittedLogBytes: 0, + omittedSessionInfoBytes: 0, + omittedFailedExportErrorBytes: 0, + omittedDiagnosticsBytes: 0, + }, + }), + "```", + ].join("\r\n") + + expect(parseProblemReportPayload(report).sessionExport.status).toBe("none") + }) + + test("skips invalid fenced JSON before a valid report", () => { + const report = [ + "```json", + "{ invalid", + "```", + "```json", + JSON.stringify({ + reportVersion: 1, + generatedAt: new Date().toISOString(), + diagnostics: base.diagnostics, + logTail: "", + sessionExport: { status: "none" }, + truncation: { + omittedMessages: 0, + omittedLogBytes: 0, + omittedSessionInfoBytes: 0, + omittedFailedExportErrorBytes: 0, + omittedDiagnosticsBytes: 0, + }, + }), + "```", + ].join("\n") + + expect(parseProblemReportPayload(report).sessionExport.status).toBe("none") + }) + + test("rejects fenced JSON that is not a valid problem report payload", () => { + const report = [ + "```json", + JSON.stringify({ + reportVersion: 1, + diagnostics: base.diagnostics, + logTail: "", + sessionExport: { status: "none" }, + truncation: { omittedMessages: 0, omittedLogBytes: 0, omittedSessionInfoBytes: 0, omittedDiagnosticsBytes: 0 }, + }), + "```", + ].join("\n") + + expect(() => parseProblemReportPayload(report)).toThrow("Problem report JSON block not found") + }) +}) diff --git a/packages/desktop-electron/src/main/problem-report.ts b/packages/desktop-electron/src/main/problem-report.ts new file mode 100644 index 000000000..fe85c1074 --- /dev/null +++ b/packages/desktop-electron/src/main/problem-report.ts @@ -0,0 +1,309 @@ +// Bound clipboard payloads while preserving recent logs and session snippets for diagnosis. +// Default clipboard payload limit: 5 MB. +const DEFAULT_MAX_BYTES = 5 * 1024 * 1024 + +export type ProblemReportDiagnostics = { + appVersion: string + channel: string + packaged: boolean + updaterEnabled: boolean + platform: NodeJS.Platform | string + osVersion: string + arch: string + electronVersion: string + locale: string + route: string + directory: string | null + sessionID: string | null + logPath: string +} + +type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[] + +export type SessionExport = + | { status: "none" } + | { status: "failed"; error: string } + | { status: "ok"; info: unknown; messages: unknown[] } + +type SafeSessionExport = + | { status: "none" } + | { status: "failed"; error: string } + | { status: "ok"; info: JsonValue; messages: JsonValue[] } + +type Input = { + diagnostics: ProblemReportDiagnostics + logTail: string + sessionExport: SessionExport +} + +type Options = { + maxBytes?: number +} + +type Payload = { + reportVersion: 1 + generatedAt: string + diagnostics: ProblemReportDiagnostics + logTail: string + sessionExport: SafeSessionExport + truncation: { + omittedMessages: number + omittedLogBytes: number + omittedSessionInfoBytes: number + omittedFailedExportErrorBytes: number + omittedDiagnosticsBytes: number + } +} + +function bytes(value: string) { + return Buffer.byteLength(value, "utf8") +} + +function jsonBytes(value: unknown) { + return bytes(JSON.stringify(toJsonSafe(value)) ?? "") +} + +function markdown(payload: Payload) { + return [ + "# PawWork Problem Report", + "", + "Paste this report into the feedback form after reviewing it.", + "", + "```json", + JSON.stringify(payload, null, 2), + "```", + "", + ].join("\n") +} + +function sessionMessages(sessionExport: SessionExport) { + return sessionExport.status === "ok" ? sessionExport.messages.map((message) => toJsonSafe(message)) : [] +} + +function withMessages(sessionExport: SafeSessionExport, messages: JsonValue[]): SafeSessionExport { + if (sessionExport.status !== "ok") return sessionExport + return { ...sessionExport, messages } +} + +function withSessionInfo(sessionExport: SafeSessionExport, info: JsonValue): SafeSessionExport { + if (sessionExport.status !== "ok") return sessionExport + return { ...sessionExport, info } +} + +function withFailedExportError(sessionExport: SafeSessionExport, error: string | undefined): SafeSessionExport { + if (sessionExport.status !== "failed") return sessionExport + return { ...sessionExport, error: error ?? "" } +} + +function toJsonSafe(value: unknown, seen = new WeakSet()): JsonValue { + if (value === null) return null + if (typeof value === "string" || typeof value === "boolean") return value + if (typeof value === "number") return Number.isFinite(value) ? value : String(value) + if (typeof value === "bigint") return value.toString() + if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") return String(value) + if (typeof value !== "object") return String(value) + if (seen.has(value)) return "[Circular]" + seen.add(value) + if (Array.isArray(value)) { + const result = value.map((item) => toJsonSafe(item, seen)) + seen.delete(value) + return result + } + const result: { [key: string]: JsonValue } = {} + for (const [key, nested] of Object.entries(value)) result[key] = toJsonSafe(nested, seen) + seen.delete(value) + return result +} + +function sanitizeSessionExport(sessionExport: SessionExport): SafeSessionExport { + if (sessionExport.status === "none") return sessionExport + if (sessionExport.status === "failed") return sessionExport + return { + status: "ok", + info: toJsonSafe(sessionExport.info), + messages: sessionExport.messages.map((message) => toJsonSafe(message)), + } +} + +function truncateString(value: string, limit: number) { + return value.length > limit ? value.slice(0, limit) : value +} + +function truncateDiagnostics(diagnostics: ProblemReportDiagnostics, stringLimit: number): ProblemReportDiagnostics { + return { + ...diagnostics, + appVersion: truncateString(diagnostics.appVersion, stringLimit), + channel: truncateString(diagnostics.channel, stringLimit), + platform: truncateString(String(diagnostics.platform), stringLimit), + osVersion: truncateString(diagnostics.osVersion, stringLimit), + arch: truncateString(diagnostics.arch, stringLimit), + electronVersion: truncateString(diagnostics.electronVersion, stringLimit), + locale: truncateString(diagnostics.locale, stringLimit), + route: truncateString(diagnostics.route, stringLimit), + directory: diagnostics.directory === null ? null : truncateString(diagnostics.directory, stringLimit), + sessionID: diagnostics.sessionID === null ? null : truncateString(diagnostics.sessionID, stringLimit), + logPath: truncateString(diagnostics.logPath, stringLimit), + } +} + +export function buildProblemReport(input: Input, options: Options = {}) { + const maxBytes = Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES) + if (!Number.isFinite(maxBytes) || maxBytes <= 0) throw new Error("maxBytes must be a positive finite number") + const sessionExport = sanitizeSessionExport(input.sessionExport) + let diagnostics = input.diagnostics + let logTail = input.logTail + let messages = sessionMessages(sessionExport) + let sessionInfo = sessionExport.status === "ok" ? sessionExport.info : undefined + let failedExportError = sessionExport.status === "failed" ? sessionExport.error : undefined + let omittedMessages = 0 + let omittedLogBytes = 0 + let omittedSessionInfoBytes = 0 + let omittedFailedExportErrorBytes = 0 + let omittedDiagnosticsBytes = 0 + + const makePayload = (): Payload => ({ + reportVersion: 1, + generatedAt: new Date().toISOString(), + diagnostics, + logTail, + sessionExport: withFailedExportError(withMessages(withSessionInfo(sessionExport, sessionInfo ?? null), messages), failedExportError), + truncation: { + omittedMessages, + omittedLogBytes, + omittedSessionInfoBytes, + omittedFailedExportErrorBytes, + omittedDiagnosticsBytes, + }, + }) + + let output = markdown(makePayload()) + + // Drop older entries first so the report keeps the most recent context around the failure. + while (bytes(output) > maxBytes && messages.length > 0) { + const remove = Math.max(1, Math.ceil(messages.length / 2)) + omittedMessages += remove + messages = messages.slice(remove) + output = markdown(makePayload()) + } + + while (bytes(output) > maxBytes && logTail.length > 0) { + const remove = Math.max(1, Math.ceil(logTail.length / 2)) + omittedLogBytes += bytes(logTail.slice(0, remove)) + logTail = logTail.slice(remove) + output = markdown(makePayload()) + } + + if (bytes(output) > maxBytes && sessionExport.status === "ok" && sessionInfo != null) { + omittedSessionInfoBytes += jsonBytes(sessionInfo) + sessionInfo = null + output = markdown(makePayload()) + } + + if (bytes(output) > maxBytes && failedExportError !== undefined) { + const originalError = failedExportError + let errorLimit = Math.max(0, Math.floor(originalError.length / 2)) + while (bytes(output) > maxBytes && errorLimit >= 0) { + failedExportError = truncateString(originalError, errorLimit) + omittedFailedExportErrorBytes = Math.max(0, bytes(originalError) - bytes(failedExportError)) + output = markdown(makePayload()) + if (errorLimit === 0) break + errorLimit = Math.floor(errorLimit / 2) + } + } + + let diagnosticStringLimit = 512 + while (bytes(output) > maxBytes && diagnosticStringLimit >= 0) { + diagnostics = truncateDiagnostics(input.diagnostics, diagnosticStringLimit) + omittedDiagnosticsBytes = Math.max(0, jsonBytes(input.diagnostics) - jsonBytes(diagnostics)) + output = markdown(makePayload()) + if (diagnosticStringLimit === 0) break + diagnosticStringLimit = Math.floor(diagnosticStringLimit / 2) + } + + if (bytes(output) > maxBytes) { + throw new Error("Problem report exceeds maxBytes after truncation") + } + + return { markdown: output } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)) +} + +function isStringOrNull(value: unknown): value is string | null { + return typeof value === "string" || value === null +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) +} + +function isDiagnostics(value: unknown): value is ProblemReportDiagnostics { + if (!isRecord(value)) return false + return ( + typeof value.appVersion === "string" && + typeof value.channel === "string" && + typeof value.packaged === "boolean" && + typeof value.updaterEnabled === "boolean" && + typeof value.platform === "string" && + typeof value.osVersion === "string" && + typeof value.arch === "string" && + typeof value.electronVersion === "string" && + typeof value.locale === "string" && + typeof value.route === "string" && + isStringOrNull(value.directory) && + isStringOrNull(value.sessionID) && + typeof value.logPath === "string" + ) +} + +function isSessionExport(value: unknown): value is SessionExport { + if (!isRecord(value) || typeof value.status !== "string") return false + if (value.status === "none") return true + if (value.status === "failed") return typeof value.error === "string" + if (value.status === "ok") return "info" in value && Array.isArray(value.messages) + return false +} + +function isTruncation(value: unknown): value is Payload["truncation"] { + if (!isRecord(value)) return false + return ( + isFiniteNumber(value.omittedMessages) && + isFiniteNumber(value.omittedLogBytes) && + isFiniteNumber(value.omittedSessionInfoBytes) && + isFiniteNumber(value.omittedFailedExportErrorBytes) && + isFiniteNumber(value.omittedDiagnosticsBytes) + ) +} + +function isProblemReportPayload(value: unknown): value is Payload { + if (!isRecord(value)) return false + return ( + value.reportVersion === 1 && + typeof value.generatedAt === "string" && + !Number.isNaN(Date.parse(value.generatedAt)) && + isDiagnostics(value.diagnostics) && + typeof value.logTail === "string" && + isSessionExport(value.sessionExport) && + isTruncation(value.truncation) + ) +} + +export function parseProblemReportPayload(input: string): Payload { + const lines = input.split(/\r?\n/) + for (let start = 0; start < lines.length; start++) { + if (lines[start] !== "```json") continue + for (let end = start + 1; end < lines.length; end++) { + if (lines[end] !== "```") continue + try { + const parsed = JSON.parse(lines.slice(start + 1, end).join("\n")) as unknown + if (isProblemReportPayload(parsed)) return parsed + } catch { + continue + } + } + } + + throw new Error("Problem report JSON block not found") +} diff --git a/packages/desktop-electron/src/main/support-links.test.ts b/packages/desktop-electron/src/main/support-links.test.ts new file mode 100644 index 000000000..68b8839fe --- /dev/null +++ b/packages/desktop-electron/src/main/support-links.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "bun:test" +import { normalizeFeedbackFormUrl } from "./support-links" + +describe("support links", () => { + test("keeps only valid https feedback form URLs", () => { + expect(normalizeFeedbackFormUrl(" https://example.com/form ")).toBe("https://example.com/form") + expect(normalizeFeedbackFormUrl("http://example.com/form")).toBe("") + expect(normalizeFeedbackFormUrl("not a url")).toBe("") + expect(normalizeFeedbackFormUrl("")).toBe("") + }) +}) diff --git a/packages/desktop-electron/src/main/support-links.ts b/packages/desktop-electron/src/main/support-links.ts new file mode 100644 index 000000000..5812c99e6 --- /dev/null +++ b/packages/desktop-electron/src/main/support-links.ts @@ -0,0 +1,14 @@ +export function normalizeFeedbackFormUrl(value: string) { + const trimmed = value.trim() + if (!trimmed) return "" + try { + const url = new URL(trimmed) + return url.protocol === "https:" ? url.toString() : "" + } catch { + return "" + } +} + +export const FEEDBACK_FORM_URL = normalizeFeedbackFormUrl(import.meta.env.PAWWORK_FEEDBACK_FORM_URL ?? "") +export const PAWWORK_GITHUB_URL = "https://github.com/Astro-Han/pawwork" +export const PAWWORK_GITHUB_ISSUE_URL = `${PAWWORK_GITHUB_URL}/issues/new?template=01-bug-report.yml` diff --git a/packages/desktop-electron/src/main/updater-dialog-labels.test.ts b/packages/desktop-electron/src/main/updater-dialog-labels.test.ts new file mode 100644 index 000000000..b68edd5c7 --- /dev/null +++ b/packages/desktop-electron/src/main/updater-dialog-labels.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { updaterDialogLabels } from "./updater-dialog-labels" + +describe("updater dialog labels", () => { + test("localizes simplified Chinese labels", () => { + const labels = updaterDialogLabels("zh") + + expect(labels.busy.title).toBe("正在检查更新") + expect(labels.disabled.message).toBe("此构建不支持更新。") + expect(labels.failed.title).toBe("检查更新失败") + expect(labels.none.message).toBe("PawWork 已是最新版本。") + expect(labels.ready.message("0.2.5")).toBe("更新 0.2.5 已下载。现在重启?") + expect(labels.ready.buttons).toEqual(["重启", "稍后"]) + }) + + test("returns English labels", () => { + const labels = updaterDialogLabels("en") + + expect(labels.busy.title).toBe("Update Check in Progress") + expect(labels.ready.message(undefined)).toBe("Update downloaded. Restart now?") + }) + + test("falls back to English for unexpected runtime locale values", () => { + const labels = updaterDialogLabels("fr" as never) + + expect(labels.busy.title).toBe("Update Check in Progress") + expect(labels.none.message).toBe("You're up to date.") + }) +}) diff --git a/packages/desktop-electron/src/main/updater-dialog-labels.ts b/packages/desktop-electron/src/main/updater-dialog-labels.ts new file mode 100644 index 000000000..ce5a22878 --- /dev/null +++ b/packages/desktop-electron/src/main/updater-dialog-labels.ts @@ -0,0 +1,63 @@ +import type { MenuLocale } from "./menu-labels" + +type Labels = { + busy: { title: string; message: string } + disabled: { title: string; message: string } + failed: { title: string; fallbackMessage: string } + none: { title: string; message: string } + ready: { title: string; message: (version?: string) => string; buttons: [string, string] } +} + +const labels: Record = { + en: { + busy: { + title: "Update Check in Progress", + message: "PawWork is already checking for updates.", + }, + disabled: { + title: "Updates Unavailable", + message: "Updates are not available in this build.", + }, + failed: { + title: "Update Check Failed", + fallbackMessage: "Failed to check for updates.", + }, + none: { + title: "No Updates", + message: "You're up to date.", + }, + ready: { + title: "Update Ready", + message: (version) => (version ? `Update ${version} downloaded. Restart now?` : "Update downloaded. Restart now?"), + buttons: ["Restart", "Later"], + }, + }, + zh: { + busy: { + title: "正在检查更新", + message: "PawWork 正在检查更新。", + }, + disabled: { + title: "更新不可用", + message: "此构建不支持更新。", + }, + failed: { + title: "检查更新失败", + fallbackMessage: "检查更新失败。", + }, + none: { + title: "没有可用更新", + message: "PawWork 已是最新版本。", + }, + ready: { + title: "更新已准备好", + message: (version) => (version ? `更新 ${version} 已下载。现在重启?` : "更新已下载。现在重启?"), + buttons: ["重启", "稍后"], + }, + }, +} + +export function updaterDialogLabels(locale: MenuLocale) { + // Runtime fallback for unexpected locale values crossing process boundaries. + return labels[locale] ?? labels.en +} diff --git a/packages/desktop-electron/src/main/updater.test.ts b/packages/desktop-electron/src/main/updater.test.ts new file mode 100644 index 000000000..5ca6983c3 --- /dev/null +++ b/packages/desktop-electron/src/main/updater.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from "bun:test" +import { createUpdaterController } from "./updater" + +function controller(overrides: Partial[0]> = {}) { + const calls = { + check: 0, + download: 0, + install: 0, + } + const deps = { + enabled: true, + currentVersion: () => "0.2.4", + checkForUpdates: async () => { + calls.check += 1 + return { isUpdateAvailable: false, updateInfo: { version: "0.2.4", files: [] } } + }, + downloadUpdate: async () => { + calls.download += 1 + }, + quitAndInstall: () => { + calls.install += 1 + }, + log: () => undefined, + error: () => undefined, + ...overrides, + } + + return { + calls, + updater: createUpdaterController(deps), + } +} + +describe("updater controller", () => { + test("reports disabled when updater is gated off", async () => { + const setup = controller({ enabled: false }) + await expect(setup.updater.check()).resolves.toEqual({ status: "disabled" }) + expect(setup.calls.check).toBe(0) + expect(setup.calls.download).toBe(0) + expect(setup.calls.install).toBe(0) + }) + + test("reports no update", async () => { + const setup = controller() + await expect(setup.updater.check()).resolves.toEqual({ status: "none" }) + expect(setup.calls.check).toBe(1) + }) + + test("treats inactive updater result as no update", async () => { + const setup = controller({ checkForUpdates: async () => null }) + await expect(setup.updater.check()).resolves.toEqual({ status: "none" }) + }) + + test("downloads available update", async () => { + const setup = controller({ + checkForUpdates: async () => ({ + isUpdateAvailable: true, + updateInfo: { version: "0.2.5", files: [{ url: "app.zip" }] }, + }), + }) + await expect(setup.updater.check()).resolves.toEqual({ status: "ready", version: "0.2.5" }) + expect(setup.calls.download).toBe(1) + }) + + test("keeps a downloaded update ready until install starts", async () => { + const setup = controller({ + checkForUpdates: async () => { + setup.calls.check += 1 + if (setup.calls.check === 1) { + return { isUpdateAvailable: true, updateInfo: { version: "0.2.5", files: [{ url: "app.zip" }] } } + } + return { isUpdateAvailable: false, updateInfo: { version: "0.2.4", files: [] } } + }, + }) + + await expect(setup.updater.check()).resolves.toEqual({ status: "ready", version: "0.2.5" }) + await expect(setup.updater.check()).resolves.toEqual({ status: "ready", version: "0.2.5" }) + expect(setup.updater.install()).toBe(true) + expect(setup.calls.install).toBe(1) + expect(setup.updater.install()).toBe(false) + }) + + test("keeps a ready update if install throws before starting", async () => { + const setup = controller({ + checkForUpdates: async () => ({ + isUpdateAvailable: true, + updateInfo: { version: "0.2.5", files: [{ url: "app.zip" }] }, + }), + quitAndInstall: () => { + throw new Error("install failed") + }, + }) + + await expect(setup.updater.check()).resolves.toEqual({ status: "ready", version: "0.2.5" }) + expect(() => setup.updater.install()).toThrow("install failed") + await expect(setup.updater.check()).resolves.toEqual({ status: "ready", version: "0.2.5" }) + }) + + test("can dismiss a ready update before checking again", async () => { + const setup = controller({ + checkForUpdates: async () => { + setup.calls.check += 1 + if (setup.calls.check === 1) { + return { isUpdateAvailable: true, updateInfo: { version: "0.2.5", files: [{ url: "app.zip" }] } } + } + return { isUpdateAvailable: false, updateInfo: { version: "0.2.4", files: [] } } + }, + }) + + await expect(setup.updater.check()).resolves.toEqual({ status: "ready", version: "0.2.5" }) + expect(setup.updater.dismissReady()).toBe(true) + await expect(setup.updater.check()).resolves.toEqual({ status: "none" }) + expect(setup.calls.check).toBe(2) + }) + + test("reports busy during inflight check", async () => { + let release: (() => void) | undefined + const setup = controller({ + checkForUpdates: () => { + setup.calls.check += 1 + return new Promise((resolve) => { + release = () => resolve({ isUpdateAvailable: false, updateInfo: { version: "0.2.4", files: [] } }) + }) + }, + }) + const first = setup.updater.check() + const second = setup.updater.check() + await expect(second).resolves.toEqual({ status: "busy" }) + release!() + await expect(first).resolves.toEqual({ status: "none" }) + expect(setup.calls.check).toBe(1) + }) + + test("reports check failure when update check throws", async () => { + const setup = controller({ + checkForUpdates: async () => { + throw new Error("network error") + }, + }) + + await expect(setup.updater.check()).resolves.toEqual({ + status: "failed", + reason: "check", + message: "network error", + }) + }) + + test("reports metadata failure when version is missing", async () => { + const setup = controller({ + checkForUpdates: async () => ({ + isUpdateAvailable: true, + updateInfo: { files: [{ url: "app.zip" }] }, + }), + }) + + await expect(setup.updater.check()).resolves.toEqual({ + status: "failed", + reason: "metadata", + message: "Update metadata has no version", + }) + }) + + test("reports metadata failure when files are missing", async () => { + const setup = controller({ + checkForUpdates: async () => ({ + isUpdateAvailable: true, + updateInfo: { version: "0.2.5", files: [] }, + }), + }) + + await expect(setup.updater.check()).resolves.toEqual({ + status: "failed", + reason: "metadata", + message: "Update metadata has no files", + }) + }) + + test("distinguishes download failure", async () => { + const setup = controller({ + checkForUpdates: async () => ({ + isUpdateAvailable: true, + updateInfo: { version: "0.2.5", files: [{ url: "app.zip" }] }, + }), + downloadUpdate: async () => { + throw new Error("download failed") + }, + }) + await expect(setup.updater.check()).resolves.toEqual({ + status: "failed", + reason: "download", + message: "download failed", + }) + }) +}) diff --git a/packages/desktop-electron/src/main/updater.ts b/packages/desktop-electron/src/main/updater.ts new file mode 100644 index 000000000..7a910822a --- /dev/null +++ b/packages/desktop-electron/src/main/updater.ts @@ -0,0 +1,99 @@ +import { errorMessage } from "./error" + +export type UpdateResult = + | { status: "disabled" } + | { status: "none" } + | { status: "busy" } + | { status: "ready"; version: string } + | { status: "failed"; reason: "check" | "download" | "metadata"; message: string } + +type UpdateInfo = { + version?: string + files?: Array<{ url: string }> +} + +type Deps = { + enabled: boolean + currentVersion: () => string + checkForUpdates: () => Promise<{ isUpdateAvailable: boolean; updateInfo?: UpdateInfo } | null> + downloadUpdate: () => Promise + quitAndInstall: () => void + log: (message: string, data?: Record) => void + error: (message: string, error: unknown) => void +} + +export function createUpdaterController(deps: Deps) { + let inflight: Promise | undefined + let updateReady = false + let readyVersion: string | undefined + + const run = async (): Promise => { + if (!deps.enabled) return { status: "disabled" } + if (updateReady && readyVersion !== undefined) { + deps.log("update already downloaded", { releaseVersion: readyVersion }) + return { status: "ready", version: readyVersion } + } + deps.log("checking for updates", { currentVersion: deps.currentVersion() }) + + let result: Awaited> + try { + result = await deps.checkForUpdates() + } catch (error) { + deps.error("update check failed", error) + return { status: "failed", reason: "check", message: errorMessage(error) } + } + + if (!result) return { status: "none" } + + const info = result.updateInfo + deps.log("update metadata fetched", { + releaseVersion: info?.version ?? null, + files: info?.files?.map((file) => file.url) ?? [], + }) + + if (!result.isUpdateAvailable) return { status: "none" } + if (!info?.version) return { status: "failed", reason: "metadata", message: "Update metadata has no version" } + if (!info.files || info.files.length === 0) { + return { status: "failed", reason: "metadata", message: "Update metadata has no files" } + } + + try { + await deps.downloadUpdate() + } catch (error) { + deps.error("update download failed", error) + return { status: "failed", reason: "download", message: errorMessage(error) } + } + + updateReady = true + readyVersion = info.version + return { status: "ready", version: info.version } + } + + return { + check() { + if (inflight) return Promise.resolve({ status: "busy" as const }) + inflight = run().finally(() => { + inflight = undefined + }) + return inflight + }, + install() { + if (!updateReady || readyVersion === undefined) return false + // Keep the ready latch if quitAndInstall throws before Electron starts installing. + deps.quitAndInstall() + updateReady = false + readyVersion = undefined + return true + }, + dismissReady() { + if (!updateReady) return false + updateReady = false + readyVersion = undefined + deps.log("dismissed ready update") + return true + }, + busy() { + return Boolean(inflight) + }, + } +} diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 6b329f436..847cb863d 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -1,8 +1,9 @@ import { contextBridge, ipcRenderer } from "electron" -import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types" +import type { DesktopContext, ElectronAPI, InitStep, SqliteMigrationProgress } from "./types" import { getRuntimeFlags } from "./runtime-flags" const runtimeFlags = getRuntimeFlags(process.env) +const invokeSetDesktopContext = (context: DesktopContext) => ipcRenderer.invoke("set-desktop-context", context) const api: ElectronAPI = { ciSmokeEnabled: runtimeFlags.ciSmokeEnabled, @@ -68,6 +69,14 @@ const api: ElectronAPI = { getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"), setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor), setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme), + setDesktopContext: (context) => invokeSetDesktopContext(context), + initializeDesktopContext: (locale) => + invokeSetDesktopContext({ + directory: null, + sessionID: null, + route: "/", + locale, + }), loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"), runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail), checkUpdate: () => ipcRenderer.invoke("check-update"), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 6450fec87..4bfa1ffe9 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -1,3 +1,9 @@ +import type { UpdateInfo } from "../../../app/src/context/platform" +import type { DesktopContext } from "../../../app/src/utils/desktop-context" + +export type { DesktopContext } +export type { UpdateInfo } + export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" } export type ServerReadyData = { @@ -71,9 +77,11 @@ export type ElectronAPI = { getZoomFactor: () => Promise setZoomFactor: (factor: number) => Promise setTitlebar: (theme: TitlebarTheme) => Promise + setDesktopContext: (context: DesktopContext) => Promise + initializeDesktopContext: (locale: DesktopContext["locale"]) => Promise loadingWindowComplete: () => void runUpdater: (alertOnFail: boolean) => Promise - checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> + checkUpdate: () => Promise installUpdate: () => Promise setBackgroundColor: (color: string) => Promise } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index d34c6b77a..aff631e69 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -32,6 +32,14 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { } void initI18n() + .then((locale) => + window.api.initializeDesktopContext(locale).catch((error) => { + console.debug("[desktop] initializeDesktopContext failed", error) + }), + ) + .catch((error) => { + console.debug("[desktop] initI18n failed", error) + }) const deepLinkEvent = "opencode:deep-link" const emitDeepLinks = (urls: string[]) => { @@ -218,7 +226,7 @@ const createPlatform = (): Platform => { storage, checkUpdate: async () => { - if (!UPDATER_ENABLED()) return { updateAvailable: false } + if (!UPDATER_ENABLED()) return { updateAvailable: false, status: "disabled" } return window.api.checkUpdate() }, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 361f7b27c..654f94bae 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1382,6 +1382,7 @@ it.live( expect(await Filesystem.exists(gitignorePath)).toBe(true) expect(await Filesystem.readText(gitignorePath)).toContain("package-lock.json") }), + 60_000, ) // Note: npm install deduplication is covered in the current Npm implementation, diff --git a/packages/opencode/test/github/build-workflow.test.ts b/packages/opencode/test/github/build-workflow.test.ts index b93973c7c..2f344a23e 100644 --- a/packages/opencode/test/github/build-workflow.test.ts +++ b/packages/opencode/test/github/build-workflow.test.ts @@ -83,7 +83,13 @@ describe("release workflow", () => { ) const signedArtifactStep = steps.find((step) => step.name === "Upload signed app artifact") const nonMacArtifactStep = steps.find((step) => step.name === "Upload packaged app artifact") + const buildElectronAppStep = steps.find((step) => step.name === "Build Electron app") const packageAppStep = steps.find((step) => step.name === "Package app") + const packageVersionStep = steps.find((step) => step.id === "package_version") + const downloadExistingMetadataStep = steps.find((step) => step.name === "Download existing updater metadata") + const collectLatestYmlStep = steps.find((step) => step.name === "Collect updater metadata") + const finalizeLatestYmlStep = steps.find((step) => step.name === "Finalize updater metadata") + const packageNotarizedStep = steps.find((step) => step.name === "Package notarized artifacts") const validateSelectedTargetStep = steps.find((step) => step.name === "Validate selected target") expect(parsed.name).toBe("release") @@ -92,8 +98,9 @@ describe("release workflow", () => { contents: "write", }) expect(parsed.concurrency?.group).toBe( - "${{ github.workflow }}-${{ github.ref }}-${{ inputs.phase || 'submit' }}-${{ inputs.channel || 'dev' }}-${{ inputs.target || 'macos' }}-${{ inputs.arch || 'arm64' }}", + "${{ github.workflow }}-${{ inputs.source_ref || github.ref_name }}-${{ inputs.phase || 'submit' }}-${{ inputs.channel || 'dev' }}-${{ inputs.target || 'macos' }}", ) + expect(parsed.concurrency?.["cancel-in-progress"]).toBe(false) expect(parsed.on?.workflow_dispatch).toBeDefined() expect(workflow).toContain("target:") expect(workflow).toContain("- macos") @@ -131,14 +138,43 @@ describe("release workflow", () => { SELECTED_TARGET: "${{ needs.select-build-target.outputs.target }}", SELECTED_ARCH: "${{ needs.select-build-target.outputs.arch }}", }) + expect(buildElectronAppStep?.env).toEqual({ + OPENCODE_CHANNEL: "${{ inputs.channel || 'dev' }}", + PAWWORK_FEEDBACK_FORM_URL: "${{ vars.PAWWORK_FEEDBACK_FORM_URL || '' }}", + }) expect(packageAppStep?.shell).toBe("bash") expect(packageAppStep?.env).toEqual({ OPENCODE_CHANNEL: "${{ inputs.channel || 'dev' }}", + PAWWORK_FEEDBACK_FORM_URL: "${{ vars.PAWWORK_FEEDBACK_FORM_URL || '' }}", GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}", }) expect(packageAppStep?.run).toContain('publish_flag="never"') expect(packageAppStep?.run).toContain('if [ "${{ inputs.phase || \'submit\' }}" = "full" ]; then') expect(packageAppStep?.run).toContain('publish_flag="always"') + expect(packageVersionStep?.run).toContain("version=$(node -p") + expect(downloadExistingMetadataStep).toBeDefined() + expect(downloadExistingMetadataStep?.run).toContain("gh release download") + expect(downloadExistingMetadataStep?.run).toContain("latest-mac.yml") + expect(downloadExistingMetadataStep?.run).toContain("latest.yml") + expect(steps.indexOf(downloadExistingMetadataStep!)).toBeLessThan(steps.indexOf(packageNotarizedStep!)) + expect(steps.indexOf(downloadExistingMetadataStep!)).toBeLessThan(steps.indexOf(packageAppStep!)) + expect(packageNotarizedStep?.env).toEqual({ + OPENCODE_CHANNEL: "${{ inputs.channel || 'dev' }}", + PAWWORK_FEEDBACK_FORM_URL: "${{ vars.PAWWORK_FEEDBACK_FORM_URL || '' }}", + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}", + }) + expect(collectLatestYmlStep?.run).toContain("latest-yml-x86_64-apple-darwin") + expect(collectLatestYmlStep?.run).toContain("latest-yml-aarch64-apple-darwin") + expect(collectLatestYmlStep?.run).toContain("latest-yml-x86_64-pc-windows-msvc") + expect(finalizeLatestYmlStep).toBeDefined() + expect(finalizeLatestYmlStep!.run).toContain("bun ./scripts/finalize-latest-yml.ts") + expect(finalizeLatestYmlStep?.env).toEqual({ + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}", + GH_REPO: "${{ github.repository }}", + EXISTING_LATEST_YML_DIR: "${{ runner.temp }}/existing-latest-yml", + LATEST_YML_DIR: "${{ runner.temp }}/latest-yml", + OPENCODE_VERSION: "${{ steps.package_version.outputs.version }}", + }) expect(workflow).not.toContain("persist-credentials: true") expect(workflow).not.toContain("pull_request_target")