Repository: caol64/wenyan Branch: main Commit: 980d7e197713 Files: 89 Total size: 149.8 KB Directory structure: gitextract_j_mnpred/ ├── .editorconfig ├── .github/ │ └── FUNDING.yml ├── .gitignore ├── .gitmodules ├── .npmrc ├── .swift-format ├── .vscode/ │ └── settings.json ├── LICENSE ├── README.md ├── WenYan/ │ ├── Assets.xcassets/ │ │ ├── AccentColor.colorset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── juejin.imageset/ │ │ │ └── Contents.json │ │ ├── medium.imageset/ │ │ │ └── Contents.json │ │ ├── toutiao.imageset/ │ │ │ └── Contents.json │ │ ├── wechat.imageset/ │ │ │ └── Contents.json │ │ ├── wenyan.imageset/ │ │ │ └── Contents.json │ │ └── zhihu.imageset/ │ │ └── Contents.json │ ├── Common/ │ │ ├── AppError.swift │ │ ├── AppState.swift │ │ ├── Commons.swift │ │ ├── Extensions.swift │ │ ├── FIFOCache.swift │ │ ├── LocalSchemeHandler.swift │ │ ├── UploadService.swift │ │ └── WechatAPI.swift │ ├── CoreData/ │ │ ├── CoreDataStack.swift │ │ ├── CustomTheme+CoreDataClass.swift │ │ ├── CustomTheme+CoreDataProperties.swift │ │ ├── DBHandler.swift │ │ ├── UploadCache+CoreDataClass.swift │ │ └── UploadCache+CoreDataProperties.swift │ ├── Credits.rtf │ ├── Info.plist │ ├── Intents/ │ │ ├── AppShortcuts.swift │ │ └── StartAppIntent.swift │ ├── Models/ │ │ ├── DataFile.swift │ │ ├── Enums.swift │ │ └── Types.swift │ ├── Preview Content/ │ │ └── Preview Assets.xcassets/ │ │ └── Contents.json │ ├── Resources.bundle/ │ │ └── .keep │ ├── Stores/ │ │ ├── ArticleStore.swift │ │ ├── CredentialStore.swift │ │ ├── SecurityScopedResourceStore.swift │ │ ├── SettingsStore.swift │ │ └── TokenStore.swift │ ├── Views/ │ │ ├── ContentView.swift │ │ ├── MainUI.swift │ │ └── MainViewModel.swift │ ├── WenYan.entitlements │ ├── WenYan.xcdatamodeld/ │ │ └── WenYan.xcdatamodel/ │ │ └── contents │ └── WenYanApp.swift ├── WenYan.xcodeproj/ │ ├── project.pbxproj │ ├── project.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata/ │ └── xcschemes/ │ └── WenYan.xcscheme ├── ci_scripts/ │ └── ci_post_clone.sh ├── package.json ├── pnpm-workspace.yaml ├── scripts/ │ └── copy_web_assets.sh ├── src/ │ ├── app.d.ts │ ├── app.html │ ├── lib/ │ │ ├── action.ts │ │ ├── adapters/ │ │ │ ├── articleStorageAdapter.ts │ │ │ ├── credentialStoreAdapter.ts │ │ │ ├── settingsStoreAdapter.ts │ │ │ ├── swiftFsAdapter.ts │ │ │ └── themeStorageAdapter.ts │ │ ├── appState.svelte.ts │ │ ├── bridge.ts │ │ ├── imageProcessor.svelte.ts │ │ ├── index.ts │ │ ├── listeners.svelte.ts │ │ ├── services/ │ │ │ ├── exportHandler.ts │ │ │ ├── fileOpenHandler.ts │ │ │ └── imageUploadService.ts │ │ ├── setHooks.ts │ │ ├── storeRegister.ts │ │ └── utils.ts │ └── routes/ │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.svelte │ └── layout.css ├── static/ │ └── example.md ├── svelte.config.js ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 4 trim_trailing_whitespace = true charset = utf-8 end_of_line = lf insert_final_newline = true max_line_length = 120 [*.json] indent_size = 2 [*.{yml,yaml}] indent_size = 2 [Makefile] indent_style = tab [*.{md,mdx}] max_line_length = off trim_trailing_whitespace = false ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: caol64 thanks_dev: # Replace with a single thanks.dev username custom: ['https://yuzhi.tech/sponsor', 'https://paypal.me/caol64'] ================================================ FILE: .gitignore ================================================ .DS_Store **/xcuserdata/ node_modules dist /.svelte-kit /build .env .env.* !.env.example !.env.test vite.config.js.timestamp-* vite.config.ts.timestamp-* WenYan/Resources.bundle/* !WenYan/Resources.bundle/.keep src-bak/ target/ ================================================ FILE: .gitmodules ================================================ [submodule "wenyan-ui"] path = wenyan-ui url = https://github.com/caol64/wenyan-ui ================================================ FILE: .npmrc ================================================ engine-strict=true ================================================ FILE: .swift-format ================================================ { "version": 1, "lineLength": 180, "indentation": { "spaces": 4 }, "maximumBlankLines": 1, "respectsExistingLineBreaks": true } ================================================ FILE: .vscode/settings.json ================================================ { "files.associations": { "*.css": "tailwindcss" } } ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================
logo
# 文颜 [![App Store](https://img.shields.io/badge/App_Store-0D96F6?label=download&logo=app-store&logoColor=white)](https://apps.apple.com/cn/app/%E6%96%87%E9%A2%9C/id6670157335?mt=12&itsct=apps_box_badge&itscg=30200) [![Guides](https://img.shields.io/badge/docs-Getting_Started-fe7d37?logo=gitbook&logoColor=fff)](https://yuzhi.tech/docs/wenyan) [![License](https://img.shields.io/github/license/caol64/wenyan)](LICENSE) [![Stars](https://img.shields.io/github/stars/caol64/wenyan?style=social)](https://github.com/caol64/wenyan) ## 简介 **[文颜(Wenyan)](https://wenyan.yuzhi.tech)** 是一款多平台 Markdown 排版与发布工具,支持将 Markdown 一键转换并发布至: - 微信公众号 - 知乎 - 今日头条 - 以及其它内容平台(持续扩展中) 文颜的目标是:**让写作者专注内容,而不是排版和平台适配**。 ## 文颜的不同版本 文颜目前提供多种形态,覆盖不同使用场景: - 👉 [macOS App Store 版](https://github.com/caol64/wenyan) - 本项目 - [跨平台桌面版](https://github.com/caol64/wenyan-pc) - Windows/Linux - [CLI 版本](https://github.com/caol64/wenyan-cli) - 命令行 / CI 自动化发布 - [MCP 版本](https://github.com/caol64/wenyan-mcp) - AI 自动发文 - [UI 库](https://github.com/caol64/wenyan-ui) - 桌面应用和 Web App 共用的 UI 层封装 - [核心库](https://github.com/caol64/wenyan-core) - 渲染、排版等核心能力 ## 功能特性 本项目的核心功能是将编辑好的`markdown`文章转换成适配各个发布平台的格式,通过一键复制,可以直接粘贴到平台的文本编辑器,无需再做额外调整。 - 使用内置主题对 Markdown 内容排版 - 自动处理并上传本地图片,[阅读文档](https://yuzhi.tech/docs/wenyan/upload) - 支持数学公式(MathJax) - 支持发布到多平台: - 公众号 - 知乎 - 今日头条 - 掘金、CSDN 等 - Medium - 支持代码高亮 - 支持链接转脚注 - 支持识别`front matter`语法 - 自定义主题 - 支持自定义样式 - 支持导入现成的主题 - [使用教程](https://babyno.top/posts/2024/11/wenyan-supports-customized-themes/) - [功能讨论](https://github.com/caol64/wenyan/discussions/9) - [主题分享](https://github.com/caol64/wenyan/discussions/13) - 支持导出长图 ## 主题效果预览 👉 [内置主题预览](https://yuzhi.tech/docs/wenyan/theme) 文颜内置并适配了多个优秀的 Typora 主题,在此感谢原作者: - [Orange Heart](https://github.com/evgo2017/typora-theme-orange-heart) - [Rainbow](https://github.com/thezbm/typora-theme-rainbow) - [Lapis](https://github.com/YiNNx/typora-theme-lapis) - [Pie](https://github.com/kevinzhao2233/typora-theme-pie) - [Maize](https://github.com/BEATREE/typora-maize-theme) - [Purple](https://github.com/hliu202/typora-purple-theme) - [物理猫-薄荷](https://github.com/sumruler/typora-theme-phycat) ## 应用截图 ![](Data/1.webp) ## 更多功能介绍 [https://wenyan.yuzhi.tech/](https://wenyan.yuzhi.tech/) ## 下载 本项目已上架`App Store`,你可以直接点击下方链接或搜索“文颜”下载: Download on the Mac App Store ## 如何贡献 - 通过 [Issue](https://github.com/caol64/wenyan/issues) 报告**bug**或进行咨询。 - 提交 [Pull Request](https://github.com/caol64/wenyan/pulls)。 - 分享 [自定义主题](https://github.com/caol64/wenyan/discussions/13)。 - 推荐美观的 `Typora` 主题。 ## 赞助 如果你觉得文颜对你有帮助,可以给我家猫咪买点罐头 ❤️ [https://yuzhi.tech/sponsor](https://yuzhi.tech/sponsor) ## License Apache License Version 2.0 ================================================ FILE: WenYan/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: WenYan/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ {"images":[{"size":"1024x1024","filename":"1024-mac.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"128x128","expected-size":"128","filename":"128-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024-mac.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]} ================================================ FILE: WenYan/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: WenYan/Assets.xcassets/juejin.imageset/Contents.json ================================================ { "images" : [ { "filename" : "juejin.svg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true } } ================================================ FILE: WenYan/Assets.xcassets/medium.imageset/Contents.json ================================================ { "images" : [ { "filename" : "medium.svg", "idiom" : "universal", "scale" : "1x" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "medium 1.svg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true } } ================================================ FILE: WenYan/Assets.xcassets/toutiao.imageset/Contents.json ================================================ { "images" : [ { "filename" : "toutiao.svg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true } } ================================================ FILE: WenYan/Assets.xcassets/wechat.imageset/Contents.json ================================================ { "images" : [ { "filename" : "wechat.svg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: WenYan/Assets.xcassets/wenyan.imageset/Contents.json ================================================ { "images" : [ { "filename" : "wenyan.svg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: WenYan/Assets.xcassets/zhihu.imageset/Contents.json ================================================ { "images" : [ { "filename" : "zhihu.svg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true } } ================================================ FILE: WenYan/Common/AppError.swift ================================================ // // AppError.swift // WenYan // // Created by Lei Cao on 2025/10/24. // import Foundation enum AppError: LocalizedError { case bizError(description: String) case networkError(description: String) var errorDescription: String? { switch self { case .bizError(let description): return description case .networkError(let description): return "网络请求失败: \(description)" } } } ================================================ FILE: WenYan/Common/AppState.swift ================================================ // // AppState.swift // WenYan // // Created by Lei Cao on 2024/8/20. // import SwiftUI @MainActor class AppState: ObservableObject { @Published var appError: AppError? } extension AppState { var showError: Binding { Binding { return self.appError != nil } set: { showError in if !showError { self.appError = nil } } } } ================================================ FILE: WenYan/Common/Commons.swift ================================================ // // Commons.swift // WenYan // // Created by Lei Cao on 2024/8/20. // import Foundation import WebKit import UniformTypeIdentifiers func getResourceBundle() -> URL? { return Bundle.main.url(forResource: "Resources", withExtension: "bundle") } func loadFile(_ path: String) throws -> String { return try String(contentsOfFile: path, encoding: .utf8) } func loadFileFromResource(path: String) throws -> String { let nsPath = path as NSString let path = nsPath.deletingPathExtension let `extension` = nsPath.pathExtension return try loadFileFromResource(forResource: path, withExtension: `extension`) } func loadFileFromResource(forResource: String, withExtension: String) throws -> String { guard let resourceBundleURL = getResourceBundle(), let resourceBundle = Bundle(url: resourceBundleURL), let filePath = resourceBundle.path(forResource: forResource, ofType: withExtension) else { throw AppError.bizError(description: "Required resource is missing") } return try loadFile(filePath) } typealias JavascriptCallback = (Result) -> Void func callJavascript(webView: WKWebView?, javascriptString: String, callback: JavascriptCallback? = nil) { DispatchQueue.main.async { webView?.evaluateJavaScript(javascriptString) { (response, error) in if let error = error { callback?(.failure(error)) } else { callback?(.success(response)) } } } } func callAsyncJavaScript(webView: WKWebView?, javascriptBody: String, args: [String: Any] = [:]) async throws -> Any? { return try await webView?.callAsyncJavaScript( javascriptBody, arguments: args, in: nil, contentWorld: .page ) } func getAppinfo(for key: String) -> String? { return Bundle.main.infoDictionary?[key] as? String } func getAppName() -> String { return getAppinfo(for: "CFBundleDisplayName") ?? AppConstants.defaultAppName } func serializeToJSONString(_ object: Any?) -> String { guard let obj = object else { return "null" // 对应 JS 的 null } // 1. 处理基础类型 (String, Bool, Number) if let stringObj = obj as? String { if let data = try? JSONSerialization.data(withJSONObject:[stringObj], options:[]), let jsonStr = String(data: data, encoding: .utf8) { let start = jsonStr.index(jsonStr.startIndex, offsetBy: 1) let end = jsonStr.index(jsonStr.endIndex, offsetBy: -1) return String(jsonStr[start.. String { // 1. 调用通用方法安全地获取文件数据 let data = try readDataWithSecurityScope(from: url) // 2. 将二进制数据编码为 Base64 let base64String = data.base64EncodedString() // 3. 获取文件的 MIME 类型 let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "image/png" // 4. 拼接 Data URI 格式并返回 return "data:\(mimeType);base64,\(base64String)" } func getFileExtension(from mimeType: String) -> String { if let utType = UTType(mimeType: mimeType), let ext = utType.preferredFilenameExtension { return ext } return "png" } func getMimeType(from extension: String) -> String { let ext = `extension`.lowercased() if let utType = UTType(filenameExtension: ext), let mimeType = utType.preferredMIMEType { return mimeType } return "application/octet-stream" } // MARK: - 通用文件读取 (沙盒权限) /// 尝试使用已保存的安全作用域书签(Security-Scoped Bookmark)读取本地文件 /// - Parameter url: 要读取的目标文件 URL /// - Returns: 文件的二进制数据 (Data) /// - Throws: 如果没有权限、书签解析失败或文件读取失败,则抛出错误 func readDataWithSecurityScope(from url: URL) throws -> Data { // 1. 查找是否有匹配的已授权目录前缀 let savedPaths = getAllSavedScopedURLs() guard let matchedPath = url.findBestSecurityScopedPrefix(in: savedPaths) else { throw AppError.bizError(description: "未找到该文件所在目录的访问权限:\(url.path)") } // 2. 从 UserDefaults 中读取该目录的书签数据 guard let bookmarkData = getBookmark(path: matchedPath) else { throw AppError.bizError(description: "书签数据丢失,请重新授权目录:\(matchedPath)") } var isStale = false // 3. 解析书签还原出受保护的目录 URL let workspaceURL = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) // 如果书签数据陈旧(例如文件被移动过),重新保存一次以更新状态 if isStale { try saveSecurityScopedBookmark(for: workspaceURL) } // 4. 声明开始访问该安全资源 guard workspaceURL.startAccessingSecurityScopedResource() else { throw AppError.bizError(description: "系统拒绝了对该目录的安全访问:\(workspaceURL.path)") } // 5. 离开作用域时,必须停止访问,防止内核资源泄漏 defer { workspaceURL.stopAccessingSecurityScopedResource() } // 6. 在已授权的上下文中,安全地读取目标文件的数据 return try Data(contentsOf: url) } ================================================ FILE: WenYan/Common/Extensions.swift ================================================ // // Extensions.swift // WenYan // // Created by Lei Cao on 2025/10/27. // import Foundation import UniformTypeIdentifiers import SwiftUI import WebKit import CryptoKit // Helper to convert Swift string to JavaScript string literal extension String { func toJavaScriptString() -> String { let escapedString = self .replacingOccurrences(of: "\\", with: "\\\\") // 转义反斜杠 .replacingOccurrences(of: "\"", with: "\\\"") // 转义引号 .replacingOccurrences(of: "\n", with: "\\n") // 转义换行符 .replacingOccurrences(of: "\r", with: "\\r") // 转义回车符 .replacingOccurrences(of: "\t", with: "\\t") // 转义制表符 // 添加前后引号,形成合法的 JavaScript 字符串字面量 return "\"\(escapedString)\"" } } extension UTType { static var md: UTType { UTType(importedAs: "com.yztech.WenYan.markdown") } static var css: UTType { UTType(importedAs: "com.yztech.WenYan.stylesheet") } } extension Link { func pointingHandCursor() -> some View { self.onHover { inside in if inside { NSCursor.pointingHand.set() } else { NSCursor.arrow.set() } } } } // 扩展 WKWebView 添加 PDF 导出支持 extension WKWebView { func exportPDF(completion: @escaping (Data?, Error?) -> Void) { let pdfConfiguration = WKPDFConfiguration() self.createPDF(configuration: pdfConfiguration) { result in switch result { case .success(let pdfData): completion(pdfData, nil) case .failure(let error): completion(nil, error) } } } } extension Error { func handle(in appState: AppState, fallback: String? = nil) { if let appError = self as? AppError { Task { @MainActor in appState.appError = appError } } else { let message = self.localizedDescription Task { @MainActor in appState.appError = .bizError(description: fallback ?? message) } } } } extension URL { func findBestSecurityScopedPrefix(in savedPaths: [String]) -> String? { let filePath = self.standardized.path return savedPaths.first { savedPath in let folderPath = savedPath.hasSuffix("/") ? savedPath : "\(savedPath)/" // 情况1:完全相等(就是目录本身) // 情况2:文件在目录下(前缀匹配且带斜杠) return filePath == savedPath || filePath.hasPrefix(folderPath) } } } extension Data { var md5: String { Insecure.MD5.hash(data: self) .map { String(format: "%02x", $0) } .joined() } } ================================================ FILE: WenYan/Common/FIFOCache.swift ================================================ // // FIFOCache.swift // WenYan // // Created by Lei Cao on 2026/3/23. // import Foundation final class FIFOCache { private var cache: [K: V] = [:] private var order: [K] = [] private let max: Int init(max: Int = 50) { self.max = max } func get(_ key: K) -> V? { return cache[key] } func set(_ key: K, value: V) { // 已存在:只更新 value,不改变顺序 if cache[key] != nil { cache[key] = value return } // 超出容量:删除最早的 key if cache.count >= max, let firstKey = order.first { cache.removeValue(forKey: firstKey) order.removeFirst() } cache[key] = value order.append(key) } func clear() { cache.removeAll() order.removeAll() } } ================================================ FILE: WenYan/Common/LocalSchemeHandler.swift ================================================ // // LocalSchemeHandler.swift // WenYan // // Created by Lei Cao on 2026/3/18. // import Foundation import WebKit import UniformTypeIdentifiers class LocalSchemeHandler: NSObject, WKURLSchemeHandler { // 拦截请求并返回本地文件数据 func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { guard let url = urlSchemeTask.request.url else { return } // 解析路径,例如 "app://_app/immutable/entry/start.js" -> "/_app/immutable/entry/start.js" var path = url.path if path.isEmpty || path == "/" { path = "/index.html" // 默认访问 index.html } // 去掉开头的 "/" if path.hasPrefix("/") { path.removeFirst() } guard let resourceBundleURL = getResourceBundle(), let resourceBundle = Bundle(url: resourceBundleURL), let fileURL = resourceBundle.url(forResource: path, withExtension: nil) else { // 如果文件没找到,返回 404 let error = NSError(domain: "LocalSchemeHandler", code: 404, userInfo: nil) urlSchemeTask.didFailWithError(error) return } do { let data = try Data(contentsOf: fileURL) let mimeType = getMimeType(for: fileURL.pathExtension) let response = URLResponse(url: url, mimeType: mimeType, expectedContentLength: data.count, textEncodingName: "utf-8") urlSchemeTask.didReceive(response) urlSchemeTask.didReceive(data) urlSchemeTask.didFinish() } catch { urlSchemeTask.didFailWithError(error) } } func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { // 请求被取消时的处理 } private func getMimeType(for extension: String) -> String { switch `extension`.lowercased() { case "html": return "text/html" case "js", "mjs": return "application/javascript" case "css": return "text/css" case "json": return "application/json" case "png": return "image/png" case "jpg", "jpeg": return "image/jpeg" case "svg": return "image/svg+xml" case "woff2": return "font/woff2" default: return "application/octet-stream" } } } ================================================ FILE: WenYan/Common/UploadService.swift ================================================ // // UploadService.swift // WenYan // // Created by Lei Cao on 2026/3/25. // import Foundation // MARK: - 核心上传逻辑 /// 将任意格式的图片来源(URL/本地/Base64)解析并上传至微信公众号,注意:本地路径必须已处理为绝对路径 /// - Parameter source: 图片的来源字符串(可以是 http 链接、file 路径或 data:base64 字符串) /// - Returns: 微信 API 返回的上传响应对象 func uploadImageToWechat(from source: String) async throws -> UploadResponse { let fileData: Data let fileName: String let mimetype: String if source.hasPrefix("http://") || source.hasPrefix("https://") { // 1. 处理网络图片 guard let url = URL(string: source) else { throw NSError(domain: "ImageError", code: -1, userInfo:[NSLocalizedDescriptionKey: "无效的网络图片链接"]) } let (data, response) = try await URLSession.shared.data(from: url) fileData = data mimetype = response.mimeType ?? WenYan.getMimeType(from: url.pathExtension) let ext = WenYan.getFileExtension(from: mimetype) var cleanFileName = url.lastPathComponent.components(separatedBy: "?").first ?? "upload" if !cleanFileName.lowercased().hasSuffix(".\(ext)") { cleanFileName = "\(cleanFileName).\(ext)" } fileName = cleanFileName } else if source.hasPrefix("data:") { // 2. 处理 Base64 (Data URI) let components = source.components(separatedBy: ",") guard components.count == 2, let header = components.first, let base64String = components.last, // Data(base64Encoded:) 能够自动处理换行符等噪音 let data = Data(base64Encoded: base64String, options: .ignoreUnknownCharacters) else { throw NSError(domain: "ImageError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Base64 图片数据解析失败"]) } fileData = data // 正则提取头部的 MIME Type if let regex = try? NSRegularExpression(pattern: "data:(.*?);"), let match = regex.firstMatch(in: header, range: NSRange(location: 0, length: header.utf16.count)) { mimetype = (header as NSString).substring(with: match.range(at: 1)) } else { mimetype = "image/png" } let ext = WenYan.getFileExtension(from: mimetype) fileName = "upload_image_\(Int(Date().timeIntervalSince1970)).\(ext)" } else { // 3. 处理本地图片绝对路径 let url = URL(fileURLWithPath: source) // 这里是沙盒模式读取外部文件,需要提前处理 Bookmark 权限 fileData = try readDataWithSecurityScope(from: url) fileName = url.lastPathComponent mimetype = WenYan.getMimeType(from: url.pathExtension) } return try await uploadImageWithCache(fileData: fileData, fileName: fileName, mimeType: mimetype) } func uploadAndReplaceImagesInMarkdown(markdown: String, predicate: (String) -> Bool) async -> (text: String, errors: [String]) { var processingText = markdown var uploadErrors: [String] = [] // ========================================== // 1. 保护特殊区域 (Frontmatter, 代码块, 行内代码) // ========================================== // 正则解析: // ^(?:---[\\s\\S]*?\\n---) : 匹配开头可能存在的 Frontmatter // ```[\\s\\S]*?``` : 匹配多行代码块 // `[^`]+` : 匹配行内代码 let protectedPattern = "^(?:---[\\s\\S]*?\\n---)|```[\\s\\S]*?```|`[^`]+`" guard let protectedRegex = try? NSRegularExpression(pattern: protectedPattern, options: []) else { return (markdown, uploadErrors) } var protectedMap: [String: String] = [:] var placeholderCount = 0 let nsOriginalText = processingText as NSString let protectedMatches = protectedRegex.matches(in: processingText, options:[], range: NSRange(location: 0, length: nsOriginalText.length)) // 关键:必须从后往前 (reversed) 替换! // 因为修改前面的字符串会导致后面匹配的 Range 坐标发生偏移失效。 for match in protectedMatches.reversed() { let nsText = processingText as NSString let originalBlock = nsText.substring(with: match.range) let placeholder = "__WENYAN_PROTECTED_\(placeholderCount)__" protectedMap[placeholder] = originalBlock processingText = nsText.replacingCharacters(in: match.range, with: placeholder) placeholderCount += 1 } // ========================================== // 2. 在安全区域内匹配真实图片 // ========================================== // 匹配: ![alt](url) 或 ![alt](url "title") // Group 1: alt text Group 2: url Group 3: 空格和title let imagePattern = "!\\[([^\\]]*)\\]\\(\\s*([^\"\\s)]+)([^)]*)\\)" guard let imageRegex = try? NSRegularExpression(pattern: imagePattern, options:[]) else { return (restoreProtectedBlocks(text: processingText, map: protectedMap), uploadErrors) } let nsSafeText = processingText as NSString let imageMatches = imageRegex.matches(in: processingText, options:[], range: NSRange(location: 0, length: nsSafeText.length)) // 提取需要上传的 URL(使用 Set 去重,避免一张图反复上传) var urlsToUpload: Set = [] for match in imageMatches { let urlRange = match.range(at: 2) // 取第 2 个括号的匹配内容 (URL) if urlRange.location != NSNotFound { let src = nsSafeText.substring(with: urlRange) if predicate(src) { urlsToUpload.insert(src) } } } if urlsToUpload.isEmpty { // 没有需要处理的图片,直接还原保护区返回 return (restoreProtectedBlocks(text: processingText, map: protectedMap), uploadErrors) } // ========================================== // 3. 遍历并执行异步上传 // ========================================== let relativePath = getLastArticleRelativePath() var uploadedUrlsMap: [String: String] = [:] for oldSrc in urlsToUpload { let resolvedSrc = resolveRelativePath(path: oldSrc, relative: relativePath) do { let resp = try await uploadImageToWechat(from: resolvedSrc) uploadedUrlsMap[oldSrc] = resp.url } catch { let errorMsg = "图片 [\(oldSrc)] 上传失败: \(error.localizedDescription)" print(errorMsg) uploadErrors.append(errorMsg) } } // ========================================== // 4. 将新 URL 替换回文本中 (精准 Range 替换) // ========================================== // 再次从后往前遍历替换,只替换 URL 所在的那个精确的 Range // 这样完美保留了用户的 alt 文本、以及可能存在的 title 悬停提示 for match in imageMatches.reversed() { let urlRange = match.range(at: 2) if urlRange.location != NSNotFound { let nsCurrentText = processingText as NSString let oldSrc = nsCurrentText.substring(with: urlRange) if let newUrl = uploadedUrlsMap[oldSrc] { processingText = nsCurrentText.replacingCharacters(in: urlRange, with: newUrl) } } } // ========================================== // 5. 还原保护区并返回最终结果 // ========================================== let finalText = restoreProtectedBlocks(text: processingText, map: protectedMap) return (finalText, uploadErrors) } /// 辅助方法:将保护占位符替换回原始文本 private func restoreProtectedBlocks(text: String, map: [String: String]) -> String { var restoredText = text for (placeholder, originalBlock) in map { restoredText = restoredText.replacingOccurrences(of: placeholder, with: originalBlock) } return restoredText } /// 解析并返回最终的文件路径 /// - Parameters: /// - path: 需要解析的路径(可以是网络链接、绝对路径或相对路径) /// - relative: 可选的基准路径(比如当前 Markdown 文件的绝对路径或所在目录) /// - Returns: 完整的路径字符串 func resolveRelativePath(path: String, relative: String? = nil) -> String { // 1. 如果是网络链接,直接返回 if path.hasPrefix("http://") || path.hasPrefix("https://") { return path } // 2. 如果已经是绝对路径,标准化后直接返回 let nsPath = path as NSString if nsPath.isAbsolutePath { // .standardizedPath 会自动解析掉里面的 "./" 或 "../" return nsPath.standardizingPath } // 3. 如果提供了基准路径 (relative),则将其作为前缀与相对路径拼接 if let base = relative { return getAbsoluteImagePath(basePath: base, relativePath: path) } // 4. 其他情况(相对路径但没有基准路径),直接返回原字符串 return path } /// 根据基准路径和相对路径,计算出最终的绝对路径 /// - Parameters: /// - basePath: 基准目录路径 /// - relativePath: 相对文件路径 /// - Returns: 标准化后的完整绝对路径 func getAbsoluteImagePath(basePath: String, relativePath: String) -> String { let baseURL = URL(fileURLWithPath: basePath) let directoryURL = baseURL.hasDirectoryPath ? baseURL : baseURL.deletingLastPathComponent() // 拼接相对路径,并标准化(去除多余的斜杠、./、../ 等) let absoluteURL = directoryURL.appendingPathComponent(relativePath).standardizedFileURL return absoluteURL.path } ================================================ FILE: WenYan/Common/WechatAPI.swift ================================================ // // WechatAPI.swift // WenYan // // Created by Lei Cao on 2026/3/24. // import Foundation struct AccessTokenResponse { var accessToken: String var expiresIn: Int } struct UploadResponse: Codable { var mediaId: String var url: String } private let tokenUrl = "https://api.weixin.qq.com/cgi-bin/token" private let publishUrl = "https://api.weixin.qq.com/cgi-bin/draft/add" private let uploadUrl = "https://api.weixin.qq.com/cgi-bin/material/add_material" private func getAccessTokenWithCache() async throws -> String { guard getSettings()?.enabledImageHost == "wechat" else { throw AppError.bizError(description: "请先在设置中启用微信图床") } guard let credential = getCredential(), let appId = credential.appId, let appSecret = credential.appSecret else { throw AppError.bizError(description: "请先在设置中配置微信的凭据") } if let token = getCachedToken(for: appId) { return token } else { let newToken = try await fetchAccessToken(appId: appId, appSecret: appSecret) let currentTime = Int(Date().timeIntervalSince1970) let expireAt = currentTime + newToken.expiresIn saveCachedToken(appId: appId, accessToken: newToken.accessToken, expireAt: expireAt) return newToken.accessToken } } func uploadImageWithCache(fileData: Data, fileName: String, mimeType: String) async throws -> UploadResponse { guard getSettings()?.uploadSettings.autoCache == true else { return try await uploadImage(fileData: fileData, fileName: fileName, mimeType: mimeType) } let md5 = fileData.md5 if let cached = try getUploadCache(md5: md5), let mediaId = cached.mediaId, let url = cached.url { return UploadResponse(mediaId: mediaId, url: url) } let response = try await uploadImage(fileData: fileData, fileName: fileName, mimeType: mimeType) let _ = try saveUploadCache(md5: md5, url: response.url, mediaId: response.mediaId) return response } private func uploadImage(fileData: Data, fileName: String, mimeType: String) async throws -> UploadResponse { try await uploadMaterial(type: "image", fileData: fileData, fileName: fileName, mimeType: mimeType) } func publishArticle(_ publishOptions: WechatPublishOptions) async throws -> String { let accessToken = try await getAccessTokenWithCache() let urlString = "\(publishUrl)?access_token=\(accessToken)" guard let url = URL(string: urlString) else { throw AppError.bizError(description: "微信接口地址配置错误") } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") do { let payload = ["articles": [publishOptions]] let bodyData = try JSONEncoder().encode(payload) request.httpBody = bodyData } catch { throw AppError.bizError(description: "构建请求参数失败: \(error.localizedDescription)") } let data: Data do { (data, _) = try await URLSession.shared.data(for: request) } catch { throw AppError.networkError(description: error.localizedDescription) } let json: [String: Any]? do { json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] } catch { throw AppError.bizError(description: "解析微信返回的数据失败") } if let mediaId = json?["media_id"] as? String { return mediaId } else if let errcode = json?["errcode"] as? Int, let errmsg = json?["errmsg"] as? String { throw AppError.bizError(description: "微信 API 错误: \(errcode): \(errmsg)") } else { throw AppError.bizError(description: "微信返回了未知的数据格式") } } private func fetchAccessToken(appId: String, appSecret: String) async throws -> AccessTokenResponse { let urlString = "\(tokenUrl)?grant_type=client_credential&appid=\(appId)&secret=\(appSecret)" guard let url = URL(string: urlString) else { throw AppError.bizError(description: "微信接口地址配置错误") } let data: Data do { (data, _) = try await URLSession.shared.data(from: url) } catch { throw AppError.networkError(description: error.localizedDescription) } let json: [String: Any]? do { json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] } catch { throw AppError.bizError(description: "解析微信返回的数据失败") } if let accessToken = json?["access_token"] as? String, let expiresIn = json?["expires_in"] as? Int { return AccessTokenResponse(accessToken: accessToken, expiresIn: expiresIn) } else if let errcode = json?["errcode"] as? Int, let errmsg = json?["errmsg"] as? String { throw AppError.bizError(description: "微信 API 错误: \(errcode): \(errmsg)") } else { throw AppError.bizError(description: "微信返回了未知的数据格式") } } private func uploadMaterial(type: String, fileData: Data, fileName: String, mimeType: String) async throws -> UploadResponse { let accessToken = try await getAccessTokenWithCache() let urlString = "\(uploadUrl)?access_token=\(accessToken)&type=\(type)" guard let url = URL(string: urlString) else { throw AppError.bizError(description: "微信接口地址配置错误") } let boundary = "Boundary-\(UUID().uuidString)" var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") let httpBody = createMultipartFormData(boundary: boundary, fileData: fileData, fileName: fileName, mimeType: mimeType) request.httpBody = httpBody let data: Data do { (data, _) = try await URLSession.shared.data(for: request) } catch { throw AppError.networkError(description: error.localizedDescription) } let json: [String: Any]? do { json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] } catch { throw AppError.bizError(description: "解析微信返回的数据失败") } if let url = json?["url"] as? String, let mediaId = json?["media_id"] as? String { return UploadResponse(mediaId: mediaId, url: url.replacingOccurrences(of: "http://", with: "https://")) } else if let errcode = json?["errcode"] as? Int, let errmsg = json?["errmsg"] as? String { throw AppError.bizError(description: "微信 API 错误: \(errcode): \(errmsg)") } else { throw AppError.bizError(description: "微信返回了未知的数据格式") } } private func createMultipartFormData(boundary: String, fileData: Data, fileName: String, mimeType: String) -> Data { var body = Data() // 添加文件数据 body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"media\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) body.append(fileData) body.append("\r\n".data(using: .utf8)!) // 结束标记 body.append("--\(boundary)--\r\n".data(using: .utf8)!) return body } ================================================ FILE: WenYan/CoreData/CoreDataStack.swift ================================================ // // CoreDataStack.swift // WenYan // // Created by Lei Cao on 2024/10/24. // import Foundation import CoreData final class CoreDataStack { // MARK: - Singleton static let shared = CoreDataStack() // MARK: - Properties var viewContext: NSManagedObjectContext { return persistentContainer.viewContext } lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "WenYan") guard let description = container.persistentStoreDescriptions.first else { fatalError("Core Data 存储描述符初始化失败") } description.shouldInferMappingModelAutomatically = true description.shouldMigrateStoreAutomatically = true container.loadPersistentStores { (storeDescription, error) in if let error = error as NSError? { fatalError("Core Data 加载失败: \(error), \(error.userInfo)") } } container.viewContext.automaticallyMergesChangesFromParent = true container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy return container }() // MARK: - Save Operations /// 保存当前上下文中的所有更改(包含新增、修改、删除的对象) /// 无论是新建了一个对象,还是修改了已查询出的对象的属性,最后都需要调用此方法。 func saveContext() throws { let context = viewContext guard context.hasChanges else { return } do { try context.save() } catch { let nserror = error as NSError print("Core Data 保存失败: \(nserror), \(nserror.userInfo)") throw error } } /// 语义化:保存单个对象的修改(本质上依然是保存其所在的上下文) /// - Parameter item: 修改或创建的 NSManagedObject 对象 func save(_ item: NSManagedObject) throws { // 如果该对象有关联的 Context,则保存该 Context;否则默认使用 viewContext let context = item.managedObjectContext ?? viewContext if context.hasChanges { try context.save() } } // MARK: - Delete Operations func delete(_ item: NSManagedObject) throws { let context = item.managedObjectContext ?? viewContext context.delete(item) if context.hasChanges { try context.save() } } func deleteAll(ofType type: T.Type) throws { let context = viewContext let fetchRequest = NSFetchRequest(entityName: String(describing: type)) let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try context.execute(deleteRequest) // 批量删除是直接操作数据库底层的,为了让内存中的 Context 知道数据被删了,需要重置 context.reset() } catch { print("批量删除 \(type) 失败: \(error.localizedDescription)") throw error } } // MARK: - Fetch (Get) Operations /// 获取泛型实体的所有数据,或根据条件过滤出列表 /// - Parameters: /// - type: 实体的类型,例如 CustomTheme.self /// - predicate: 过滤条件,默认 nil 表示获取全部 /// - sortDescriptors: 排序规则,默认 nil /// - Returns: 符合条件的实体数组 func fetch(_ type: T.Type, predicate: NSPredicate? = nil, sortDescriptors: [NSSortDescriptor]? = nil) throws -> [T] { let request = NSFetchRequest(entityName: String(describing: type)) request.predicate = predicate request.sortDescriptors = sortDescriptors return try viewContext.fetch(request) } /// 根据条件获取单个对象 /// 如果数据库中有多个符合条件的对象,只返回找到的第一个;如果没有则返回 nil。 /// - Parameters: /// - type: 实体的类型,例如 CustomTheme.self /// - predicate: 查询条件(通常是匹配唯一 ID) /// - Returns: 找到的实体对象,未找到返回 nil func get(_ type: T.Type, predicate: NSPredicate) throws -> T? { let request = NSFetchRequest(entityName: String(describing: type)) request.predicate = predicate request.fetchLimit = 1 // 告诉底层数据库只要找到1条就立刻返回,提升性能 let results = try viewContext.fetch(request) return results.first } /// 根据属性名和对应的值查询唯一的对象 /// - Parameters: /// - type: 实体的类型 /// - key: 属性名,例如 "id" 或 "uuid" /// - value: 对应的值 /// - Returns: 匹配的对象,若无则返回 nil func get(_ type: T.Type, by key: String, value: V) throws -> T? { let predicate = NSPredicate(format: "%K == %@", key, value) return try get(type, predicate: predicate) } } ================================================ FILE: WenYan/CoreData/CustomTheme+CoreDataClass.swift ================================================ // // CustomTheme+CoreDataClass.swift // WenYan // // Created by Lei Cao on 2024/10/24. // // import Foundation import CoreData @objc(CustomTheme) public class CustomTheme: NSManagedObject { } ================================================ FILE: WenYan/CoreData/CustomTheme+CoreDataProperties.swift ================================================ // // CustomTheme+CoreDataProperties.swift // WenYan // // Created by Lei Cao on 2024/10/24. // // import Foundation import CoreData extension CustomTheme { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "CustomTheme") } @NSManaged public var name: String? @NSManaged public var content: String? @NSManaged public var createdAt: Date? } extension CustomTheme : Identifiable { func toDictionary() -> [String: Any] { var dict:[String: Any] = [:] dict["name"] = self.name ?? "" dict["content"] = self.content ?? "" if let date = self.createdAt { dict["createdAt"] = Int(date.timeIntervalSince1970 * 1000) } else { dict["createdAt"] = NSNull() // 如果没有时间,传 null 给 JS } dict["id"] = self.objectID.uriRepresentation().absoluteString return dict } } ================================================ FILE: WenYan/CoreData/DBHandler.swift ================================================ // // DBHandler.swift // WenYan // // Created by Lei Cao on 2026/3/20. // import Foundation import CoreData func fetchCustomThemes() throws -> [CustomTheme] { let context = CoreDataStack.shared.viewContext let fetchRequest: NSFetchRequest = CustomTheme.fetchRequest() return try context.fetch(fetchRequest) } func getCustomThemeById(id: String) throws -> CustomTheme? { return try fetchCustomThemes().filter { item in item.objectID.uriRepresentation().absoluteString == id }.first } func saveCustomTheme(name: String, content: String) throws -> CustomTheme { let context = CoreDataStack.shared.viewContext let customTheme = CustomTheme(context: context) customTheme.name = name customTheme.content = content customTheme.createdAt = Date() try CoreDataStack.shared.save(customTheme) return customTheme } func updateCustomTheme(customTheme: CustomTheme, name: String, content: String) throws { customTheme.name = name customTheme.content = content try CoreDataStack.shared.save(customTheme) } func deleteCustomTheme(_ customTheme: CustomTheme) throws { try CoreDataStack.shared.delete(customTheme) } func getUploadCache(md5: String) throws -> UploadCache? { try CoreDataStack.shared.get(UploadCache.self, by: "md5", value: md5) } func saveUploadCache(md5: String, url: String, mediaId: String) throws -> UploadCache { let context = CoreDataStack.shared.viewContext let uploadCache = UploadCache(context: context) uploadCache.md5 = md5 uploadCache.url = url uploadCache.mediaId = mediaId uploadCache.createdAt = Date() try CoreDataStack.shared.save(uploadCache) return uploadCache } func clearUploadCache() throws { try CoreDataStack.shared.deleteAll(ofType: UploadCache.self) } ================================================ FILE: WenYan/CoreData/UploadCache+CoreDataClass.swift ================================================ // // UploadCache+CoreDataClass.swift // WenYan // // Created by Lei Cao on 2026/3/26. // // public import Foundation public import CoreData public typealias UploadCacheCoreDataClassSet = NSSet @objc(UploadCache) public class UploadCache: NSManagedObject { } ================================================ FILE: WenYan/CoreData/UploadCache+CoreDataProperties.swift ================================================ // // UploadCache+CoreDataProperties.swift // WenYan // // Created by Lei Cao on 2026/3/26. // // public import Foundation public import CoreData public typealias UploadCacheCoreDataPropertiesSet = NSSet extension UploadCache { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "UploadCache") } @NSManaged public var createdAt: Date? @NSManaged public var md5: String? @NSManaged public var mediaId: String? @NSManaged public var url: String? } extension UploadCache : Identifiable { } ================================================ FILE: WenYan/Credits.rtf ================================================ {\rtf1\ansi\ansicpg936\cocoartf2868 \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset134 PingFangSC-Regular;} {\colortbl;\red255\green255\blue255;\red0\green0\blue0;} {\*\expandedcolortbl;;\cssrgb\c0\c0\c0;} \paperw11900\paperh16840\margl1133\margr1133\margb1133\margt1133\vieww17300\viewh11400\viewkind0 \deftab720 \pard\pardeftab720\partightenfactor0 \f0\fs22 \cf2 \'ce\'ca\'cc\'e2\'b7\'b4\'c0\'a1\'a3\'ba{\field{\*\fldinst{HYPERLINK "https://yuzhi.tech/contact"}}{\fldrslt https://yuzhi.tech/contact}}\ \'d4\'de\'d6\'fa\'d6\'a7\'b3\'d6\'a3\'ba{\field{\*\fldinst{HYPERLINK "https://yuzhi.tech/sponsor"}}{\fldrslt https://yuzhi.tech/sponsor}}} ================================================ FILE: WenYan/Info.plist ================================================ UIDesignRequiresCompatibility HelpUrl https://yuzhi.tech/docs/wenyan UTImportedTypeDeclarations UTTypeConformsTo UTTypeIcons UTTypeIdentifier com.yztech.WenYan.markdown UTTypeTagSpecification public.filename-extension md UTTypeIcons UTTypeIdentifier com.yztech.WenYan.stylesheet UTTypeTagSpecification public.filename-extension css ================================================ FILE: WenYan/Intents/AppShortcuts.swift ================================================ // // AppShortcuts.swift // WenYan // // Created by Lei Cao on 2024/12/6. // //import AppIntents // //@available(macOS 13.0, *) //struct AppShortcuts: AppShortcutsProvider { // // static var appShortcuts: [AppShortcut] { // AppShortcut( // intent: StartAppIntent(), // phrases: ["Start a \(.applicationName)"] // ) // } // //} ================================================ FILE: WenYan/Intents/StartAppIntent.swift ================================================ // // StartAppIntent.swift // WenYan // // Created by Lei Cao on 2024/12/6. // //import AppIntents // //@available(macOS 13.0, *) //struct StartAppIntent: AppIntent { // static let title: LocalizedStringResource = "Start Meditation Session" // // @MainActor // func perform() async throws -> some IntentResult { // print("perform") // return .result() // } //} ================================================ FILE: WenYan/Models/DataFile.swift ================================================ // // DataFile.swift // WenYan // // Created by Lei Cao on 2025/10/27. // import Foundation import SwiftUI import UniformTypeIdentifiers struct DataFile: FileDocument { static var readableContentTypes: [UTType] { [.data] } var data: Data init(data: Data) { self.data = data } init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { return FileWrapper(regularFileWithContents: data) } } ================================================ FILE: WenYan/Models/Enums.swift ================================================ // // Enums.swift // WenYan // // Created by Lei Cao on 2024/11/27. // // MARK: - User Intents enum UserAction { case changePlatform(Platform) case openSettings case setContent(String) case toggleFileSidebar case onError(String) } enum AppConstants { static let defaultAppName = "文颜" } enum Platform: String, CaseIterable, Identifiable { case wechat case toutiao case zhihu case juejin case medium var id: Self { self } } ================================================ FILE: WenYan/Models/Types.swift ================================================ // // Types.swift // WenYan // // Created by Lei Cao on 2025/10/27. // import Foundation struct JsCustomTheme: Codable { var id: String var name: String var css: String } struct ArticlePathInfo: Codable { let fileName: String let filePath: String let relativePath: String } struct WechatPublishOptions: Codable { let title: String let author: String? let content: String let thumb_media_id: String let content_source_url: String? } ================================================ FILE: WenYan/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: WenYan/Resources.bundle/.keep ================================================ ================================================ FILE: WenYan/Stores/ArticleStore.swift ================================================ // // ArticleStore.swift // WenYan // // Created by Lei Cao on 2026/3/25. // import Foundation func loadArticle() -> String? { return UserDefaults.standard.string(forKey: "lastArticle") } func saveArticle(_ payload: String?) { UserDefaults.standard.set(payload, forKey: "lastArticle") } func setLastArticlePath(fileName: String, filePath: String, relativePath: String) { let info = ArticlePathInfo(fileName: fileName, filePath: filePath, relativePath: relativePath) if let encoded = try? JSONEncoder().encode(info) { UserDefaults.standard.set(encoded, forKey: "lastArticlePath") } } func resetLastArticlePath() { UserDefaults.standard.removeObject(forKey: "lastArticlePath") } func getLastArticleRelativePath() -> String? { guard let savedData = UserDefaults.standard.data(forKey: "lastArticlePath") else { return nil } guard let info = try? JSONDecoder().decode(ArticlePathInfo.self, from: savedData) else { return nil } return info.relativePath } ================================================ FILE: WenYan/Stores/CredentialStore.swift ================================================ // // CredentialStore.swift // WenYan // // Created by Lei Cao on 2026/3/24. // import Foundation struct GenericCredential: Codable { var type = "wechat" var appId: String? var appSecret: String? } func getCredential() -> GenericCredential? { if let savedData = UserDefaults.standard.data(forKey: "wenyanCredential"), let decoded = try? JSONDecoder().decode(GenericCredential.self, from: savedData) { return decoded } // 兼容老数据 var data = GenericCredential() if let savedData = UserDefaults.standard.data(forKey: "gzhImageHost"), let jsonObject = try? JSONSerialization.jsonObject(with: savedData, options:[]), let dict = jsonObject as? [String: Any] { if let appId = dict["appId"] as? String, !appId.isEmpty { data.appId = appId } if let appSecret = dict["appSecret"] as? String, !appSecret.isEmpty { data.appSecret = appSecret } UserDefaults.standard.removeObject(forKey: "gzhImageHost") saveCredential(credential: data) } return data } func saveCredential(credential: GenericCredential) { if let encoded = try? JSONEncoder().encode(credential) { UserDefaults.standard.set(encoded, forKey: "wenyanCredential") } } func clearCredential() { UserDefaults.standard.removeObject(forKey: "wenyanCredential") } ================================================ FILE: WenYan/Stores/SecurityScopedResourceStore.swift ================================================ // // SecurityScopedResourceStore.swift // WenYan // // Created by Lei Cao on 2026/3/26. // import Foundation func saveSecurityScopedBookmark(for url: URL) throws { // 1. 生成 Bookmark 数据 let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) let path = url.path let bookmarkKey = "Bookmark_\(path)" let indexKey = "SavedBookmarkPaths" // 2. 存储具体的 Bookmark 数据 UserDefaults.standard.set(bookmarkData, forKey: bookmarkKey) // 3. 更新目录索引集合 var savedPaths = UserDefaults.standard.stringArray(forKey: indexKey) ?? [] if !savedPaths.contains(path) { savedPaths.append(path) UserDefaults.standard.set(savedPaths, forKey: indexKey) } } func getAllSavedScopedURLs() -> [String] { let indexKey = "SavedBookmarkPaths" guard let savedPaths = UserDefaults.standard.stringArray(forKey: indexKey) else { return [] } return savedPaths } func getBookmark(path: String) -> Data? { let bookmarkKey = "Bookmark_\(path)" return UserDefaults.standard.data(forKey: bookmarkKey) } ================================================ FILE: WenYan/Stores/SettingsStore.swift ================================================ // // SettingsStore.swift // WenYan // // Created by Lei Cao on 2026/3/24. // import Foundation // MARK: - ParagraphSettings struct ParagraphSettings: Codable { var isFollowTheme: Bool = true var lineHeight: String = "1.75" var fontSize: String = "16px" var fontWeight: String = "400" var fontFamily: String = "sans" var paragraphSpacing: String = "1em" var letterSpacing: String = "0.1em" } // MARK: - CodeblockSettings struct CodeblockSettings: Codable { var isFollowTheme: Bool = true var isMacStyle: Bool = true var fontSize: String = "12px" var fontFamily: String? var hlThemeId: String = "github" } // MARK: - UploadSettings struct UploadSettings: Codable { var autoUploadLocal: Bool = false var autoUploadNetwork: Bool = false var autoCache: Bool = false } // MARK: - Settings struct Settings: Codable { var wechatTheme: String = "default" var enabledImageHost: String? var paragraphSettings: ParagraphSettings = ParagraphSettings() var codeblockSettings: CodeblockSettings = CodeblockSettings() var uploadSettings: UploadSettings = UploadSettings() } func getSettings() -> Settings? { if let savedData = UserDefaults.standard.data(forKey: "wenyanSettings"), let decoded = try? JSONDecoder().decode(Settings.self, from: savedData) { return decoded } // 兼容老数据 var settings = Settings() if let hostID = UserDefaults.standard.string(forKey: "ebabledImageHost"), !hostID.isEmpty { settings.enabledImageHost = "wechat" } UserDefaults.standard.removeObject(forKey: "ebabledImageHost") if let savedData = UserDefaults.standard.data(forKey: "paragraphSettings"), let decoded = try? JSONDecoder().decode(_ParagraphSettings.self, from: savedData) { settings.paragraphSettings = decoded.convertTo() } UserDefaults.standard.removeObject(forKey: "paragraphSettings") if let savedData = UserDefaults.standard.data(forKey: "codeblockSettings"), let decoded = try? JSONDecoder().decode(_CodeblockSettings.self, from: savedData) { settings.codeblockSettings = decoded.convertTo() } UserDefaults.standard.removeObject(forKey: "codeblockSettings") if let gzhTheme = UserDefaults.standard.string(forKey: "gzhTheme") { settings.wechatTheme = gzhTheme.replacingOccurrences(of: "custom/", with: "custom:") } UserDefaults.standard.removeObject(forKey: "gzhTheme") saveSettings(settings: settings) return settings } func saveSettings(settings: Settings) { if let encoded = try? JSONEncoder().encode(settings) { UserDefaults.standard.set(encoded, forKey: "wenyanSettings") } } func clearSettings() { UserDefaults.standard.removeObject(forKey: "wenyanSettings") } // MARK: 老数据 private struct _CodeblockSettings: Codable { var isEnabled = false var isMacStyle = false var theme = "github" var fontSize = "12px" var fontFamily = "" } private struct _ParagraphSettings: Codable { var isEnabled = false var fontSize = "16px" var fontType = "sans" var fontWeight = "400" var wordSpacing = "0.1em" var lineSpacing = "1.75" var paragraphSpacing = "1em" } extension _CodeblockSettings { func convertTo() -> CodeblockSettings { return CodeblockSettings( isFollowTheme: !self.isEnabled, isMacStyle: self.isMacStyle, fontSize: self.fontSize, fontFamily: self.fontFamily, hlThemeId: self.theme ) } } extension _ParagraphSettings { func convertTo() -> ParagraphSettings { return ParagraphSettings( isFollowTheme: !self.isEnabled, lineHeight: self.lineSpacing, fontSize: self.fontSize, fontWeight: self.fontWeight, fontFamily: self.fontType, paragraphSpacing: self.paragraphSpacing, letterSpacing: self.wordSpacing ) } } ================================================ FILE: WenYan/Stores/TokenStore.swift ================================================ // // TokenStore.swift // WenYan // // Created by Lei Cao on 2026/3/24. // import Foundation struct TokenData: Codable { var accessToken: String var expireAt: Int /// 校验 Token 是否有效 func isValid() -> Bool { let currentTime = Int(Date().timeIntervalSince1970) let bufferTime: Int = 600 // 10分钟缓冲期 return self.expireAt > currentTime + bufferTime } } // 字典结构,Key 为 appId typealias WechatToken = [String: TokenData] func getCachedToken(for appId: String) -> String? { guard let storedTokens = _getAllCachedTokens(), let tokenData = storedTokens[appId] else { return nil } if tokenData.isValid() { return tokenData.accessToken } removeCachedToken(for: appId) return nil } func saveCachedToken(appId: String, accessToken: String, expireAt: Int) { // 1. 先获取现有的所有 Token var allTokens = _getAllCachedTokens() ?? [:] // 2. 更新或插入当前 appId 的数据 let newTokenData = TokenData(accessToken: accessToken, expireAt: expireAt) allTokens[appId] = newTokenData // 3. 将整个字典重新编码并保存 if let encoded = try? JSONEncoder().encode(allTokens) { UserDefaults.standard.set(encoded, forKey: "wechatToken") } } func removeCachedToken(for appId: String) { guard var allTokens = _getAllCachedTokens() else { return } allTokens.removeValue(forKey: appId) if let encoded = try? JSONEncoder().encode(allTokens) { UserDefaults.standard.set(encoded, forKey: "wechatToken") } } func clearAllCachedTokens() { UserDefaults.standard.removeObject(forKey: "wechatToken") } /// 从 UserDefaults 读取完整的 Token 字典 private func _getAllCachedTokens() -> WechatToken? { if let savedData = UserDefaults.standard.data(forKey: "wechatToken"), let decoded = try? JSONDecoder().decode(WechatToken.self, from: savedData) { return decoded } return nil } ================================================ FILE: WenYan/Views/ContentView.swift ================================================ // // ContentView.swift // WenYan // // Created by Lei Cao on 2024/8/19. // import SwiftUI struct ContentView: View { @EnvironmentObject private var appState: AppState @EnvironmentObject private var viewModel: MainViewModel var body: some View { MainUI() .frame(minWidth: 1140, idealWidth: .infinity, minHeight: 645, idealHeight: .infinity) .toolbar { ToolbarItem(placement: .navigation) { Button { viewModel.dispatch(.toggleFileSidebar) } label: { Image(systemName: "sidebar.left") } } ToolbarItemGroup(placement: .primaryAction) { ForEach(Platform.allCases) { platform in Button { viewModel.dispatch(.changePlatform(platform)) } label: { Image(platform.rawValue) } } } } .navigationTitle(getAppName()) .background(Color(NSColor.windowBackgroundColor)) .alert(isPresented: appState.showError, error: appState.appError) {} .fileExporter( isPresented: $viewModel.isFileExporting, document: viewModel.exportFileData, contentType: viewModel.exportContentType, defaultFilename: viewModel.exportDefaultFilename ) { result in // 处理保存成功或失败的结果 switch result { case .success(let url): print("文件成功保存到: \(url)") case .failure(let error): print("文件保存失败: \(error.localizedDescription)") } } } } ================================================ FILE: WenYan/Views/MainUI.swift ================================================ // // MainUI.swift // WenYan // // Created by Lei Cao on 2026/3/18. // import SwiftUI import WebKit struct MainUI: NSViewRepresentable { @EnvironmentObject private var viewModel: MainViewModel func makeCoordinator() -> MainViewModel { viewModel } func makeNSView(context: Context) -> WKWebView { let userController = WKUserContentController() let configuration = WKWebViewConfiguration() configuration.userContentController = userController let schemeHandler = LocalSchemeHandler() configuration.setURLSchemeHandler(schemeHandler, forURLScheme: "app") let webView = WKWebView(frame: .zero, configuration: configuration) // if #available(macOS 13.3, *) { // webView.isInspectable = true // } webView.uiDelegate = context.coordinator webView.setValue(true, forKey: "drawsTransparentBackground") webView.allowsMagnification = false // 注册 JS 通信接口 userController.add(context.coordinator, name: "wenyanBridge") // 初始加载 if let url = URL(string: "app://index.html") { let request = URLRequest(url: url) webView.load(request) } context.coordinator.webView = webView return webView } func updateNSView(_ webView: WKWebView, context: Context) { // 当 viewModel的 @Published 属性变化时更新网页内容 } } ================================================ FILE: WenYan/Views/MainViewModel.swift ================================================ // // MainViewModel.swift // WenYan // // Created by Lei Cao on 2026/3/18. // import WebKit import UniformTypeIdentifiers @MainActor final class MainViewModel: NSObject, ObservableObject { private let appState: AppState weak var webView: WKWebView? private let imageLocalCache = FIFOCache(max: 50) @Published var isFileExporting: Bool = false @Published var exportFileData: DataFile? @Published var exportContentType: UTType = .png @Published var exportDefaultFilename: String = "out.png" init(appState: AppState) { self.appState = appState } // MARK: - Call Javascript private func callbackJavascript(callbackId: String, data: Any? = nil, error: String? = nil) { let dataJsonString = serializeToJSONString(data) let errorJsonString = serializeToJSONString(error) let jsScript = "if(window.__WENYAN_BRIDGE__.invokeCallback) { window.__WENYAN_BRIDGE__.invokeCallback('\(callbackId)', \(dataJsonString), \(errorJsonString)); }" WenYan.callJavascript(webView: webView, javascriptString: jsScript) } private func emitJavascript(event: String, data: Any? = nil) { let dataJsonString = serializeToJSONString(data) let jsScript = "if(window.__WENYAN_BRIDGE__.emit) { window.__WENYAN_BRIDGE__.emit('\(event)', \(dataJsonString)); }" WenYan.callJavascript(webView: webView, javascriptString: jsScript) } } // MARK: - ScriptMessageHandler extension MainViewModel: WKScriptMessageHandler { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "wenyanBridge", let body = message.body as? [String: Any], let action = body["action"] as? String, let callbackId = body["callbackId"] as? String else { return } let payload = body["payload"] switch action { case "loadArticles": handleLoadArticles(callbackId: callbackId) case "saveArticle": handleSaveArticle(payload: payload as? String) case "pageInit": handlePageInit() case "loadThemes": handleLoadThemes(callbackId: callbackId) case "saveTheme": handleSaveTheme(callbackId: callbackId, payload: payload) case "removeTheme": handleRemoveTheme(payload: payload as? String) case "openDirectoryPicker": handleOpenDirectoryPicker(callbackId: callbackId) case "readDir": handleReadDir(callbackId: callbackId, payload: payload as? String) case "handleMarkdownFile": handleMarkdownFile(callbackId: callbackId, payload: payload as? String) case "pathToBase64": pathToBase64(callbackId: callbackId, payload: payload as? String) case "uploadBase64Image": resolveUploadBase64Image(callbackId: callbackId, payload: payload) case "handleMarkdownContent": handleMarkdownContent(callbackId: callbackId, payload: payload as? String) case "uploadImage": resolveUploadImage(callbackId: callbackId, payload: payload as? String) case "resetLastArticlePath": handleResetLastArticlePath() case "getCredential": handleGetCredential(callbackId: callbackId) case "saveCredential": handleSaveCredential(payload: payload) case "getSettings": handleGetSettings(callbackId: callbackId) case "saveSettings": handleSaveSettings(payload: payload) case "openLink": handleOpenLink(payload: payload as? String) case "autoCacheChange": handleAutoCacheChange() case "resetWechatAccessToken": handleResetWechatAccessToken() case "publishArticleToDraft": resolvePublishArticleToDraft(callbackId: callbackId, payload: payload) case "saveExportedFile": handleSaveExportedFile(payload: payload) default: break } } } // MARK: - Article Handlers extension MainViewModel { func handleLoadArticles(callbackId: String) { let article = loadArticle() callbackJavascript(callbackId: callbackId, data: article) } func handleSaveArticle(payload: String?) { saveArticle(payload) } func handlePageInit() { if let article = loadArticle() { dispatch(.setContent(article)) } else { do { let content = try loadFileFromResource(forResource: "example", withExtension: "md") dispatch(.setContent(content)) } catch { dispatch(.onError(error.localizedDescription)) } } } } // MARK: - Theme Handlers extension MainViewModel { func handleLoadThemes(callbackId: String) { do { let themes = try fetchCustomThemes().map { $0.toDictionary() } callbackJavascript(callbackId: callbackId, data: themes) } catch { callbackJavascript(callbackId: callbackId, error: error.localizedDescription) } } func handleSaveTheme(callbackId: String, payload: Any?) { guard let dict = payload as? [String: Any] else { callbackJavascript(callbackId: callbackId, error: "不能保存自定义主题") return } do { let data = try JSONSerialization.data(withJSONObject: dict, options: []) let themeToSave = try JSONDecoder().decode(JsCustomTheme.self, from: data) if let theme = try getCustomThemeById(id: themeToSave.id) { try updateCustomTheme(customTheme: theme, name: themeToSave.name, content: themeToSave.css) callbackJavascript(callbackId: callbackId, data: themeToSave.id) } else { let result = try saveCustomTheme(name: themeToSave.name, content: themeToSave.css) callbackJavascript(callbackId: callbackId, data: result.objectID.uriRepresentation().absoluteString) } } catch { callbackJavascript(callbackId: callbackId, error: error.localizedDescription) } } func handleRemoveTheme(payload: String?) { guard let idToDelete = payload, let theme = try? getCustomThemeById(id: idToDelete) else { dispatch(.onError("不能删除主题")) return } do { try deleteCustomTheme(theme) } catch { dispatch(.onError(error.localizedDescription)) } } } // MARK: - Credential Handlers extension MainViewModel { func handleGetCredential(callbackId: String) { let credential = getCredential() callbackJavascript(callbackId: callbackId, data: credential) } func handleSaveCredential(payload: Any?) { if let dict = payload as? [String: Any], let appId = dict["appId"] as? String, let appSecret = dict["appSecret"] as? String { saveCredential(credential: GenericCredential(appId: appId, appSecret: appSecret)) } } } // MARK: - Settings Handlers extension MainViewModel { func handleGetSettings(callbackId: String) { let settings = getSettings() callbackJavascript(callbackId: callbackId, data: settings) } func handleSaveSettings(payload: Any?) { if let dict = payload as? [String: Any] { do { let data = try JSONSerialization.data(withJSONObject: dict, options: []) let settings = try JSONDecoder().decode(Settings.self, from: data) saveSettings(settings: settings) } catch { dispatch(.onError(error.localizedDescription)) } } } } // MARK: - Directory Handlers extension MainViewModel { func handleOpenDirectoryPicker(callbackId: String) { // UI 操作必须在主线程执行 DispatchQueue.main.async { let panel = NSOpenPanel() panel.title = "选择工作目录" panel.canChooseDirectories = true // 允许选择文件夹 panel.canChooseFiles = false // 不允许选择单文件 panel.allowsMultipleSelection = false // 不允许多选 panel.canCreateDirectories = true // 允许新建文件夹 // 弹出模态窗口 if panel.runModal() == .OK { // 用户点击了“打开”或“确定” if let url = panel.url { let path = url.path do { try saveSecurityScopedBookmark(for: url) } catch { self.callbackJavascript(callbackId: callbackId, data: "保存目录访问权限失败: \(error.localizedDescription)") } self.callbackJavascript(callbackId: callbackId, data: path) } else { // 异常情况,返回 null self.callbackJavascript(callbackId: callbackId, data: nil) } } else { // 用户点击了“取消”或关闭了窗口,返回 null self.callbackJavascript(callbackId: callbackId, data: nil) } } } func handleReadDir(callbackId: String, payload: String?) { guard let path = payload else { callbackJavascript(callbackId: callbackId, error: "不能读取目录") return } Task { [weak self] in guard let self = self else { return } let fileManager = FileManager.default let url = URL(fileURLWithPath: path) var resultEntries: [[String: Any]] = [] do { let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles) for fileUrl in contents { // 检查是否是文件夹 let resourceValues = try fileUrl.resourceValues(forKeys: [.isDirectoryKey]) let isDirectory = resourceValues.isDirectory ?? false // 构造前端需要的 FileEntry 结构字典 let entryDict: [String: Any] = [ "name": fileUrl.lastPathComponent, "path": fileUrl.path, // 绝对路径 "isDirectory": isDirectory ] resultEntries.append(entryDict) } self.callbackJavascript(callbackId: callbackId, data: resultEntries, error: nil) } catch { self.callbackJavascript(callbackId: callbackId, error: error.localizedDescription) } } } /// open file func handleMarkdownFile(callbackId: String, payload: String?) { guard let path = payload else { callbackJavascript(callbackId: callbackId, error: "不能打开文件") return } do { let content = try String(contentsOfFile: path, encoding: .utf8) let fileUrl = URL(fileURLWithPath: path) let fileName = fileUrl.lastPathComponent let dir = fileUrl.deletingLastPathComponent().path setLastArticlePath(fileName: fileName, filePath: path, relativePath: dir) handleMarkdownContent(callbackId: callbackId, payload: content) } catch { callbackJavascript(callbackId: callbackId, error: error.localizedDescription) } } /// editor paste and drop func handleMarkdownContent(callbackId: String, payload: String?) { guard let content = payload else { callbackJavascript(callbackId: callbackId, data: "") return } guard let settings = getSettings() else { callbackJavascript(callbackId: callbackId, data: content) return } let autoUploadLocal = settings.uploadSettings.autoUploadLocal let autoUploadNetwork = settings.uploadSettings.autoUploadNetwork var uploadErrors: [String] = [] Task { [weak self] in guard let self = self else { return } let _result = await uploadAndReplaceImagesInMarkdown(markdown: content) { src in guard autoUploadLocal == true else { return false } return !src.starts(with: "http") } uploadErrors.append(contentsOf: _result.errors) let result = await uploadAndReplaceImagesInMarkdown(markdown: _result.text) { src in guard autoUploadNetwork == true else { return false } return src.starts(with: "http") && !src.starts(with: "https://mmbiz.qpic.cn") } uploadErrors.append(contentsOf: result.errors) self.callbackJavascript(callbackId: callbackId, data: result.text) if !uploadErrors.isEmpty { self.dispatch(.onError(uploadErrors.joined(separator: "\n"))) } } } } // MARK: - Image Handlers extension MainViewModel { func pathToBase64(callbackId: String, payload: String?) { guard let path = payload?.trimmingCharacters(in: .whitespaces), !path.isEmpty else { callbackJavascript(callbackId: callbackId, error: "图片路径为空") return } let isNetwork = path.lowercased().hasPrefix("http://") || path.lowercased().hasPrefix("https://") let cacheKey: String var localFileURL: URL? = nil if isNetwork { // 1. 网络图片:直接以 URL 字符串作为缓存的 Key cacheKey = path } else if path.lowercased().hasPrefix("data:") { // 2. 已经是 Base64 的图片:直接原路返回,无需处理和缓存 callbackJavascript(callbackId: callbackId, data: path) return } else { // 3. 本地图片:解析相对/绝对路径 if (path as NSString).isAbsolutePath { localFileURL = URL(fileURLWithPath: path).standardizedFileURL } else { guard let articleDir = getLastArticleRelativePath(), !articleDir.isEmpty else { // 相对路径但无法获取基准目录时,原路返回给前端兜底 callbackJavascript(callbackId: callbackId, data: path) return } let baseURL = URL(fileURLWithPath: articleDir) let directoryURL = baseURL.hasDirectoryPath ? baseURL : baseURL.deletingLastPathComponent() localFileURL = directoryURL.appendingPathComponent(path).standardizedFileURL } // 本地图片以绝对路径作为缓存的 Key cacheKey = localFileURL!.path } // 统一查询缓存 if let cached = imageLocalCache.get(cacheKey), !cached.isEmpty { callbackJavascript(callbackId: callbackId, data: cached) return } // 缓存未命中:启动后台任务异步下载或读取文件 Task { [weak self] in guard let self = self else { return } do { let base64URI: String if isNetwork { // --- 处理网络图片 --- guard let url = URL(string: path) else { throw AppError.bizError(description: "无效的网络图片链接") } // 异步下载图片数据 let (data, response) = try await URLSession.shared.data(from: url) // 校验 HTTP 状态码 guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { throw AppError.bizError(description: "网络图片下载失败") } // 优先使用服务器返回的 MIME Type,获取不到则兜底 let mimeType = response.mimeType ?? "image/png" base64URI = "data:\(mimeType);base64,\(data.base64EncodedString())" } else { // --- 处理本地图片 --- guard let targetURL = localFileURL else { throw AppError.bizError(description: "本地路径解析失败") } base64URI = try getDataURIFromFile(at: targetURL) } // 写入缓存 self.imageLocalCache.set(cacheKey, value: base64URI) self.callbackJavascript(callbackId: callbackId, data: base64URI) } catch { self.callbackJavascript(callbackId: callbackId, error: "图片处理失败: \(error.localizedDescription)") } } } /// 前端送来的图片已转成 base64 字符串 func resolveUploadBase64Image(callbackId: String, payload: Any?) { guard let dict = payload as? [String: Any], let data = dict["file"] as? String, let fileData = Data(base64Encoded: data) else { callbackJavascript(callbackId: callbackId, error: "上传图片失败:未找到图片") return } let fileName = dict["fileName"] as? String ?? "upload" let mimetype = dict["mimetype"] as? String ?? "image/png" Task { [weak self] in guard let self = self else { return } do { let response = try await uploadImageWithCache(fileData: fileData, fileName: fileName, mimeType: mimetype) self.callbackJavascript(callbackId: callbackId, data: response.url) } catch { self.callbackJavascript(callbackId: callbackId, error: error.localizedDescription) } } } /// 前端送来的是 src 后面的图片路径 func resolveUploadImage(callbackId: String, payload: String?) { guard let dataString = payload else { callbackJavascript(callbackId: callbackId, error: "上传图片失败:无法找到图片路径") return } let relativePath = getLastArticleRelativePath() let resolvedSrc = resolveRelativePath(path: dataString, relative: relativePath) Task { [weak self] in guard let self = self else { return } do { let response = try await uploadImageToWechat(from: resolvedSrc) self.callbackJavascript(callbackId: callbackId, data: response) } catch { self.callbackJavascript(callbackId: callbackId, error: error.localizedDescription) } } } func resolvePublishArticleToDraft(callbackId: String, payload: Any?) { guard let dict = payload as? [String: Any] else { callbackJavascript(callbackId: callbackId, error: "不能发布文章") return } do { let data = try JSONSerialization.data(withJSONObject: dict, options: []) let publishOptions = try JSONDecoder().decode(WechatPublishOptions.self, from: data) Task { [weak self] in guard let self = self else { return } do { let mediaId = try await publishArticle(publishOptions) self.callbackJavascript(callbackId: callbackId, data: mediaId) } catch { self.callbackJavascript(callbackId: callbackId, error: error.localizedDescription) } } } catch { callbackJavascript(callbackId: callbackId, error: error.localizedDescription) } } } extension MainViewModel { func handleResetLastArticlePath() { resetLastArticlePath() } func handleOpenLink(payload: String?) { guard let urlString = payload, let url = URL(string: urlString) else { return } DispatchQueue.main.async { NSWorkspace.shared.open(url) } } func handleAutoCacheChange() { do { try clearUploadCache() } catch { dispatch(.onError(error.localizedDescription)) } } func handleResetWechatAccessToken() { clearAllCachedTokens() } func handleSaveExportedFile(payload: Any?) { guard let dict = payload as? [String: Any], let fileType = dict["fileType"] as? String, let fileName = dict["fileName"] as? String, let base64String = dict["base64Data"] as? String, let data = Data(base64Encoded: base64String) else { dispatch(.onError("缺少导出参数或 Base64 解析失败")) return } // 切回主线程触发 SwiftUI 弹窗 DispatchQueue.main.async { self.exportFileData = DataFile(data: data) self.exportDefaultFilename = fileName if fileType == "pdf" { self.exportContentType = .pdf } else { self.exportContentType = fileName.hasSuffix(".png") ? .png : .jpeg } // 触发弹窗 self.isFileExporting = true } } } // MARK: - Dispatcher extension MainViewModel { func dispatch(_ action: UserAction) { switch action { case .changePlatform(let platform): emitJavascript(event: "setPlatform", data: platform.rawValue) case .openSettings: emitJavascript(event: "openSettings") case .setContent(let content): emitJavascript(event: "setContent", data: content) case .toggleFileSidebar: emitJavascript(event: "toggleFileSidebar") case .onError(let error): emitJavascript(event: "onError", data: error) } } } // MARK: - UIDelegate extension MainViewModel: WKUIDelegate { // 为方便调试,让SwiftUI模拟JS的alert方法 func webView( _ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void ) { let alert = NSAlert() alert.messageText = "JavaScript Alert" alert.informativeText = message alert.addButton(withTitle: "OK") alert.runModal() completionHandler() } } ================================================ FILE: WenYan/WenYan.entitlements ================================================ ================================================ FILE: WenYan/WenYan.xcdatamodeld/WenYan.xcdatamodel/contents ================================================ ================================================ FILE: WenYan/WenYanApp.swift ================================================ // // WenYanApp.swift // WenYan // // Created by Lei Cao on 2024/8/19. // import SwiftUI @main struct WenYanApp: App { @StateObject private var appState: AppState @StateObject private var mainViewModel: MainViewModel @State private var showFileImporter = false @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate init() { let appState = AppState() let mainViewModel = MainViewModel(appState: appState) _appState = StateObject(wrappedValue: appState) _mainViewModel = StateObject(wrappedValue: mainViewModel) } var body: some Scene { WindowGroup { ContentView() .environmentObject(appState) .environmentObject(mainViewModel) .onAppear { DispatchQueue.main.async { if let window = NSApp.keyWindow ?? NSApp.windows.first, let screen = window.screen ?? NSScreen.main { window.setFrame(screen.visibleFrame, display: true, animate: true) } } } } .commands { CommandGroup(replacing: .appInfo) { Button("关于\(getAppName())") { NSApplication.shared.orderFrontStandardAboutPanel( options: [ NSApplication.AboutPanelOptionKey.version: "" ] ) } } CommandGroup(replacing: .help) { if let helpURL = getAppinfo(for: "HelpUrl"), let url = URL(string: helpURL) { Link("\(getAppName())使用帮助", destination: url) } } CommandGroup(after: .newItem) { Button("打开示例文本") { do { let content = try loadFileFromResource(forResource: "example", withExtension: "md") mainViewModel.dispatch(.setContent(content)) } catch { mainViewModel.dispatch(.onError(error.localizedDescription)) } } } CommandGroup(replacing: .appSettings) { Button("设置...") { mainViewModel.dispatch(.openSettings) } .keyboardShortcut(",", modifiers: .command) } } } } class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func applicationDidFinishLaunching(_ notification: Notification) { if let window = NSApplication.shared.windows.first { window.delegate = self } } func windowWillClose(_ notification: Notification) { NSApplication.shared.terminate(nil) } } ================================================ FILE: WenYan.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 70; objects = { /* Begin PBXFileReference section */ AB0B2F4E2EAEFE1A0014EBC1 /* 文颜.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "文颜.app"; sourceTree = BUILT_PRODUCTS_DIR; }; ABD1FC832EAB303D00FF6560 /* .swift-format */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ".swift-format"; sourceTree = ""; }; ABD1FC842EAB414700FF6560 /* .editorconfig */ = {isa = PBXFileReference; lastKnownFileType = text; path = .editorconfig; sourceTree = ""; }; ABE070B72C772B6A002A94AA /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; ABE070B82C772B6A002A94AA /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; ABE070B92C772B6A002A94AA /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ AB9E74342CCF23FD006F11AA /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, ); target = ABB7F8292C72EB290029E73E /* WenYan */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ AB9E74062CCF1C10006F11AA /* Data */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Data; sourceTree = ""; }; AB9E74192CCF1C15006F11AA /* WenYan */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (AB9E74342CCF23FD006F11AA /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WenYan; sourceTree = ""; }; ABB05EDB2F69561C00A5F18C /* ci_scripts */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ci_scripts; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ ABB7F8272C72EB290029E73E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ ABB7F8212C72EB290029E73E = { isa = PBXGroup; children = ( ABB05EDB2F69561C00A5F18C /* ci_scripts */, ABD1FC842EAB414700FF6560 /* .editorconfig */, ABE070B92C772B6A002A94AA /* .gitignore */, ABD1FC832EAB303D00FF6560 /* .swift-format */, AB9E74062CCF1C10006F11AA /* Data */, ABE070B72C772B6A002A94AA /* LICENSE */, ABE070B82C772B6A002A94AA /* README.md */, AB9E74192CCF1C15006F11AA /* WenYan */, AB0B2F4E2EAEFE1A0014EBC1 /* 文颜.app */, ); sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ ABB7F8292C72EB290029E73E /* WenYan */ = { isa = PBXNativeTarget; buildConfigurationList = ABB7F8512C72EB2A0029E73E /* Build configuration list for PBXNativeTarget "WenYan" */; buildPhases = ( ABB7F8262C72EB290029E73E /* Sources */, ABB7F8272C72EB290029E73E /* Frameworks */, ABB7F8282C72EB290029E73E /* Resources */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( AB9E74192CCF1C15006F11AA /* WenYan */, ); name = WenYan; productName = WenYan; productReference = AB0B2F4E2EAEFE1A0014EBC1 /* 文颜.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ ABB7F8222C72EB290029E73E /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1540; LastUpgradeCheck = 2600; TargetAttributes = { ABB7F8292C72EB290029E73E = { CreatedOnToolsVersion = 15.4; }; }; }; buildConfigurationList = ABB7F8252C72EB290029E73E /* Build configuration list for PBXProject "WenYan" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = "zh-Hans"; hasScannedForEncodings = 0; knownRegions = ( Base, "zh-Hans", ); mainGroup = ABB7F8212C72EB290029E73E; productRefGroup = ABB7F8212C72EB290029E73E; projectDirPath = ""; projectRoot = ""; targets = ( ABB7F8292C72EB290029E73E /* WenYan */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ ABB7F8282C72EB290029E73E /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ ABB7F8262C72EB290029E73E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ ABB7F84F2C72EB2A0029E73E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = NNQB2TH9QC; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_KEY_CFBundleDisplayName = "文颜"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 13.5; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; ABB7F8502C72EB2A0029E73E /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = NNQB2TH9QC; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_KEY_CFBundleDisplayName = "文颜"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 13.5; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; }; ABB7F8522C72EB2A0029E73E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = WenYan/WenYan.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 23; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"WenYan/Preview Content\""; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = WenYan/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "文颜"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2024-2026 Lei Cao. All rights reserved."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.5; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.5; MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.yztech.WenYan; PRODUCT_MODULE_NAME = WenYan; PRODUCT_NAME = "文颜"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SUPPORTED_PLATFORMS = macosx; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Debug; }; ABB7F8532C72EB2A0029E73E /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = WenYan/WenYan.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 23; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"WenYan/Preview Content\""; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = WenYan/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "文颜"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2024-2026 Lei Cao. All rights reserved."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.5; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.5; MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.yztech.WenYan; PRODUCT_MODULE_NAME = WenYan; PRODUCT_NAME = "文颜"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SUPPORTED_PLATFORMS = macosx; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ ABB7F8252C72EB290029E73E /* Build configuration list for PBXProject "WenYan" */ = { isa = XCConfigurationList; buildConfigurations = ( ABB7F84F2C72EB2A0029E73E /* Debug */, ABB7F8502C72EB2A0029E73E /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; ABB7F8512C72EB2A0029E73E /* Build configuration list for PBXNativeTarget "WenYan" */ = { isa = XCConfigurationList; buildConfigurations = ( ABB7F8522C72EB2A0029E73E /* Debug */, ABB7F8532C72EB2A0029E73E /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = ABB7F8222C72EB290029E73E /* Project object */; } ================================================ FILE: WenYan.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: WenYan.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: WenYan.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ ================================================ FILE: WenYan.xcodeproj/xcshareddata/xcschemes/WenYan.xcscheme ================================================ ================================================ FILE: ci_scripts/ci_post_clone.sh ================================================ #!/bin/sh set -e # git submodule update --init --recursive brew install node npm install -g pnpm # $CI_PRIMARY_REPOSITORY_PATH 是 Xcode Cloud 提供的环境变量,指向项目的根目录。 cd $CI_PRIMARY_REPOSITORY_PATH pnpm ui:init cd $CI_PRIMARY_REPOSITORY_PATH pnpm web:install pnpm web:build ================================================ FILE: package.json ================================================ { "name": "@wenyan-md/mac", "private": true, "type": "module", "version": "4.0.0", "scripts": { "ui:sync": "git submodule update --remote --merge && pnpm ui:init", "ui:init": "cd wenyan-ui && pnpm install && pnpm svelte-kit sync", "web:install": "pnpm install && pnpm svelte-kit sync", "web:dev": "vite dev", "web:build": "vite build && pnpm copy-assets", "copy-assets": "bash scripts/copy_web_assets.sh", "build": "xcodebuild -scheme WenYan -configuration Release -destination 'platform=macOS,arch=arm64' SYMROOT=target build", "prerun": "pnpm build", "run": "open target/Release/文颜.app" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/vite": "^4.1.17", "@types/node": "^24.3.0", "svelte": "^5.45.6", "svelte-check": "^4.3.4", "tailwindcss": "^4.1.17", "typescript": "^5.9.3", "vite": "^7.2.6" }, "dependencies": { "@codemirror/view": "^6.39.12", "@wenyan-md/core": "^3.0.1", "@wenyan-md/ui": "file:./wenyan-ui", "modern-screenshot": "^4.6.8" }, "packageManager": "pnpm@10.7.1" } ================================================ FILE: pnpm-workspace.yaml ================================================ onlyBuiltDependencies: - esbuild ================================================ FILE: scripts/copy_web_assets.sh ================================================ #!/bin/bash set -e BUILD_DIR="build" BUNDLE_DIR="WenYan/Resources.bundle" echo "=== Cleaning old assets ===" find "$BUNDLE_DIR" -mindepth 1 ! -name '.keep' -exec rm -rf {} + echo "=== Copying assets to Resources.bundle ===" cp -R "$BUILD_DIR"/* "$BUNDLE_DIR" echo "=== Done ===" ================================================ FILE: src/app.d.ts ================================================ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { namespace App { // interface Error {} // interface Locals {} // interface PageData {} // interface PageState {} // interface Platform {} } } export {}; ================================================ FILE: src/app.html ================================================ %sveltekit.head%
%sveltekit.body%
================================================ FILE: src/lib/action.ts ================================================ import type { CustomTheme, Settings } from "@wenyan-md/ui"; import { invokeSwift } from "./bridge"; import type { WechatPublishOptions } from "@wenyan-md/core/wechat"; interface ThemeDO { id: string; name: string; content: string; createdAt: number; } interface UploadImagePayload { file: string; fileName: string; mimetype: string; } interface ExportedFilePayload { fileType: string; fileName: string; base64Data: string; } interface CredentialDO { appId: string; appSecret: string; } interface UploadImageResponse { mediaId: string; url: string; } export async function pageInit(): Promise { return await invokeSwift("pageInit", null, false); } export async function loadArticles(): Promise { return await invokeSwift("loadArticles", null, true); } export async function saveArticle(payload: string): Promise { return await invokeSwift("saveArticle", payload, false); } export async function loadThemes(): Promise { return await invokeSwift("loadThemes", null, true); } export async function saveTheme(payload: CustomTheme): Promise { return await invokeSwift("saveTheme", payload, true); } export async function removeTheme(id: string): Promise { await invokeSwift("removeTheme", id, false); } export async function pathToBase64(path: string): Promise { return await invokeSwift("pathToBase64", path, true); } export async function uploadBase64Image(payload: UploadImagePayload): Promise { return await invokeSwift("uploadBase64Image", payload, true); } export async function uploadImage(path: string): Promise { return await invokeSwift("uploadImage", path, true); } export async function handleMarkdownContent(content: string): Promise { return await invokeSwift("handleMarkdownContent", content, true); } export async function handleMarkdownFile(path: string): Promise { return await invokeSwift("handleMarkdownFile", path, true); } export async function resetLastArticlePath(): Promise { return await invokeSwift("resetLastArticlePath", null, false); } export async function getCredential(): Promise { return await invokeSwift("getCredential", null, true); } export async function saveCredential(credential: CredentialDO): Promise { return await invokeSwift("saveCredential", credential, false); } export async function getSettings(): Promise { return await invokeSwift("getSettings", null, true); } export async function saveSettings(settings: Settings): Promise { return await invokeSwift("saveSettings", settings, false); } export async function openLink(url: string): Promise { return await invokeSwift("openLink", url, false); } export async function autoCacheChange(): Promise { return await invokeSwift("autoCacheChange", null, false); } export async function resetWechatAccessToken(): Promise { return await invokeSwift("resetWechatAccessToken", null, false); } export async function publishArticleToDraft(publishOption: WechatPublishOptions): Promise { return await invokeSwift("publishArticleToDraft", publishOption, true); } export async function saveExportedFile(payload: ExportedFilePayload): Promise { return await invokeSwift("saveExportedFile", payload, false); } ================================================ FILE: src/lib/adapters/articleStorageAdapter.ts ================================================ import type { ArticleStorageAdapter, Article } from "@wenyan-md/ui"; import { loadArticles, saveArticle } from "../action"; export const articleStorageAdapter: ArticleStorageAdapter = { async load(): Promise { const content = await loadArticles(); return content ? [{ id: "last-article", title: "Last Article", content, created: Date.now(), }] : []; }, async save(article: Article): Promise { await saveArticle(article.content); }, async remove(id: string): Promise { }, }; ================================================ FILE: src/lib/adapters/credentialStoreAdapter.ts ================================================ import { getCredential, saveCredential } from "$lib/action"; import type { CredentialStoreAdapter, CredentialType, GenericCredential } from "@wenyan-md/ui"; export const credentialStoreAdapter: CredentialStoreAdapter = { async load(): Promise { const credential = await getCredential(); if (credential) { return [{ type: "wechat" as CredentialType, appId: credential.appId, appSecret: credential.appSecret, }]; } return []; }, async save(credential: GenericCredential): Promise { await saveCredential({ appId: credential.appId || "", appSecret: credential.appSecret || "", }); }, async remove(type: string): Promise { throw new Error("Function not implemented."); }, }; ================================================ FILE: src/lib/adapters/settingsStoreAdapter.ts ================================================ import { getSettings, saveSettings } from "$lib/action"; import type { Settings, SettingsStorageAdapter } from "@wenyan-md/ui"; export const settingsStorageAdapter: SettingsStorageAdapter = { async load(): Promise { return await getSettings(); }, async save(settings: Settings): Promise { await saveSettings(settings); }, }; ================================================ FILE: src/lib/adapters/swiftFsAdapter.ts ================================================ import { filterAndSortEntries, type FileEntry, type FileSystemAdapter } from "@wenyan-md/ui"; import { invokeSwift } from "../bridge"; export const swiftFsAdapter: FileSystemAdapter = { async openDirectoryPicker(): Promise { const path = await invokeSwift("openDirectoryPicker", null, true); return path; }, async readDir(path: string): Promise { const entries = await invokeSwift("readDir", path, true); return filterAndSortEntries(entries); } }; ================================================ FILE: src/lib/adapters/themeStorageAdapter.ts ================================================ import { loadThemes, removeTheme, saveTheme } from "../action"; import type { ThemeStorageAdapter, CustomTheme } from "@wenyan-md/ui"; export const themeStorageAdapter: ThemeStorageAdapter = { async load() { const themes = await loadThemes(); const customThemes: Record = Object.fromEntries( themes.map((theme) => [ String(theme.id), { id: String(theme.id), name: theme.name, css: theme.content, }, ]), ); return customThemes; }, async save(id: string, name: string, css: string): Promise { const result = await saveTheme({ id, name, css }); return result; }, async remove(id: string) { await removeTheme(id); }, }; ================================================ FILE: src/lib/appState.svelte.ts ================================================ class AppState { private _isShowSettingsPage = $state(false); get isShowSettingsPage() { return this._isShowSettingsPage; } set isShowSettingsPage(value: boolean) { this._isShowSettingsPage = value; } } export const appState = new AppState(); ================================================ FILE: src/lib/bridge.ts ================================================ import { globalState } from "@wenyan-md/ui"; declare global { interface Window { webkit?: { messageHandlers: { wenyanBridge: { postMessage(message: any): void; }; }; }; // 供 Swift 调用的全局回调管家 __WENYAN_BRIDGE__: { callbacks: Map; listeners: Map>; invokeCallback: (callbackId: string, data: any, error: string | null) => void; emit: (event: string, payload: any) => void; }; } } if (typeof window !== "undefined" && !window.__WENYAN_BRIDGE__) { window.__WENYAN_BRIDGE__ = { callbacks: new Map(), listeners: new Map(), invokeCallback: (callbackId: string, data: any, error: string | null) => { const cb = window.__WENYAN_BRIDGE__.callbacks.get(callbackId); if (!cb) return; if (error) { globalState.setAlertMessage({ type: "error", message: `Error: ${error}`, }); cb.reject(new Error(error)); } else { cb.resolve(data); } // 响应结束后清理内存 window.__WENYAN_BRIDGE__.callbacks.delete(callbackId); }, emit: (event: string, payload: any) => { const handlers = window.__WENYAN_BRIDGE__.listeners.get(event); if (!handlers) return; handlers.forEach((fn) => fn(payload)); }, }; } export function invokeSwift(action: string, payload?: T | null, isCallback?: boolean): Promise { return new Promise((resolve, reject) => { if (!window.webkit?.messageHandlers?.wenyanBridge) { resolve(undefined as unknown as R); return; } if (isCallback) { // 生成唯一的 callbackId const callbackId = Math.random().toString(36).substring(2, 15) + Date.now().toString(36); // 登记回调 window.__WENYAN_BRIDGE__.callbacks.set(callbackId, { resolve, reject }); // 发送消息给 Swift window.webkit.messageHandlers.wenyanBridge.postMessage({ action, callbackId, payload, }); } else { window.webkit.messageHandlers.wenyanBridge.postMessage({ action, callbackId: "", payload, }); resolve(undefined as unknown as R); } }); } export function onSwift(event: string, handler: (data: any) => void) { const bridge = window.__WENYAN_BRIDGE__; if (!bridge.listeners.has(event)) { bridge.listeners.set(event, new Set()); } bridge.listeners.get(event)!.add(handler); return () => { bridge.listeners.get(event)?.delete(handler); }; } ================================================ FILE: src/lib/imageProcessor.svelte.ts ================================================ import { pathToBase64 } from "$lib/action"; import type { ImageProcessorAction } from "@wenyan-md/ui"; export const imageProcessorAction: ImageProcessorAction = (node) => { const run = async () => { const images = node.querySelectorAll("img"); if (images.length === 0) return; for (const img of images) { const dataSrc = img.getAttribute("src"); if (!dataSrc || dataSrc.startsWith("data:") || dataSrc.startsWith("http")) { continue; } const resolvedSrc = await pathToBase64(dataSrc); if (resolvedSrc && resolvedSrc.startsWith("data:")) { img.setAttribute("data-src", dataSrc); img.src = resolvedSrc; } } }; // 首次运行 run(); // 如果内容动态变化,可以用 MutationObserver const observer = new MutationObserver(() => run()); observer.observe(node, { childList: true, subtree: true, }); return { destroy() { observer.disconnect(); }, }; }; ================================================ FILE: src/lib/index.ts ================================================ // place files you want to import through the `$lib` alias in this folder. ================================================ FILE: src/lib/listeners.svelte.ts ================================================ import { globalState, type Platform } from "@wenyan-md/ui"; import { onSwift } from "./bridge"; import { appState } from "./appState.svelte"; export function useSwiftListeners() { $effect(() => { const unsubscribeSetContent = onSwift("setContent", (content: string) => { globalState.setMarkdownText(content); }); const unsubscribeSetPlatform = onSwift("setPlatform", (platform: Platform) => { globalState.setPlatform(platform); }); const unsubscribeOpenSettings = onSwift("openSettings", () => { appState.isShowSettingsPage = true; }); const unsubscribeToggleFileSidebar = onSwift("toggleFileSidebar", () => { globalState.isShowFileSidebar = !globalState.isShowFileSidebar; }); const unsubscribeError = onSwift("onError", (error: string) => { globalState.setAlertMessage({ type: "error", message: `Error: ${error}`, }); }); return () => { unsubscribeSetContent(); unsubscribeSetPlatform(); unsubscribeOpenSettings(); unsubscribeToggleFileSidebar(); unsubscribeError(); }; }); } ================================================ FILE: src/lib/services/exportHandler.ts ================================================ import { domToPng } from "modern-screenshot"; import { globalState } from "@wenyan-md/ui"; import { pathToBase64, saveExportedFile } from "$lib/action"; export async function exportImage() { const element = document.getElementById("wenyan"); if (!element) return; let bgColor = window.getComputedStyle(document.body).backgroundColor; // 如果获取到的是透明色 (rgba(0, 0, 0, 0)) 或者 transparent,设置为白色 if (bgColor === "rgba(0, 0, 0, 0)" || bgColor === "transparent") { bgColor = "#ffffff"; } // 1. 克隆并配置 const clonedWenyan = element.cloneNode(true) as HTMLElement; Object.assign(clonedWenyan.style, { position: "fixed", top: "0", left: "0", zIndex: "-9999", width: "420px", backgroundColor: bgColor, pointerEvents: "none", }); try { globalState.isLoading = true; // 2. 处理图片替换 (等待全部下载完成) const images = clonedWenyan.querySelectorAll("img"); const promises = Array.from(images).map(async (img) => { if (!img.src.startsWith("data:")) { img.src = await pathToBase64(img.src); } }); await Promise.all(promises); // 等待所有图片下载完再往下走 // 3. 挂载 DOM document.body.appendChild(clonedWenyan); // 4. 生成图片 (此时 clonedWenyan 确定在 DOM 中) const dataUrl = await domToPng(clonedWenyan, { scale: 2, backgroundColor: bgColor, fetch: { requestInit: { mode: "cors" } }, }); const base64Part = dataUrl.split(",")[1]; await saveExportedFile({ fileType: "image", fileName: "wenyan-export.png", base64Data: base64Part, }); } catch (error) { console.error("保存失败:", error); globalState.setAlertMessage({ type: "error", message: `保存失败: ${error instanceof Error ? error.message : String(error)}`, }); } finally { if (clonedWenyan.parentNode) { document.body.removeChild(clonedWenyan); } globalState.isLoading = false; } } ================================================ FILE: src/lib/services/fileOpenHandler.ts ================================================ import { handleMarkdownFile } from "$lib/action"; import { globalState } from "@wenyan-md/ui"; export async function handleFileOpen(file: string) { try { globalState.isLoading = true; const content = await handleMarkdownFile(file); globalState.setMarkdownText(content); } catch (error) { globalState.setAlertMessage({ type: "error", message: `处理文件出错: ${error instanceof Error ? error.message : error}`, }); } finally { globalState.isLoading = false; } } ================================================ FILE: src/lib/services/imageUploadService.ts ================================================ import { uploadBase64Image, uploadImage } from "$lib/action"; import type { WechatUploadResponse } from "@wenyan-md/core/wechat"; export async function uploadPathImage(imageUrl: string): Promise { const resp = await uploadImage(imageUrl); return { url: resp.url, media_id: resp.mediaId }; } export async function uploadBlobImage(file: File): Promise { const arrayBuffer = await file.arrayBuffer(); const resp = await uploadBase64Image({ file: bytesToBase64(arrayBuffer), fileName: file.name, mimetype: file.type }); return { url: resp, media_id: "" }; } function bytesToBase64(data: Uint8Array | ArrayBuffer): string { const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); const chunkSize = 8192; if (bytes.length <= chunkSize) { let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } const chunks: string[] = []; for (let i = 0; i < bytes.length; i += chunkSize) { const sub = bytes.subarray(i, i + chunkSize); chunks.push(String.fromCharCode(...sub)); } return btoa(chunks.join("")); } ================================================ FILE: src/lib/setHooks.ts ================================================ import { setEditorDrop, setEditorPaste, setUploadHelpClick, setResetTokenClick, setExportImageClick, setImageProcessorAction, setPublishArticleClick, setAutoCacheChangeClick, setImportCssClick, globalState, themeStore, setHandleFileOpen, defaultPublishHandler, setGetWenyanElement, setPublishArticleToDraft, setUploadImage, setPublishHelpClick, defaultEditorPasteHandler, defaultEditorDropHandler, setHandleMarkdownContent, setMarkdownFileDrop, setUploadBlobImage, } from "@wenyan-md/ui"; import { handleFileOpen } from "./services/fileOpenHandler"; import { imageProcessorAction } from "./imageProcessor.svelte"; import { getWenyanElement } from "./utils"; import { uploadBlobImage, uploadPathImage } from "./services/imageUploadService"; import { autoCacheChange, handleMarkdownContent, openLink, publishArticleToDraft, resetLastArticlePath, resetWechatAccessToken } from "./action"; import { exportImage } from "./services/exportHandler"; export function setHooks() { setEditorPaste(defaultEditorPasteHandler); setEditorDrop(defaultEditorDropHandler); setUploadHelpClick(uploadHelpClick); setResetTokenClick(resetWechatAccessToken); setExportImageClick(exportImage); setImageProcessorAction(imageProcessorAction); setPublishArticleClick(defaultPublishHandler); setAutoCacheChangeClick(autoCacheChange); setImportCssClick(importCssHandler); setHandleFileOpen(handleFileOpen); setGetWenyanElement(getWenyanElement); setPublishArticleToDraft(publishArticleToDraft); setUploadImage(uploadPathImage); setPublishHelpClick(publishHelpClick); setHandleMarkdownContent(handleMarkdownContent); setMarkdownFileDrop(resetLastArticlePath); setUploadBlobImage(uploadBlobImage); } async function uploadHelpClick() { await openLink("https://yuzhi.tech/docs/wenyan/upload"); } async function publishHelpClick() { await openLink("https://yuzhi.tech/docs/wenyan/publish"); } async function importCssHandler(url: string, name: string) { const resp = await fetch(url); if (!resp.ok) { globalState.setAlertMessage({ type: "error", title: "导入 CSS 失败", message: `无法从 ${url} 获取 CSS 文件。`, }); return; } const cssText = await resp.text(); const themeId = globalState.getCurrentThemeId(); themeStore.addCustomTheme(`0:${themeId}`, name); const currentTheme = globalState.getCurrentTheme(); currentTheme.name = name; currentTheme.css = cssText; currentTheme.id = `0:${themeId}`; globalState.customThemeName = name; } ================================================ FILE: src/lib/storeRegister.ts ================================================ import { themeStore, settingsStore, articleStore, credentialStore } from "@wenyan-md/ui"; import { articleStorageAdapter } from "./adapters/articleStorageAdapter"; import { themeStorageAdapter } from "./adapters/themeStorageAdapter"; import { credentialStoreAdapter } from "./adapters/credentialStoreAdapter"; import { settingsStorageAdapter } from "./adapters/settingsStoreAdapter"; export async function registerStore() { await themeStore.register(themeStorageAdapter); await settingsStore.register(settingsStorageAdapter); await articleStore.register(articleStorageAdapter); await credentialStore.register(credentialStoreAdapter); } ================================================ FILE: src/lib/utils.ts ================================================ export function getWenyanElement(): HTMLElement { const wenyanElement = document.getElementById("wenyan"); if (!wenyanElement) { throw new Error("Wenyan element not found"); } const clonedWenyan = wenyanElement.cloneNode(true) as HTMLElement; clonedWenyan.querySelectorAll("img").forEach(async (element) => { const dataSrc = element.getAttribute("data-src"); if (dataSrc) { element.src = dataSrc; } }); return clonedWenyan; } ================================================ FILE: src/routes/+layout.svelte ================================================ {@render children()} ================================================ FILE: src/routes/+layout.ts ================================================ export const prerender = true; export const ssr = false; ================================================ FILE: src/routes/+page.svelte ================================================
{#if globalState.isShowFileSidebar} {/if} {#if globalState.judgeSidebarOpen()}
{/if} {#if globalState.isLoading} {/if}
(appState.isShowSettingsPage = false)} /> (globalState.isShowCreateThemeModal = false)} /> ================================================ FILE: src/routes/layout.css ================================================ @import "tailwindcss"; @source "../../node_modules/@wenyan-md/ui"; :root { color-scheme: light dark; @apply font-sans; } body { @apply bg-white dark:bg-[#1e1e1e] text-gray-900 dark:text-gray-100 overflow-hidden text-sm; } .dark-container { @apply bg-white dark:bg-[#1e1e1e]; } .smooth-border { @apply border border-gray-300 dark:border-gray-600; } .overlay-button { @apply bg-white dark:bg-[#1e1e1e] inline-flex flex-row items-center gap-2 text-center cursor-pointer text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-sm; } .inline-equation { display: inline-block; } .inline-equation mjx-container { display: inline-block !important; margin: 0 !important; } ================================================ FILE: static/example.md ================================================ --- author: 路边的阿不 title: 在本地跑一个大语言模型(2) - 给模型提供外部知识库 slug: run-a-large-language-model-locally-2 description: Make your local large language models (LLMs) smarter! This guide shows how to use LangChain and RAG to let them retrieve data from external knowledge bases, improving answer accuracy. date: 2024-03-04 11:18:00 draft: false ShowToc: true TocOpen: true tags: - Ollama - RAG categories: - AI --- 在[上一篇文章](https://babyno.top/posts/2024/02/running-a-large-language-model-locally/)里,我们展示了如何通过Ollama这款工具,在本地运行大型语言模型。本篇文章将着重介绍下如何让模型从外部知识库中检索定制数据,来提升大型语言模型的准确性,让它看起来更“智能”。 本篇文章将涉及到`LangChain`和`RAG`两个概念,在本文中不做详细解释。 ## 准备模型 访问`Ollama`的模型页面,搜索`qwen`,我们这次将使用对中文语义了解的更好的“[通义千问](https://ollama.com/library/qwen:7b)”模型进行实验。 ## 运行模型 ```shell ollama run qwen:7b ``` ## 第一轮测试 编写代码如下: ```python from langchain_community.chat_models import ChatOllama from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate model_local = ChatOllama(model="qwen:7b") template = "{topic}" prompt = ChatPromptTemplate.from_template(template) chain = model_local | StrOutputParser() print(chain.invoke("身长七尺,细眼长髯的是谁?")) ``` 模型返回的答案: > 这句话描述的是中国古代文学作品《三国演义》中的角色刘备。刘备被描绘为一位身高七尺(约1.78米),眼睛细小但有神,长着长须的蜀汉开国皇帝。 可以看到,我问了模型一个问题:"身长七尺,细眼长髯的是谁?"这是一个开放型的问题,没有指定上下文,答案并不确定。模型给到的答案是“刘备”,作为中国人训练出来的模型,四大名著应该是没有少看的。因此凭借问题的描述,模型能联想到三国里的人物,并不让人感觉意外。但答案还不对。 ## 引入RAG 检索增强生成(Retrieval Augmented Generation),简称 RAG。RAG的工作方式是在共享的语义空间中,从外部知识库中检索事实,将这些事实用作决策过程的一部分,以此来提升大型语言模型的准确性。因此第二轮测试我们将让模型在回答问题之前,阅读一篇事先准备好的《三国演义》章节,让其在这篇章节里寻找我们需要的答案。 RAG前的工作流程如下:向模型提问->模型从已训练数据中查询数据->组织语言->生成答案。 RAG后的工作流程如下:读取文档->分词->嵌入->将嵌入数据存入向量数据库->向模型提问->模型从向量数据库中查询数据->组织语言->生成答案。 ## 嵌入 在人工智能中,嵌入(Embedding)是将数据向量化的一个过程,可以理解为将人类语言转换为大语言模型所需要的计算机语言的一个过程。在我们第二轮测试开始前,首先下载一个嵌入模型:[nomic-embed-text](https://ollama.com/library/nomic-embed-text) 。它可以使我们的`Ollama`具备将文档向量化的能力。 ``` ollama run nomic-embed-text ``` ## 使用LangChain 接下来需要一个`Document loaders`,[文档](https://python.langchain.com/docs/modules/data_connection/document_loaders/)。 ```python from langchain_community.document_loaders import TextLoader loader = TextLoader("./index.md") loader.load() ``` 接下来需要一个分词器`Text Splitter`,[文档](https://python.langchain.com/docs/modules/data_connection/document_transformers/split_by_token)。 ```python from langchain_text_splitters import CharacterTextSplitter text_splitter = CharacterTextSplitter.from_tiktoken_encoder( chunk_size=100, chunk_overlap=0 ) texts = text_splitter.split_text(state_of_the_union) ``` 接下来需要一个向量数据库来存储使用`nomic-embed-text`模型项量化的数据。既然是测试,我们就使用内存型的`DocArray InMemorySearch`,[文档](https://python.langchain.com/docs/integrations/vectorstores/docarray_in_memory)。 ```python embeddings = OllamaEmbeddings(model='nomic-embed-text') vectorstore = DocArrayInMemorySearch.from_documents(doc_splits, embeddings) ``` ## 第二轮测试 首先下载[测试文档](http://babyno.top/data/%E4%B8%89%E5%9B%BD%E6%BC%94%E4%B9%89.txt),我们将会把此文档作为外部数据库供模型检索。注意该文档中提到的: > 忽见一彪军马,尽打红旗,当头来到,截住去路。为首闪出一将,身长七尺,细眼长髯,官拜骑都尉,沛国谯郡人也,姓曹,名操,字孟德。 编写代码如下: ```python from langchain_community.document_loaders import TextLoader from langchain_community import embeddings from langchain_community.chat_models import ChatOllama from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain.text_splitter import CharacterTextSplitter from langchain_community.vectorstores import DocArrayInMemorySearch from langchain_community.embeddings import OllamaEmbeddings model_local = ChatOllama(model="qwen:7b") # 1. 读取文件并分词 documents = TextLoader("../../data/三国演义.txt").load() text_splitter = CharacterTextSplitter.from_tiktoken_encoder(chunk_size=7500, chunk_overlap=100) doc_splits = text_splitter.split_documents(documents) # 2. 嵌入并存储 embeddings = OllamaEmbeddings(model='nomic-embed-text') vectorstore = DocArrayInMemorySearch.from_documents(doc_splits, embeddings) retriever = vectorstore.as_retriever() # 3. 向模型提问 template = """Answer the question based only on the following context: {context} Question: {question} """ prompt = ChatPromptTemplate.from_template(template) chain = ( {"context": retriever, "question": RunnablePassthrough()} | prompt | model_local | StrOutputParser() ) print(chain.invoke("身长七尺,细眼长髯的是谁?")) ``` 模型返回的答案: > 身长七尺,细眼长髯的人物是曹操,字孟德,沛国谯郡人。在《三国演义》中,他是主要人物之一。 可见,使用`RAG`后,模型给到了正确答案。 ## 总结 本篇文章我们使用`LangChain`和`RAG`对大语言模型进行了一些微调,使之生成答案前可以在我们给到的文档内进行检索,以生成更准确的答案。 `RAG`是检索增强生成(Retrieval Augmented Generation),主要目的是让用户可以给模型制定一些额外的资料。这一点非常有用,我们可以给模型提供各种各样的知识库,让模型扮演各种各样的角色。 `LangChain`是开发大语言模型应用的一个框架,内置了很多有用的方法,比如:文本读取、分词、嵌入等。利用它内置的这些功能,我们可以轻松构建出一个`RAG`的应用。 这次的文章就到这里了,下回我们将继续介绍更多本地`LLM`的实用场景。 ================================================ FILE: svelte.config.js ================================================ import adapter from "@sveltejs/adapter-static"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; /** @type {import('@sveltejs/kit').Config} */ const config = { // Consult https://svelte.dev/docs/kit/integrations // for more information about preprocessors preprocess: vitePreprocess(), kit: { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://svelte.dev/docs/kit/adapters for more information about adapters. adapter: adapter(), alias: { "@wenyan-md/ui": "./wenyan-ui/src/lib", }, }, }; export default config; ================================================ FILE: tailwind.config.js ================================================ /** @type {import('tailwindcss').Config} */ export default { content: [ "./src/**/*.{html,js,svelte,ts}", "./wenyan-ui/src/lib/**/*.{html,js,svelte,ts}", ], theme: { extend: {}, }, plugins: [], }; ================================================ FILE: tsconfig.json ================================================ { "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "rewriteRelativeImportExtensions": true, "allowJs": true, "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, "strict": true, "moduleResolution": "bundler" } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // // To make changes to top-level options such as include and exclude, we recommend extending // the generated config; see https://svelte.dev/docs/kit/configuration#typescript } ================================================ FILE: vite.config.ts ================================================ import tailwindcss from "@tailwindcss/vite"; import { sveltekit } from "@sveltejs/kit/vite"; import { defineConfig } from "vite"; import path from "node:path"; export default defineConfig({ plugins: [tailwindcss(), sveltekit()], optimizeDeps: { exclude: ["@wenyan-md/ui"], }, resolve: { alias: { "@wenyan-md/ui": path.resolve(__dirname, "./wenyan-ui/src/lib/index.ts"), }, }, server: { fs: { allow: ["./wenyan-ui"], }, }, });