Explorar el Código

fix(writing): 后台长任务不再被休眠或 timer 节流卡住

wake lock 下沉到 LLM/agent/审查/拆书路径并支持嵌套共享;
macOS 额外声明 NSProcessInfo activity,隐藏窗口时跳过 rAF。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi hace 1 mes
padre
commit
553fe39574

+ 2 - 0
src-tauri/Cargo.lock

@@ -5809,6 +5809,8 @@ dependencies = [
  "lancedb",
  "md-5",
  "notify",
+ "objc2",
+ "objc2-foundation",
  "serde",
  "serde_json",
  "sha2",

+ 4 - 0
src-tauri/Cargo.toml

@@ -63,6 +63,10 @@ walkdir = "2"
 tauri-plugin-single-instance = "2"
 keepawake = "0.6"
 
+[target.'cfg(target_os = "macos")'.dependencies]
+objc2 = "0.6.4"
+objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSString", "NSProcessInfo"] }
+
 [dev-dependencies]
 tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
 

+ 45 - 5
src-tauri/src/commands/writing_wake_lock.rs

@@ -54,8 +54,43 @@ impl<T> WakeLockRegistry<T> {
     }
 }
 
-fn create_system_wake_lock() -> keepawake::Result<keepawake::KeepAwake> {
-    keepawake::Builder::default()
+struct SystemAssertions {
+    _keep_awake: keepawake::KeepAwake,
+    #[cfg(target_os = "macos")]
+    _activity: Option<macos_activity::MacosProcessActivity>,
+}
+
+#[cfg(target_os = "macos")]
+mod macos_activity {
+    use objc2::runtime::{NSObjectProtocol, ProtocolObject};
+    use objc2::rc::Retained;
+    use objc2_foundation::{ns_string, NSActivityOptions, NSProcessInfo};
+
+    pub struct MacosProcessActivity {
+        process: Retained<NSProcessInfo>,
+        activity: Retained<ProtocolObject<dyn NSObjectProtocol>>,
+    }
+
+    impl MacosProcessActivity {
+        pub fn begin() -> Option<Self> {
+            let process = NSProcessInfo::processInfo();
+            let activity = process.beginActivityWithOptions_reason(
+                NSActivityOptions::UserInteractive,
+                ns_string!("QMaiWrite 正在执行长任务"),
+            );
+            Some(Self { process, activity })
+        }
+    }
+
+    impl Drop for MacosProcessActivity {
+        fn drop(&mut self) {
+            unsafe { self.process.endActivity(&self.activity) }
+        }
+    }
+}
+
+fn create_system_wake_lock() -> Result<SystemAssertions, String> {
+    let keep_awake = keepawake::Builder::default()
         .display(true)
         .idle(true)
         .sleep(false)
@@ -63,6 +98,13 @@ fn create_system_wake_lock() -> keepawake::Result<keepawake::KeepAwake> {
         .app_name("QMaiWrite")
         .app_reverse_domain("com.qingmuai.writer")
         .create()
+        .map_err(|error| format!("无法启用写作防休眠:{error}"))?;
+
+    Ok(SystemAssertions {
+        _keep_awake: keep_awake,
+        #[cfg(target_os = "macos")]
+        _activity: macos_activity::MacosProcessActivity::begin(),
+    })
 }
 
 fn run_worker(receiver: mpsc::Receiver<WorkerCommand>) {
@@ -73,9 +115,7 @@ fn run_worker(receiver: mpsc::Receiver<WorkerCommand>) {
     while let Ok(command) = receiver.recv() {
         match command {
             WorkerCommand::Acquire { reply } => {
-                let result = registry
-                    .acquire(create_system_wake_lock)
-                    .map_err(|error| format!("无法启用写作防休眠:{error}"));
+                let result = registry.acquire(create_system_wake_lock);
                 let _ = reply.send(result);
             }
             WorkerCommand::Release { token, reply } => {

+ 5 - 2
src-tauri/src/lib.rs

@@ -197,7 +197,7 @@ pub fn run() {
         })
         .run(|app, event| {
             #[cfg(target_os = "macos")]
-            if let tauri::RunEvent::Reopen { has_visible_windows, .. } = event {
+            if let tauri::RunEvent::Reopen { has_visible_windows, .. } = &event {
                 if !has_visible_windows {
                     use tauri::Manager;
                     if let Some(window) = app.get_webview_window("main") {
@@ -206,6 +206,9 @@ pub fn run() {
                     }
                 }
             }
-            let _ = (app, event);
+            // macOS 红叉只 hide;真正退出(Cmd+Q)必须在这里落盘。
+            if matches!(event, tauri::RunEvent::ExitRequested { .. }) {
+                app_state::persist_app_state_before_exit(app);
+            }
         });
 }

+ 5 - 2
src-tauri/src/main.rs

@@ -199,7 +199,7 @@ fn main() {
         })
         .run(|app, event| {
             #[cfg(target_os = "macos")]
-            if let tauri::RunEvent::Reopen { has_visible_windows, .. } = event {
+            if let tauri::RunEvent::Reopen { has_visible_windows, .. } = &event {
                 if !has_visible_windows {
                     use tauri::Manager;
                     if let Some(window) = app.get_webview_window("main") {
@@ -208,6 +208,9 @@ fn main() {
                     }
                 }
             }
-            let _ = (app, event);
+            // macOS 红叉只 hide;真正退出(Cmd+Q)必须在这里落盘。
+            if matches!(event, tauri::RunEvent::ExitRequested { .. }) {
+                app_state::persist_app_state_before_exit(app);
+            }
         });
 }

+ 2 - 1
src-tauri/tauri.conf.json

@@ -16,7 +16,8 @@
         "width": 1200,
         "height": 800,
         "resizable": true,
-        "fullscreen": false
+        "fullscreen": false,
+        "additionalBrowserArgs": "--disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-renderer-backgrounding"
       }
     ],
     "security": {

+ 2 - 2
src/components/chat/chat-panel.spec.tsx

@@ -402,7 +402,7 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
   })
 
   it("skips chapter plan confirmation when Plan Execute is disabled", () => {
-    const runnerIndex = source.indexOf("const record = await withWritingWakeLock(")
+    const runnerIndex = source.indexOf("const record = await runAiChatSession(")
     const afterRunner = source.slice(runnerIndex)
     expect(afterRunner).toContain("if (!hasAgentError && planExecuteActive)")
   })
@@ -421,7 +421,7 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
   })
 
   it("waits for chapter plan confirmation outside the completed stream guard before follow-up execution", () => {
-    const runnerIndex = source.indexOf("const record = await withWritingWakeLock(")
+    const runnerIndex = source.indexOf("const record = await runAiChatSession(")
     const finishIndex = source.indexOf("finishAgentSession(() => {", runnerIndex)
     const planConfirmIndex = source.indexOf("await requestChapterPlanConfirm(", runnerIndex)
     const followupIndex = source.indexOf("await handleSendRef.current(followupText", runnerIndex)

+ 2 - 8
src/components/chat/chat-panel.tsx

@@ -42,7 +42,6 @@ import { runAiChatSession } from "@/lib/agent/ai-chat-session"
 import { resolveRequiredToolsOnce } from "@/lib/agent/required-tools-gate"
 import { runDraftReviewSkill } from "@/lib/agent/skills/draft-review-skill"
 import { useDraftReviewStore } from "@/stores/draft-review-store"
-import { shouldKeepAwakeForWriting, withWritingWakeLock } from "@/lib/writing-wake-lock"
 import { ToolRegistry } from "@/lib/agent/registry"
 import { registerAllBuiltInTools } from "@/lib/agent/tools"
 import {
@@ -1886,11 +1885,6 @@ export function ChatPanel() {
       }
 
       try {
-        const keepAwake = shouldKeepAwakeForWriting({
-          novelMode,
-          intent: effectiveTaskRoute?.intent,
-          planExecuteActive,
-        })
         const requiredToolsOnce = resolveRequiredToolsOnce({
           novelMode,
           intent: effectiveTaskRoute?.intent,
@@ -1927,7 +1921,7 @@ export function ChatPanel() {
         })
         // Seed this turn's baseline so the ring can grow with tool reads before the first usage report.
         useChatStore.getState().setConversationContextUsage(capturedConvId, usageSnapshotBase)
-        const record = await withWritingWakeLock(keepAwake, () => runAiChatSession({
+        const record = await runAiChatSession({
           userMessage: plainText,
           projectPath,
           agentConfig: {
@@ -2024,7 +2018,7 @@ export function ChatPanel() {
               markError(error)
             },
           },
-        }))
+        })
 
         if (controller.signal.aborted) return
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return

+ 5 - 3
src/components/novel/book-analysis-view.tsx

@@ -11,6 +11,7 @@ import { useBookAnalysisPipelineStore } from "@/stores/book-analysis-pipeline-st
 import { useWikiStore } from "@/stores/wiki-store"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import { yieldToBrowserFrame } from "@/lib/novel/yield-to-browser"
 import {
   toBookAnalysisResult,
   type BookAnalysisLibraryState,
@@ -645,8 +646,9 @@ export function BookAnalysisView() {
         percentage: 5,
         currentItem: `第 ${range.startOrder}~${range.endOrder} 章,共 ${totalChapters} 章`,
       })
-      // 让出一帧,避免读章过快时 UI 直接跳到 AI 阶段、看不到 x/y
-      await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
+      // 让出一帧,避免读章过快时 UI 直接跳到 AI 阶段、看不到 x/y。
+      // 窗口隐藏时 rAF 不会触发,yieldToBrowserFrame 会直接放行。
+      await yieldToBrowserFrame()
       for (let i = 0; i < totalChapters; i++) {
         const chapter = selectedChapters[i]
         const chapterPath = joinPath(bookPath, "chapters", `${chapter.chapterId}.md`)
@@ -659,7 +661,7 @@ export function BookAnalysisView() {
           percentage: 5 + Math.floor((readCount / totalChapters) * 40),
           currentItem: `第 ${chapter.order} 章 · ${chapter.title}`,
         })
-        await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
+        await yieldToBrowserFrame()
       }
       reportRecognition({
         stageLabel: `正在用 AI 识别角色(已读 ${totalChapters}/${totalChapters} 章)`,

+ 11 - 0
src/lib/agent/codex-app-server-runner.ts

@@ -17,6 +17,7 @@ import {
   missingRequiredToolsOnce,
 } from "./required-tools-gate"
 import { executeAgentTool } from "./tool-executor"
+import { withWritingWakeLock } from "../writing-wake-lock"
 import { ToolEvidenceLedger } from "./tool-evidence-ledger"
 import { DEFAULT_TOOL_RESULT_CONTEXT_LIMIT } from "./tool-result"
 import {
@@ -104,6 +105,16 @@ export class CodexAppServerRunner {
     messages: AgentMessage[],
     callbacks: AgentRunCallbacks,
     signal?: AbortSignal,
+  ): Promise<AgentRunRecord> {
+    return withWritingWakeLock(true, () => this.runHeld(config, registry, messages, callbacks, signal))
+  }
+
+  private async runHeld(
+    config: AgentConfig,
+    registry: ToolRegistry,
+    messages: AgentMessage[],
+    callbacks: AgentRunCallbacks,
+    signal?: AbortSignal,
   ): Promise<AgentRunRecord> {
     const record: AgentRunRecord = {
       toolCalls: [],

+ 11 - 0
src/lib/agent/runner.ts

@@ -28,6 +28,7 @@ import {
 } from "./required-tools-gate"
 import { executeAgentTool } from "./tool-executor"
 import { CodexAppServerRunner } from "./codex-app-server-runner"
+import { withWritingWakeLock } from "../writing-wake-lock"
 
 export class ModelDoesNotSupportToolsError extends Error {
   constructor() {
@@ -50,6 +51,16 @@ export class AgentRunner {
     messages: AgentMessage[],
     callbacks: AgentRunCallbacks,
     signal?: AbortSignal,
+  ): Promise<AgentRunRecord> {
+    return withWritingWakeLock(true, () => this.runHeld(config, registry, messages, callbacks, signal))
+  }
+
+  private async runHeld(
+    config: AgentConfig,
+    registry: ToolRegistry,
+    messages: AgentMessage[],
+    callbacks: AgentRunCallbacks,
+    signal?: AbortSignal,
   ): Promise<AgentRunRecord> {
     if (config.llmConfig.provider === "codex-cli") {
       return new CodexAppServerRunner().run(config, registry, messages, callbacks, signal)

+ 17 - 0
src/lib/llm-client.ts

@@ -39,6 +39,7 @@ import {
   type LlmRequestCacheTrace,
   type LlmRequestTraceStatus,
 } from "./llm-request-trace"
+import { withWritingWakeLock } from "./writing-wake-lock"
 
 export type { ChatMessage, RequestOverrides } from "./llm-providers"
 export { isFetchNetworkError } from "./tauri-fetch"
@@ -202,6 +203,22 @@ function isLocalCliProvider(provider: LlmConfig["provider"]): boolean {
 }
 
 export async function streamChat(
+  config: LlmConfig,
+  messages: import("./llm-providers").ChatMessage[],
+  callbacks: StreamCallbacks,
+  signal?: AbortSignal,
+  requestOverrides?: RequestOverrides,
+): Promise<void> {
+  return withWritingWakeLock(true, () => streamChatHeld(
+    config,
+    messages,
+    callbacks,
+    signal,
+    requestOverrides,
+  ))
+}
+
+async function streamChatHeld(
   config: LlmConfig,
   messages: import("./llm-providers").ChatMessage[],
   callbacks: StreamCallbacks,

+ 2 - 1
src/lib/novel/book-analysis/analysis-scheduler.ts

@@ -1,4 +1,5 @@
 import type { LlmConfig } from "@/stores/wiki-store"
+import { withWritingWakeLock } from "@/lib/writing-wake-lock"
 import {
   loadAnalysisChunkResult,
   saveAnalysisChunk,
@@ -457,7 +458,7 @@ export function createAnalysisScheduler(options: AnalysisSchedulerOptions): Anal
     if (disposed) return Promise.resolve()
     const existing = taskRuns.get(taskId)
     if (existing) return existing
-    const operation = runTaskInternal(taskId).finally(() => {
+    const operation = withWritingWakeLock(true, () => runTaskInternal(taskId)).finally(() => {
       taskRuns.delete(taskId)
       pauseRequested.delete(taskId)
       cancelRequested.delete(taskId)

+ 41 - 38
src/lib/novel/start-review-run.ts

@@ -8,6 +8,7 @@ import { getFileStem } from "@/lib/path-utils"
 import { useWikiStore } from "@/stores/wiki-store"
 import { createReviewThinkingPublisher } from "./review-thinking-publisher"
 import { yieldToBrowserFrame } from "./yield-to-browser"
+import { withWritingWakeLock } from "@/lib/writing-wake-lock"
 
 interface StartNovelReviewRunArgs {
   fileContent: string
@@ -58,46 +59,48 @@ export async function startNovelReviewRun({
   const runId = `${Date.now()}-${Math.random()}`
   useWikiStore.getState().setReviewRun({ runId, projectPath, filePath: selectedFile, running: true, results: [] })
   await yieldToBrowserFrame()
-  const thinkingPublisher = createReviewThinkingPublisher({
-    publish: (thinking) => {
-      useWikiStore.getState().finishReviewRun(runId, { running: true, thinking })
-    },
-  })
-
-  try {
-    const results = await reviewChapter(projectPath, fileContent, target.chapterNumber, {
-      onThinking: (thinking) => {
-        thinkingPublisher.publish(thinking)
+  await withWritingWakeLock(true, async () => {
+    const thinkingPublisher = createReviewThinkingPublisher({
+      publish: (thinking) => {
+        useWikiStore.getState().finishReviewRun(runId, { running: true, thinking })
       },
     })
-    thinkingPublisher.flush()
-    useWikiStore.getState().finishReviewRun(runId, { running: true, results, error: undefined })
-    await saveGenerationHistoryEntry(projectPath, {
-      kind: "review",
-      title: target.chapterNumber ? t("novel.review.historyEntryTitle", { chapter: target.chapterNumber }) : t("novel.review.historyEntryTitleNoChapter"),
-      chapterNumber: target.chapterNumber,
-      sourcePath: selectedFile,
-      results,
-    })
-    await onHistorySaved?.()
 
-    if (target.chapterNumber) {
-      await persistRevisionFeedbackForChapter(
-        projectPath,
-        target.chapterNumber,
-        "review",
-        pickRevisionFeedbackFromReviewResults(results),
-      )
-    }
-  } catch (error) {
-    console.error("审查失败:", error)
-    thinkingPublisher.flush()
-    useWikiStore.getState().finishReviewRun(runId, { running: false, error: t("novel.review.runFailed") })
-  } finally {
-    thinkingPublisher.flush()
-    const current = useWikiStore.getState().reviewRun
-    if (current?.runId === runId) {
-      useWikiStore.getState().finishReviewRun(runId, { running: false, results: current.results })
+    try {
+      const results = await reviewChapter(projectPath, fileContent, target.chapterNumber, {
+        onThinking: (thinking) => {
+          thinkingPublisher.publish(thinking)
+        },
+      })
+      thinkingPublisher.flush()
+      useWikiStore.getState().finishReviewRun(runId, { running: true, results, error: undefined })
+      await saveGenerationHistoryEntry(projectPath, {
+        kind: "review",
+        title: target.chapterNumber ? t("novel.review.historyEntryTitle", { chapter: target.chapterNumber }) : t("novel.review.historyEntryTitleNoChapter"),
+        chapterNumber: target.chapterNumber,
+        sourcePath: selectedFile,
+        results,
+      })
+      await onHistorySaved?.()
+
+      if (target.chapterNumber) {
+        await persistRevisionFeedbackForChapter(
+          projectPath,
+          target.chapterNumber,
+          "review",
+          pickRevisionFeedbackFromReviewResults(results),
+        )
+      }
+    } catch (error) {
+      console.error("审查失败:", error)
+      thinkingPublisher.flush()
+      useWikiStore.getState().finishReviewRun(runId, { running: false, error: t("novel.review.runFailed") })
+    } finally {
+      thinkingPublisher.flush()
+      const current = useWikiStore.getState().reviewRun
+      if (current?.runId === runId) {
+        useWikiStore.getState().finishReviewRun(runId, { running: false, results: current.results })
+      }
     }
-  }
+  })
 }

+ 43 - 0
src/lib/novel/yield-to-browser.spec.ts

@@ -0,0 +1,43 @@
+import { afterEach, describe, expect, it, vi } from "vitest"
+import { isDocumentVisible, yieldToBrowserFrame } from "./yield-to-browser"
+
+function stubDocument(visibilityState: DocumentVisibilityState) {
+  vi.stubGlobal("document", { visibilityState })
+}
+
+describe("yieldToBrowserFrame", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals()
+    vi.useRealTimers()
+  })
+
+  it("resolves immediately without rAF when the document is hidden", async () => {
+    const raf = vi.fn()
+    vi.stubGlobal("requestAnimationFrame", raf)
+    stubDocument("hidden")
+
+    expect(isDocumentVisible()).toBe(false)
+    await yieldToBrowserFrame()
+    expect(raf).not.toHaveBeenCalled()
+  })
+
+  it("waits for a frame then a timeout when the document is visible", async () => {
+    vi.useFakeTimers()
+    const raf = vi.fn((callback: FrameRequestCallback) => {
+      callback(0)
+      return 1
+    })
+    vi.stubGlobal("window", {
+      requestAnimationFrame: raf,
+      setTimeout: globalThis.setTimeout.bind(globalThis),
+    })
+    stubDocument("visible")
+
+    expect(isDocumentVisible()).toBe(true)
+    const done = yieldToBrowserFrame()
+    expect(raf).toHaveBeenCalledTimes(1)
+    await vi.runAllTimersAsync()
+    await done
+  })
+})
+

+ 8 - 0
src/lib/novel/yield-to-browser.ts

@@ -1,4 +1,12 @@
+export function isDocumentVisible(): boolean {
+  return typeof document === "undefined" || document.visibilityState === "visible"
+}
+
 export function yieldToBrowserFrame(): Promise<void> {
+  if (!isDocumentVisible()) {
+    return Promise.resolve()
+  }
+
   if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
     return new Promise((resolve) => {
       window.requestAnimationFrame(() => {

+ 49 - 25
src/lib/writing-wake-lock.spec.ts

@@ -1,6 +1,6 @@
-import { describe, expect, it, vi } from "vitest"
+import { afterEach, describe, expect, it, vi } from "vitest"
 import {
-  shouldKeepAwakeForWriting,
+  resetWritingWakeLockForTests,
   withWritingWakeLock,
   type WritingWakeLockBindings,
 } from "./writing-wake-lock"
@@ -13,31 +13,11 @@ function bindings(invoke: WritingWakeLockBindings["invoke"], tauri = true) {
   } satisfies WritingWakeLockBindings
 }
 
-describe("shouldKeepAwakeForWriting", () => {
-  it.each(["write_chapter", "continue_chapter", "rewrite_chapter", "polish_chapter"] as const)(
-    "enables the wake lock for %s",
-    (intent) => {
-      expect(shouldKeepAwakeForWriting({ novelMode: true, intent, planExecuteActive: false })).toBe(true)
-    },
-  )
-
-  it.each(["general_chat", "generate_outline", "review_chapter", "lint_chapter"] as const)(
-    "does not enable the wake lock for %s",
-    (intent) => {
-      expect(shouldKeepAwakeForWriting({ novelMode: true, intent, planExecuteActive: false })).toBe(false)
-    },
-  )
-
-  it("does not enable the wake lock while waiting for plan execution", () => {
-    expect(shouldKeepAwakeForWriting({
-      novelMode: true,
-      intent: "write_chapter",
-      planExecuteActive: true,
-    })).toBe(false)
+describe("withWritingWakeLock", () => {
+  afterEach(() => {
+    resetWritingWakeLockForTests()
   })
-})
 
-describe("withWritingWakeLock", () => {
   it("acquires before the operation and releases after it completes", async () => {
     const events: string[] = []
     const invoke = vi.fn(async <T>(command: string) => {
@@ -104,4 +84,48 @@ describe("withWritingWakeLock", () => {
     await expect(withWritingWakeLock(false, async () => "disabled", bindings(invoke))).resolves.toBe("disabled")
     expect(invoke).not.toHaveBeenCalled()
   })
+
+  it("nests holds so only the first acquire and last release talk to Tauri", async () => {
+    const invoke = vi.fn(async <T>(command: string) => {
+      return (command === "acquire_writing_wake_lock" ? "shared-token" : undefined) as T
+    })
+    const testBindings = bindings(invoke)
+
+    await withWritingWakeLock(true, async () => {
+      await withWritingWakeLock(true, async () => {
+        expect(invoke).toHaveBeenCalledTimes(1)
+        expect(invoke).toHaveBeenCalledWith("acquire_writing_wake_lock")
+      }, testBindings)
+      expect(invoke).toHaveBeenCalledTimes(1)
+    }, testBindings)
+
+    expect(invoke).toHaveBeenCalledTimes(2)
+    expect(invoke).toHaveBeenLastCalledWith("release_writing_wake_lock", { token: "shared-token" })
+  })
+
+  it("serializes concurrent acquires so only one IPC token is created", async () => {
+    let releaseFirst!: () => void
+    const firstAcquire = new Promise<void>((resolve) => {
+      releaseFirst = resolve
+    })
+    let acquireCalls = 0
+    const invoke = vi.fn(async <T>(command: string) => {
+      if (command === "acquire_writing_wake_lock") {
+        acquireCalls += 1
+        if (acquireCalls === 1) await firstAcquire
+        return "concurrent-token" as T
+      }
+      return undefined as T
+    })
+    const testBindings = bindings(invoke)
+
+    const first = withWritingWakeLock(true, async () => "a", testBindings)
+    const second = withWritingWakeLock(true, async () => "b", testBindings)
+    await Promise.resolve()
+    expect(invoke).toHaveBeenCalledTimes(1)
+    releaseFirst()
+    await expect(Promise.all([first, second])).resolves.toEqual(["a", "b"])
+    expect(invoke.mock.calls.filter(([command]) => command === "acquire_writing_wake_lock")).toHaveLength(1)
+    expect(invoke.mock.calls.filter(([command]) => command === "release_writing_wake_lock")).toHaveLength(1)
+  })
 })

+ 44 - 31
src/lib/writing-wake-lock.ts

@@ -1,5 +1,4 @@
 import { isTauri } from "@/lib/platform"
-import type { NovelTaskIntent } from "@/lib/novel/task-router"
 
 type TauriInvoke = <T>(command: string, args?: Record<string, unknown>) => Promise<T>
 
@@ -9,13 +8,6 @@ export interface WritingWakeLockBindings {
   warn: (message: string, error: unknown) => void
 }
 
-const WRITING_WAKE_LOCK_INTENTS = new Set<NovelTaskIntent>([
-  "write_chapter",
-  "continue_chapter",
-  "rewrite_chapter",
-  "polish_chapter",
-])
-
 const defaultBindings: WritingWakeLockBindings = {
   isTauri,
   invoke: async <T>(command: string, args?: Record<string, unknown>) => {
@@ -25,15 +17,48 @@ const defaultBindings: WritingWakeLockBindings = {
   warn: (message, error) => console.warn(message, error),
 }
 
-export function shouldKeepAwakeForWriting(options: {
-  novelMode: boolean
-  intent?: NovelTaskIntent | null
-  planExecuteActive: boolean
-}): boolean {
-  return options.novelMode
-    && !options.planExecuteActive
-    && !!options.intent
-    && WRITING_WAKE_LOCK_INTENTS.has(options.intent)
+let holdCount = 0
+let sharedToken: string | null = null
+let mutex: Promise<void> = Promise.resolve()
+
+function enqueue<T>(task: () => Promise<T>): Promise<T> {
+  const run = mutex.then(task, task)
+  mutex = run.then(() => undefined, () => undefined)
+  return run
+}
+
+async function acquireSharedLock(bindings: WritingWakeLockBindings): Promise<void> {
+  await enqueue(async () => {
+    holdCount += 1
+    if (holdCount !== 1) return
+    try {
+      sharedToken = await bindings.invoke<string>("acquire_writing_wake_lock")
+    } catch (error) {
+      sharedToken = null
+      bindings.warn("[writing-wake-lock] 启用失败,继续执行正文生成", error)
+    }
+  })
+}
+
+async function releaseSharedLock(bindings: WritingWakeLockBindings): Promise<void> {
+  await enqueue(async () => {
+    holdCount = Math.max(0, holdCount - 1)
+    if (holdCount !== 0) return
+    const token = sharedToken
+    sharedToken = null
+    if (!token) return
+    try {
+      await bindings.invoke<void>("release_writing_wake_lock", { token })
+    } catch (error) {
+      bindings.warn("[writing-wake-lock] 释放失败,将在应用退出时清理", error)
+    }
+  })
+}
+
+export function resetWritingWakeLockForTests(): void {
+  holdCount = 0
+  sharedToken = null
+  mutex = Promise.resolve()
 }
 
 export async function withWritingWakeLock<T>(
@@ -45,22 +70,10 @@ export async function withWritingWakeLock<T>(
     return operation()
   }
 
-  let token: string | null = null
-  try {
-    token = await bindings.invoke<string>("acquire_writing_wake_lock")
-  } catch (error) {
-    bindings.warn("[writing-wake-lock] 启用失败,继续执行正文生成", error)
-  }
-
+  await acquireSharedLock(bindings)
   try {
     return await operation()
   } finally {
-    if (token) {
-      try {
-        await bindings.invoke<void>("release_writing_wake_lock", { token })
-      } catch (error) {
-        bindings.warn("[writing-wake-lock] 释放失败,将在应用退出时清理", error)
-      }
-    }
+    await releaseSharedLock(bindings)
   }
 }