From 335b1367c84026971669c87423810eee50c240ff Mon Sep 17 00:00:00 2001 From: zhangweijian Date: Thu, 1 Oct 2026 16:48:34 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E6=B2=89=E6=B7=80=E5=88=B0=E6=96=87=E4=BB=B6=E5=A4=B9=E3=80=8D?= =?UTF-8?q?=E5=88=86=E4=BA=AB=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FolderDelivery 的组装能力接成一等入口:KnowledgeDelivery 抽出共享组装 (聊天名.md + 附件/),folder 入口直接落入选定目录(默认 ~/Downloads)。 安静落盘没有屏幕证据,是唯一报告成功的投递——Toast 带「在访达中显示」。 --- .../en.lproj/Localizable.strings | 10 ++ .../zh-Hans.lproj/Localizable.strings | 10 ++ .../Folder/en.lproj/InfoPlist.strings | 1 + .../Folder/zh-Hans.lproj/InfoPlist.strings | 1 + Scripts/check-release-config.sh | 4 +- Scripts/share-slots.sh | 7 +- Sources/WeChatBridgeApp/ActionRunner.swift | 54 +++++++- Sources/WeChatBridgeApp/AppModel.swift | 2 +- Sources/WeChatBridgeApp/Feedback/Toast.swift | 4 +- .../WeChatBridgeApp/KnowledgeDelivery.swift | 49 +++++++- Sources/WeChatBridgeApp/Preferences.swift | 19 +++ .../Views/Settings/EntriesPane.swift | 53 ++++++++ .../Views/Settings/ShareEntryList.swift | 16 ++- Sources/WeChatBridgeCore/BatchState.swift | 2 +- Sources/WeChatBridgeCore/ShareAction.swift | 12 +- Tests/KnowledgeDeliveryTests.swift | 119 ++++++++++++++++++ .../ShareActionTests.swift | 24 +++- 17 files changed, 362 insertions(+), 25 deletions(-) create mode 100644 Resources/ShareLocalizations/Folder/en.lproj/InfoPlist.strings create mode 100644 Resources/ShareLocalizations/Folder/zh-Hans.lproj/InfoPlist.strings create mode 100644 Tests/KnowledgeDeliveryTests.swift diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index fb177fb..04e6144 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -17,6 +17,7 @@ "发给 WeSight" = "Send to WeSight"; "发给 DeepSeek Harness" = "Send to DeepSeek Harness"; "沉淀到 Obsidian" = "Save to Obsidian"; +"沉淀到文件夹" = "Save to Folder"; "添加应用…" = "Add an App…"; /* Forward targets */ @@ -31,8 +32,13 @@ "豆包" = "Doubao"; "WeSight" = "WeSight"; "Obsidian" = "Obsidian"; +"文件夹" = "the folder"; "剪贴板" = "the clipboard"; +/* Folder delivery — lands quietly, so its success gets said */ +"已保存到 %@" = "Saved to %@"; +"在访达中显示" = "Reveal in Finder"; + /* Share extension — no interface; only a failure still speaks */ "系统没有返回文件。" = "The system returned no file."; "没能接住这次转发:%@" = "Couldn't receive that share: %@"; @@ -109,6 +115,7 @@ "激活 WeSight 并直接粘贴到输入框。" = "Brings WeSight forward and pastes straight into its input."; "激活 DeepSeek Harness 并直接粘贴到输入框。" = "Brings DeepSeek Harness forward and pastes straight into its input."; "把聊天记录转成 Markdown,写入选定的 Obsidian 知识库。" = "Turns the chat archive into Markdown and writes it into the selected Obsidian vault."; +"把聊天记录转成 Markdown 和附件,写入选定的文件夹。" = "Turns the chat into Markdown and attachments in the folder you choose."; "只放进剪贴板,去哪儿按 ⌘V 由你决定。" = "Puts them on the clipboard and leaves the ⌘V to you."; "转发时从你自己的清单里挑一个 App,激活它并粘贴。" = "Pick an app from your own list when you forward; WeChatBridge brings it forward and pastes."; "未注册" = "Not registered"; @@ -125,10 +132,13 @@ "只粘贴文件路径" = "Paste path only"; "给终端类应用用:粘贴的是带引号的文件路径,跟把文件拖进终端一样。" = "For terminal-style apps: pastes the quoted file path, exactly as dropping the file on a terminal would."; "Obsidian 沉淀" = "Obsidian"; +"文件夹沉淀" = "Folder"; "知识库文件夹" = "Vault folder"; +"目标文件夹" = "Destination folder"; "尚未选择" = "Not selected yet"; "选择文件夹…" = "Choose Folder…"; "选择知识库" = "Choose Vault"; +"选择目标文件夹" = "Choose Destination Folder"; "子文件夹" = "Subfolder"; "聊天 Markdown 与原始 ZIP 会写入这个目录。" = "Chat Markdown and the original ZIP are written under this folder."; diff --git a/Resources/Localizations/zh-Hans.lproj/Localizable.strings b/Resources/Localizations/zh-Hans.lproj/Localizable.strings index a6220cb..5167be0 100644 --- a/Resources/Localizations/zh-Hans.lproj/Localizable.strings +++ b/Resources/Localizations/zh-Hans.lproj/Localizable.strings @@ -17,6 +17,7 @@ "发给 WeSight" = "发给 WeSight"; "发给 DeepSeek Harness" = "发给 DeepSeek Harness"; "沉淀到 Obsidian" = "沉淀到 Obsidian"; +"沉淀到文件夹" = "沉淀到文件夹"; "添加应用…" = "添加应用…"; /* 转发目标 */ @@ -31,8 +32,13 @@ "豆包" = "豆包"; "WeSight" = "WeSight"; "Obsidian" = "Obsidian"; +"文件夹" = "文件夹"; "剪贴板" = "剪贴板"; +/* 文件夹投递 · 安静落盘,成功要说一声 */ +"已保存到 %@" = "已保存到 %@"; +"在访达中显示" = "在访达中显示"; + /* 共享扩展 · 无界面,只有失败还会说话 */ "系统没有返回文件。" = "系统没有返回文件。"; "没能接住这次转发:%@" = "没能接住这次转发:%@"; @@ -109,6 +115,7 @@ "激活 WeSight 并直接粘贴到输入框。" = "激活 WeSight 并直接粘贴到输入框。"; "激活 DeepSeek Harness 并直接粘贴到输入框。" = "激活 DeepSeek Harness 并直接粘贴到输入框。"; "把聊天记录转成 Markdown,写入选定的 Obsidian 知识库。" = "把聊天记录转成 Markdown,写入选定的 Obsidian 知识库。"; +"把聊天记录转成 Markdown 和附件,写入选定的文件夹。" = "把聊天记录转成 Markdown 和附件,写入选定的文件夹。"; "只放进剪贴板,去哪儿按 ⌘V 由你决定。" = "只放进剪贴板,去哪儿按 ⌘V 由你决定。"; "转发时从你自己的清单里挑一个 App,激活它并粘贴。" = "转发时从你自己的清单里挑一个 App,激活它并粘贴。"; "未注册" = "未注册"; @@ -125,10 +132,13 @@ "只粘贴文件路径" = "只粘贴文件路径"; "给终端类应用用:粘贴的是带引号的文件路径,跟把文件拖进终端一样。" = "给终端类应用用:粘贴的是带引号的文件路径,跟把文件拖进终端一样。"; "Obsidian 沉淀" = "Obsidian 沉淀"; +"文件夹沉淀" = "文件夹沉淀"; "知识库文件夹" = "知识库文件夹"; +"目标文件夹" = "目标文件夹"; "尚未选择" = "尚未选择"; "选择文件夹…" = "选择文件夹…"; "选择知识库" = "选择知识库"; +"选择目标文件夹" = "选择目标文件夹"; "子文件夹" = "子文件夹"; "聊天 Markdown 与原始 ZIP 会写入这个目录。" = "聊天 Markdown 与原始 ZIP 会写入这个目录。"; diff --git a/Resources/ShareLocalizations/Folder/en.lproj/InfoPlist.strings b/Resources/ShareLocalizations/Folder/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..e710100 --- /dev/null +++ b/Resources/ShareLocalizations/Folder/en.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"CFBundleDisplayName" = "Save to Folder"; diff --git a/Resources/ShareLocalizations/Folder/zh-Hans.lproj/InfoPlist.strings b/Resources/ShareLocalizations/Folder/zh-Hans.lproj/InfoPlist.strings new file mode 100644 index 0000000..6160ec2 --- /dev/null +++ b/Resources/ShareLocalizations/Folder/zh-Hans.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"CFBundleDisplayName" = "沉淀到文件夹"; diff --git a/Scripts/check-release-config.sh b/Scripts/check-release-config.sh index 2f81ade..e20e655 100755 --- a/Scripts/check-release-config.sh +++ b/Scripts/check-release-config.sh @@ -19,8 +19,8 @@ plutil -lint "$APP_INFO" "$SHARE_INFO" "$APP_ENTITLEMENTS" "$SHARE_ENTITLEMENTS" plutil -lint "$ROOT"/Resources/Localizations/*/*.strings "$ROOT"/Resources/ShareLocalizations/*/*/*.strings >/dev/null python3 -m json.tool "$ROOT/Resources/Skills/catalog.json" >/dev/null -if [ "${#SHARE_SLOTS[@]}" -ne 10 ]; then - echo "expected 10 Share-menu entries, found ${#SHARE_SLOTS[@]}" >&2 +if [ "${#SHARE_SLOTS[@]}" -ne 11 ]; then + echo "expected 11 Share-menu entries, found ${#SHARE_SLOTS[@]}" >&2 exit 1 fi diff --git a/Scripts/share-slots.sh b/Scripts/share-slots.sh index 5aebed4..82b5aa6 100644 --- a/Scripts/share-slots.sh +++ b/Scripts/share-slots.sh @@ -6,7 +6,7 @@ # same pkd election System Settings → General → Login Items & Extensions → # Sharing does. # -# All nine run the same executable and tell themselves apart by DKShareAction, +# All eleven run the same executable and tell themselves apart by DKShareAction, # so adding an entry costs a row here and a pair of InfoPlist.strings — not a # second copy of the import code. # @@ -26,12 +26,13 @@ SHARE_SLOTS=( "WeSight|WeChatBridgeShareWeSight|ShareWeSight|weSight|发给 WeSight" "DeepSeekHarness|WeChatBridgeShareDeepSeekHarness|ShareDeepSeekHarness|deepSeekHarness|发给 DeepSeek Harness" "Obsidian|WeChatBridgeShareObsidian|ShareObsidian|obsidian|沉淀到 Obsidian" + "Folder|WeChatBridgeShareFolder|ShareFolder|folder|沉淀到文件夹" "Clipboard|WeChatBridgeShareClipboard|ShareClipboard|clipboard|复制到剪贴板" "Custom|WeChatBridgeShareCustom|ShareCustom|custom|发送到自定义" ) -# Artwork copied into each extension that targets another app. Clipboard and -# Custom have no destination app logo to borrow. +# Artwork copied into each extension that targets another app. Clipboard, +# Custom and Folder have no destination app logo to borrow. share_logo_name() { case "$1" in Codex) printf '%s' "04-chatgpt.png" ;; diff --git a/Sources/WeChatBridgeApp/ActionRunner.swift b/Sources/WeChatBridgeApp/ActionRunner.swift index 335e612..7f1e5da 100644 --- a/Sources/WeChatBridgeApp/ActionRunner.swift +++ b/Sources/WeChatBridgeApp/ActionRunner.swift @@ -10,9 +10,11 @@ import Foundation /// files and records the request; this runs it, in the app, where a failure has /// somewhere to be reported. /// -/// Success is silent. Every destination ends with the result in front of the -/// user — the share sheet's own 「已复制」 or the pasted files in the target app -/// — so the only thing left to report is a failure. +/// Success is silent. Every paste destination ends with the result in front of +/// the user — the share sheet's own 「已复制」 or the pasted files in the target +/// app — so the only thing left to report is a failure. 「沉淀到文件夹」 is the +/// one exception: nothing appears on screen when a note lands in a folder, so +/// it gets the one success toast, with a way to jump to the file. @MainActor final class ActionRunner { /// Opens 设置 → 入口, which is the only place the 「发送到自定义」 list can be @@ -79,7 +81,7 @@ final class ActionRunner { // Nothing is shown: the share sheet said 「已复制到剪贴板」 a moment // ago and is still on screen. A second capsule saying it again is // WeChatBridge talking over the system. - case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness, .obsidian, .custom: + case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness, .obsidian, .folder, .custom: // Shares and WeChat captures share the same clipboard queue. // // Read here rather than where the panel opens. This forward may wait @@ -102,8 +104,10 @@ final class ActionRunner { self.model.recordExpired(urls: arrival.urls) return } + // The knowledge destinations paste nothing, so there is no + // prompt to attach and no scene to pick. let context = await self.sceneCoordinator.prepare( - enabled: arrival.action != .obsidian + enabled: arrival.action != .obsidian && arrival.action != .folder && !self.preferences.scenes.enabledScenes.isEmpty, groupName: nil, captureTitle: arrival.capturesGroupName, @@ -144,6 +148,10 @@ final class ActionRunner { await deliverToObsidian(arrival, context: context) return } + if arrival.action == .folder { + await deliverToFolder(arrival, context: context) + return + } guard arrival.action == .custom, arrival.target == nil else { await forward(arrival, context: context) return @@ -250,6 +258,42 @@ final class ActionRunner { } } + /// 「沉淀到文件夹」 is not a paste destination either: the archive becomes a + /// note in the folder the user picked. Unlike every other destination, + /// nothing appears on screen when it works, so this is the one delivery + /// that reports success — a capsule with a way to the file (Issue #19). + private func deliverToFolder( + _ arrival: ArrivedBatch, + context: SceneCoordinator.Selection + ) async { + do { + let notes = try KnowledgeDelivery.deliver( + urls: arrival.urls, + folderPath: preferences.folderDeliveryPath, + chatName: context.groupName, + sceneName: context.scene?.name + ) + model.recordDelivery(urls: arrival.urls, action: .folder) + sceneCoordinator.advance(context) + let folder = URL( + fileURLWithPath: preferences.folderDeliveryPath, + isDirectory: true + ) + toast.show( + L10n.format("已保存到 %@", folder.lastPathComponent), + symbol: "checkmark.circle.fill", + action: ToastPresenter.Action(title: L10n.text("在访达中显示")) { + NSWorkspace.shared.activateFileViewerSelecting(notes) + } + ) + } catch { + fallBack( + arrival, + message: (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + ) + } + } + private func openInObsidian(_ notes: [URL], vaultPath: String) { let vault = URL(fileURLWithPath: vaultPath, isDirectory: true).standardizedFileURL let prefix = vault.path.hasSuffix("/") ? vault.path : vault.path + "/" diff --git a/Sources/WeChatBridgeApp/AppModel.swift b/Sources/WeChatBridgeApp/AppModel.swift index a800273..476e2a2 100644 --- a/Sources/WeChatBridgeApp/AppModel.swift +++ b/Sources/WeChatBridgeApp/AppModel.swift @@ -164,7 +164,7 @@ final class AppModel: ObservableObject { // Already copied, by the extension, and already recorded as // such by `BatchState.initial`. Nothing left to do. return false - case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness, .obsidian, .custom: + case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness, .obsidian, .folder, .custom: // A forward whose one-shot request is already gone: the file was // consumed by a run that then died before it could act, or the // extension never managed to write it. Either way nothing can be diff --git a/Sources/WeChatBridgeApp/Feedback/Toast.swift b/Sources/WeChatBridgeApp/Feedback/Toast.swift index 6639f0c..48908d7 100644 --- a/Sources/WeChatBridgeApp/Feedback/Toast.swift +++ b/Sources/WeChatBridgeApp/Feedback/Toast.swift @@ -13,7 +13,9 @@ import SwiftUI /// Nothing here reports success any more (2026-09-05, at the user's request): /// every destination leaves its own evidence on screen, so a capsule saying /// 「已发给 Claude」 was one more thing to dismiss over a paste the user had -/// just watched happen. +/// just watched happen. One exception since 「沉淀到文件夹」: a note that lands +/// in a folder shows nothing anywhere, so that delivery reports its own success +/// (see `ActionRunner.deliverToFolder`). /// /// It used to appear next to the pointer, which put it over whatever the user /// was about to click and moved it somewhere new every time. One fixed corner diff --git a/Sources/WeChatBridgeApp/KnowledgeDelivery.swift b/Sources/WeChatBridgeApp/KnowledgeDelivery.swift index 04a68ea..7e3b379 100644 --- a/Sources/WeChatBridgeApp/KnowledgeDelivery.swift +++ b/Sources/WeChatBridgeApp/KnowledgeDelivery.swift @@ -1,9 +1,14 @@ import WeChatBridgeCore import Foundation -/// Writes a WeChat archive into an Obsidian vault as Markdown plus a durable -/// copy of the original ZIP. Keeping the archive makes a parsing change or a -/// future converter able to rebuild the note without asking WeChat again. +/// Writes a WeChat archive into a folder as Markdown plus a durable copy of +/// the original ZIP. Keeping the archive makes a parsing change or a future +/// converter able to rebuild the note without asking WeChat again. +/// +/// Two destinations share this assembly: 「沉淀到 Obsidian」 lands the note in +/// a subfolder of the user's vault, and 「沉淀到文件夹」 lands it directly in +/// the folder the user picked. The note shape — `聊天名.md` plus `附件/` — is +/// the same either way. enum KnowledgeDelivery { enum Failure: LocalizedError { case notConfigured @@ -17,6 +22,8 @@ enum KnowledgeDelivery { } } + /// The Obsidian entry: the vault is what the user configured, and the note + /// goes into a `微信流` (or named) subfolder of it. @discardableResult static func deliver( urls: [URL], @@ -25,13 +32,45 @@ enum KnowledgeDelivery { chatName: String?, sceneName: String? ) throws -> [URL] { - guard !urls.isEmpty else { throw Failure.unreadableArchive } let vault = URL(fileURLWithPath: vaultPath, isDirectory: true) try FolderDelivery.validateFolder(vault) let subfolderPath = DisplayName.subfolderPath(subfolder) let folderName = subfolderPath.isEmpty ? "微信流" : subfolderPath - let root = vault.appendingPathComponent(folderName, isDirectory: true) + return try write( + urls: urls, + root: vault.appendingPathComponent(folderName, isDirectory: true), + chatName: chatName, + sceneName: sceneName + ) + } + + /// The folder entry: the note and its `附件/` land directly in the folder, + /// with no subfolder of WeChatBridge's own between them. + @discardableResult + static func deliver( + urls: [URL], + folderPath: String, + chatName: String?, + sceneName: String? + ) throws -> [URL] { + let folder = URL(fileURLWithPath: folderPath, isDirectory: true) + try FolderDelivery.validateFolder(folder) + return try write(urls: urls, root: folder, chatName: chatName, sceneName: sceneName) + } + + /// The shared assembly: one `聊天名.md` (merged when a same-named note is + /// already there, uniquely numbered otherwise) and `附件/` holding the + /// original archive plus any media unpacked from it. `root` is created if + /// absent; its nearest existing parent must already be writable. + @discardableResult + private static func write( + urls: [URL], + root: URL, + chatName: String?, + sceneName: String? + ) throws -> [URL] { + guard !urls.isEmpty else { throw Failure.unreadableArchive } let attachments = root.appendingPathComponent("附件", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: attachments, withIntermediateDirectories: true) diff --git a/Sources/WeChatBridgeApp/Preferences.swift b/Sources/WeChatBridgeApp/Preferences.swift index 0ff54e9..d014575 100644 --- a/Sources/WeChatBridgeApp/Preferences.swift +++ b/Sources/WeChatBridgeApp/Preferences.swift @@ -30,6 +30,7 @@ final class Preferences: ObservableObject { static let pendingScene = "com.xiangming.wechatbridge.pendingScene.v1" static let obsidianVaultPath = "com.xiangming.wechatbridge.obsidianVaultPath" static let obsidianSubfolder = "com.xiangming.wechatbridge.obsidianSubfolder" + static let folderDeliveryPath = "com.xiangming.wechatbridge.folderDeliveryPath" } /// A week: long enough that last Friday's chat export is still there on @@ -37,6 +38,15 @@ final class Preferences: ObservableObject { /// user's archive of every file they ever forwarded. static let defaultHistoryRetentionDays = 7 + /// 「沉淀到文件夹」 needs somewhere to land on a fresh install before the + /// user has opened 设置, and ~/Downloads is the one folder every Mac has. + static var defaultFolderDeliveryPath: String { + FileManager.default + .homeDirectoryForCurrentUser + .appendingPathComponent("Downloads", isDirectory: true) + .path + } + private let defaults: UserDefaults init(defaults: UserDefaults = .standard) { @@ -65,6 +75,8 @@ final class Preferences: ObservableObject { .flatMap { try? JSONDecoder().decode(PendingSceneSelection.self, from: $0) } obsidianVaultPath = defaults.string(forKey: Key.obsidianVaultPath) obsidianSubfolder = defaults.string(forKey: Key.obsidianSubfolder) ?? "微信流" + folderDeliveryPath = defaults.string(forKey: Key.folderDeliveryPath) + ?? Self.defaultFolderDeliveryPath } /// Set only by finishing the guide. Closing its window half way through is @@ -135,6 +147,13 @@ final class Preferences: ObservableObject { didSet { defaults.set(obsidianSubfolder, forKey: Key.obsidianSubfolder) } } + /// Where 「沉淀到文件夹」 lands its notes. A real path, never `~`: the + /// folder entry ships working out of the box, and the tilde would have to + /// be re-expanded on every read for no gain. + @Published var folderDeliveryPath: String { + didSet { defaults.set(folderDeliveryPath, forKey: Key.folderDeliveryPath) } + } + /// Global scene shortcuts choose for the next share, not forever. A stale /// choice is ignored rather than silently changing a later forward. func selectSceneForNextForward(id: String) { diff --git a/Sources/WeChatBridgeApp/Views/Settings/EntriesPane.swift b/Sources/WeChatBridgeApp/Views/Settings/EntriesPane.swift index f1646c6..50d4a85 100644 --- a/Sources/WeChatBridgeApp/Views/Settings/EntriesPane.swift +++ b/Sources/WeChatBridgeApp/Views/Settings/EntriesPane.swift @@ -26,10 +26,12 @@ struct EntriesPane: View { probe: probe, compactDetails: true, obsidianVaultPath: preferences.obsidianVaultPath, + folderPath: preferences.folderDeliveryPath, customTargetCount: targets.targets.count ) { action in switch action { case .obsidian: configuration = .obsidian + case .folder: configuration = .folder case .custom: configuration = .custom default: break } @@ -60,6 +62,8 @@ struct EntriesPane: View { switch item { case .obsidian: obsidianSettings + case .folder: + folderSettings case .custom: ForwardTargetList(targets: targets) } @@ -124,6 +128,38 @@ struct EntriesPane: View { ) } + /// One row, not two: the folder entry has no subfolder of its own — the + /// note and `附件/` land directly in the chosen folder. + private var folderSettings: some View { + VStack(spacing: 0) { + SettingRow( + title: L10n.text("目标文件夹"), + detail: URL( + fileURLWithPath: preferences.folderDeliveryPath, + isDirectory: true + ).lastPathComponent, + alignment: .center + ) { + HStack(spacing: Space.s) { + Button(L10n.text("在 Finder 中显示")) { + NSWorkspace.shared.activateFileViewerSelecting([ + URL(fileURLWithPath: preferences.folderDeliveryPath, isDirectory: true) + ]) + } + .buttonStyle(SettingsActionButtonStyle()) + Button(L10n.text("选择文件夹…")) { chooseDeliveryFolder() } + .buttonStyle(SettingsActionButtonStyle()) + } + } + .padding(Space.m) + } + .background(Theme.surface, in: RoundedRectangle(cornerRadius: Radius.card, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.card, style: .continuous) + .strokeBorder(Theme.stroke, lineWidth: Stroke.hairline) + ) + } + private func chooseObsidianVault() { let panel = NSOpenPanel() panel.canChooseDirectories = true @@ -134,10 +170,26 @@ struct EntriesPane: View { guard panel.runModal() == .OK, let folder = panel.url else { return } preferences.obsidianVaultPath = folder.path } + + private func chooseDeliveryFolder() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + panel.prompt = L10n.text("选择目标文件夹") + panel.directoryURL = URL( + fileURLWithPath: preferences.folderDeliveryPath, + isDirectory: true + ) + guard panel.runModal() == .OK, let folder = panel.url else { return } + preferences.folderDeliveryPath = folder.path + } } private enum EntryConfiguration: String, Identifiable { case obsidian + case folder case custom var id: String { rawValue } @@ -145,6 +197,7 @@ private enum EntryConfiguration: String, Identifiable { var title: String { switch self { case .obsidian: L10n.text("Obsidian 沉淀") + case .folder: L10n.text("文件夹沉淀") case .custom: L10n.text("「发送到自定义」的应用") } } diff --git a/Sources/WeChatBridgeApp/Views/Settings/ShareEntryList.swift b/Sources/WeChatBridgeApp/Views/Settings/ShareEntryList.swift index b109f86..7b84959 100644 --- a/Sources/WeChatBridgeApp/Views/Settings/ShareEntryList.swift +++ b/Sources/WeChatBridgeApp/Views/Settings/ShareEntryList.swift @@ -18,6 +18,9 @@ struct ShareEntryList: View { /// Settings uses terse state; onboarding keeps the explanatory copy. var compactDetails = false var obsidianVaultPath: String? + /// Where 「沉淀到文件夹」 lands its notes; unlike the vault it always has a + /// value, so the row can name the folder straight away. + var folderPath = "" var customTargetCount = 0 var configure: ((ShareAction) -> Void)? @@ -70,8 +73,8 @@ struct ShareEntryList: View { HStack(spacing: Space.s) { if compactDetails, let configure, - action == .obsidian || action == .custom { - Button(action == .obsidian ? L10n.text("设置…") : L10n.text("管理…")) { + action == .obsidian || action == .folder || action == .custom { + Button(action == .custom ? L10n.text("管理…") : L10n.text("设置…")) { configure(action) } .buttonStyle(SettingsActionButtonStyle()) @@ -162,7 +165,7 @@ struct ShareEntryList: View { case .workBuddy: file = "06-workbuddy.png" case .weSight: file = "07-wesight.png" case .obsidian: file = "05-obsidian.png" - case .deepSeekHarness, .clipboard, .custom: return nil + case .deepSeekHarness, .folder, .clipboard, .custom: return nil } guard let url = Bundle.main.url( forResource: file, @@ -178,6 +181,7 @@ struct ShareEntryList: View { switch action { case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness: "paperplane" case .obsidian: "book.closed" + case .folder: "folder" case .clipboard: "doc.on.clipboard" case .custom: "paperplane.circle" } @@ -197,6 +201,8 @@ struct ShareEntryList: View { return obsidianVaultPath.map { URL(fileURLWithPath: $0, isDirectory: true).lastPathComponent } ?? L10n.text("未选择知识库") + case .folder: + return URL(fileURLWithPath: folderPath, isDirectory: true).lastPathComponent case .clipboard: return L10n.text("只复制,不自动粘贴") case .custom: @@ -215,6 +221,7 @@ struct ShareEntryList: View { case .weSight: return L10n.text("激活 WeSight 并直接粘贴到输入框。") case .deepSeekHarness: return L10n.text("激活 DeepSeek Harness 并直接粘贴到输入框。") case .obsidian: return L10n.text("把聊天记录转成 Markdown,写入选定的 Obsidian 知识库。") + case .folder: return L10n.text("把聊天记录转成 Markdown 和附件,写入选定的文件夹。") case .clipboard: return L10n.text("只放进剪贴板,去哪儿按 ⌘V 由你决定。") case .custom: return L10n.text("转发时从你自己的清单里挑一个 App,激活它并粘贴。") } @@ -229,6 +236,9 @@ struct ShareEntryList: View { } ?? true case .obsidian: return obsidianVaultPath == nil + case .folder: + // Always somewhere to land: ~/Downloads out of the box. + return false case .custom: return customTargetCount == 0 case .clipboard: diff --git a/Sources/WeChatBridgeCore/BatchState.swift b/Sources/WeChatBridgeCore/BatchState.swift index f727a33..9300c8c 100644 --- a/Sources/WeChatBridgeCore/BatchState.swift +++ b/Sources/WeChatBridgeCore/BatchState.swift @@ -134,7 +134,7 @@ public struct BatchState: Codable, Sendable, Hashable { action: action, targetName: targetName ) - case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness, .obsidian, .custom: + case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness, .obsidian, .folder, .custom: return BatchState(outcome: nil, action: action, targetName: targetName) } } diff --git a/Sources/WeChatBridgeCore/ShareAction.swift b/Sources/WeChatBridgeCore/ShareAction.swift index ef23607..9b052ab 100644 --- a/Sources/WeChatBridgeCore/ShareAction.swift +++ b/Sources/WeChatBridgeCore/ShareAction.swift @@ -25,6 +25,9 @@ public enum ShareAction: String, Codable, Sendable, CaseIterable { case deepSeekHarness /// Write a Markdown note and its source archive into the configured vault. case obsidian + /// Write a Markdown note and its attachments into the configured folder, + /// no app involved. + case folder /// Put the files on the clipboard and stop there. case clipboard /// Ask which app, every time. The entry itself names no destination and @@ -82,7 +85,9 @@ public enum ShareAction: String, Codable, Sendable, CaseIterable { // `.custom` has no fixed destination and no destination in its intent // either. `ActionRunner` resolves one from the user's own list, or takes // the one a 发给 ▸ menu inside WeChatBridge named. - case .clipboard, .custom: return nil + // `.folder` delivers to a path, not a process — the app writes the + // note itself and no app is ever launched. + case .clipboard, .custom, .folder: return nil } } @@ -95,7 +100,7 @@ public enum ShareAction: String, Codable, Sendable, CaseIterable { public var needsIntent: Bool { switch self { case .clipboard: return false - case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness, .obsidian, .custom: return true + case .codex, .claude, .doubao, .qwen, .workBuddy, .weSight, .deepSeekHarness, .obsidian, .folder, .custom: return true } } @@ -112,6 +117,7 @@ public enum ShareAction: String, Codable, Sendable, CaseIterable { case .weSight: return L10n.text("WeSight") case .deepSeekHarness: return "DeepSeek Harness" case .obsidian: return L10n.text("Obsidian") + case .folder: return L10n.text("文件夹") case .clipboard: return L10n.text("剪贴板") // Only ever reached when the chosen target is missing — a failure // message has to name something, and this build has nothing better. @@ -133,6 +139,7 @@ public enum ShareAction: String, Codable, Sendable, CaseIterable { case .weSight: return L10n.text("发给 WeSight") case .deepSeekHarness: return L10n.text("发给 DeepSeek Harness") case .obsidian: return L10n.text("沉淀到 Obsidian") + case .folder: return L10n.text("沉淀到文件夹") case .clipboard: return L10n.text("复制到剪贴板") case .custom: return L10n.text("发送到自定义") } @@ -152,6 +159,7 @@ public enum ShareAction: String, Codable, Sendable, CaseIterable { case .weSight: return "ShareWeSight" case .deepSeekHarness: return "ShareDeepSeekHarness" case .obsidian: return "ShareObsidian" + case .folder: return "ShareFolder" case .clipboard: return "ShareClipboard" case .custom: return "ShareCustom" } diff --git a/Tests/KnowledgeDeliveryTests.swift b/Tests/KnowledgeDeliveryTests.swift new file mode 100644 index 0000000..e5f989f --- /dev/null +++ b/Tests/KnowledgeDeliveryTests.swift @@ -0,0 +1,119 @@ +// Standalone app-helper tests; no app launch or Package.swift change needed. +// After `swift build`: +// test_output="$(mktemp -d /tmp/wechatbridge-knowledge-tests.XXXXXX)" +// test_sdk="$(xcode-select -p)/Platforms/MacOSX.platform/Developer" +// mise exec -- swiftc -swift-version 5 -D KNOWLEDGE_DELIVERY_TEST_MAIN \ +// -I .build/debug -F "$test_sdk/Library/Frameworks" -L "$test_sdk/usr/lib" \ +// -Xlinker -rpath -Xlinker "$test_sdk/Library/Frameworks" \ +// -Xlinker -rpath -Xlinker "$test_sdk/usr/lib" \ +// .build/debug/WeChatBridgeCore.o -lz \ +// Sources/WeChatBridgeApp/FolderDelivery.swift Sources/WeChatBridgeApp/KnowledgeDelivery.swift \ +// Tests/KnowledgeDeliveryTests.swift \ +// -o "$test_output/runner" +// "$test_output/runner" +import Foundation +import WeChatBridgeCore +import XCTest + +final class KnowledgeDeliveryTests: XCTestCase { + /// 聊天记录.txt with three messages (甲 twice, 乙 once), the last one + /// naming images/photo.png, plus that image — the same fixture + /// WeChatNativeArchiveTests parses. + private let storedZIP = "UEsDBBQAAAgAAMBAJV0fGRySiQAAAIkAAAAQAAAA6IGK5aSp6K6w5b2VLnR4dMK355SyCjIwMjblubQ55pyINeaXpSAwODowNQrkvaDlpb0K56ys5LqM6KGMCgrCt+S5mQoyMDI25bm0OeaciDXml6UgMDg6MDUK5L2g5aW9CuesrOS6jOihjAoKwrfnlLIKMjAyNuW5tDnmnIg15pelIDA4OjA2CmltYWdlcy9waG90by5wbmcKUEsDBBQAAAAAAMBAJV2KfiaRIAAAACAAAAAQAAAAaW1hZ2VzL3Bob3RvLnBuZwABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fUEsBAhQDFAAACAAAwEAlXR8ZHJKJAAAAiQAAABAAAAAAAAAAAAAAAIABAAAAAOiBiuWkqeiusOW9lS50eHRQSwECFAMUAAAAAADAQCVdin4mkSAAAAAgAAAAEAAAAAAAAAAAAAAAgAG3AAAAaW1hZ2VzL3Bob3RvLnBuZ1BLBQYAAAAAAgACAHwAAAAFAQAAAAA=" + + private var root: URL! + private var destination: URL! + private let fm = FileManager.default + + override func setUpWithError() throws { + root = fm.temporaryDirectory.appendingPathComponent("wechatbridge-knowledge-tests-" + UUID().uuidString, isDirectory: true) + destination = root.appendingPathComponent("Notes", isDirectory: true) + try fm.createDirectory(at: destination, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { try fm.removeItem(at: root) } + + private func source() throws -> URL { + let url = root.appendingPathComponent("聊天记录.zip") + try XCTUnwrap(Data(base64Encoded: storedZIP)).write(to: url) + return url + } + + /// The folder entry's shape: the note and its 附件/ sit directly in the + /// chosen folder, with no 微信流 subfolder of WeChatBridge's own. + func testFolderDeliveryWritesNoteAndAttachmentsIntoTheChosenFolder() throws { + let notes = try KnowledgeDelivery.deliver( + urls: [try source()], + folderPath: destination.path, + chatName: "项目群", + sceneName: nil + ) + let note = destination.appendingPathComponent("项目群的聊天.md") + XCTAssertEqual(notes, [note]) + XCTAssertEqual(try fm.contentsOfDirectory(atPath: destination.path).sorted(), ["附件", "项目群的聊天.md"]) + + let markdown = try String(contentsOf: note, encoding: .utf8) + XCTAssertTrue(markdown.contains("# 项目群的聊天")) + XCTAssertTrue(markdown.contains("甲")) + XCTAssertTrue(markdown.contains("[[附件/聊天记录.zip]]")) + XCTAssertTrue(markdown.contains("![[附件/photo.png]]")) + + let attachments = try fm.contentsOfDirectory(atPath: destination.appendingPathComponent("附件").path).sorted() + XCTAssertEqual(attachments, ["photo.png", "聊天记录.zip"]) + } + + /// A folder the user has not got (deleted, unmounted) is a failure, not a + /// silently recreated directory. + func testFolderDeliveryRejectsAMissingDestinationFolder() throws { + let archive = try source() + let missing = root.appendingPathComponent("gone", isDirectory: true) + XCTAssertThrowsError( + try KnowledgeDelivery.deliver(urls: [archive], folderPath: missing.path, chatName: nil, sceneName: nil) + ) + XCTAssertFalse(fm.fileExists(atPath: missing.path)) + XCTAssertEqual(try fm.contentsOfDirectory(atPath: destination.path), []) + } + + /// A same-named note WeChatBridge did not write is never touched; the new + /// note takes the next free number instead. + func testFolderDeliveryNumbersANoteItDidNotWrite() throws { + let existing = destination.appendingPathComponent("项目群的聊天.md") + try Data("user's own note".utf8).write(to: existing) + let notes = try KnowledgeDelivery.deliver( + urls: [try source()], + folderPath: destination.path, + chatName: "项目群", + sceneName: nil + ) + XCTAssertEqual(notes, [destination.appendingPathComponent("项目群的聊天 2.md")]) + XCTAssertEqual(try String(contentsOf: existing, encoding: .utf8), "user's own note") + } + + /// The Obsidian entry keeps its own landing place: the note goes into the + /// 微信流 subfolder (or the one the user named), not the vault root. + func testObsidianDeliveryKeepsItsSubfolder() throws { + let vault = root.appendingPathComponent("Vault", isDirectory: true) + try fm.createDirectory(at: vault, withIntermediateDirectories: true) + let notes = try KnowledgeDelivery.deliver( + urls: [try source()], + vaultPath: vault.path, + subfolder: "参考/微信流", + chatName: "项目群", + sceneName: nil + ) + XCTAssertEqual(notes, [vault.appendingPathComponent("参考/微信流/项目群的聊天.md")]) + XCTAssertEqual(try fm.contentsOfDirectory(atPath: vault.path), ["参考"]) + XCTAssertTrue(fm.fileExists(atPath: vault.appendingPathComponent("参考/微信流/附件/聊天记录.zip").path)) + } +} + +#if KNOWLEDGE_DELIVERY_TEST_MAIN +@main +enum KnowledgeDeliveryTestRunner { + static func main() { + let suite = KnowledgeDeliveryTests.defaultTestSuite + suite.run() + guard let run = suite.testRun, run.executionCount > 0, run.hasSucceeded else { exit(1) } + } +} +#endif diff --git a/Tests/WeChatBridgeCoreTests/ShareActionTests.swift b/Tests/WeChatBridgeCoreTests/ShareActionTests.swift index 6414b1c..81cc043 100644 --- a/Tests/WeChatBridgeCoreTests/ShareActionTests.swift +++ b/Tests/WeChatBridgeCoreTests/ShareActionTests.swift @@ -16,7 +16,7 @@ final class ShareActionTests: XCTestCase { } func testOnlyForwardingActionsNameATargetApp() { - XCTAssertEqual(ShareAction.allCases.count, 10) + XCTAssertEqual(ShareAction.allCases.count, 11) XCTAssertEqual(ShareAction.codex.targetBundleIdentifier, "com.openai.codex") XCTAssertEqual(ShareAction.claude.targetBundleIdentifier, "com.anthropic.claudefordesktop") XCTAssertEqual(ShareAction.doubao.targetBundleIdentifier, "com.bot.pc.doubao") @@ -26,11 +26,31 @@ final class ShareActionTests: XCTestCase { XCTAssertEqual(ShareAction.deepSeekHarness.targetBundleIdentifier, "com.deepseek.dsh") XCTAssertEqual(ShareAction.obsidian.targetBundleIdentifier, "md.obsidian") XCTAssertNil(ShareAction.clipboard.targetBundleIdentifier) + // 「沉淀到文件夹」 delivers to a path, not an app. + XCTAssertNil(ShareAction.folder.targetBundleIdentifier) // 「发送到自定义」 names no app of its own: the one it goes to is chosen // in the share panel and travels in the intent. XCTAssertNil(ShareAction.custom.targetBundleIdentifier) } + /// The folder entry travels in manifests and intents like every other + /// action: a build that cannot decode `folder` would drop the request, so + /// the round trip is pinned here. + func testFolderActionRoundTripsThroughCodable() throws { + let data = try JSONEncoder().encode(ShareAction.folder) + XCTAssertEqual(String(data: data, encoding: .utf8), "\"folder\"") + XCTAssertEqual(try JSONDecoder().decode(ShareAction.self, from: data), .folder) + + let manifest = BatchManifest( + batchID: UUID(), + createdAt: Date(), + items: [], + action: .folder + ) + let manifestData = try BatchManifest.encoder().encode(manifest) + XCTAssertEqual(try BatchManifest.decoder().decode(BatchManifest.self, from: manifestData).action, .folder) + } + func testAnIntentIsReadableExactlyOnce() throws { let batchID = try commitBatch(intent: BatchIntent(action: .codex, requestedAt: Date())) @@ -64,7 +84,7 @@ final class ShareActionTests: XCTestCase { XCTAssertFalse(ShareAction.clipboard.needsIntent) for action in [ ShareAction.codex, .claude, .doubao, .qwen, .workBuddy, - .weSight, .deepSeekHarness, .obsidian, .custom, + .weSight, .deepSeekHarness, .obsidian, .folder, .custom, ] { XCTAssertTrue(action.needsIntent, "\(action)") } From c3dd47990a1c9549ebb64182c1fc70a8cd7d4c41 Mon Sep 17 00:00:00 2001 From: zhangweijian Date: Thu, 1 Oct 2026 17:36:10 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E6=B2=89=E6=B7=80=E5=88=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=B9=E8=A1=A5=E5=AD=90=E6=96=87=E4=BB=B6=E5=A4=B9?= =?UTF-8?q?=E5=B1=82=EF=BC=8C=E8=90=BD=E7=9B=98=E5=B1=82=E7=BA=A7=E4=B8=8E?= =?UTF-8?q?=20Obsidian=20=E5=85=A5=E5=8F=A3=E5=90=8C=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/WeChatBridgeApp/ActionRunner.swift | 1 + .../WeChatBridgeApp/KnowledgeDelivery.swift | 22 +++++++--- Sources/WeChatBridgeApp/Preferences.swift | 9 ++++ .../Views/Settings/EntriesPane.swift | 20 ++++++++- Tests/KnowledgeDeliveryTests.swift | 42 ++++++++++++++----- 5 files changed, 76 insertions(+), 18 deletions(-) diff --git a/Sources/WeChatBridgeApp/ActionRunner.swift b/Sources/WeChatBridgeApp/ActionRunner.swift index 7f1e5da..5ef0ec9 100644 --- a/Sources/WeChatBridgeApp/ActionRunner.swift +++ b/Sources/WeChatBridgeApp/ActionRunner.swift @@ -270,6 +270,7 @@ final class ActionRunner { let notes = try KnowledgeDelivery.deliver( urls: arrival.urls, folderPath: preferences.folderDeliveryPath, + subfolder: preferences.folderDeliverySubfolder, chatName: context.groupName, sceneName: context.scene?.name ) diff --git a/Sources/WeChatBridgeApp/KnowledgeDelivery.swift b/Sources/WeChatBridgeApp/KnowledgeDelivery.swift index 7e3b379..4ba2cad 100644 --- a/Sources/WeChatBridgeApp/KnowledgeDelivery.swift +++ b/Sources/WeChatBridgeApp/KnowledgeDelivery.swift @@ -6,9 +6,9 @@ import Foundation /// converter able to rebuild the note without asking WeChat again. /// /// Two destinations share this assembly: 「沉淀到 Obsidian」 lands the note in -/// a subfolder of the user's vault, and 「沉淀到文件夹」 lands it directly in -/// the folder the user picked. The note shape — `聊天名.md` plus `附件/` — is -/// the same either way. +/// a subfolder of the user's vault, and 「沉淀到文件夹」 lands it in a subfolder +/// of the folder the user picked — the same shape either way. The note shape — +/// `聊天名.md` plus `附件/` — is the same too. enum KnowledgeDelivery { enum Failure: LocalizedError { case notConfigured @@ -45,18 +45,28 @@ enum KnowledgeDelivery { ) } - /// The folder entry: the note and its `附件/` land directly in the folder, - /// with no subfolder of WeChatBridge's own between them. + /// The folder entry: the folder is what the user configured, and the note + /// goes into a `微信流` (or named) subfolder of it — the same landing + /// shape the Obsidian entry writes inside its vault. @discardableResult static func deliver( urls: [URL], folderPath: String, + subfolder: String, chatName: String?, sceneName: String? ) throws -> [URL] { let folder = URL(fileURLWithPath: folderPath, isDirectory: true) try FolderDelivery.validateFolder(folder) - return try write(urls: urls, root: folder, chatName: chatName, sceneName: sceneName) + + let subfolderPath = DisplayName.subfolderPath(subfolder) + let folderName = subfolderPath.isEmpty ? "微信流" : subfolderPath + return try write( + urls: urls, + root: folder.appendingPathComponent(folderName, isDirectory: true), + chatName: chatName, + sceneName: sceneName + ) } /// The shared assembly: one `聊天名.md` (merged when a same-named note is diff --git a/Sources/WeChatBridgeApp/Preferences.swift b/Sources/WeChatBridgeApp/Preferences.swift index d014575..33bb7fc 100644 --- a/Sources/WeChatBridgeApp/Preferences.swift +++ b/Sources/WeChatBridgeApp/Preferences.swift @@ -31,6 +31,7 @@ final class Preferences: ObservableObject { static let obsidianVaultPath = "com.xiangming.wechatbridge.obsidianVaultPath" static let obsidianSubfolder = "com.xiangming.wechatbridge.obsidianSubfolder" static let folderDeliveryPath = "com.xiangming.wechatbridge.folderDeliveryPath" + static let folderDeliverySubfolder = "com.xiangming.wechatbridge.folderDeliverySubfolder" } /// A week: long enough that last Friday's chat export is still there on @@ -77,6 +78,7 @@ final class Preferences: ObservableObject { obsidianSubfolder = defaults.string(forKey: Key.obsidianSubfolder) ?? "微信流" folderDeliveryPath = defaults.string(forKey: Key.folderDeliveryPath) ?? Self.defaultFolderDeliveryPath + folderDeliverySubfolder = defaults.string(forKey: Key.folderDeliverySubfolder) ?? "" } /// Set only by finishing the guide. Closing its window half way through is @@ -154,6 +156,13 @@ final class Preferences: ObservableObject { didSet { defaults.set(folderDeliveryPath, forKey: Key.folderDeliveryPath) } } + /// The subfolder of the destination folder the note lands in, allowed to + /// name several levels with `/`. Empty means the `微信流` default — the + /// same fallback the Obsidian entry applies inside the vault. + @Published var folderDeliverySubfolder: String { + didSet { defaults.set(folderDeliverySubfolder, forKey: Key.folderDeliverySubfolder) } + } + /// Global scene shortcuts choose for the next share, not forever. A stale /// choice is ignored rather than silently changing a later forward. func selectSceneForNextForward(id: String) { diff --git a/Sources/WeChatBridgeApp/Views/Settings/EntriesPane.swift b/Sources/WeChatBridgeApp/Views/Settings/EntriesPane.swift index 50d4a85..078d2ec 100644 --- a/Sources/WeChatBridgeApp/Views/Settings/EntriesPane.swift +++ b/Sources/WeChatBridgeApp/Views/Settings/EntriesPane.swift @@ -128,8 +128,8 @@ struct EntriesPane: View { ) } - /// One row, not two: the folder entry has no subfolder of its own — the - /// note and `附件/` land directly in the chosen folder. + /// Two rows, the same pair the Obsidian entry shows: where the folder is, + /// then the subfolder of it the note lands in (`微信流` unless named). private var folderSettings: some View { VStack(spacing: 0) { SettingRow( @@ -152,6 +152,22 @@ struct EntriesPane: View { } } .padding(Space.m) + + Rectangle() + .fill(Theme.stroke) + .frame(height: Stroke.hairline) + .padding(.leading, Space.m) + + SettingRow( + title: L10n.text("子文件夹"), + detail: L10n.text("聊天 Markdown 与原始 ZIP 会写入这个目录。"), + alignment: .center + ) { + TextField(L10n.text("子文件夹"), text: $preferences.folderDeliverySubfolder) + .textFieldStyle(SettingsTextFieldStyle()) + .frame(width: SettingsControlMetrics.actionWidth) + } + .padding(Space.m) } .background(Theme.surface, in: RoundedRectangle(cornerRadius: Radius.card, style: .continuous)) .overlay( diff --git a/Tests/KnowledgeDeliveryTests.swift b/Tests/KnowledgeDeliveryTests.swift index e5f989f..1e92b1b 100644 --- a/Tests/KnowledgeDeliveryTests.swift +++ b/Tests/KnowledgeDeliveryTests.swift @@ -39,18 +39,22 @@ final class KnowledgeDeliveryTests: XCTestCase { return url } - /// The folder entry's shape: the note and its 附件/ sit directly in the - /// chosen folder, with no 微信流 subfolder of WeChatBridge's own. - func testFolderDeliveryWritesNoteAndAttachmentsIntoTheChosenFolder() throws { + /// The folder entry's shape, the same as the Obsidian entry's: the note + /// and its 附件/ sit in a 微信流 subfolder of the chosen folder, never + /// directly in the folder itself. + func testFolderDeliveryWritesNoteAndAttachmentsIntoAWeChatFlowSubfolder() throws { let notes = try KnowledgeDelivery.deliver( urls: [try source()], folderPath: destination.path, + subfolder: "", chatName: "项目群", sceneName: nil ) - let note = destination.appendingPathComponent("项目群的聊天.md") + let flow = destination.appendingPathComponent("微信流", isDirectory: true) + let note = flow.appendingPathComponent("项目群的聊天.md") XCTAssertEqual(notes, [note]) - XCTAssertEqual(try fm.contentsOfDirectory(atPath: destination.path).sorted(), ["附件", "项目群的聊天.md"]) + XCTAssertEqual(try fm.contentsOfDirectory(atPath: destination.path), ["微信流"]) + XCTAssertEqual(try fm.contentsOfDirectory(atPath: flow.path).sorted(), ["附件", "项目群的聊天.md"]) let markdown = try String(contentsOf: note, encoding: .utf8) XCTAssertTrue(markdown.contains("# 项目群的聊天")) @@ -58,17 +62,17 @@ final class KnowledgeDeliveryTests: XCTestCase { XCTAssertTrue(markdown.contains("[[附件/聊天记录.zip]]")) XCTAssertTrue(markdown.contains("![[附件/photo.png]]")) - let attachments = try fm.contentsOfDirectory(atPath: destination.appendingPathComponent("附件").path).sorted() + let attachments = try fm.contentsOfDirectory(atPath: flow.appendingPathComponent("附件").path).sorted() XCTAssertEqual(attachments, ["photo.png", "聊天记录.zip"]) } /// A folder the user has not got (deleted, unmounted) is a failure, not a - /// silently recreated directory. + /// silently recreated directory — the subfolder never brings it back. func testFolderDeliveryRejectsAMissingDestinationFolder() throws { let archive = try source() let missing = root.appendingPathComponent("gone", isDirectory: true) XCTAssertThrowsError( - try KnowledgeDelivery.deliver(urls: [archive], folderPath: missing.path, chatName: nil, sceneName: nil) + try KnowledgeDelivery.deliver(urls: [archive], folderPath: missing.path, subfolder: "", chatName: nil, sceneName: nil) ) XCTAssertFalse(fm.fileExists(atPath: missing.path)) XCTAssertEqual(try fm.contentsOfDirectory(atPath: destination.path), []) @@ -77,18 +81,36 @@ final class KnowledgeDeliveryTests: XCTestCase { /// A same-named note WeChatBridge did not write is never touched; the new /// note takes the next free number instead. func testFolderDeliveryNumbersANoteItDidNotWrite() throws { - let existing = destination.appendingPathComponent("项目群的聊天.md") + let flow = destination.appendingPathComponent("微信流", isDirectory: true) + let existing = flow.appendingPathComponent("项目群的聊天.md") + try fm.createDirectory(at: flow, withIntermediateDirectories: true) try Data("user's own note".utf8).write(to: existing) let notes = try KnowledgeDelivery.deliver( urls: [try source()], folderPath: destination.path, + subfolder: "", chatName: "项目群", sceneName: nil ) - XCTAssertEqual(notes, [destination.appendingPathComponent("项目群的聊天 2.md")]) + XCTAssertEqual(notes, [flow.appendingPathComponent("项目群的聊天 2.md")]) XCTAssertEqual(try String(contentsOf: existing, encoding: .utf8), "user's own note") } + /// A subfolder the user named is honoured level by level, the same way the + /// Obsidian entry honours one inside the vault. + func testFolderDeliveryHonoursACustomSubfolder() throws { + let notes = try KnowledgeDelivery.deliver( + urls: [try source()], + folderPath: destination.path, + subfolder: "参考/微信流", + chatName: "项目群", + sceneName: nil + ) + XCTAssertEqual(notes, [destination.appendingPathComponent("参考/微信流/项目群的聊天.md")]) + XCTAssertEqual(try fm.contentsOfDirectory(atPath: destination.path), ["参考"]) + XCTAssertTrue(fm.fileExists(atPath: destination.appendingPathComponent("参考/微信流/附件/聊天记录.zip").path)) + } + /// The Obsidian entry keeps its own landing place: the note goes into the /// 微信流 subfolder (or the one the user named), not the vault root. func testObsidianDeliveryKeepsItsSubfolder() throws { From 456cf13f47cacfd78450d24ceb7022fe60ea0879 Mon Sep 17 00:00:00 2001 From: zhangweijian Date: Thu, 1 Oct 2026 19:21:59 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=E6=B2=89=E6=B7=80=E5=88=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=B9=E6=88=90=E5=8A=9F=E5=8F=8D=E9=A6=88=E6=94=B9?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E9=80=9A=E7=9F=A5=E2=80=94=E2=80=94=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=8A=95=E9=80=92=E4=B8=8B=E8=87=AA=E7=BB=98=E8=83=B6?= =?UTF-8?q?=E5=9B=8A=E4=B8=8D=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分享扩展触发投递时 app 必然在后台,macOS 不渲染后台 app 的自绘窗口 (orderFrontRegardless 与强激活均实证无效);通知中心独立进程渲染, 不受前后台影响,且原生支持「在访达中显示」操作按钮。失败路径的 Toast 反馈保留不动。 --- Sources/WeChatBridgeApp/ActionRunner.swift | 18 +-- .../Feedback/DeliveryNotifier.swift | 133 ++++++++++++++++++ Sources/WeChatBridgeApp/Feedback/Toast.swift | 3 +- Tests/DeliveryNotifierTests.swift | 64 +++++++++ 4 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 Sources/WeChatBridgeApp/Feedback/DeliveryNotifier.swift create mode 100644 Tests/DeliveryNotifierTests.swift diff --git a/Sources/WeChatBridgeApp/ActionRunner.swift b/Sources/WeChatBridgeApp/ActionRunner.swift index 5ef0ec9..0b15ebf 100644 --- a/Sources/WeChatBridgeApp/ActionRunner.swift +++ b/Sources/WeChatBridgeApp/ActionRunner.swift @@ -14,7 +14,9 @@ import Foundation /// the user — the share sheet's own 「已复制」 or the pasted files in the target /// app — so the only thing left to report is a failure. 「沉淀到文件夹」 is the /// one exception: nothing appears on screen when a note lands in a folder, so -/// it gets the one success toast, with a way to jump to the file. +/// it gets the one success report — a system notification, which shows even +/// while this app sits in the background where a self-drawn capsule cannot +/// (see `DeliveryNotifier`). @MainActor final class ActionRunner { /// Opens 设置 → 入口, which is the only place the 「发送到自定义」 list can be @@ -33,6 +35,7 @@ final class ActionRunner { private let sceneCoordinator: SceneCoordinator private let skills: SkillLibrary private let toast = ToastPresenter() + private let notifier = DeliveryNotifier() private let picker = TargetPickerPanel() /// The forward currently in flight, so the next one waits for it. /// @@ -261,7 +264,10 @@ final class ActionRunner { /// 「沉淀到文件夹」 is not a paste destination either: the archive becomes a /// note in the folder the user picked. Unlike every other destination, /// nothing appears on screen when it works, so this is the one delivery - /// that reports success — a capsule with a way to the file (Issue #19). + /// that reports success — a notification with a way to the file (Issue + /// #19). A notification rather than a capsule because the share extension + /// puts this app in the background for every delivery, where a self-drawn + /// window never appears. private func deliverToFolder( _ arrival: ArrivedBatch, context: SceneCoordinator.Selection @@ -280,13 +286,7 @@ final class ActionRunner { fileURLWithPath: preferences.folderDeliveryPath, isDirectory: true ) - toast.show( - L10n.format("已保存到 %@", folder.lastPathComponent), - symbol: "checkmark.circle.fill", - action: ToastPresenter.Action(title: L10n.text("在访达中显示")) { - NSWorkspace.shared.activateFileViewerSelecting(notes) - } - ) + await notifier.notify(savedTo: folder.lastPathComponent, revealing: notes) } catch { fallBack( arrival, diff --git a/Sources/WeChatBridgeApp/Feedback/DeliveryNotifier.swift b/Sources/WeChatBridgeApp/Feedback/DeliveryNotifier.swift new file mode 100644 index 0000000..86cd005 --- /dev/null +++ b/Sources/WeChatBridgeApp/Feedback/DeliveryNotifier.swift @@ -0,0 +1,133 @@ +import AppKit +import UserNotifications +import WeChatBridgeCore + +/// The success report for 「沉淀到文件夹」: a system notification, not a +/// corner capsule. +/// +/// A folder delivery always happens with WeChatBridge in the background — the +/// share extension triggered it — and macOS will not render a background app's +/// own windows: the capsule's `show` ran, its frame was right, +/// `orderFrontRegardless` and a hard `NSApp.activate(true)` were tried, and +/// nothing appeared. The notification centre is a separate process and shows +/// the banner whatever owns the foreground; its action buttons give the +/// 「在访达中显示」 jump the toast used to carry. +/// +/// Failures keep the toast (`ToastPresenter`): they usually arrive while the +/// user is working in WeChatBridge's own settings window, where a self-drawn +/// capsule does appear. +@MainActor +final class DeliveryNotifier: NSObject, UNUserNotificationCenterDelegate { + nonisolated static let categoryIdentifier = "folderDelivery" + nonisolated static let revealActionIdentifier = "revealInFinder" + + /// The userInfo key that carries the note paths an action replays. Plain + /// path strings, not anything richer: userInfo crosses into the + /// notification centre's own storage and back, and strings make that round + /// trip lossless. + private nonisolated static let revealPathsKey = "revealPaths" + + private var isConfigured = false + + /// `folderName` lands in the title; `notes` come back when the action is + /// clicked. Asking for permission here — not at launch — means the prompt + /// appears at the first delivery, when the user has a reason to answer it; + /// `requestAuthorization` only prompts while the choice is undecided, so + /// this is silent from the second delivery on. A refusal also returns + /// immediately and quietly: the delivery itself succeeded, and the notice + /// was a bonus — not something to escalate into a failure report. + /// + /// The authorization prompt can hold this call until the user answers it, + /// which holds the delivery queue behind it — harmless, because the modal + /// prompt has the user's attention anyway, and it never appears again once + /// answered. + func notify(savedTo folderName: String, revealing notes: [URL]) async { + configureOnce() + + let center = UNUserNotificationCenter.current() + guard (try? await center.requestAuthorization(options: [.alert])) == true else { return } + + let content = UNMutableNotificationContent() + // No sound asked for, and only `.alert` in the authorization: this is + // an "it worked" banner, not an alarm; its visual presence is the + // whole message. + content.title = L10n.format("已保存到 %@", folderName) + content.categoryIdentifier = Self.categoryIdentifier + content.userInfo = Self.userInfo(forRevealing: notes) + try? await center.add( + UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + ) + } + + // MARK: - Reveal round trip + + /// The encode side of the only userInfo this notifier writes. + nonisolated static func userInfo(forRevealing notes: [URL]) -> [AnyHashable: Any] { + [revealPathsKey: notes.map(\.path)] + } + + /// The decode side: anything unexpected — a missing key, a foreign type — + /// decodes to nothing rather than crashing in a notification callback. + nonisolated static func revealPaths(from userInfo: [AnyHashable: Any]) -> [URL] { + (userInfo[revealPathsKey] as? [String])?.map { URL(fileURLWithPath: $0) } ?? [] + } + + // MARK: - UNUserNotificationCenterDelegate + + /// The category and the delegate both land here, once, on the way to the + /// first notification — there is nothing to configure before the first + /// delivery, and this object lives as long as the app does. + private func configureOnce() { + guard !isConfigured else { return } + isConfigured = true + let center = UNUserNotificationCenter.current() + center.setNotificationCategories([ + UNNotificationCategory( + identifier: Self.categoryIdentifier, + actions: [ + UNNotificationAction( + identifier: Self.revealActionIdentifier, + title: L10n.text("在访达中显示") + ) + ], + intentIdentifiers: [] + ) + ]) + // The centre holds its delegate weakly; `ActionRunner` owns this + // notifier for the app's lifetime, so the callback has somewhere to + // land however much later the button is clicked. + center.delegate = self + } + + /// Both the button and a bare click on the banner mean "take me there"; + /// a dismissal does not. The callback can arrive off the main thread. + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + guard response.actionIdentifier == Self.revealActionIdentifier + || response.actionIdentifier == UNNotificationDefaultActionIdentifier + else { + completionHandler() + return + } + let notes = Self.revealPaths(from: response.notification.request.content.userInfo) + Task { @MainActor in + if !notes.isEmpty { + NSWorkspace.shared.activateFileViewerSelecting(notes) + } + completionHandler() + } + } + + /// A foreground app gets no banner unless it opts in — and a delivery can + /// be replayed from WeChatBridge's own 记录 window while it is frontmost. + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list]) + } +} diff --git a/Sources/WeChatBridgeApp/Feedback/Toast.swift b/Sources/WeChatBridgeApp/Feedback/Toast.swift index 48908d7..ca3e5f6 100644 --- a/Sources/WeChatBridgeApp/Feedback/Toast.swift +++ b/Sources/WeChatBridgeApp/Feedback/Toast.swift @@ -15,7 +15,8 @@ import SwiftUI /// 「已发给 Claude」 was one more thing to dismiss over a paste the user had /// just watched happen. One exception since 「沉淀到文件夹」: a note that lands /// in a folder shows nothing anywhere, so that delivery reports its own success -/// (see `ActionRunner.deliverToFolder`). +/// — through a system notification (`DeliveryNotifier`), which is visible even +/// while this app sits in the background, where the capsule was not. /// /// It used to appear next to the pointer, which put it over whatever the user /// was about to click and moved it somewhere new every time. One fixed corner diff --git a/Tests/DeliveryNotifierTests.swift b/Tests/DeliveryNotifierTests.swift new file mode 100644 index 0000000..ab29aef --- /dev/null +++ b/Tests/DeliveryNotifierTests.swift @@ -0,0 +1,64 @@ +// Standalone app-helper tests; no app launch or Package.swift change needed. +// After `swift build`: +// test_output="$(mktemp -d /tmp/wechatbridge-notifier-tests.XXXXXX)" +// test_sdk="$(xcode-select -p)/Platforms/MacOSX.platform/Developer" +// mise exec -- swiftc -swift-version 5 -D DELIVERY_NOTIFIER_TEST_MAIN \ +// -I .build/debug -F "$test_sdk/Library/Frameworks" -L "$test_sdk/usr/lib" \ +// -Xlinker -rpath -Xlinker "$test_sdk/Library/Frameworks" \ +// -Xlinker -rpath -Xlinker "$test_sdk/usr/lib" \ +// .build/debug/WeChatBridgeCore.o -lz \ +// Sources/WeChatBridgeApp/Feedback/DeliveryNotifier.swift Tests/DeliveryNotifierTests.swift \ +// -o "$test_output/runner" +// "$test_output/runner" +// +// What is not here: `notify`, the authorization prompt, the category +// registration and the action callback are the notification centre's system +// behaviour — they only run inside a bundled, signed app, not a bare test +// runner. The part this file can pin down is the round trip the callback +// depends on: the paths written into userInfo must come back out unchanged, +// whatever characters the folder names carry. +import WeChatBridgeCore +import XCTest + +final class DeliveryNotifierTests: XCTestCase { + func testRoundTripPreservesPathsOfEveryShape() { + let notes = [ + URL(fileURLWithPath: "/Users/甲/沉淀/微信群 聊天记录.md"), + URL(fileURLWithPath: "/tmp/emoji-🗂- attachment (2).zip"), + URL(fileURLWithPath: "/tmp/wxbridge/a'b\"c/hyphen-name.txt"), + ] + XCTAssertEqual(DeliveryNotifier.revealPaths(from: DeliveryNotifier.userInfo(forRevealing: notes)), notes) + } + + func testEmptyDeliveryRoundTripsToEmpty() { + XCTAssertEqual(DeliveryNotifier.revealPaths(from: DeliveryNotifier.userInfo(forRevealing: [])), []) + } + + func testForeignOrMissingUserInfoDecodesToNothing() { + XCTAssertEqual(DeliveryNotifier.revealPaths(from: [:]), []) + XCTAssertEqual(DeliveryNotifier.revealPaths(from: ["unrelated": "value"]), []) + // A wrong type under the right key — say a future change puts a single + // path there — must not crash a notification callback. + XCTAssertEqual(DeliveryNotifier.revealPaths(from: ["revealPaths": "/a/single/path.md"]), []) + } + + func testIdentifiersAreStableAndDistinct() { + // The category and its action are matched by string on both sides of + // the userInfo round trip; equal identifiers would make the plain + // click indistinguishable from the button. + XCTAssertFalse(DeliveryNotifier.categoryIdentifier.isEmpty) + XCTAssertFalse(DeliveryNotifier.revealActionIdentifier.isEmpty) + XCTAssertNotEqual(DeliveryNotifier.categoryIdentifier, DeliveryNotifier.revealActionIdentifier) + } +} + +#if DELIVERY_NOTIFIER_TEST_MAIN +@main +enum DeliveryNotifierTestRunner { + static func main() { + let suite = DeliveryNotifierTests.defaultTestSuite + suite.run() + guard let run = suite.testRun, run.executionCount > 0, run.hasSucceeded else { exit(1) } + } +} +#endif From df41135437e7474ac0d64aeaab51b02248ab3496 Mon Sep 17 00:00:00 2001 From: freestylefly <2689458656@qq.com> Date: Fri, 2 Oct 2026 14:28:22 +0800 Subject: [PATCH 4/5] fix: register delivery notification actions at startup and run helper tests in CI --- Package.swift | 8 +++ Sources/WeChatBridgeApp/ActionRunner.swift | 4 ++ Sources/WeChatBridgeApp/App.swift | 1 + .../Feedback/DeliveryNotifier.swift | 34 +++++++--- Tests/DeliveryNotifierTests.swift | 62 +++++++++++-------- Tests/KnowledgeDeliveryTests.swift | 25 +------- 6 files changed, 76 insertions(+), 58 deletions(-) diff --git a/Package.swift b/Package.swift index ad54fef..626862b 100644 --- a/Package.swift +++ b/Package.swift @@ -56,6 +56,14 @@ let package = Package( ]) ] ), + .testTarget( + name: "WeChatBridgeDeliveryTests", + dependencies: ["WeChatBridgeApp", "WeChatBridgeCore"], + path: "Tests", + exclude: ["WeChatBridgeCoreTests"], + sources: ["DeliveryNotifierTests.swift", "KnowledgeDeliveryTests.swift"], + swiftSettings: [.swiftLanguageMode(.v5)] + ), .testTarget( name: "WeChatBridgeCoreTests", dependencies: ["WeChatBridgeCore"], diff --git a/Sources/WeChatBridgeApp/ActionRunner.swift b/Sources/WeChatBridgeApp/ActionRunner.swift index 0b15ebf..c62de9a 100644 --- a/Sources/WeChatBridgeApp/ActionRunner.swift +++ b/Sources/WeChatBridgeApp/ActionRunner.swift @@ -71,6 +71,10 @@ final class ActionRunner { pending = Task { await previous?.value; await operation() } } + func configureNotifications() { + notifier.configure() + } + func handle(_ arrival: ArrivedBatch) { switch arrival.action { case .clipboard: diff --git a/Sources/WeChatBridgeApp/App.swift b/Sources/WeChatBridgeApp/App.swift index 8fc73db..813772e 100644 --- a/Sources/WeChatBridgeApp/App.swift +++ b/Sources/WeChatBridgeApp/App.swift @@ -165,6 +165,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { runner.openEntries = { [weak self] in self?.openMainWindow(.entries) } runner.openSkills = { [weak self] in self?.openMainWindow(.skills) } actionRunner = runner + runner.configureNotifications() sceneShortcuts = SceneShortcutController(preferences: preferences) // One place decides what an arriving batch means. diff --git a/Sources/WeChatBridgeApp/Feedback/DeliveryNotifier.swift b/Sources/WeChatBridgeApp/Feedback/DeliveryNotifier.swift index 86cd005..14e52f3 100644 --- a/Sources/WeChatBridgeApp/Feedback/DeliveryNotifier.swift +++ b/Sources/WeChatBridgeApp/Feedback/DeliveryNotifier.swift @@ -2,6 +2,17 @@ import AppKit import UserNotifications import WeChatBridgeCore +/// The registration surface lets startup wiring be checked without asking the +/// system notification service from an unbundled test runner. +protocol DeliveryNotificationCenter: AnyObject { + var delegate: UNUserNotificationCenterDelegate? { get set } + func setNotificationCategories(_ categories: Set) + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool + func add(_ request: UNNotificationRequest) async throws +} + +extension UNUserNotificationCenter: DeliveryNotificationCenter {} + /// The success report for 「沉淀到文件夹」: a system notification, not a /// corner capsule. /// @@ -28,6 +39,15 @@ final class DeliveryNotifier: NSObject, UNUserNotificationCenterDelegate { private nonisolated static let revealPathsKey = "revealPaths" private var isConfigured = false + private let providedCenter: (any DeliveryNotificationCenter)? + private var center: any DeliveryNotificationCenter { + providedCenter ?? UNUserNotificationCenter.current() + } + + init(center: (any DeliveryNotificationCenter)? = nil) { + providedCenter = center + super.init() + } /// `folderName` lands in the title; `notes` come back when the action is /// clicked. Asking for permission here — not at launch — means the prompt @@ -42,9 +62,9 @@ final class DeliveryNotifier: NSObject, UNUserNotificationCenterDelegate { /// prompt has the user's attention anyway, and it never appears again once /// answered. func notify(savedTo folderName: String, revealing notes: [URL]) async { - configureOnce() + configure() - let center = UNUserNotificationCenter.current() + let center = self.center guard (try? await center.requestAuthorization(options: [.alert])) == true else { return } let content = UNMutableNotificationContent() @@ -74,13 +94,13 @@ final class DeliveryNotifier: NSObject, UNUserNotificationCenterDelegate { // MARK: - UNUserNotificationCenterDelegate - /// The category and the delegate both land here, once, on the way to the - /// first notification — there is nothing to configure before the first - /// delivery, and this object lives as long as the app does. - private func configureOnce() { + /// Called during application startup so actions on previously delivered + /// notifications work after a restart, before another note is saved. + /// Authorization is still requested only when a new delivery succeeds. + func configure() { guard !isConfigured else { return } isConfigured = true - let center = UNUserNotificationCenter.current() + let center = self.center center.setNotificationCategories([ UNNotificationCategory( identifier: Self.categoryIdentifier, diff --git a/Tests/DeliveryNotifierTests.swift b/Tests/DeliveryNotifierTests.swift index ab29aef..06605c8 100644 --- a/Tests/DeliveryNotifierTests.swift +++ b/Tests/DeliveryNotifierTests.swift @@ -1,26 +1,32 @@ -// Standalone app-helper tests; no app launch or Package.swift change needed. -// After `swift build`: -// test_output="$(mktemp -d /tmp/wechatbridge-notifier-tests.XXXXXX)" -// test_sdk="$(xcode-select -p)/Platforms/MacOSX.platform/Developer" -// mise exec -- swiftc -swift-version 5 -D DELIVERY_NOTIFIER_TEST_MAIN \ -// -I .build/debug -F "$test_sdk/Library/Frameworks" -L "$test_sdk/usr/lib" \ -// -Xlinker -rpath -Xlinker "$test_sdk/Library/Frameworks" \ -// -Xlinker -rpath -Xlinker "$test_sdk/usr/lib" \ -// .build/debug/WeChatBridgeCore.o -lz \ -// Sources/WeChatBridgeApp/Feedback/DeliveryNotifier.swift Tests/DeliveryNotifierTests.swift \ -// -o "$test_output/runner" -// "$test_output/runner" -// -// What is not here: `notify`, the authorization prompt, the category -// registration and the action callback are the notification centre's system -// behaviour — they only run inside a bundled, signed app, not a bare test -// runner. The part this file can pin down is the round trip the callback -// depends on: the paths written into userInfo must come back out unchanged, -// whatever characters the folder names carry. +@testable import WeChatBridgeApp import WeChatBridgeCore import XCTest +import UserNotifications final class DeliveryNotifierTests: XCTestCase { + @MainActor + func testStartupRegistersActionsBeforeAnyNewDelivery() { + let center = TestNotificationCenter() + let notifier = DeliveryNotifier(center: center) + notifier.configure() + XCTAssertTrue(center.delegate === notifier) + XCTAssertEqual(Set(center.categories.map(\.identifier)), [DeliveryNotifier.categoryIdentifier]) + XCTAssertEqual(center.categories.first?.actions.first?.identifier, DeliveryNotifier.revealActionIdentifier) + } + + @MainActor + func testRestartReplacesDelegateWithoutSendingANewNotification() { + let center = TestNotificationCenter() + var previous: DeliveryNotifier? = DeliveryNotifier(center: center) + previous?.configure() + previous = nil + XCTAssertNil(center.delegate) + let restarted = DeliveryNotifier(center: center) + restarted.configure() + XCTAssertTrue(center.delegate === restarted) + XCTAssertEqual(center.registrationCount, 2) + } + func testRoundTripPreservesPathsOfEveryShape() { let notes = [ URL(fileURLWithPath: "/Users/甲/沉淀/微信群 聊天记录.md"), @@ -52,13 +58,15 @@ final class DeliveryNotifierTests: XCTestCase { } } -#if DELIVERY_NOTIFIER_TEST_MAIN -@main -enum DeliveryNotifierTestRunner { - static func main() { - let suite = DeliveryNotifierTests.defaultTestSuite - suite.run() - guard let run = suite.testRun, run.executionCount > 0, run.hasSucceeded else { exit(1) } + +private final class TestNotificationCenter: DeliveryNotificationCenter { + weak var delegate: UNUserNotificationCenterDelegate? + private(set) var categories = Set() + private(set) var registrationCount = 0 + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { false } + func add(_ request: UNNotificationRequest) async throws {} + func setNotificationCategories(_ categories: Set) { + self.categories = categories + registrationCount += 1 } } -#endif diff --git a/Tests/KnowledgeDeliveryTests.swift b/Tests/KnowledgeDeliveryTests.swift index 1e92b1b..6883494 100644 --- a/Tests/KnowledgeDeliveryTests.swift +++ b/Tests/KnowledgeDeliveryTests.swift @@ -1,16 +1,4 @@ -// Standalone app-helper tests; no app launch or Package.swift change needed. -// After `swift build`: -// test_output="$(mktemp -d /tmp/wechatbridge-knowledge-tests.XXXXXX)" -// test_sdk="$(xcode-select -p)/Platforms/MacOSX.platform/Developer" -// mise exec -- swiftc -swift-version 5 -D KNOWLEDGE_DELIVERY_TEST_MAIN \ -// -I .build/debug -F "$test_sdk/Library/Frameworks" -L "$test_sdk/usr/lib" \ -// -Xlinker -rpath -Xlinker "$test_sdk/Library/Frameworks" \ -// -Xlinker -rpath -Xlinker "$test_sdk/usr/lib" \ -// .build/debug/WeChatBridgeCore.o -lz \ -// Sources/WeChatBridgeApp/FolderDelivery.swift Sources/WeChatBridgeApp/KnowledgeDelivery.swift \ -// Tests/KnowledgeDeliveryTests.swift \ -// -o "$test_output/runner" -// "$test_output/runner" +@testable import WeChatBridgeApp import Foundation import WeChatBridgeCore import XCTest @@ -128,14 +116,3 @@ final class KnowledgeDeliveryTests: XCTestCase { XCTAssertTrue(fm.fileExists(atPath: vault.appendingPathComponent("参考/微信流/附件/聊天记录.zip").path)) } } - -#if KNOWLEDGE_DELIVERY_TEST_MAIN -@main -enum KnowledgeDeliveryTestRunner { - static func main() { - let suite = KnowledgeDeliveryTests.defaultTestSuite - suite.run() - guard let run = suite.testRun, run.executionCount > 0, run.hasSucceeded else { exit(1) } - } -} -#endif From 0a3b8c75cf0faea89781e9d0aee79ec78cb7962f Mon Sep 17 00:00:00 2001 From: freestylefly <2689458656@qq.com> Date: Fri, 2 Oct 2026 14:29:01 +0800 Subject: [PATCH 5/5] test: use async entry points for main actor notification tests --- Tests/DeliveryNotifierTests.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Tests/DeliveryNotifierTests.swift b/Tests/DeliveryNotifierTests.swift index 06605c8..e6c5a87 100644 --- a/Tests/DeliveryNotifierTests.swift +++ b/Tests/DeliveryNotifierTests.swift @@ -5,7 +5,7 @@ import UserNotifications final class DeliveryNotifierTests: XCTestCase { @MainActor - func testStartupRegistersActionsBeforeAnyNewDelivery() { + func testStartupRegistersActionsBeforeAnyNewDelivery() async { let center = TestNotificationCenter() let notifier = DeliveryNotifier(center: center) notifier.configure() @@ -15,7 +15,7 @@ final class DeliveryNotifierTests: XCTestCase { } @MainActor - func testRestartReplacesDelegateWithoutSendingANewNotification() { + func testRestartReplacesDelegateWithoutSendingANewNotification() async { let center = TestNotificationCenter() var previous: DeliveryNotifier? = DeliveryNotifier(center: center) previous?.configure() @@ -58,7 +58,6 @@ final class DeliveryNotifierTests: XCTestCase { } } - private final class TestNotificationCenter: DeliveryNotificationCenter { weak var delegate: UNUserNotificationCenterDelegate? private(set) var categories = Set()