From 13daa9b24ea010e8b8e7858b3d1837e7d70b61f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E4=B8=AD?= <1053338060@qq.com> Date: Tue, 8 Sep 2026 19:02:14 +0800 Subject: [PATCH 1/4] fix(macos): restore share blocks and improve ZIP import presentation --- apps/macos/DEVELOPMENT.md | 12 ++- apps/macos/RELEASE_STATUS.md | 10 +- .../Sources/MathNotesMac/ContentView.swift | 83 +++++++++------ .../MathNotesMac/SharePackagePicker.swift | 100 ++++++++++++++++++ .../MathNotesMac/SidecarProtocol.swift | 2 + apps/macos/WINDOWS_SHARE_IMPORT_FOLLOWUP.md | 4 +- deploy/releases/macos-release.json | 8 +- deploy/releases/macos-v0.3.7.md | 41 +++++++ .../sessionSharePackageService.test.ts | 39 +++++++ .../src/session/sessionSharePackageService.ts | 15 ++- .../src/session/sharePackageArchive.ts | 20 +++- .../src/session/sharePackageBlocks.test.ts | 41 +++++++ .../src/session/sharePackageBlocks.ts | 63 +++++++++++ test_tool/macos_preview_smoke.swift | 16 ++- 14 files changed, 404 insertions(+), 50 deletions(-) create mode 100644 apps/macos/Sources/MathNotesMac/SharePackagePicker.swift create mode 100644 deploy/releases/macos-v0.3.7.md create mode 100644 packages/core-server/src/session/sharePackageBlocks.test.ts create mode 100644 packages/core-server/src/session/sharePackageBlocks.ts diff --git a/apps/macos/DEVELOPMENT.md b/apps/macos/DEVELOPMENT.md index 0289f08..f40c1f5 100644 --- a/apps/macos/DEVELOPMENT.md +++ b/apps/macos/DEVELOPMENT.md @@ -1,6 +1,6 @@ # 在 Mac 本地开发 MathNotes -当前发布:macOS 0.3.5,集成 PWA 0.3.4。[功能与延期验收状态](RELEASE_STATUS.md);跨 Windows 真实互联暂缓,日常本机使用无需 Windows 在线。 +当前发布:macOS 0.3.6;0.3.7 为待鼠标验收的候选版本。集成 PWA 0.3.4。[功能与延期验收状态](RELEASE_STATUS.md);跨 Windows 真实互联暂缓,日常本机使用无需 Windows 在线。 从仓库根目录执行下面的命令。本仓库已经包含 Mac 原生界面、共享 Core/协议、PWA、打包脚本和测试,不需要私有仓库,也不需要 Windows 电脑在线。 @@ -125,3 +125,13 @@ bash test_tool/diagnose_macos_connection.command Core 测试 `sessionSharePackageService.test.ts` 使用 Windows 同一个 `exportSessionMarkdown(... packageMode: "share")` 准备包,验证目录/ZIP 往返、图片字节、编码文件名、无覆盖导入与损坏包拒绝。`npm run test:macos:preview` 同时检查真实 Swift 客户端导入导出、导入图像渲染,以及源码编辑器无变化更新不触发 NSTextStorage 重排或焦点激活。 导入上限:总 256 MB、4096 个文件/目录、单文件 64 MB、Markdown 8 MB、路径深度 32。资源使用相对 assets 路径;拒绝符号链接、路径穿越、大小写/Unicode 冲突、加密 ZIP 和 CRC 不一致。分享的是合并正文与资源,不迁移原始块 ID、编辑锁、AI 历史或密钥。Windows 源码无需本轮修改;其现有分享导出为目录,Mac ZIP 解压后采用同一目录格式。 + +## 文件选择与块结构(0.3.7) + +分享导入由 Notebooks 的 `onDismiss` 回调启动异步 `NSOpenPanel`,禁止在关闭 SwiftUI sheet 的同一轮用 `runModal()` 嵌套事件循环。文件框挂到根视图持有的实际工作区窗口,不在关闭回调中依赖可能为空或指向其他窗口的 `NSApp.mainWindow/keyWindow`。文件筛选通过扩展名/目录判断,不将 ZIP、folder 和动态 Markdown UTI 混作一个类型过滤。主界面支持拖入 ZIP/分享目录;未保存草稿仍受保护。 + +`restoreShareBlocks` 使用 Markdown/数学解析器识别顶层导出注释,按原始字符位置切片,保留换行与内容。有效的块 ID 和来源恢复到新 Session;非法/重复 ID 重新编号,文件名始终由程序生成。连续元数据注释是 Windows 合并续接块的格式,缺少内部偏移,因此保留完整组并告知用户。没有元数据时不凭标题猜测原块边界。Mac 分享导出开启块元数据,保证普通块能够往返恢复;导出中不存在的锁定信息无法恢复。 + +核心回归:`sharePackageBlocks.test.ts` 与 `sessionSharePackageService.test.ts`;原生回归在 `test:macos:preview` 中验证两个 Windows 标记块、原图和 ZIP 再导入结构。人工验收需要实际点击文件框中的文件夹和 ZIP,取消并再次打开;仅通过路径输入框打开不能证明鼠标选择已修复。 + +ZIP 文件名:兼容未设置 UTF-8 标志的 macOS `ditto` 压缩包,优先有效 Unicode 路径扩展字段,其次严格 UTF-8,失败才回退旧 CP437;解码之后仍执行路径、重复项、大小与 CRC 校验。 diff --git a/apps/macos/RELEASE_STATUS.md b/apps/macos/RELEASE_STATUS.md index 3bdac46..43b3634 100644 --- a/apps/macos/RELEASE_STATUS.md +++ b/apps/macos/RELEASE_STATUS.md @@ -1,4 +1,4 @@ -# Mac 0.3.6 开发与验收状态 +# Mac 0.3.7 开发与验收状态 2026-09-08 发布收尾。Mac 的本机主要功能与 PWA 集成已实现;跨 Windows 的真实互联延期,不阻塞本机版发布。本文件用于从公开仓库恢复开发,不包含私人笔记、设备地址、令牌或本机绝对路径。 @@ -14,6 +14,14 @@ | PWA 0.3.4 | 单测、构建、浏览器上传/裁剪/成品预览 | 手机相机和后台策略 | | 跨主机副本与目录同步 | Windows PR #39 Core 跨版本 23 项及 Mac 正式视图 loopback 验收 | **真实 Mac ↔ Windows 尚待验证** | +## 0.3.7 文件选择与块结构(候选,尚未发布) + +**待完成:真实前台鼠标单击文件夹/ZIP 的验收。自动化运行时应用可能处于后台,键盘选择可用不能替代鼠标验收;在此项确认前不发布 0.3.7。** + +已通过 Core 425 项(另 2 项跳过,含分享导入/块恢复 16 项)、原生合同 99 项、UI 合同 205 项和真实预览回归。独立合成库中,Windows 格式 ZIP 与 macOS `ditto` 实际生成的 ZIP 导入后均显示 3 个独立源码块、原图和公式;这些流程使用键盘选择 ZIP;Mac 包曾因未设置 UTF-8 标志导致中文资源名乱码,修复后已用原失败包复测。 + +分享包选择器改为等待 Notebooks 完成关闭后异步呈现,使用扩展名/目录筛选,并增加主界面 ZIP/文件夹拖入。Windows 默认导出的顶层块标记可恢复独立块的顺序、ID 与来源;代码/公式/引用中的标记不作为边界。Mac 再导出也保留标记。无标记旧包保留全文;连续标记对应的合并续接块缺少切分位置时保留整组并提示,不能声称恢复不存在的锁定或历史元数据。 + ## 0.3.6 分享包与滚动稳定性 源码编辑器的无变化更新不再写入 NSTextStorage 字体,不触发源码激活;阅读位置改为停滚 500 ms 后保存,卸载/退出/失去前台时补存。原生回归检查重复更新不改变布局、光标与激活状态,真实字体修改仍能应用;个别设备的间歇闪烁仍需持续观察。 diff --git a/apps/macos/Sources/MathNotesMac/ContentView.swift b/apps/macos/Sources/MathNotesMac/ContentView.swift index b6eb8c9..ee13287 100644 --- a/apps/macos/Sources/MathNotesMac/ContentView.swift +++ b/apps/macos/Sources/MathNotesMac/ContentView.swift @@ -33,6 +33,12 @@ struct ContentView: View { @State private var sessionRefreshNonce = 0 @State private var isImportingSharePackage = false @State private var sharePackageError: String? + @State private var sharePackageNotice: String? + @State private var isChoosingSharePackage = false + @State private var pendingShareImport: ShareImportDestination? + @State private var sharePanelHost = SharePackagePanelHost() + + private struct ShareImportDestination { let notebookID: String? } var body: some View { NavigationSplitView { @@ -47,10 +53,15 @@ struct ContentView: View { } detail: { detailPane } + .background(SharePackagePanelAnchor(host: sharePanelHost).frame(width: 0, height: 0)) .tint(MathNotesTheme.accent) .frame(minWidth: 780, minHeight: 500) .dropDestination(for: URL.self) { urls, _ in - Task { await handleMarkdownDrop(urls) } + if urls.count == 1, let url = urls.first, SharePackagePicker.isDroppedPackage(url) { + Task { await importSharePackage(url, notebookID: selectedSession?.notebookId ?? selectedNotebookId) } + } else { + Task { await handleMarkdownDrop(urls) } + } return !urls.isEmpty } .task { @@ -104,7 +115,7 @@ struct ContentView: View { onOpenSettings: { openSettings() } ) } - .sheet(isPresented: $showNotebookBrowser) { + .sheet(isPresented: $showNotebookBrowser, onDismiss: presentPendingShareImport) { MacNotebookBrowser( notebooks: loadedNotebooks, sourceMode: sourceMode, @@ -123,17 +134,8 @@ struct ContentView: View { onClose: { showNotebookBrowser = false } ) } - .sheet(isPresented: $isImportingSharePackage) { - VStack(spacing: 14) { - ProgressView() - Text("正在导入正文和资源…") - } - .frame(width: 320, height: 140) - .interactiveDismissDisabled() - } - .alert("无法导入分享包", isPresented: Binding(get: { sharePackageError != nil }, set: { if !$0 { sharePackageError = nil } })) { - Button("好", role: .cancel) { sharePackageError = nil } - } message: { Text(sharePackageError ?? "") } + .modifier(SharePackageImportStatus(isImporting: isImportingSharePackage, + error: $sharePackageError, notice: $sharePackageNotice)) .sheet(isPresented: $showMarkdownArchive) { MarkdownArchiveSheet( documents: temporaryMarkdownDocuments, @@ -669,31 +671,46 @@ struct ContentView: View { } private func beginSharePackageImport(notebookID: String?) { - guard sourceMode == .local, !editingState.hasUnsavedSourceDrafts, !isImportingSharePackage else { return } + guard sourceMode == .local, !editingState.hasUnsavedSourceDrafts, + !isImportingSharePackage, !isChoosingSharePackage else { return } + pendingShareImport = ShareImportDestination(notebookID: notebookID) showNotebookBrowser = false + } + + private func presentPendingShareImport() { + guard let destination = pendingShareImport else { return } + pendingShareImport = nil + isChoosingSharePackage = true Task { @MainActor in - await Task.yield() - let panel = NSOpenPanel() - panel.title = "导入分享包" - panel.message = "选择 Windows/Mac 分享包文件夹、ZIP,或与 assets 文件夹相邻的 Markdown。" - panel.canChooseFiles = true - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.allowedContentTypes = [.zip, .folder, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "markdown") ?? .plainText] - guard panel.runModal() == .OK, let url = panel.url else { return } - let access = url.startAccessingSecurityScopedResource() - defer { if access { url.stopAccessingSecurityScopedResource() } } - isImportingSharePackage = true - defer { isImportingSharePackage = false } - do { - let result = try await supervisor.importSharePackage(packagePath: url.path, notebookId: notebookID) - selectedNotebookId = result.session.notebookId - openSession(result.session) - sessionRefreshNonce += 1 - } catch { sharePackageError = error.localizedDescription } + defer { isChoosingSharePackage = false } + guard let parent = sharePanelHost.window else { + sharePackageError = "当前笔记窗口不可用,请重新打开导入。" + return + } + guard let url = await SharePackagePicker.choose(parent: parent) else { return } + await importSharePackage(url, notebookID: destination.notebookID) } } + private func importSharePackage(_ url: URL, notebookID: String?) async { + guard !isImportingSharePackage else { return } + guard sourceMode == .local else { sharePackageError = "请切换到本机后导入分享包。"; return } + guard !editingState.hasUnsavedSourceDrafts else { sharePackageError = "请先保存当前修改,再导入分享包。"; return } + let access = url.startAccessingSecurityScopedResource() + defer { if access { url.stopAccessingSecurityScopedResource() } } + isImportingSharePackage = true + defer { isImportingSharePackage = false } + do { + let result = try await supervisor.importSharePackage(packagePath: url.path, notebookId: notebookID) + selectedNotebookId = result.session.notebookId + openSession(result.session) + sessionRefreshNonce += 1 + if (result.mergedContinuationGroups ?? 0) > 0 { + sharePackageNotice = "已恢复 \(result.blockCount ?? 1) 个内容块。原包中有续接块已被导出器合并,缺少内部切分位置;这部分保留完整正文,没有强行拆分公式或表格。" + } + } catch { sharePackageError = error.localizedDescription } + } + private func openSession(_ session: SessionCatalogItem) { selectedNotebookId = session.notebookId selectedSession = session diff --git a/apps/macos/Sources/MathNotesMac/SharePackagePicker.swift b/apps/macos/Sources/MathNotesMac/SharePackagePicker.swift new file mode 100644 index 0000000..715101d --- /dev/null +++ b/apps/macos/Sources/MathNotesMac/SharePackagePicker.swift @@ -0,0 +1,100 @@ +import AppKit +import SwiftUI + +/// Extension-based filtering also accepts ZIP files whose type metadata came +/// from Windows, without consulting Launch Services for each selection. +@MainActor +enum SharePackagePicker { + static func supports(_ url: URL) -> Bool { + guard url.isFileURL else { return false } + if ["zip", "md", "markdown"].contains(url.pathExtension.lowercased()) { return true } + return url.hasDirectoryPath || (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + } + + static func isDroppedPackage(_ url: URL) -> Bool { + url.isFileURL && (url.pathExtension.lowercased() == "zip" || url.hasDirectoryPath || + (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true) + } + + private final class Filter: NSObject, NSOpenSavePanelDelegate { + func panel(_ sender: Any, shouldEnable url: URL) -> Bool { SharePackagePicker.supports(url) } + } + + static func choose(parent: NSWindow) async -> URL? { + let panel = NSOpenPanel() + let filter = Filter() + panel.title = "导入分享包" + panel.prompt = "导入" + panel.message = "选择 ZIP、分享文件夹或 Markdown 正文。ZIP 无需先解压。" + panel.canChooseFiles = true + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.delegate = filter + // Do not combine folder and dynamically inferred Markdown UTIs. The + // delegate enables folders and supported extensions independently. + panel.allowedContentTypes = [] + panel.allowsOtherFileTypes = true + let response: NSApplication.ModalResponse = await withCheckedContinuation { continuation in + let complete: (NSApplication.ModalResponse) -> Void = { result in + panel.orderOut(nil) + continuation.resume(returning: result) + } + panel.beginSheetModal(for: parent, completionHandler: complete) + } + withExtendedLifetime(filter) {} + return response == .OK ? panel.url : nil + } +} + +struct SharePackageImportStatus: ViewModifier { + let isImporting: Bool + @Binding var error: String? + @Binding var notice: String? + + func body(content: Content) -> some View { + content.overlay { + if isImporting { + ZStack { + MathNotesTheme.canvas.opacity(0.65) + VStack(spacing: 14) { + ProgressView() + Text("正在导入正文、内容块和资源…") + } + .padding(28) + .background(MathNotesTheme.sidebar, in: RoundedRectangle(cornerRadius: 16)) + } + } + } + .alert("分享包已导入", isPresented: Binding(get: { notice != nil }, set: { if !$0 { notice = nil } })) { + Button("好", role: .cancel) { notice = nil } + } message: { Text(notice ?? "") } + .alert("无法导入分享包", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { + Button("好", role: .cancel) { error = nil } + } message: { Text(error ?? "") } + } +} + +/// Keep the initiating workspace window even while SwiftUI dismisses its sheet; +/// NSApp.keyWindow/mainWindow may be nil or refer to another workspace then. +@MainActor +final class SharePackagePanelHost { + weak var window: NSWindow? +} + +struct SharePackagePanelAnchor: NSViewRepresentable { + let host: SharePackagePanelHost + final class AnchorView: NSView { + var host: SharePackagePanelHost? + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + host?.window = window + } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + } + func makeNSView(context: Context) -> AnchorView { + let view = AnchorView() + view.host = host + return view + } + func updateNSView(_ view: AnchorView, context: Context) { host.window = view.window } +} diff --git a/apps/macos/Sources/MathNotesMac/SidecarProtocol.swift b/apps/macos/Sources/MathNotesMac/SidecarProtocol.swift index 51af0b8..0e42633 100644 --- a/apps/macos/Sources/MathNotesMac/SidecarProtocol.swift +++ b/apps/macos/Sources/MathNotesMac/SidecarProtocol.swift @@ -1045,4 +1045,6 @@ struct SharePackageImportResult: Decodable { let session: SessionCatalogItem let assetCount: Int let byteLength: Int + let blockCount: Int? + let mergedContinuationGroups: Int? } diff --git a/apps/macos/WINDOWS_SHARE_IMPORT_FOLLOWUP.md b/apps/macos/WINDOWS_SHARE_IMPORT_FOLLOWUP.md index 73c970c..5ef83bd 100644 --- a/apps/macos/WINDOWS_SHARE_IMPORT_FOLLOWUP.md +++ b/apps/macos/WINDOWS_SHARE_IMPORT_FOLLOWUP.md @@ -1,10 +1,10 @@ # Windows 后续:带资源分享包导入 -Mac 0.3.6 已支持导入 Windows 导出的分享目录,也可导出 ZIP;解压格式仍是根目录 `.md` 和 `assets/...`。资源 URL 使用已有 `encodeMarkdownAssetPath`,不要再次解码文件名。分享包只迁移合并正文及资源,不迁移锁定、块 ID、AI 历史或凭据。 +Mac 0.3.7 已支持导入 Windows 导出的分享目录,也可导出 ZIP;解压格式仍是根目录 `.md` 和 `assets/...`。资源 URL 使用已有 `encodeMarkdownAssetPath`,不要再次解码文件名。分享包迁移正文及资源;0.3.7 可从默认导出的顶层 block:id/source 注释恢复普通块的顺序、ID 和来源。锁定、AI 历史或凭据未包含在 Markdown 中;续接组缺少内部偏移时保留合并正文并提示。 Windows 当前 `src/core/exporter.ts` 调用的共享导出器可以继续沿用。后续在 Windows Codex 中完成: -1. 拉取包含 Mac 0.3.6 的 main,保留 Windows 正在开发的工作;不修改 Mac 或 Android 界面。 +1. 拉取包含 Mac 0.3.7 的 main,保留 Windows 正在开发的工作;不修改 Mac 或 Android 界面。 2. 在 Windows 文件选择/拖入入口添加分享目录、ZIP、Markdown + assets 导入。复用 `packages/core-server/src/session/sessionSharePackageService.ts` 的 `importPackage`,传入本地 root 和已有同一份 `SessionWriteCoordinator`,不要另外实例化写入屏障。 3. 通过可信 IPC 接收系统选择的路径,在主进程校验。目标 Notebook 可选;导入创建新 Session,不覆盖已有笔记。未保存草稿要保留。 4. 导入完成刷新目录并单击可打开,验证 Markdown 公式和图片实际渲染。错误展示清晰,不留下半份 Session。 diff --git a/deploy/releases/macos-release.json b/deploy/releases/macos-release.json index 513248a..9597eab 100644 --- a/deploy/releases/macos-release.json +++ b/deploy/releases/macos-release.json @@ -1,6 +1,6 @@ { - "tag": "macos-v0.3.6", - "title": "MathNotes macOS 0.3.6 Preview", - "notes": "deploy/releases/macos-v0.3.6.md", - "version": "0.3.6" + "tag": "macos-v0.3.7", + "title": "MathNotes macOS 0.3.7 Preview", + "notes": "deploy/releases/macos-v0.3.7.md", + "version": "0.3.7" } diff --git a/deploy/releases/macos-v0.3.7.md b/deploy/releases/macos-v0.3.7.md new file mode 100644 index 0000000..10119e4 --- /dev/null +++ b/deploy/releases/macos-v0.3.7.md @@ -0,0 +1,41 @@ +# MathNotes macOS 0.3.7 + +面向 Apple silicon Mac 的原生预览版,内置 PWA 0.3.4、笔记 Core 和 Node 运行时。日常本机笔记与手机连接不需要 Windows 电脑在线。本次只发布 Mac 安装包;已有 Windows、Android 和独立 PWA 发行版保持不变。 + +## 0.3.7 更新 + +- 分享包文件选择框改为异步打开,等待 Notebooks 窗口关闭后挂到发起操作的工作区窗口,消除关闭动画与阻塞式文件对话框重叠的调用方式。 +- 修复 Mac 压缩文件夹后 ZIP 未设置 UTF-8 标志时中文文件名乱码、图片缺失的问题。 +- 按文件扩展名与文件夹分别筛选,不依赖 ZIP/Markdown 的系统类型识别;支持直接拖入 ZIP 或分享文件夹到主界面。 +- 导入识别 Windows 默认导出的 `` 标记,恢复独立内容块、原顺序、有效块 ID 和来源;分隔注释不再作为正文显示。 +- Mac 分享 ZIP 导出保留块标记,支持分块结构再次导入;图片仍随包保留。 +- 代码示例、数学公式、引用里的类似标记不会被误拆。Windows 合并后的续接块未记录内部切分位置时,保留合并正文并明确提示,不强行拆坏公式或表格。 + +没有块标记的旧分享包只能保留为完整正文;导出文件未携带的锁定状态、编辑历史等信息无法从 Markdown 恢复。分享包格式仍是 Markdown + assets,正文 8 MB、单资源 64 MB、总包 256 MB。Windows GUI 的带资源分享包回导和真实跨机同步仍待后续实现/验收;本轮不修改 Windows/Android 界面。 + +## 已包含的主要功能 + +- 修复手机连接后台启动失败和局域网 HTTP 连接;首次连接手机时可自动准备缺少的接收位置。 +- 修复连续预览一直加载;支持公式渲染、Notebook/Session 悬停预览、单击打开和块标题原始图片/PDF 预览。 +- 紧凑侧栏和创建入口;AI 对话、阅读与展开按钮浮在正文上方,不再占据整行。 +- Notebook/Session 右键重命名、移至废纸篓与恢复。 +- 固定选区按原文字顺序拆成前段、固定块、后段,保持连续公式和表格的渲染。 +- AI 修改单个块或整个 Session 的候选方案、差异与修改摘要;固定块由程序保护,另外列出未应用的建议。 +- 识别结果可内嵌本次原图;源码/预览双向定位、阅读位置恢复、⌘F 查找替换与撤销。 +- 内置最新 PWA 0.3.4,保留上传、裁剪旋转、识别预览等功能,并修复手机视口的编辑面板裁切。 + +## 安装与验证范围 + +下载 `MathNotes-macOS-native-arm64-0.3.7-…-unsigned.zip`,核对同名 SHA-256 文件,解压后将 MathNotes.app 放入 Applications。应用支持声明为 macOS 14 或更新版本;当前实际本机验收为 Apple silicon、macOS 26。Intel Mac 和较早系统尚未实测。 + +此包使用 ad-hoc 签名,**没有 Apple Developer ID 签名或公证**。macOS 可能拦截首次打开;确认下载来源和校验和后,可按系统“隐私与安全性”页面提供的“仍要打开”流程处理。无需关闭 Gatekeeper、修改网络或删除钥匙串。更新前建议备份自己的笔记目录。 + +本机合成库已验证原生预览、保存、固定块、AI 候选应用、查找替换和目录恢复;PWA 浏览器上传流程使用受控识别服务验证。真实模型输出质量、不同手机的相机权限及后台行为仍依赖各自环境,不能把合成测试当作所有设备验收通过。 + +## 跨 Windows 同步:实验性,尚待真实跨机验证 + +按主机隔离的 Mac 本地副本、编辑队列、冲突保留、新建/改名/删除/恢复协议已实现。与 Windows PR #39 的实际 Core 代码完成了 23 项跨版本协议测试和 Mac 正式界面的隔离操作验收。 + +**Mac ↔ Windows 的真实跨机写入、断网重连和双方同时编辑尚待验证,本次不作为已验收功能宣传。** 当前发布以各电脑独立使用为主。相关代码和复测步骤保留在仓库,今后有需要可以继续开发;详见 [Mac 开发与验收状态](https://github.com/theincrediblewwz/MathNotes-OpenSource/blob/macos-v0.3.7/apps/macos/RELEASE_STATUS.md) 和 [目录协议](https://github.com/theincrediblewwz/MathNotes-OpenSource/blob/macos-v0.3.7/apps/macos/REMOTE_CATALOG_SYNC_V1.md)。 + +代码、构建版本及发行资产校验和以本 Release 对应的提交和随附文件为准。 diff --git a/packages/core-server/src/session/sessionSharePackageService.test.ts b/packages/core-server/src/session/sessionSharePackageService.test.ts index 70f977e..08fb60d 100644 --- a/packages/core-server/src/session/sessionSharePackageService.test.ts +++ b/packages/core-server/src/session/sessionSharePackageService.test.ts @@ -70,6 +70,45 @@ describe("Windows/Mac share packages", () => { await expect(service.exportZip({ ...imported.session, baseRevision: "0".repeat(64) })).rejects.toMatchObject({ code: "revision_conflict" }); }); + it("restores blocks from a Windows share folder compressed by an ordinary ZIP tool", async () => { + const dir = await folder("\n\n# 第一块\n\n![原图](" + encodeMarkdownAssetPath(asset) + ")\n\n\n\n第二块 $x^2$"); + const packed = await zip([["Windows 分享目录/课程.md", await readFile(join(dir, "课程.md"))], [`Windows 分享目录/${asset}`, photo], ["__MACOSX/._Windows 分享目录", Buffer.from("metadata")]]); + const imported = await service.importPackage({ packagePath: packed }); + expect(imported.blockCount).toBe(2); + const manifest = await readReadonlySessionManifest({ rootDir: root, ...imported.session }); + expect(manifest.blocks.map(b => b.id)).toEqual(["0012", "0018"]); + const exported = await service.exportZip({ ...imported.session, baseRevision: manifest.revision }); + const nextZip = join(temp, "Mac-reexport.zip"); await writeFile(nextZip, exported.bytes); + const again = await service.importPackage({ packagePath: nextZip }); + const nextManifest = await readReadonlySessionManifest({ rootDir: root, ...again.session }); + expect(nextManifest.blocks.map(b => b.id)).toEqual(["0012", "0018"]); + expect(again.assetCount).toBe(1); + const nextRoot = join(root, "notebooks", again.session.notebookId, "sessions", again.session.sessionId); + expect(await readFile(join(nextRoot, asset))).toEqual(photo); + expect(await readFile(join(nextRoot, "blocks/0002_imported.md"), "utf8")).toContain("第二块 $x^2$"); + }); + + it("imports Mac ZIP UTF-8 names even when the archive omitted the UTF-8 flag", async () => { + const packed = await zip([["分享目录/课程.md", Buffer.from(`\n\n![图](${encodeMarkdownAssetPath(asset)})`)], [`分享目录/${asset}`, photo]]); + const bytes = await readFile(packed); + const end = bytes.length - 22; + let cursor = bytes.readUInt32LE(end + 16); + const count = bytes.readUInt16LE(end + 10); + for (let index = 0; index < count; index++) { + expect(bytes.readUInt32LE(cursor)).toBe(0x02014b50); + bytes.writeUInt16LE(bytes.readUInt16LE(cursor + 8) & ~0x800, cursor + 8); + const local = bytes.readUInt32LE(cursor + 42); + bytes.writeUInt16LE(bytes.readUInt16LE(local + 6) & ~0x800, local + 6); + cursor += 46 + bytes.readUInt16LE(cursor + 28) + bytes.readUInt16LE(cursor + 30) + bytes.readUInt16LE(cursor + 32); + } + await writeFile(packed, bytes); + const imported = await service.importPackage({ packagePath: packed }); + const sessionRoot = join(root, "notebooks", imported.session.notebookId, "sessions", imported.session.sessionId); + expect(await readFile(join(sessionRoot, asset))).toEqual(photo); + expect(imported.session.title).toBe("课程"); + expect(imported.assetCount).toBe(1); + }); + it("accepts a wrapped ZIP and explicit Markdown with adjacent assets", async () => { const dir = await folder(); const first = await service.importPackage({ packagePath: join(dir, "课程.md") }); diff --git a/packages/core-server/src/session/sessionSharePackageService.ts b/packages/core-server/src/session/sessionSharePackageService.ts index 06b3f83..a8582a8 100644 --- a/packages/core-server/src/session/sessionSharePackageService.ts +++ b/packages/core-server/src/session/sessionSharePackageService.ts @@ -1,3 +1,4 @@ +import { restoreShareBlocks } from "./sharePackageBlocks"; import { randomUUID } from "node:crypto"; import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -11,7 +12,7 @@ import { SessionWriteCoordinator } from "./sessionWriteCoordinator"; import { extractShareZip, safeSharePath, shareFiles, SHARE_LIMITS, SharePackageError, zipShareDirectory } from "./sharePackageArchive"; export type SharePackageImport = { packagePath: string; notebookId?: string; title?: string }; -export type SharePackageImportResult = { session: NotebookSessionSummary; assetCount: number; byteLength: number }; +export type SharePackageImportResult = { session: NotebookSessionSummary; assetCount: number; byteLength: number; blockCount: number; mergedContinuationGroups: number }; export class SessionSharePackageService { constructor(private readonly rootDir: string, private readonly writes: SessionWriteCoordinator) {} @@ -20,7 +21,7 @@ export class SessionSharePackageService { const temporary = await mkdtemp(join(tmpdir(), "mathnotes-share-export-")); try { const result = await exportSessionMarkdown({ ...input, rootDir: this.rootDir, defaultExportDir: temporary, - includeMetadataComments: false, mathCompatibility: "portable", packageMode: "share" }); + includeMetadataComments: true, mathCompatibility: "portable", packageMode: "share" }); if (result.missingAssets?.length) throw new SharePackageError("missing_assets", `原笔记缺少 ${result.missingAssets.length} 个引用资源,未生成不完整分享包。`); const target = join(temporary, "share.zip"); await zipShareDirectory(result.packageDir!, target); @@ -84,7 +85,9 @@ export class SessionSharePackageService { // Read everything into a private staging tree before any visible catalog mutation. const prepared = join(temporary, "prepared"); await mkdir(join(prepared, "blocks"), { recursive: true }); let byteLength = Buffer.byteLength(markdown); - await writeFile(join(prepared, "blocks/0001_imported.md"), markdown); + const structure = restoreShareBlocks(markdown); + const blockPath = (index: number) => `blocks/${String(index + 1).padStart(4, "0")}_imported.md`; + for (const [index, block] of structure.blocks.entries()) await writeFile(join(prepared, blockPath(index)), block.markdown); for (const asset of assets) { if (sessionAssetPathFromMarkdown(encodeMarkdownAssetPath(asset)) !== asset) throw new SharePackageError("unsafe_package_path", "资源文件名不兼容 Windows/Mac。"); const sourceAsset = join(source, asset), target = join(prepared, asset); @@ -108,7 +111,9 @@ export class SessionSharePackageService { } const sessionId = `import_${randomUUID()}`; const session = createSessionRecord({ id: sessionId, title, createdAt: now }); - session.blocks.push(createBlockRef({ id: "0001", type: "markdown", path: "blocks/0001_imported.md", source: "user", createdAt: now })); + session.blocks.push(...structure.blocks.map((block, index) => createBlockRef({ + id: block.id, type: "markdown", path: blockPath(index), source: block.source, createdAt: now + }))); await writeFile(join(prepared, "session.json"), JSON.stringify(session, null, 2)); // Same-volume staging makes the final rename atomic even when /tmp is on another volume. const staging = await mkdtemp(join(this.rootDir, ".share-import-")); @@ -125,7 +130,7 @@ export class SessionSharePackageService { await rename(staging, notebookDir); } } finally { await rm(staging, { recursive: true, force: true }); } - return { session: { notebookId, sessionId, title, status: session.status, createdAt: now, updatedAt: now }, assetCount: assets.length, byteLength }; + return { session: { notebookId, sessionId, title, status: session.status, createdAt: now, updatedAt: now }, assetCount: assets.length, byteLength, blockCount: structure.blocks.length, mergedContinuationGroups: structure.mergedContinuationGroups }; }); } finally { await rm(temporary, { recursive: true, force: true }); } } diff --git a/packages/core-server/src/session/sharePackageArchive.ts b/packages/core-server/src/session/sharePackageArchive.ts index d8ae78d..f4d3680 100644 --- a/packages/core-server/src/session/sharePackageArchive.ts +++ b/packages/core-server/src/session/sharePackageArchive.ts @@ -4,7 +4,7 @@ import { dirname, join } from "node:path"; import { Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; import { crc32 } from "node:zlib"; -import { openPromise } from "yauzl"; +import { openPromise, type Entry } from "yauzl"; import { ZipFile } from "yazl"; export const SHARE_LIMITS = { files: 4096, fileBytes: 64 * 1024 * 1024, totalBytes: 256 * 1024 * 1024, markdownBytes: 8 * 1024 * 1024 }; @@ -22,6 +22,19 @@ export function safeSharePath(value: string): string { } const ignoredMetadata = (value: string) => value.split("/").some(part => part === "__MACOSX" || part === ".DS_Store" || part.startsWith("._")); +// macOS ditto/Archive Utility can store UTF-8 names without setting bit 11. +// Honor valid Unicode-path metadata first; otherwise prefer strictly valid +// UTF-8 bytes and retain yauzl's CP437 fallback for older non-UTF-8 archives. +function shareEntryName(entry: Entry): string { + const unicodePath = entry.extraFields.some(field => field.id === 0x7075 && field.data.length >= 6 && + field.data[0] === 1 && field.data.readUInt32LE(1) === crc32(entry.fileNameRaw)); + if (!(entry.generalPurposeBitFlag & 0x800) && !unicodePath) { + try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(entry.fileNameRaw); } + catch { /* Legacy CP437 name, already decoded and checked by yauzl. */ } + } + return entry.fileName; +} + export async function extractShareZip(source: string, destination: string): Promise { if ((await lstat(source)).size > SHARE_LIMITS.totalBytes) throw new SharePackageError("package_too_large", "分享包超过 256 MB。"); const zip = await openPromise(source, { lazyEntries: true, validateEntrySizes: true }); @@ -31,8 +44,9 @@ export async function extractShareZip(source: string, destination: string): Prom try { for await (const entry of zip.eachEntry()) { if (++count > SHARE_LIMITS.files) throw new SharePackageError("too_many_files", "分享包文件数量过多。"); - const directory = entry.fileName.endsWith("/"); - const name = safeSharePath(directory ? entry.fileName.slice(0, -1) : entry.fileName); + const entryName = shareEntryName(entry); + const directory = entryName.endsWith("/"); + const name = safeSharePath(directory ? entryName.slice(0, -1) : entryName); const mode = (entry.externalFileAttributes >>> 16) & 0xf000; if ((mode !== 0 && mode !== (directory ? 0x4000 : 0x8000)) || (entry.generalPurposeBitFlag & 1)) { throw new SharePackageError("unsupported_zip_entry", "不支持加密文件、符号链接或特殊文件。"); diff --git a/packages/core-server/src/session/sharePackageBlocks.test.ts b/packages/core-server/src/session/sharePackageBlocks.test.ts new file mode 100644 index 0000000..1841800 --- /dev/null +++ b/packages/core-server/src/session/sharePackageBlocks.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { restoreShareBlocks } from "./sharePackageBlocks"; + +describe("share block restoration", () => { + it("restores Windows marker IDs, sources and ordered Markdown without displaying metadata", () => { + const restored = restoreShareBlocks("\n\n# 第一块\n\n\n\n第二块 $x^2$\n"); + expect(restored.blocks).toEqual([ + { id: "0002", source: "ai_transcription", markdown: "\n# 第一块\n\n" }, + { id: "0009", source: "user_revision", markdown: "\n第二块 $x^2$\n" } + ]); + expect(restored.mergedContinuationGroups).toBe(0); + }); + it("preserves CRLF, Unicode, preamble and an empty block", () => { + const markdown = "前言😀\r\n\r\n\r\n\r\n\r\n\r\n\r\n结尾\r\n"; + const { blocks } = restoreShareBlocks(markdown); + expect(blocks.map(b => b.id)).toEqual(["import_preamble", "a", "b"]); + expect(blocks.map(b => b.markdown)).toEqual(["前言😀\r\n\r\n", "\r\n\r\n", "\r\n结尾\r\n"]); + }); + it("does not mistake code, display math or blockquote examples for metadata", () => { + const text = "```md\n\n```\n\n$$\n\n$$\n\n> \n"; + expect(restoreShareBlocks(text).blocks).toEqual([{ id: "0001", source: "user", markdown: text }]); + }); + it("keeps merged continuations intact when Windows omitted their internal offsets", () => { + const { blocks, mergedContinuationGroups } = restoreShareBlocks("\n\n\n$$a+b$$\n\n\n\nnext"); + expect(blocks.map(b => b.id)).toEqual(["a", "c"]); + expect(blocks[0].markdown).toContain("$$a+b$$"); + expect(mergedContinuationGroups).toBe(1); + }); + it("remaps unsafe and repeated IDs without losing content", () => { + const { blocks } = restoreShareBlocks("\n\nfirst\n\n\n\nsecond\n\n\n\nthird"); + expect(new Set(blocks.map(b => b.id)).size).toBe(3); + expect(blocks.every(b => /^[a-zA-Z0-9_-]+$/.test(b.id))).toBe(true); + expect(blocks.map(b => b.markdown.trim())).toEqual(["first", "second", "third"]); + }); + it("leaves unmarked Markdown and ordinary HTML untouched", () => { + for (const text of ["# heading\n\n---\n\nother", "
\n\n
"]) { + expect(restoreShareBlocks(text).blocks[0].markdown).toBe(text); + expect(restoreShareBlocks(text).blocks).toHaveLength(1); + } + }); +}); diff --git a/packages/core-server/src/session/sharePackageBlocks.ts b/packages/core-server/src/session/sharePackageBlocks.ts new file mode 100644 index 0000000..325fc5f --- /dev/null +++ b/packages/core-server/src/session/sharePackageBlocks.ts @@ -0,0 +1,63 @@ +import type { BlockSource } from "@mathnotes/shared"; +import { createPortableMarkdownParser } from "../render/portableMarkdown"; +import { SharePackageError, SHARE_LIMITS } from "./sharePackageArchive"; + +export type ImportedShareBlock = { id: string; source: BlockSource; markdown: string }; +const sources = new Set(["pdf_import", "android_camera", "ai_transcription", "ai_explanation", "user", "user_revision", "mixed"]); +const marker = /^ {0,3}[ \t]*(?:\r\n|\r|\n)?$/; + +/** Only exported, top-level comment lines are boundaries. Code, math, quoted + * examples and arbitrary HTML stay literal. Offsets refer to original text. */ +export function restoreShareBlocks(markdown: string): { blocks: ImportedShareBlock[]; mergedContinuationGroups: number } { + const parser = createPortableMarkdownParser(); + parser.options.html = true; + const starts = [0]; + for (const line of markdown.matchAll(/\r\n|\n|\r/g)) starts.push(line.index! + line[0].length); + const at = (line: number) => starts[line] ?? markdown.length; + const markers: { start: number; end: number; id: string; source: BlockSource }[] = []; + for (const token of parser.parse(markdown, {})) { + if (token.type !== "html_block" || token.level !== 0 || !token.map) continue; + const entries = []; + let onlyMarkers = true; + for (let line = token.map[0]; line < token.map[1]; line++) { + const raw = markdown.slice(at(line), at(line + 1)); + const match = marker.exec(raw); + if (match) entries.push({ start: at(line), end: at(line + 1), id: match[1], source: sources.has(match[2] as BlockSource) ? match[2] as BlockSource : "user" as const }); + else if (raw.trim()) { onlyMarkers = false; break; } + } + if (onlyMarkers) markers.push(...entries); + } + if (markers.length > SHARE_LIMITS.files) throw new SharePackageError("too_many_blocks", "分享包的内容块数量过多。"); + if (!markers.length) return { blocks: [{ id: "0001", source: "user", markdown }], mergedContinuationGroups: 0 }; + const groups: typeof markers = []; + let mergedContinuationGroups = 0, inContinuation = false; + for (const entry of markers) { + const previous = groups.at(-1); + // Windows writes consecutive markers before a merged continuation group, + // without recording its internal character offsets. Keep that text intact. + if (previous && previous.end === entry.start) { + previous.end = entry.end; + if (!inContinuation) mergedContinuationGroups++; + inContinuation = true; + } else { groups.push({ ...entry }); inContinuation = false; } + } + const blocks: ImportedShareBlock[] = []; + if (markdown.slice(0, groups[0].start).trim()) blocks.push({ id: "import_preamble", source: "user", markdown: markdown.slice(0, groups[0].start) }); + for (let index = 0; index < groups.length; index++) { + const entry = groups[index]; + blocks.push({ id: entry.id, source: entry.source, markdown: markdown.slice(entry.end, groups[index + 1]?.start ?? markdown.length) }); + } + if (blocks.length > SHARE_LIMITS.files) throw new SharePackageError("too_many_blocks", "分享包的内容块数量过多。"); + const reserved = new Set(blocks.map(block => block.id)); + const used = new Set(); + blocks.forEach((block, index) => { + if (!/^[A-Za-z0-9_-]{1,128}$/.test(block.id) || used.has(block.id)) { + let suffix = 0; + let id = `imported_${index + 1}`; + while (reserved.has(id) || used.has(id)) id = `imported_${index + 1}_${++suffix}`; + block.id = id; + } + used.add(block.id); + }); + return { blocks, mergedContinuationGroups }; +} diff --git a/test_tool/macos_preview_smoke.swift b/test_tool/macos_preview_smoke.swift index fd6d1f5..0b5b9c6 100644 --- a/test_tool/macos_preview_smoke.swift +++ b/test_tool/macos_preview_smoke.swift @@ -481,19 +481,33 @@ struct PreviewSmoke { print("MACOS_PREVIEW_PARTIAL_FAILURE_REMAINS_READABLE_OK") let shareFolder = URL(fileURLWithPath: root + "/share-fixture") try FileManager.default.createDirectory(at: shareFolder.appendingPathComponent("assets"), withIntermediateDirectories: true) - let shareMarkdown = "# Share import\n\nFormula $x^2$\n\n![image](assets/photo.png)" + let shareMarkdown = "\n\n# Share import\n\nFormula $x^2$\n\n![image](assets/photo.png)\n\n\n\nSecond restored block" let sharePhoto = Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH3sAAAAASUVORK5CYII=")! try shareMarkdown.write(to: shareFolder.appendingPathComponent("Windows.md"), atomically: true, encoding: .utf8) try sharePhoto.write(to: shareFolder.appendingPathComponent("assets/photo.png")) let shared = try await supervisor.importSharePackage(packagePath: shareFolder.path, notebookId: nil) try expect(shared.assetCount == 1, "native import decodes resource count") let sharedManifest = try await supervisor.fetchSessionManifest(shared.session) + try expect(sharedManifest.blocks.map(\.id) == ["0012", "0018"], "native import restores Windows block boundaries") let shareBytes = try await supervisor.exportSharePackage(shared.session, baseRevision: sharedManifest.revision) try expect(shareBytes.prefix(2) == Data([0x50, 0x4b]), "native export returns a ZIP") let shareZip = URL(fileURLWithPath: root + "/round-trip.zip") try shareBytes.write(to: shareZip) let sharedAgain = try await supervisor.importSharePackage(packagePath: shareZip.path, notebookId: shared.session.notebookId) try expect(sharedAgain.assetCount == 1 && sharedAgain.session.sessionId != shared.session.sessionId, "native round trip creates independent Session") + let againManifest = try await supervisor.fetchSessionManifest(sharedAgain.session) + try expect(againManifest.blocks.map(\.id) == ["0012", "0018"], "ZIP reexport retains imported block boundaries") + let pickerHost = SharePackagePanelHost() + let pickerWindow = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: [.titled], backing: .buffered, defer: false) + let pickerAnchor = SharePackagePanelAnchor.AnchorView() + pickerAnchor.host = pickerHost + pickerWindow.contentView = pickerAnchor + try expect(pickerHost.window === pickerWindow, "picker retains its initiating window without querying application focus") + try expect(pickerAnchor.hitTest(.zero) == nil, "picker window anchor never intercepts content clicks") + pickerWindow.contentView = nil + try expect(pickerHost.window == nil, "picker clears detached workspace window") + try expect(SharePackagePicker.supports(shareZip) && SharePackagePicker.supports(shareFolder), "picker enables ZIP and folders without UTI metadata") + try expect(!SharePackagePicker.supports(shareFolder.appendingPathComponent("unsupported.exe")), "picker filters unrelated files") print("MACOS_SHARE_PACKAGE_NATIVE_CLIENT_ROUND_TRIP_OK") let importedWorkspace = SessionSourceWorkspace() importedWorkspace.prepare(sessionID: shared.session.id, revision: sharedManifest.revision, blocks: sharedManifest.blocks) From 1b228a43a6e2ce216696edde0bac1b2b44cb17dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E4=B8=AD?= <1053338060@qq.com> Date: Tue, 8 Sep 2026 19:02:22 +0800 Subject: [PATCH 2/4] chore: refresh public source manifest for import fixes --- PUBLIC_SOURCE_MANIFEST.json | 64 ++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 6bd236d..92ddadf 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "license": "GPL-3.0-only", "historyPolicy": "sanitized-single-snapshot", - "generatedAt": "2026-09-08T09:41:38.424Z", - "fileCount": 860, - "snapshotSha256": "bb885e7c47f174052af805a58622132f2a456bd7384e65bca2b181c4587b8835", + "generatedAt": "2026-09-08T11:02:14.894Z", + "fileCount": 864, + "snapshotSha256": "ca7aa6944119de1c2afe7347a5abb4579a3b6529012f93582393017408b40c80", "files": [ { "path": ".gitattributes", @@ -1058,8 +1058,8 @@ }, { "path": "apps/macos/DEVELOPMENT.md", - "bytes": 10614, - "sha256": "4790fa418e904d979e7cb3b5b5159219ef352af220aab29f7cade5ac1e31285a" + "bytes": 12310, + "sha256": "d30f3f4d95d7fe7a13b962dde3477c34935c31f312a81f36a2f6660ee5ef85b7" }, { "path": "apps/macos/Package.swift", @@ -1068,8 +1068,8 @@ }, { "path": "apps/macos/RELEASE_STATUS.md", - "bytes": 4834, - "sha256": "562ab984f3408c91c87365d87c591303e2b664d37086ca1f12cb2132333322df" + "bytes": 5989, + "sha256": "220fe3cbc3b73c8b2de352bdeb30ce2062cc71484bd1b05d20b069a27366dee8" }, { "path": "apps/macos/REMOTE_CATALOG_SYNC_V1.md", @@ -1118,8 +1118,8 @@ }, { "path": "apps/macos/Sources/MathNotesMac/ContentView.swift", - "bytes": 39851, - "sha256": "172b43384c80fdd2a9c11ffaf70391406a38954e9b564767b0cb7ffe3c002f6d" + "bytes": 40912, + "sha256": "78a08a442df1d59f9e995a1a6e8159d2dbf0d48a467c494c7e32ffcc8a7610a5" }, { "path": "apps/macos/Sources/MathNotesMac/KeychainCredentialStore.swift", @@ -1376,6 +1376,11 @@ "bytes": 8365, "sha256": "26bfe64f73905dffbf5acb3a9f01ebf3eea55e6cead2f42c81a6a6aaf3a2ebc8" }, + { + "path": "apps/macos/Sources/MathNotesMac/SharePackagePicker.swift", + "bytes": 4055, + "sha256": "1a7d1d331c05ee49afbf791bc52f9300476bdc1a2f35dea7d2c77072713634b2" + }, { "path": "apps/macos/Sources/MathNotesMac/SidecarConfiguration.swift", "bytes": 7344, @@ -1388,8 +1393,8 @@ }, { "path": "apps/macos/Sources/MathNotesMac/SidecarProtocol.swift", - "bytes": 36651, - "sha256": "437334648d6ee8b90af5a30d1a451caa4174476e0e3845a04405450ca69662d7" + "bytes": 36715, + "sha256": "2da2ee2659b3ef265eba6057a1125239eb81b89a0db392d9eff0aad4d397ef6a" }, { "path": "apps/macos/Sources/MathNotesMac/SidecarSupervisor.swift", @@ -1398,8 +1403,8 @@ }, { "path": "apps/macos/WINDOWS_SHARE_IMPORT_FOLLOWUP.md", - "bytes": 1629, - "sha256": "74ff624e1892557bec523a25721c8e3e1b931050d775393d52698b7190ef0204" + "bytes": 1785, + "sha256": "18616aa36ec549f79e853d88be51e272c797e4e87bb6d243a85c2167f8a80abf" }, { "path": "apps/pwa/index.html", @@ -2909,7 +2914,7 @@ { "path": "deploy/releases/macos-release.json", "bytes": 140, - "sha256": "3e67a551609bb3bcf4756ff1458da49c37bd4a752fde79f2982fd29409faaf86" + "sha256": "4885b668fee7772417a93acfc488a313a65423f93ee6e4bbe61dff4d85d11af0" }, { "path": "deploy/releases/macos-v0.3.4.md", @@ -2926,6 +2931,11 @@ "bytes": 4383, "sha256": "1ad6db93c6b1be1ffaebda34ad6c8e60758f78676110586148f05f9fce611300" }, + { + "path": "deploy/releases/macos-v0.3.7.md", + "bytes": 4469, + "sha256": "4b4c49aa22d5170a5c8e5778334de4595f2688bd9713ba4c4b1414eef46270e1" + }, { "path": "deploy/releases/v0.3.4-acceptance.md", "bytes": 9598, @@ -3453,13 +3463,13 @@ }, { "path": "packages/core-server/src/session/sessionSharePackageService.test.ts", - "bytes": 9889, - "sha256": "3dc5be4ea481258b2d59b38cc743c4a55a67a7f1384f952bd17845fb4985ffbb" + "bytes": 12768, + "sha256": "44f1e96a42025e763e101fcd2f5c7f9eb83b08aba39c1a8fc3e652331f648e4a" }, { "path": "packages/core-server/src/session/sessionSharePackageService.ts", - "bytes": 10441, - "sha256": "c65d422434bec9f7b2f67436a89ae7d38e07eedbee81cd8a75a35b2692b5e0c9" + "bytes": 10923, + "sha256": "7dc4e5af33d9b3a1b30ae2e2220961423dae0e7f7850b36c0ae43cca176319f3" }, { "path": "packages/core-server/src/session/sessionWriteCoordinator.test.ts", @@ -3473,8 +3483,18 @@ }, { "path": "packages/core-server/src/session/sharePackageArchive.ts", - "bytes": 5981, - "sha256": "4349dec21591477a7ade4f753bdea466f9eef367a1ce1e3d777d2dc3d095f59e" + "bytes": 6771, + "sha256": "e131893e553b16f7e1bcf9745f040079d53035cf2035aaaf23857ed9405402bc" + }, + { + "path": "packages/core-server/src/session/sharePackageBlocks.test.ts", + "bytes": 2743, + "sha256": "ed98082eb1c796cefd9cc41a7ef03a094818e5d025a9e5f310e5ecac582d1c20" + }, + { + "path": "packages/core-server/src/session/sharePackageBlocks.ts", + "bytes": 3628, + "sha256": "63eb2a88d3cba11f34b4169d2e8634494f3a0acfcfc9fd9ef8739c8eacae832d" }, { "path": "packages/core-server/src/session/sourceImageIntegration.mac.test.ts", @@ -3998,8 +4018,8 @@ }, { "path": "test_tool/macos_preview_smoke.swift", - "bytes": 46712, - "sha256": "3d14c12524a27a7b062a5fd1863f2c1bd85641842fc3818c5825b871bc6ed0dd" + "bytes": 48182, + "sha256": "8ac5d6ca87bd8ce0c5a081b23a91d1e0e3d5c7e51de7f5c58146b598bf44b3a4" }, { "path": "test_tool/macos_remote_acceptance.swift", From 5cd5e6330e136a5f8a4878d5eb42b2e198fa3f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E4=B8=AD?= <1053338060@qq.com> Date: Tue, 8 Sep 2026 19:10:56 +0800 Subject: [PATCH 3/4] docs(macos): record user acceptance and prepare 0.3.7 release --- README.md | 6 +++--- apps/macos/DEVELOPMENT.md | 2 +- apps/macos/RELEASE_STATUS.md | 4 ++-- deploy/releases/macos-v0.3.7.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ed297b6..2662cd9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ MathNotes 是一个 GPLv3、本地优先的数学笔记项目,包含 Windows Windows 最新测试构建为 **0.3.4「Mac 目录同步测试版」**:在正文、素材和草稿保护之外,增加远程新建笔记本/笔记、改名、移入废纸篓及恢复,并修复特殊文件名图片和删除后重启的问题。设置中显示构建编号,便于区分旧包。[下载 Windows 目录同步测试包](https://github.com/theincrediblewwz/MathNotes-OpenSource/releases/tag/windows-catalog-sync-20260908) · [目录接口与逐项验收表](deploy/windows-mac-sync/CATALOG_SYNC_V1.md) · [原正文同步说明](deploy/windows-mac-sync/WINDOWS_MAC_SYNC_RESULT.md)。该 Windows 发行版没有重新发布 Android/PWA;真实 Mac 跨机验收仍待完成。 -**Mac 原生预览版 0.3.6** 已集成 PWA 0.3.4,面向 Apple silicon、macOS 14+,本机实际验收为 macOS 26。0.3.6 减少阅读滚动引起的源码重绘,并支持 Windows 分享目录/ZIP 导入和含图分享包导出;此前已修复手机后台启动、连续预览,并新增紧凑 UI、笔记管理、固定块、AI 修改、原图内嵌与阅读工具。**跨 Windows 同步尚待真实跨机验证**,本次以独立本机使用为主。[下载 Mac 预览版](https://github.com/theincrediblewwz/MathNotes-OpenSource/releases/tag/macos-v0.3.6) · [Mac 发布说明](deploy/releases/macos-v0.3.6.md) · [接续开发与验收状态](apps/macos/RELEASE_STATUS.md)。 +**Mac 原生预览版 0.3.7** 已集成 PWA 0.3.4,面向 Apple silicon、macOS 14+,本机实际验收为 macOS 26。0.3.7 修复分享包文件框交互及 Mac ZIP 中文文件名兼容,并按 Windows 导出标记恢复内容块,支持含图分享包往返;此前已修复手机后台启动、连续预览,并新增紧凑 UI、笔记管理、固定块、AI 修改、原图内嵌与阅读工具。**跨 Windows 同步尚待真实跨机验证**,本次以独立本机使用为主。[下载 Mac 预览版](https://github.com/theincrediblewwz/MathNotes-OpenSource/releases/tag/macos-v0.3.7) · [Mac 发布说明](deploy/releases/macos-v0.3.7.md) · [接续开发与验收状态](apps/macos/RELEASE_STATUS.md)。 [下载最新发行版](https://github.com/theincrediblewwz/MathNotes-OpenSource/releases/latest) · [0.3.4 固定发行页](https://github.com/theincrediblewwz/MathNotes-OpenSource/releases/tag/v0.3.4) · [逐项操作验收表](deploy/releases/v0.3.4-acceptance.md) @@ -17,9 +17,9 @@ Windows 最新测试构建为 **0.3.4「Mac 目录同步测试版」**:在正 | Windows | 0.3.4 | 已内置新版 PWA 的便携 ZIP,解压后运行 MathNotes.exe | | Android | 0.3.4,versionCode 17 | APK,沿用既有测试签名 | | PWA | 0.3.4 | 独立网页/源码更新 ZIP,附公开集成说明 | -| macOS | 0.3.6 原生预览版 | Apple silicon ZIP,内置 PWA;ad-hoc 签名,未公证 | +| macOS | 0.3.7 原生预览版 | Apple silicon ZIP,内置 PWA;ad-hoc 签名,未公证 | -本版提供 Windows 便携 ZIP、Android APK 和独立 PWA 更新包。Windows 为未签名测试包,Android 沿用既有测试签名;覆盖安装前请备份笔记。Mac 安装包在独立的 macos-v0.3.6 发行页提供,详见其安装说明。 +本版提供 Windows 便携 ZIP、Android APK 和独立 PWA 更新包。Windows 为未签名测试包,Android 沿用既有测试签名;覆盖安装前请备份笔记。Mac 安装包在独立的 macos-v0.3.7 发行页提供,详见其安装说明。 0.3.4 修复了块拖动原地放下误报导入、重新识别未实际执行,并补齐已上传素材预览与正文定位、左上角可滚动气泡目录、阅读底栏显隐和画笔内矩形遮盖。各包的版本、SHA-256、验证范围与已知限制以该发行页为准。 diff --git a/apps/macos/DEVELOPMENT.md b/apps/macos/DEVELOPMENT.md index f40c1f5..ed71fc6 100644 --- a/apps/macos/DEVELOPMENT.md +++ b/apps/macos/DEVELOPMENT.md @@ -1,6 +1,6 @@ # 在 Mac 本地开发 MathNotes -当前发布:macOS 0.3.6;0.3.7 为待鼠标验收的候选版本。集成 PWA 0.3.4。[功能与延期验收状态](RELEASE_STATUS.md);跨 Windows 真实互联暂缓,日常本机使用无需 Windows 在线。 +当前发布:macOS 0.3.7,集成 PWA 0.3.4。[功能与延期验收状态](RELEASE_STATUS.md);跨 Windows 真实互联暂缓,日常本机使用无需 Windows 在线。 从仓库根目录执行下面的命令。本仓库已经包含 Mac 原生界面、共享 Core/协议、PWA、打包脚本和测试,不需要私有仓库,也不需要 Windows 电脑在线。 diff --git a/apps/macos/RELEASE_STATUS.md b/apps/macos/RELEASE_STATUS.md index 43b3634..ac5ffc5 100644 --- a/apps/macos/RELEASE_STATUS.md +++ b/apps/macos/RELEASE_STATUS.md @@ -14,9 +14,9 @@ | PWA 0.3.4 | 单测、构建、浏览器上传/裁剪/成品预览 | 手机相机和后台策略 | | 跨主机副本与目录同步 | Windows PR #39 Core 跨版本 23 项及 Mac 正式视图 loopback 验收 | **真实 Mac ↔ Windows 尚待验证** | -## 0.3.7 文件选择与块结构(候选,尚未发布) +## 0.3.7 文件选择与块结构 -**待完成:真实前台鼠标单击文件夹/ZIP 的验收。自动化运行时应用可能处于后台,键盘选择可用不能替代鼠标验收;在此项确认前不发布 0.3.7。** +2026-09-08 用户在本机独立验收版确认文件框点选与导入已可正常使用,并授权打包发布。自动化的后台键盘选择与用户的前台手动验收分别记录,不将前者代替后者。 已通过 Core 425 项(另 2 项跳过,含分享导入/块恢复 16 项)、原生合同 99 项、UI 合同 205 项和真实预览回归。独立合成库中,Windows 格式 ZIP 与 macOS `ditto` 实际生成的 ZIP 导入后均显示 3 个独立源码块、原图和公式;这些流程使用键盘选择 ZIP;Mac 包曾因未设置 UTF-8 标志导致中文资源名乱码,修复后已用原失败包复测。 diff --git a/deploy/releases/macos-v0.3.7.md b/deploy/releases/macos-v0.3.7.md index 10119e4..a9fda3f 100644 --- a/deploy/releases/macos-v0.3.7.md +++ b/deploy/releases/macos-v0.3.7.md @@ -30,7 +30,7 @@ 此包使用 ad-hoc 签名,**没有 Apple Developer ID 签名或公证**。macOS 可能拦截首次打开;确认下载来源和校验和后,可按系统“隐私与安全性”页面提供的“仍要打开”流程处理。无需关闭 Gatekeeper、修改网络或删除钥匙串。更新前建议备份自己的笔记目录。 -本机合成库已验证原生预览、保存、固定块、AI 候选应用、查找替换和目录恢复;PWA 浏览器上传流程使用受控识别服务验证。真实模型输出质量、不同手机的相机权限及后台行为仍依赖各自环境,不能把合成测试当作所有设备验收通过。 +本机已通过 Core 425 项(另 2 项跳过)、原生合同 99 项、UI 合同 205 项及真实预览回归;用户已确认修复版文件框点选与导入可用。Windows 格式 ZIP 与 Mac 实际压缩 ZIP 均在独立库验证内容块、原图和公式恢复。本机合成库另已验证保存、固定块、AI 候选应用、查找替换和目录恢复;PWA 浏览器上传流程使用受控识别服务验证。真实模型输出质量、不同手机的相机权限及后台行为仍依赖各自环境,不能把合成测试当作所有设备验收通过。 ## 跨 Windows 同步:实验性,尚待真实跨机验证 From 403f47a3910b5acb55350ac6cae27d6962d35077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E4=B8=AD?= <1053338060@qq.com> Date: Tue, 8 Sep 2026 19:10:57 +0800 Subject: [PATCH 4/4] chore: refresh accepted release source manifest --- PUBLIC_SOURCE_MANIFEST.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 92ddadf..b43008c 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "license": "GPL-3.0-only", "historyPolicy": "sanitized-single-snapshot", - "generatedAt": "2026-09-08T11:02:14.894Z", + "generatedAt": "2026-09-08T11:10:57.253Z", "fileCount": 864, - "snapshotSha256": "ca7aa6944119de1c2afe7347a5abb4579a3b6529012f93582393017408b40c80", + "snapshotSha256": "f0c6f3b4d064c0abc6927915eca02abc2b55be86ce0cb067303f63d5874585b3", "files": [ { "path": ".gitattributes", @@ -68,8 +68,8 @@ }, { "path": "README.md", - "bytes": 10303, - "sha256": "81517c9ff67ac33f259a22d0b6ab916c5dad457566295645fc6cdf905ff43588" + "bytes": 10334, + "sha256": "dcca49a0eccd87a12e625b109d39c1a5a3f40268e75d14a491905e939e015bf4" }, { "path": "SECURITY.md", @@ -1058,8 +1058,8 @@ }, { "path": "apps/macos/DEVELOPMENT.md", - "bytes": 12310, - "sha256": "d30f3f4d95d7fe7a13b962dde3477c34935c31f312a81f36a2f6660ee5ef85b7" + "bytes": 12268, + "sha256": "3497d74585b719362926e41117d911907cee4de1b2e26ab1c3928c63bd911b4a" }, { "path": "apps/macos/Package.swift", @@ -1068,8 +1068,8 @@ }, { "path": "apps/macos/RELEASE_STATUS.md", - "bytes": 5989, - "sha256": "220fe3cbc3b73c8b2de352bdeb30ce2062cc71484bd1b05d20b069a27366dee8" + "bytes": 5988, + "sha256": "21e58353cc52efc1aa6991ea579935015d7a2e136600838d5018663a247ef168" }, { "path": "apps/macos/REMOTE_CATALOG_SYNC_V1.md", @@ -2933,8 +2933,8 @@ }, { "path": "deploy/releases/macos-v0.3.7.md", - "bytes": 4469, - "sha256": "4b4c49aa22d5170a5c8e5778334de4595f2688bd9713ba4c4b1414eef46270e1" + "bytes": 4730, + "sha256": "33d03aa36bf235f7fadd6771d74ac4dee4b87f89f2d09c7492f8c483155ef117" }, { "path": "deploy/releases/v0.3.4-acceptance.md",